From cff07788e5448064d97d056e46be6f0185c97b81 Mon Sep 17 00:00:00 2001 From: IRBorisov <8611739+IRBorisov@users.noreply.github.com> Date: Tue, 22 Aug 2023 17:52:59 +0300 Subject: [PATCH] Refactor pyconcept interaction and minor UI fixes --- rsconcept/backend/.pylintrc | 3 +- .../rsform/migrations/0002_load_commons.py | 6 +- rsconcept/backend/apps/rsform/models.py | 322 ++++++++++-------- rsconcept/backend/apps/rsform/serializers.py | 210 +++++++++--- .../backend/apps/rsform/tests/__init__.py | 1 + .../backend/apps/rsform/tests/t_models.py | 84 ++--- .../backend/apps/rsform/tests/t_utils.py | 16 + rsconcept/backend/apps/rsform/utils.py | 22 +- rsconcept/backend/apps/rsform/views.py | 107 +++--- .../backend/apps/users/tests/__init__.py | 1 - rsconcept/frontend/src/App.tsx | 2 +- .../src/components/Common/SubmitButton.tsx | 16 +- .../src/components/Common/TextURL.tsx | 2 +- .../src/pages/LibraryPage/ViewLibrary.tsx | 7 +- rsconcept/frontend/src/pages/LoginPage.tsx | 14 +- .../frontend/src/pages/RSFormPage/RSTabs.tsx | 8 +- .../elements/DependencyModePicker.tsx | 2 +- .../RSFormPage/elements/MatchModePicker.tsx | 2 +- .../elements/ViewSideConstituents.tsx | 4 +- 19 files changed, 493 insertions(+), 336 deletions(-) create mode 100644 rsconcept/backend/apps/rsform/tests/t_utils.py diff --git a/rsconcept/backend/.pylintrc b/rsconcept/backend/.pylintrc index a348a176..64be31a3 100644 --- a/rsconcept/backend/.pylintrc +++ b/rsconcept/backend/.pylintrc @@ -430,7 +430,8 @@ disable=too-many-public-methods, unused-argument, missing-function-docstring, attribute-defined-outside-init, - ungrouped-imports + ungrouped-imports, + abstract-method # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/rsconcept/backend/apps/rsform/migrations/0002_load_commons.py b/rsconcept/backend/apps/rsform/migrations/0002_load_commons.py index e96a6c46..a491ada6 100644 --- a/rsconcept/backend/apps/rsform/migrations/0002_load_commons.py +++ b/rsconcept/backend/apps/rsform/migrations/0002_load_commons.py @@ -3,6 +3,7 @@ from django.db import migrations from apps.rsform import utils from apps.rsform.models import RSForm +from apps.rsform.serializers import RSFormTRSSerializer from apps.users.models import User @@ -11,7 +12,10 @@ def load_initial_schemas(apps, schema_editor): for subdir, dirs, files in os.walk(rootdir): for file in files: data = utils.read_trs(os.path.join(subdir, file)) - RSForm.create_from_trs(None, data) + data['is_common'] = True + serializer = RSFormTRSSerializer(data=data, context={'load_meta': True}) + serializer.is_valid(raise_exception=True) + serializer.save() def load_initial_users(apps, schema_editor): diff --git a/rsconcept/backend/apps/rsform/models.py b/rsconcept/backend/apps/rsform/models.py index a6d9ae97..2a221e89 100644 --- a/rsconcept/backend/apps/rsform/models.py +++ b/rsconcept/backend/apps/rsform/models.py @@ -1,7 +1,9 @@ ''' Models: RSForms for conceptual schemas. ''' import json -from typing import Iterable, Optional -import pyconcept +from copy import deepcopy +import re +from typing import Iterable, Optional, cast + from django.db import transaction from django.db.models import ( CASCADE, SET_NULL, ForeignKey, Model, PositiveIntegerField, QuerySet, @@ -10,9 +12,16 @@ from django.db.models import ( from django.core.validators import MinValueValidator from django.core.exceptions import ValidationError from django.urls import reverse + +import pyconcept from apps.users.models import User from cctext import Resolver, Entity, extract_entities from .graph import Graph +from .utils import apply_mapping_pattern + + +_REF_ENTITY_PATTERN = re.compile(r'@{([^0-9\-].*?)\|.*?}') +_GLOBAL_ID_PATTERN = re.compile(r'([XCSADFPT][0-9]+)') class CstType(TextChoices): @@ -37,6 +46,26 @@ class Syntax(TextChoices): def _empty_forms(): return [] +def _get_type_prefix(cst_type: CstType) -> str: + ''' Get alias prefix. ''' + if cst_type == CstType.BASE: + return 'X' + if cst_type == CstType.CONSTANT: + return 'C' + if cst_type == CstType.STRUCTURED: + return 'S' + if cst_type == CstType.AXIOM: + return 'A' + if cst_type == CstType.TERM: + return 'D' + if cst_type == CstType.FUNCTION: + return 'F' + if cst_type == CstType.PREDICATE: + return 'P' + if cst_type == CstType.THEOREM: + return 'T' + return 'X' + class RSForm(Model): ''' RSForm is a math form of capturing conceptual schema ''' @@ -134,7 +163,7 @@ class RSForm(Model): alias=alias, cst_type=insert_type ) - self._update_order() + self.update_order() self.save() result.refresh_from_db() return result @@ -151,7 +180,7 @@ class RSForm(Model): alias=alias, cst_type=insert_type ) - self._update_order() + self.update_order() self.save() result.refresh_from_db() return result @@ -177,7 +206,7 @@ class RSForm(Model): count_moved += 1 update_list.append(cst) Constituenta.objects.bulk_update(update_list, ['order']) - self._update_order() + self.update_order() self.save() @transaction.atomic @@ -185,8 +214,8 @@ class RSForm(Model): ''' Delete multiple constituents. Do not check if listCst are from this schema ''' for cst in listCst: cst.delete() - self._update_order() - self._resolve_all_text() + self.update_order() + self.resolve_all_text() self.save() @transaction.atomic @@ -209,89 +238,61 @@ class RSForm(Model): cst.refresh_from_db() return cst - def _insert_new(self, data: dict, insert_after: Optional[str]=None) -> 'Constituenta': - if insert_after is not None: - cstafter = Constituenta.objects.get(pk=insert_after) - return self.insert_at(cstafter.order + 1, data['alias'], data['cst_type']) - else: - return self.insert_last(data['alias'], data['cst_type']) + def reset_aliases(self): + ''' Recreate all aliases based on cst order. ''' + mapping = self._create_reset_mapping() + self._apply_mapping(mapping) + + def _create_reset_mapping(self) -> dict[str, str]: + bases = cast(dict[str, int], {}) + mapping = cast(dict[str, str], {}) + for cst_type in CstType.values: + bases[cst_type] = 1 + cst_list = self.constituents().order_by('order') + for cst in cst_list: + alias = f'{_get_type_prefix(cst.cst_type)}{bases[cst.cst_type]}' + bases[cst.cst_type] += 1 + if cst.alias != alias: + mapping[cst.alias] = alias + return mapping @transaction.atomic - def load_trs(self, data: dict, sync_metadata: bool, skip_update: bool): - if sync_metadata: - self.title = data.get('title', 'Без названия') - self.alias = data.get('alias', '') - self.comment = data.get('comment', '') - order = 1 - prev_constituents = self.constituents() - loaded_ids = set() - for cst_data in data['items']: - uid = int(cst_data['entityUID']) - if prev_constituents.filter(pk=uid).exists(): - cst: Constituenta = prev_constituents.get(pk=uid) - cst.order = order - cst.load_trs(cst_data) + def _apply_mapping(self, mapping: dict[str, str]): + cst_list = self.constituents().order_by('order') + for cst in cst_list: + modified = False + if cst.alias in mapping: + modified = True + cst.alias = mapping[cst.alias] + expression = apply_mapping_pattern(cst.definition_formal, mapping, _GLOBAL_ID_PATTERN) + if expression != cst.definition_formal: + modified = True + cst.definition_formal = expression + convention = apply_mapping_pattern(cst.convention, mapping, _GLOBAL_ID_PATTERN) + if convention != cst.convention: + modified = True + cst.convention = convention + term = apply_mapping_pattern(cst.term_raw, mapping, _REF_ENTITY_PATTERN) + if term != cst.term_raw: + modified = True + cst.term_raw = term + definition = apply_mapping_pattern(cst.definition_raw, mapping, _REF_ENTITY_PATTERN) + if definition != cst.definition_raw: + modified = True + cst.definition_raw = definition + if modified: cst.save() - else: - cst = Constituenta.create_from_trs(cst_data, self, order) - cst.save() - uid = cst.pk - loaded_ids.add(uid) - order += 1 - for prev_cst in prev_constituents: - if prev_cst.pk not in loaded_ids: - prev_cst.delete() - if not skip_update: - self._update_order() - self._resolve_all_text() - self.save() - - @staticmethod - @transaction.atomic - def create_from_trs(owner: User, data: dict, is_common: bool = True) -> 'RSForm': - schema = RSForm.objects.create( - title=data.get('title', 'Без названия'), - owner=owner, - alias=data.get('alias', ''), - comment=data.get('comment', ''), - is_common=is_common - ) - # pylint: disable=protected-access - schema._create_items_from_trs(data['items']) - return schema - - def to_trs(self) -> dict: - ''' Generate JSON string containing all data from RSForm ''' - result = self._prepare_json_rsform() - items = self.constituents().order_by('order') - for cst in items: - result['items'].append(cst.to_trs()) - return result - - def __str__(self): - return self.title - - def get_absolute_url(self): - return reverse('rsform-detail', kwargs={'pk': self.pk}) - - def _prepare_json_rsform(self: 'RSForm') -> dict: - return { - 'type': 'rsform', - 'title': self.title, - 'alias': self.alias, - 'comment': self.comment, - 'items': [] - } @transaction.atomic - def _update_order(self): - checked = json.loads(pyconcept.check_schema(json.dumps(self.to_trs()))) + def update_order(self): + ''' Update constituents order. ''' + checked = PyConceptAdapter(self).basic() update_list = self.constituents().only('id', 'order') if len(checked['items']) != update_list.count(): raise ValidationError('Invalid constituents count') order = 1 for cst in checked['items']: - cst_id = cst['entityUID'] + cst_id = cst['id'] for oldCst in update_list: if oldCst.pk == cst_id: oldCst.order = order @@ -300,14 +301,8 @@ class RSForm(Model): Constituenta.objects.bulk_update(update_list, ['order']) @transaction.atomic - def _create_items_from_trs(self, items): - order = 1 - for cst in items: - cst_object = Constituenta.create_from_trs(cst, self, order) - cst_object.save() - order += 1 - - def _resolve_all_text(self): + def resolve_all_text(self): + ''' Trigger reference resolution for all texts. ''' graph_terms = self._term_graph() resolver = Resolver({}) for alias in graph_terms.topological_order(): @@ -323,6 +318,19 @@ class RSForm(Model): cst.definition_resolved = resolved cst.save() + def _insert_new(self, data: dict, insert_after: Optional[str]=None) -> 'Constituenta': + if insert_after is not None: + cstafter = Constituenta.objects.get(pk=insert_after) + return self.insert_at(cstafter.order + 1, data['alias'], data['cst_type']) + else: + return self.insert_last(data['alias'], data['cst_type']) + + def __str__(self) -> str: + return f'{self.title}' + + def get_absolute_url(self): + return reverse('rsform-detail', kwargs={'pk': self.pk}) + def _term_graph(self) -> Graph: result = Graph() cst_list = self.constituents().only('order', 'alias', 'term_raw').order_by('order') @@ -333,7 +341,7 @@ class RSForm(Model): if result.contains(alias): result.add_edge(id_from=alias, id_to=cst.alias) return result - + def _definition_graph(self) -> Graph: result = Graph() cst_list = self.constituents().only('order', 'alias', 'definition_raw').order_by('order') @@ -413,9 +421,9 @@ class Constituenta(Model): ''' URL access. ''' return reverse('constituenta-detail', kwargs={'pk': self.pk}) - def __str__(self): - return self.alias - + def __str__(self) -> str: + return f'{self.alias}' + def set_term_resolved(self, new_term: str): ''' Set term and reset forms if needed. ''' if new_term == self.term_resolved: @@ -423,61 +431,83 @@ class Constituenta(Model): self.term_resolved = new_term self.term_forms = [] - @staticmethod - def create_from_trs(data: dict, schema: RSForm, order: int) -> 'Constituenta': - ''' Create constituenta from TRS json ''' - cst = Constituenta( - alias=data['alias'], - schema=schema, - order=order, - cst_type=data['cstType'], - ) - # pylint: disable=protected-access - cst._load_texts(data) - return cst +class PyConceptAdapter: + ''' RSForm adapter for interacting with pyconcept module. ''' + def __init__(self, instance: RSForm): + self.schema = instance + self.data = self._prepare_request() + self._checked_data: Optional[dict] = None - def load_trs(self, data: dict): - ''' Load data from TRS json ''' - self.alias = data['alias'] - self.cst_type = data['cstType'] - self._load_texts(data) + def basic(self) -> dict: + ''' Check RSForm and return check results. + Warning! Does not include texts. ''' + self._produce_response() + if self._checked_data is None: + raise ValueError('Invalid data response from pyconcept') + return self._checked_data - def _load_texts(self, data: dict): - self.convention = data.get('convention', '') - if 'definition' in data: - self.definition_formal = data['definition'].get('formal', '') - if 'text' in data['definition']: - self.definition_raw = data['definition']['text'].get('raw', '') - self.definition_resolved = data['definition']['text'].get('resolved', '') - else: - self.definition_raw = '' - self.definition_resolved = '' - if 'term' in data: - self.term_raw = data['term'].get('raw', '') - self.term_resolved = data['term'].get('resolved', '') - self.term_forms = data['term'].get('forms', []) - else: - self.term_raw = '' - self.term_resolved = '' - self.term_forms = [] + def full(self) -> dict: + ''' Check RSForm and return check results including initial texts. ''' + self._produce_response() + if self._checked_data is None: + raise ValueError('Invalid data response from pyconcept') + return self._complete_rsform_details(self._checked_data) - def to_trs(self) -> dict: - return { - 'entityUID': self.pk, - 'type': 'constituenta', - 'cstType': self.cst_type, - 'alias': self.alias, - 'convention': self.convention, - 'term': { - 'raw': self.term_raw, - 'resolved': self.term_resolved, - 'forms': self.term_forms, - }, - 'definition': { - 'formal': self.definition_formal, - 'text': { - 'raw': self.definition_raw, - 'resolved': self.definition_resolved, - }, - }, + def _complete_rsform_details(self, data: dict) -> dict: + result = deepcopy(data) + result['id'] = self.schema.pk + result['alias'] = self.schema.alias + result['title'] = self.schema.title + result['comment'] = self.schema.comment + result['time_update'] = self.schema.time_update + result['time_create'] = self.schema.time_create + result['is_common'] = self.schema.is_common + result['owner'] = (self.schema.owner.pk if self.schema.owner is not None else None) + for cst_data in result['items']: + cst = Constituenta.objects.get(pk=cst_data['id']) + cst_data['convention'] = cst.convention + cst_data['term'] = { + 'raw': cst.term_raw, + 'resolved': cst.term_resolved, + 'forms': cst.term_forms + } + cst_data['definition']['text'] = { + 'raw': cst.definition_raw, + 'resolved': cst.definition_resolved, + } + return result + + def _prepare_request(self) -> dict: + result: dict = { + 'items': [] } + items = self.schema.constituents().order_by('order') + for cst in items: + result['items'].append({ + 'entityUID': cst.pk, + 'cstType': cst.cst_type, + 'alias': cst.alias, + 'definition': { + 'formal': cst.definition_formal + } + }) + return result + + def _produce_response(self): + if self._checked_data is not None: + return + response = pyconcept.check_schema(json.dumps(self.data)) + data = json.loads(response) + self._checked_data = { + 'items': [] + } + for cst in data['items']: + self._checked_data['items'].append({ + 'id': cst['entityUID'], + 'cstType': cst['cstType'], + 'alias': cst['alias'], + 'definition': { + 'formal': cst['definition']['formal'] + }, + 'parse': cst['parse'] + }) diff --git a/rsconcept/backend/apps/rsform/serializers.py b/rsconcept/backend/apps/rsform/serializers.py index ca7ea826..f48f3b16 100644 --- a/rsconcept/backend/apps/rsform/serializers.py +++ b/rsconcept/backend/apps/rsform/serializers.py @@ -1,35 +1,28 @@ ''' Serializers for conceptual schema API. ''' -import json from typing import Optional from rest_framework import serializers +from django.db import transaction -import pyconcept from .models import Constituenta, RSForm +_CST_TYPE = 'constituenta' +_TRS_TYPE = 'rsform' +_TRS_VERSION_MIN = 16 +_TRS_VERSION = 16 +_TRS_HEADER = 'Exteor 4.8.13.1000 - 30/05/2022' + class FileSerializer(serializers.Serializer): ''' Serializer: File input. ''' file = serializers.FileField(allow_empty_file=False) - def create(self, validated_data): - raise NotImplementedError('unexpected `create()` call') - - def update(self, instance, validated_data): - raise NotImplementedError('unexpected `update()` call') - class ExpressionSerializer(serializers.Serializer): ''' Serializer: RSLang expression. ''' expression = serializers.CharField() - def create(self, validated_data): - raise NotImplementedError('unexpected `create()` call') - def update(self, instance, validated_data): - raise NotImplementedError('unexpected `update()` call') - - -class RSFormSerializer(serializers.ModelSerializer): +class RSFormMetaSerializer(serializers.ModelSerializer): ''' Serializer: General purpose RSForm data. ''' class Meta: ''' serializer metadata. ''' @@ -43,12 +36,6 @@ class RSFormUploadSerializer(serializers.Serializer): file = serializers.FileField() load_metadata = serializers.BooleanField() - def create(self, validated_data): - raise NotImplementedError('unexpected `create()` call') - - def update(self, instance, validated_data): - raise NotImplementedError('unexpected `update()` call') - class RSFormContentsSerializer(serializers.ModelSerializer): ''' Serializer: Detailed data for RSForm. ''' @@ -57,35 +44,168 @@ class RSFormContentsSerializer(serializers.ModelSerializer): model = RSForm def to_representation(self, instance: RSForm): - result = RSFormSerializer(instance).data + result = RSFormMetaSerializer(instance).data result['items'] = [] for cst in instance.constituents().order_by('order'): result['items'].append(ConstituentaSerializer(cst).data) return result -class RSFormDetailsSerlializer(serializers.BaseSerializer): - ''' Serializer: Processed data for RSForm. ''' +class RSFormTRSSerializer(serializers.Serializer): + ''' Serializer: TRS file production and loading for RSForm. ''' class Meta: ''' serializer metadata. ''' model = RSForm - def to_representation(self, instance: RSForm): - trs = pyconcept.check_schema(json.dumps(instance.to_trs())) - trs = trs.replace('entityUID', 'id') - result = json.loads(trs) - result['id'] = instance.pk - result['time_update'] = instance.time_update - result['time_create'] = instance.time_create - result['is_common'] = instance.is_common - result['owner'] = (instance.owner.pk if instance.owner is not None else None) + def to_representation(self, instance: RSForm) -> dict: + result = self._prepare_json_rsform(instance) + items = instance.constituents().order_by('order') + for cst in items: + result['items'].append(self._prepare_json_constituenta(cst)) return result - def create(self, validated_data): - raise NotImplementedError('unexpected `create()` call') + @staticmethod + def _prepare_json_rsform(schema: RSForm) -> dict: + return { + 'type': _TRS_TYPE, + 'title': schema.title, + 'alias': schema.alias, + 'comment': schema.comment, + 'items': [], + 'claimed': False, + 'selection': [], + 'version': _TRS_VERSION, + 'versionInfo': _TRS_HEADER + } - def update(self, instance, validated_data): - raise NotImplementedError('unexpected `update()` call') + @staticmethod + def _prepare_json_constituenta(cst: Constituenta) -> dict: + return { + 'entityUID': cst.pk, + 'type': _CST_TYPE, + 'cstType': cst.cst_type, + 'alias': cst.alias, + 'convention': cst.convention, + 'term': { + 'raw': cst.term_raw, + 'resolved': cst.term_resolved, + 'forms': cst.term_forms + }, + 'definition': { + 'formal': cst.definition_formal, + 'text': { + 'raw': cst.definition_raw, + 'resolved': cst.definition_resolved + }, + }, + } + + def to_internal_value(self, data): + result = super().to_internal_value(data) + if 'owner' in data: + result['owner'] = data['owner'] + if 'is_common' in data: + result['is_common'] = data['is_common'] + result['items'] = data.get('items', []) + if self.context['load_meta']: + result['title'] = data.get('title', 'Без названия') + result['alias'] = data.get('alias', '') + result['comment']= data.get('comment', '') + if 'id' in data: + result['id'] = data['id'] + self.instance = RSForm.objects.get(pk=result['id']) + return result + + def validate(self, attrs: dict): + if 'version' not in self.initial_data \ + or self.initial_data['version'] < _TRS_VERSION_MIN \ + or self.initial_data['version'] > _TRS_VERSION: + raise serializers.ValidationError({ + 'version': 'Некорректная версия файла Экстеор. Пересохраните файл в новой версии' + }) + return attrs + + @transaction.atomic + def create(self, validated_data: dict) -> RSForm: + self.instance = RSForm( + owner=validated_data.get('owner', None), + alias=validated_data['alias'], + title=validated_data['title'], + comment=validated_data['comment'], + is_common=validated_data['is_common'] + ) + self.instance.save() + order = 1 + for cst_data in validated_data['items']: + cst = Constituenta( + alias=cst_data['alias'], + schema=self.instance, + order=order, + cst_type=cst_data['cstType'], + ) + self._load_cst_texts(cst, cst_data) + cst.save() + order += 1 + self.instance.resolve_all_text() + return self.instance + + @transaction.atomic + def update(self, instance: RSForm, validated_data) -> RSForm: + if 'alias' in validated_data: + instance.alias = validated_data['alias'] + if 'title' in validated_data: + instance.title = validated_data['title'] + if 'comment' in validated_data: + instance.comment = validated_data['comment'] + + order = 1 + prev_constituents = instance.constituents() + loaded_ids = set() + for cst_data in validated_data['items']: + uid = int(cst_data['entityUID']) + if prev_constituents.filter(pk=uid).exists(): + cst: Constituenta = prev_constituents.get(pk=uid) + cst.order = order + cst.alias = cst_data['alias'] + cst.cst_type = cst_data['cstType'] + self._load_cst_texts(cst, cst_data) + cst.save() + else: + cst = Constituenta( + alias=cst_data['alias'], + schema=instance, + order=order, + cst_type=cst_data['cstType'], + ) + self._load_cst_texts(cst, cst_data) + cst.save() + uid = cst.pk + loaded_ids.add(uid) + order += 1 + for prev_cst in prev_constituents: + if prev_cst.pk not in loaded_ids: + prev_cst.delete() + + instance.update_order() + instance.resolve_all_text() + instance.save() + return instance + + @staticmethod + def _load_cst_texts(cst: Constituenta, data: dict): + cst.convention = data.get('convention', '') + if 'definition' in data: + cst.definition_formal = data['definition'].get('formal', '') + if 'text' in data['definition']: + cst.definition_raw = data['definition']['text'].get('raw', '') + else: + cst.definition_raw = '' + if 'term' in data: + cst.term_raw = data['term'].get('raw', '') + cst.term_forms = data['term'].get('forms', []) + else: + cst.term_raw = '' + cst.term_forms = [] class ConstituentaSerializer(serializers.ModelSerializer): @@ -116,7 +236,7 @@ class ConstituentaSerializer(serializers.ModelSerializer): return result -class StandaloneCstSerializer(serializers.ModelSerializer): +class CstStandaloneSerializer(serializers.ModelSerializer): ''' Serializer: Constituenta in current context. ''' id = serializers.IntegerField() @@ -146,7 +266,7 @@ class CstCreateSerializer(serializers.ModelSerializer): class CstListSerlializer(serializers.Serializer): ''' Serializer: List of constituents from one origin. ''' items = serializers.ListField( - child=StandaloneCstSerializer() + child=CstStandaloneSerializer() ) def validate(self, attrs): @@ -161,19 +281,7 @@ class CstListSerlializer(serializers.Serializer): attrs['constituents'] = cstList return attrs - def create(self, validated_data): - raise NotImplementedError('unexpected `create()` call') - - def update(self, instance, validated_data): - raise NotImplementedError('unexpected `update()` call') - class CstMoveSerlializer(CstListSerlializer): ''' Serializer: Change constituenta position. ''' move_to = serializers.IntegerField() - - def create(self, validated_data): - raise NotImplementedError('unexpected `create()` call') - - def update(self, instance, validated_data): - raise NotImplementedError('unexpected `update()` call') diff --git a/rsconcept/backend/apps/rsform/tests/__init__.py b/rsconcept/backend/apps/rsform/tests/__init__.py index 48cc1f6b..ec2efcc7 100644 --- a/rsconcept/backend/apps/rsform/tests/__init__.py +++ b/rsconcept/backend/apps/rsform/tests/__init__.py @@ -4,3 +4,4 @@ from .t_views import * from .t_models import * from .t_serializers import * from .t_graph import * +from .t_utils import * diff --git a/rsconcept/backend/apps/rsform/tests/t_models.py b/rsconcept/backend/apps/rsform/tests/t_models.py index 74b4b5f7..669e0837 100644 --- a/rsconcept/backend/apps/rsform/tests/t_models.py +++ b/rsconcept/backend/apps/rsform/tests/t_models.py @@ -227,64 +227,30 @@ class TestRSForm(TestCase): self.assertEqual(x1.order, 2) self.assertEqual(x2.order, 1) - def test_to_trs(self): - schema = RSForm.objects.create(title='Test', alias='KS1', comment='Test') - x1 = schema.insert_at(4, 'X1', CstType.BASE) - x2 = schema.insert_at(1, 'X2', CstType.BASE) - expected = json.loads( - f'{{"type": "rsform", "title": "Test", "alias": "KS1", ' - f'"comment": "Test", "items": ' - f'[{{"entityUID": {x2.id}, "type": "constituenta", "cstType": "basic", "alias": "X2", "convention": "", ' - f'"term": {{"raw": "", "resolved": "", "forms": []}}, ' - f'"definition": {{"formal": "", "text": {{"raw": "", "resolved": ""}}}}}}, ' - f'{{"entityUID": {x1.id}, "type": "constituenta", "cstType": "basic", "alias": "X1", "convention": "", ' - f'"term": {{"raw": "", "resolved": "", "forms": []}}, ' - f'"definition": {{"formal": "", "text": {{"raw": "", "resolved": ""}}}}}}]}}' - ) - self.assertEqual(schema.to_trs(), expected) + def test_reset_aliases(self): + schema = RSForm.objects.create(title='Test') + x1 = schema.insert_last('X11', CstType.BASE) + x2 = schema.insert_last('X21', CstType.BASE) + d1 = schema.insert_last('D11', CstType.TERM) + x1.term_raw = 'человек' + x1.term_resolved = 'человек' + d1.convention = 'D11 - cool' + d1.definition_formal = 'X21=X21' + d1.term_raw = '@{X21|sing}' + d1.definition_raw = '@{X11|datv}' + d1.definition_resolved = 'test' + d1.save() + x1.save() - def test_create_from_trs(self): - input = json.loads( - '{"type": "rsform", "title": "Test", "alias": "KS1", ' - '"comment": "Test", "items": ' - '[{"entityUID": 1337, "type": "constituenta", "cstType": "basic", "alias": "X1", "convention": "", ' - '"term": {"raw": "", "resolved": ""}, ' - '"definition": {"formal": "123", "text": {"raw": "", "resolved": ""}}}, ' - '{"entityUID": 55, "type": "constituenta", "cstType": "basic", "alias": "X2", "convention": "", ' - '"term": {"raw": "", "resolved": ""}, ' - '"definition": {"formal": "", "text": {"raw": "", "resolved": ""}}}]}' - ) - schema = RSForm.create_from_trs(self.user1, input, False) - self.assertEqual(schema.owner, self.user1) - self.assertEqual(schema.title, 'Test') - self.assertEqual(schema.alias, 'KS1') - self.assertEqual(schema.is_common, False) - constituents = schema.constituents().order_by('order') - self.assertEqual(constituents.count(), 2) - self.assertEqual(constituents[0].alias, 'X1') - self.assertEqual(constituents[0].definition_formal, '123') - - def test_load_trs(self): - schema = RSForm.objects.create(title='Test', owner=self.user1, alias='КС1') - x2 = schema.insert_last('X2', CstType.BASE) - schema.insert_last('X3', CstType.BASE) - input = json.loads( - '{"title": "Test1", "alias": "KS1", ' - '"comment": "Test", "items": ' - '[{"entityUID": "' + str(x2.id) + '", "cstType": "basic", "alias": "X1", "convention": "test", ' - '"term": {"raw": "t1", "resolved": "t2"}, ' - '"definition": {"formal": "123", "text": {"raw": "@{X1|datv}", "resolved": "t4"}}}]}' - ) - schema.load_trs(input, sync_metadata=True, skip_update=True) + schema.reset_aliases() + x1.refresh_from_db() x2.refresh_from_db() - self.assertEqual(schema.constituents().count(), 1) - self.assertEqual(schema.title, input['title']) - self.assertEqual(schema.alias, input['alias']) - self.assertEqual(schema.comment, input['comment']) - self.assertEqual(x2.alias, input['items'][0]['alias']) - self.assertEqual(x2.convention, input['items'][0]['convention']) - self.assertEqual(x2.term_raw, input['items'][0]['term']['raw']) - self.assertEqual(x2.term_resolved, input['items'][0]['term']['raw']) - self.assertEqual(x2.definition_formal, input['items'][0]['definition']['formal']) - self.assertEqual(x2.definition_raw, input['items'][0]['definition']['text']['raw']) - self.assertEqual(x2.definition_resolved, input['items'][0]['term']['raw']) + d1.refresh_from_db() + + self.assertEqual(x1.alias, 'X1') + self.assertEqual(x2.alias, 'X2') + self.assertEqual(d1.alias, 'D1') + self.assertEqual(d1.convention, 'D1 - cool') + self.assertEqual(d1.term_raw, '@{X2|sing}') + self.assertEqual(d1.definition_raw, '@{X1|datv}') + self.assertEqual(d1.definition_resolved, 'test') diff --git a/rsconcept/backend/apps/rsform/tests/t_utils.py b/rsconcept/backend/apps/rsform/tests/t_utils.py new file mode 100644 index 00000000..808ad85f --- /dev/null +++ b/rsconcept/backend/apps/rsform/tests/t_utils.py @@ -0,0 +1,16 @@ +''' Unit tests: utils. ''' +import unittest +import re + +from apps.rsform.utils import apply_mapping_pattern + + +class TestUtils(unittest.TestCase): + ''' Test various utilitiy functions. ''' + def test_apply_mapping_patter(self): + mapping = {'X101': 'X20'} + pattern = re.compile(r'(X[0-9]+)') + self.assertEqual(apply_mapping_pattern('', mapping, pattern), '') + self.assertEqual(apply_mapping_pattern('X20', mapping, pattern), 'X20') + self.assertEqual(apply_mapping_pattern('X101', mapping, pattern), 'X20') + self.assertEqual(apply_mapping_pattern('asdf X101 asdf', mapping, pattern), 'asdf X20 asdf') diff --git a/rsconcept/backend/apps/rsform/utils.py b/rsconcept/backend/apps/rsform/utils.py index 3729821b..4f36b491 100644 --- a/rsconcept/backend/apps/rsform/utils.py +++ b/rsconcept/backend/apps/rsform/utils.py @@ -1,6 +1,7 @@ ''' Utility functions ''' import json from io import BytesIO +import re from zipfile import ZipFile from rest_framework.permissions import BasePermission @@ -35,13 +36,24 @@ def read_trs(file) -> dict: def write_trs(json_data: dict) -> bytes: ''' Write json data to TRS file including version info ''' - json_data["claimed"] = False - json_data["selection"] = [] - json_data["version"] = 16 - json_data["versionInfo"] = "Exteor 4.8.13.1000 - 30/05/2022" - content = BytesIO() data = json.dumps(json_data, indent=4, ensure_ascii=False) with ZipFile(content, 'w') as archive: archive.writestr('document.json', data=data) return content.getvalue() + +def apply_mapping_pattern(text: str, mapping: dict[str, str], pattern: re.Pattern[str]) -> str: + ''' Apply mapping to matching in regular expression patter subgroup 1. ''' + if text == '' or pattern == '': + return text + pos_input: int = 0 + output: str = '' + for segment in re.finditer(pattern, text): + entity = segment.group(1) + if entity in mapping: + output += text[pos_input : segment.start(1)] + output += mapping[entity] + output += text[segment.end(1) : segment.end(0)] + pos_input = segment.end(0) + output += text[pos_input : len(text)] + return output diff --git a/rsconcept/backend/apps/rsform/views.py b/rsconcept/backend/apps/rsform/views.py index 36ba729e..c8b23a42 100644 --- a/rsconcept/backend/apps/rsform/views.py +++ b/rsconcept/backend/apps/rsform/views.py @@ -17,7 +17,7 @@ from . import utils class LibraryView(generics.ListAPIView): ''' Endpoint: Get list of rsforms available for active user. ''' permission_classes = (permissions.AllowAny,) - serializer_class = serializers.RSFormSerializer + serializer_class = serializers.RSFormMetaSerializer def get_queryset(self): user = self.request.user @@ -45,7 +45,7 @@ class ConstituentAPIView(generics.RetrieveUpdateAPIView): class RSFormViewSet(viewsets.ModelViewSet): ''' Endpoint: RSForm operations. ''' queryset = models.RSForm.objects.all() - serializer_class = serializers.RSFormSerializer + serializer_class = serializers.RSFormMetaSerializer filter_backends = (DjangoFilterBackend, filters.OrderingFilter) filterset_fields = ['owner', 'is_common'] @@ -80,10 +80,9 @@ class RSFormViewSet(viewsets.ModelViewSet): data = serializer.validated_data new_cst = schema.create_cst(data, data['insert_after'] if 'insert_after' in data else None) schema.refresh_from_db() - outSerializer = serializers.RSFormDetailsSerlializer(schema) response = Response(status=201, data={ 'new_cst': serializers.ConstituentaSerializer(new_cst).data, - 'schema': outSerializer.data + 'schema': models.PyConceptAdapter(schema).full() }) response['Location'] = new_cst.get_absolute_url() return response @@ -96,8 +95,7 @@ class RSFormViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) schema.delete_cst(serializer.validated_data['constituents']) schema.refresh_from_db() - outSerializer = serializers.RSFormDetailsSerlializer(schema) - return Response(status=202, data=outSerializer.data) + return Response(status=202, data=models.PyConceptAdapter(schema).full()) @action(detail=True, methods=['patch'], url_path='cst-moveto') def cst_moveto(self, request, pk): @@ -107,17 +105,14 @@ class RSFormViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to']) schema.refresh_from_db() - outSerializer = serializers.RSFormDetailsSerlializer(schema) - return Response(status=200, data=outSerializer.data) + return Response(status=200, data=models.PyConceptAdapter(schema).full()) @action(detail=True, methods=['patch'], url_path='reset-aliases') def reset_aliases(self, request, pk): ''' Endpoint: Recreate all aliases based on order. ''' schema = self._get_schema() - result = json.loads(pyconcept.reset_aliases(json.dumps(schema.to_trs()))) - schema.load_trs(data=result, sync_metadata=False, skip_update=True) - outSerializer = serializers.RSFormDetailsSerlializer(schema) - return Response(status=200, data=outSerializer.data) + schema.reset_aliases() + return Response(status=200, data=models.PyConceptAdapter(schema).full()) @action(detail=True, methods=['patch'], url_path='load-trs') def load_trs(self, request, pk): @@ -127,25 +122,30 @@ class RSFormViewSet(viewsets.ModelViewSet): schema = self._get_schema() load_metadata = serializer.validated_data['load_metadata'] data = utils.read_trs(request.FILES['file'].file) - schema.load_trs(data, load_metadata, skip_update=False) - outSerializer = serializers.RSFormDetailsSerlializer(schema) - return Response(status=200, data=outSerializer.data) + data['id'] = schema.pk + + serializer = serializers.RSFormTRSSerializer(data=data, context={'load_meta': load_metadata}) + serializer.is_valid(raise_exception=True) + schema = serializer.save() + return Response(status=200, data=models.PyConceptAdapter(schema).full()) @action(detail=True, methods=['post'], url_path='clone') def clone(self, request, pk): ''' Endpoint: Clone RSForm constituents and create new schema using new metadata. ''' - serializer = serializers.RSFormSerializer(data=request.data) + serializer = serializers.RSFormMetaSerializer(data=request.data) serializer.is_valid(raise_exception=True) - new_schema = models.RSForm.objects.create( - title=serializer.validated_data['title'], - owner=self.request.user, - alias=serializer.validated_data.get('alias', ''), - comment=serializer.validated_data.get('comment', ''), - is_common=serializer.validated_data.get('is_common', False), - ) - new_schema.load_trs(data=self._get_schema().to_trs(), sync_metadata=False, skip_update=True) - outSerializer = serializers.RSFormDetailsSerlializer(new_schema) - return Response(status=201, data=outSerializer.data) + + clone_data = serializers.RSFormTRSSerializer(self._get_schema()).data + clone_data['owner'] = self.request.user + clone_data['title'] = serializer.validated_data['title'] + clone_data['alias'] = serializer.validated_data.get('alias', '') + clone_data['comment'] = serializer.validated_data.get('comment', '') + clone_data['is_common'] = serializer.validated_data.get('is_common', False) + + clone = serializers.RSFormTRSSerializer(data=clone_data, context={'load_meta': True}) + clone.is_valid(raise_exception=True) + new_schema = clone.save() + return Response(status=201, data=models.PyConceptAdapter(new_schema).full()) @action(detail=True, methods=['post']) def claim(self, request, pk=None): @@ -156,7 +156,7 @@ class RSFormViewSet(viewsets.ModelViewSet): else: schema.owner = self.request.user schema.save() - return Response(status=200, data=serializers.RSFormSerializer(schema).data) + return Response(status=200, data=serializers.RSFormMetaSerializer(schema).data) @action(detail=True, methods=['get']) def contents(self, request, pk): @@ -166,25 +166,25 @@ class RSFormViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['get']) def details(self, request, pk): - ''' Endpoint: Detailed schema view including statuses. ''' + ''' Endpoint: Detailed schema view including statuses and parse. ''' schema = self._get_schema() - serializer = serializers.RSFormDetailsSerlializer(schema) - return Response(serializer.data) + serializer = models.PyConceptAdapter(schema) + return Response(serializer.full()) @action(detail=True, methods=['post']) def check(self, request, pk): ''' Endpoint: Check RSLang expression against schema context. ''' - schema = self._get_schema().to_trs() + schema = models.PyConceptAdapter(self._get_schema()) serializer = serializers.ExpressionSerializer(data=request.data) serializer.is_valid(raise_exception=True) expression = serializer.validated_data['expression'] - result = pyconcept.check_expression(json.dumps(schema), expression) + result = pyconcept.check_expression(json.dumps(schema.data), expression) return Response(json.loads(result)) @action(detail=True, methods=['get'], url_path='export-trs') def export_trs(self, request, pk): ''' Endpoint: Download Exteor compatible file. ''' - schema = self._get_schema().to_trs() + schema = serializers.RSFormTRSSerializer(self._get_schema()).data trs = utils.write_trs(schema) filename = self._get_schema().alias if filename == '' or not filename.isascii(): @@ -207,8 +207,11 @@ class TrsImportView(views.APIView): owner = self.request.user if owner.is_anonymous: owner = None - schema = models.RSForm.create_from_trs(owner, data) - result = serializers.RSFormSerializer(schema) + _prepare_rsform_data(data, request, owner) + serializer = serializers.RSFormTRSSerializer(data=data, context={'load_meta': True}) + serializer.is_valid(raise_exception=True) + schema = serializer.save() + result = serializers.RSFormMetaSerializer(schema) return Response(status=201, data=result.data) @@ -219,7 +222,7 @@ def create_rsform(request): if owner.is_anonymous: owner = None if 'file' not in request.FILES: - serializer = serializers.RSFormSerializer(data=request.data) + serializer = serializers.RSFormMetaSerializer(data=request.data) serializer.is_valid(raise_exception=True) schema = models.RSForm.objects.create( title=serializer.validated_data['title'], @@ -230,21 +233,27 @@ def create_rsform(request): ) else: data = utils.read_trs(request.FILES['file'].file) - if 'title' in request.data and request.data['title'] != '': - data['title'] = request.data['title'] - if data['title'] == '': - data['title'] = 'Без названия ' + request.FILES['file'].fileName - if 'alias' in request.data and request.data['alias'] != '': - data['alias'] = request.data['alias'] - if 'comment' in request.data and request.data['comment'] != '': - data['comment'] = request.data['comment'] - is_common = True - if 'is_common' in request.data: - is_common = request.data['is_common'] == 'true' - schema = models.RSForm.create_from_trs(owner, data, is_common) - result = serializers.RSFormSerializer(schema) + _prepare_rsform_data(data, request, owner) + serializer = serializers.RSFormTRSSerializer(data=data, context={'load_meta': True}) + serializer.is_valid(raise_exception=True) + schema = serializer.save() + result = serializers.RSFormMetaSerializer(schema) return Response(status=201, data=result.data) +def _prepare_rsform_data(data: dict, request, owner: models.User): + if 'title' in request.data and request.data['title'] != '': + data['title'] = request.data['title'] + if data['title'] == '': + data['title'] = 'Без названия ' + request.FILES['file'].fileName + if 'alias' in request.data and request.data['alias'] != '': + data['alias'] = request.data['alias'] + if 'comment' in request.data and request.data['comment'] != '': + data['comment'] = request.data['comment'] + is_common = True + if 'is_common' in request.data: + is_common = request.data['is_common'] == 'true' + data['is_common'] = is_common + data['owner'] = owner @api_view(['POST']) def parse_expression(request): diff --git a/rsconcept/backend/apps/users/tests/__init__.py b/rsconcept/backend/apps/users/tests/__init__.py index c52ff2c0..1236f318 100644 --- a/rsconcept/backend/apps/users/tests/__init__.py +++ b/rsconcept/backend/apps/users/tests/__init__.py @@ -1,4 +1,3 @@ ''' Tests. ''' -# flake8: noqa from .t_views import * from .t_serializers import * diff --git a/rsconcept/frontend/src/App.tsx b/rsconcept/frontend/src/App.tsx index a0abb79f..b4c7b1e1 100644 --- a/rsconcept/frontend/src/App.tsx +++ b/rsconcept/frontend/src/App.tsx @@ -43,7 +43,7 @@ function App () { />