Refactor pyconcept interaction and minor UI fixes

This commit is contained in:
IRBorisov 2023-08-22 17:52:59 +03:00
parent 05645a29e8
commit cff07788e5
19 changed files with 493 additions and 336 deletions

View File

@ -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

View File

@ -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):

View File

@ -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')
@ -413,8 +421,8 @@ 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. '''
@ -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']
})

View File

@ -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')

View File

@ -4,3 +4,4 @@ from .t_views import *
from .t_models import *
from .t_serializers import *
from .t_graph import *
from .t_utils import *

View File

@ -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')

View File

@ -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')

View File

@ -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

View File

@ -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,6 +233,14 @@ def create_rsform(request):
)
else:
data = utils.read_trs(request.FILES['file'].file)
_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'] == '':
@ -241,10 +252,8 @@ def create_rsform(request):
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)
return Response(status=201, data=result.data)
data['is_common'] = is_common
data['owner'] = owner
@api_view(['POST'])
def parse_expression(request):

View File

@ -1,4 +1,3 @@
''' Tests. '''
# flake8: noqa
from .t_views import *
from .t_serializers import *

View File

@ -43,7 +43,7 @@ function App () {
/>
<div className='overflow-auto' style={{maxHeight: scrollWindowSize}}>
<main className='px-2' style={{minHeight: mainSize}}>
<main style={{minHeight: mainSize}}>
<Routes>
<Route path='/' element={ <HomePage/>} />

View File

@ -1,14 +1,20 @@
interface SubmitButtonProps {
text: string
interface SubmitButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'children' | 'title'> {
text?: string
tooltip?: string
loading?: boolean
disabled?: boolean
icon?: React.ReactNode
widthClass?: string
}
function SubmitButton({ text = 'ОК', icon, disabled, loading = false }: SubmitButtonProps) {
function SubmitButton({
text = 'ОК', icon, disabled, tooltip, loading,
widthClass = 'w-fit h-fit'
}: SubmitButtonProps) {
return (
<button type='submit'
className={`px-4 py-2 inline-flex items-center gap-2 align-middle justify-center font-bold select-none disabled:cursor-not-allowed border rounded clr-btn-primary ${loading ? ' cursor-progress' : ''}`}
title={tooltip}
className={`px-4 py-2 inline-flex items-center gap-2 align-middle justify-center font-bold select-none disabled:cursor-not-allowed border rounded clr-btn-primary ${widthClass} ${loading ? ' cursor-progress' : ''}`}
disabled={disabled ?? loading}
>
{icon && <span>{icon}</span>}

View File

@ -7,7 +7,7 @@ interface TextURLProps {
function TextURL({ text, href }: TextURLProps) {
return (
<Link className='font-bold hover:underline clr-text' to={href}>
<Link className='font-bold hover:underline text-url' to={href}>
{text}
</Link>
);

View File

@ -68,8 +68,9 @@ function ViewLibrary({ schemas }: ViewLibraryProps) {
highlightOnHover
pointerOnHover
noDataComponent={<span className='flex flex-col justify-center p-2 text-center'>
<p>Список схем пуст</p>
noDataComponent={
<div className='flex flex-col gap-4 justify-center p-2 text-center min-h-[10rem]'>
<p><b>Список схем пуст</b></p>
<p>
<TextURL text='Создать схему' href='/rsform-create'/>
<span> | </span>
@ -79,7 +80,7 @@ function ViewLibrary({ schemas }: ViewLibraryProps) {
<b>Очистить фильтр</b>
</span>
</p>
</span>}
</div>}
pagination
paginationPerPage={50}

View File

@ -39,7 +39,7 @@ function LoginPage() {
}
return (
<div className='w-full py-2'> { user
<div className='w-full py-4'> { user
? <b>{`Вы вошли в систему как ${user.username}`}</b>
: <Form title='Ввод данных пользователя' onSubmit={handleSubmit} widthClass='w-[21rem]'>
<TextInput id='username'
@ -58,11 +58,15 @@ function LoginPage() {
onChange={event => { setPassword(event.target.value); }}
/>
<div className='flex items-center justify-between mt-4'>
<SubmitButton text='Вход' loading={loading}/>
<TextURL text='Восстановить пароль...' href='/restore-password' />
<div className='flex justify-center w-full gap-2 mt-4'>
<SubmitButton
text='Вход'
widthClass='w-[7rem]'
loading={loading}
/>
</div>
<div className='mt-2'>
<div className='flex flex-col mt-2 text-sm'>
<TextURL text='Восстановить пароль...' href='/restore-password' />
<TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' />
</div>
{ error && <BackendError error={error} />}

View File

@ -217,7 +217,7 @@ function RSTabs() {
defaultFocus={true}
selectedTabClassName='font-bold'
>
<TabList className='flex items-start select-none w-fit clr-bg-pop'>
<TabList className='flex items-start px-2 select-none w-fit clr-bg-pop'>
<RSTabsMenu
onDestroy={onDestroySchema}
showCloneDialog={() => setShowClone(true)}
@ -232,7 +232,7 @@ function RSTabs() {
<ConceptTab>Граф термов</ConceptTab>
</TabList>
<TabPanel className='flex items-start w-full gap-2'>
<TabPanel className='flex items-start w-full gap-2 px-2'>
<EditorRSForm
onDestroy={onDestroySchema}
/>
@ -247,7 +247,7 @@ function RSTabs() {
/>
</TabPanel>
<TabPanel>
<TabPanel className='pl-2'>
<EditorConstituenta
activeID={activeID}
onOpenEdit={onOpenCst}
@ -257,7 +257,7 @@ function RSTabs() {
/>
</TabPanel>
<TabPanel>
<TabPanel className='pl-2'>
<EditorTermGraph
onOpenEdit={onOpenCst}
onCreateCst={promptCreateCst}

View File

@ -21,7 +21,7 @@ function DependencyModePicker({ value, onChange }: DependencyModePickerProps) {
}, [pickerMenu, onChange]);
return (
<div ref={pickerMenu.ref}>
<div ref={pickerMenu.ref} className='h-full'>
<span
className='text-sm font-semibold underline cursor-pointer select-none whitespace-nowrap'
tabIndex={-1}

View File

@ -21,7 +21,7 @@ function MatchModePicker({ value, onChange }: MatchModePickerProps) {
}, [pickerMenu, onChange]);
return (
<div ref={pickerMenu.ref}>
<div ref={pickerMenu.ref} className='h-full'>
<span
className='text-sm font-semibold underline cursor-pointer select-none whitespace-nowrap'
tabIndex={-1}

View File

@ -152,13 +152,13 @@ function ViewSideConstituents({ expression, baseHeight, activeID, onOpenEdit }:
}, [noNavigation, baseHeight]);
return (<>
<div className='sticky top-0 left-0 right-0 z-10 flex items-center justify-between w-full gap-1 px-2 py-1 bg-white border-b rounded clr-bg-pop clr-border'>
<div className='sticky top-0 left-0 right-0 z-10 flex items-start justify-between w-full gap-1 px-2 py-1 bg-white border-b rounded clr-bg-pop clr-border'>
<MatchModePicker
value={filterMatch}
onChange={setFilterMatch}
/>
<input type='text'
className='w-full px-2 bg-white outline-none select-none hover:text-clip clr-bg-pop clr-border'
className='w-full px-2 bg-white outline-none select-none hover:text-clip clr-bg-pop'
placeholder='наберите текст фильтра'
value={filterText}
onChange={event => setFilterText(event.target.value)}