Add minimal text resolution capabilities to API

This commit is contained in:
IRBorisov 2023-08-21 00:25:12 +03:00
parent 3360facd10
commit bf8af6317a
6 changed files with 106 additions and 53 deletions

View File

@ -1,5 +1,6 @@
''' Models: RSForms for conceptual schemas. ''' ''' Models: RSForms for conceptual schemas. '''
import json import json
from typing import Optional
import pyconcept import pyconcept
from django.db import transaction from django.db import transaction
from django.db.models import ( from django.db.models import (
@ -10,6 +11,7 @@ from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from apps.users.models import User from apps.users.models import User
from cctext import Resolver, Entity
class CstType(TextChoices): class CstType(TextChoices):
@ -73,10 +75,34 @@ class RSForm(Model):
verbose_name = 'Схема' verbose_name = 'Схема'
verbose_name_plural = 'Схемы' verbose_name_plural = 'Схемы'
def constituents(self) -> QuerySet: def constituents(self) -> QuerySet['Constituenta']:
''' Get QuerySet containing all constituents of current RSForm ''' ''' Get QuerySet containing all constituents of current RSForm '''
return Constituenta.objects.filter(schema=self) return Constituenta.objects.filter(schema=self)
def resolver(self) -> Resolver:
''' Create resolver for text references based on schema terms. '''
result = Resolver({})
for cst in self.constituents():
entity = Entity(alias=cst.alias, nominal=cst.term_resolved, manual_forms=cst.term_forms)
result.context[cst.alias] = entity
return result
@transaction.atomic
def on_term_change(self, alias: str):
''' Trigger cascade resolutions when term changes. '''
pass
# void Thesaurus::OnTermChange(const EntityUID target) {
# auto expansion = TermGraph().ExpandOutputs({ target });
# const auto ordered = TermGraph().Sort(expansion);
# for (const auto entity : ordered) {
# storage.at(entity).term.UpdateFrom(Context());
# }
# expansion = DefGraph().ExpandOutputs(expansion);
# for (const auto entity : expansion) {
# storage.at(entity).definition.UpdateFrom(Context());
# }
@transaction.atomic @transaction.atomic
def insert_at(self, position: int, alias: str, insert_type: CstType) -> 'Constituenta': def insert_at(self, position: int, alias: str, insert_type: CstType) -> 'Constituenta':
''' Insert new constituenta at given position. All following constituents order is shifted by 1 position ''' ''' Insert new constituenta at given position. All following constituents order is shifted by 1 position '''
@ -147,6 +173,30 @@ class RSForm(Model):
self._update_from_core() self._update_from_core()
self.save() self.save()
@transaction.atomic
def create_cst(self, data: dict, insert_after: Optional[str]=None) -> 'Constituenta':
''' Create new cst from data. '''
resolver = self.resolver()
cst = self._insert_new(data, insert_after)
cst.convention = data.get('convention', '')
cst.definition_formal = data.get('definition_formal', '')
cst.term_raw = data.get('term_raw', '')
if cst.term_raw != '':
cst.term_resolved = resolver.resolve(cst.term_raw)
cst.definition_raw = data.get('definition_raw', '')
if cst.definition_raw != '':
cst.definition_resolved = resolver.resolve(cst.definition_raw)
cst.save()
self.on_term_change(cst.alias)
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'])
@transaction.atomic @transaction.atomic
def load_trs(self, data: dict, sync_metadata: bool, skip_update: bool): def load_trs(self, data: dict, sync_metadata: bool, skip_update: bool):
if sync_metadata: if sync_metadata:
@ -170,7 +220,7 @@ class RSForm(Model):
loaded_ids.add(uid) loaded_ids.add(uid)
order += 1 order += 1
for prev_cst in prev_constituents: for prev_cst in prev_constituents:
if prev_cst.id not in loaded_ids: if prev_cst.pk not in loaded_ids:
prev_cst.delete() prev_cst.delete()
if not skip_update: if not skip_update:
self._update_from_core() self._update_from_core()
@ -214,8 +264,9 @@ class RSForm(Model):
} }
@transaction.atomic @transaction.atomic
def _update_from_core(self) -> dict: def _update_from_core(self):
checked: dict = json.loads(pyconcept.check_schema(json.dumps(self.to_trs()))) # TODO: resolve text refs
checked = json.loads(pyconcept.check_schema(json.dumps(self.to_trs())))
update_list = self.constituents().only('id', 'order') update_list = self.constituents().only('id', 'order')
if len(checked['items']) != update_list.count(): if len(checked['items']) != update_list.count():
raise ValidationError('Invalid constituents count') raise ValidationError('Invalid constituents count')
@ -223,12 +274,11 @@ class RSForm(Model):
for cst in checked['items']: for cst in checked['items']:
cst_id = cst['entityUID'] cst_id = cst['entityUID']
for oldCst in update_list: for oldCst in update_list:
if oldCst.id == cst_id: if oldCst.pk == cst_id:
oldCst.order = order oldCst.order = order
order += 1 order += 1
break break
Constituenta.objects.bulk_update(update_list, ['order']) Constituenta.objects.bulk_update(update_list, ['order'])
return checked
@transaction.atomic @transaction.atomic
def _create_items_from_trs(self, items): def _create_items_from_trs(self, items):

View File

@ -1,5 +1,6 @@
''' Serializers for conceptual schema API. ''' ''' Serializers for conceptual schema API. '''
import json import json
from typing import Optional
from rest_framework import serializers from rest_framework import serializers
import pyconcept import pyconcept
@ -93,16 +94,25 @@ class ConstituentaSerializer(serializers.ModelSerializer):
''' serializer metadata. ''' ''' serializer metadata. '''
model = Constituenta model = Constituenta
fields = '__all__' fields = '__all__'
read_only_fields = ('id', 'order', 'alias', 'cst_type') read_only_fields = ('id', 'order', 'alias', 'cst_type', 'definition_resolved', 'term_resolved')
def update(self, instance: Constituenta, validated_data) -> Constituenta: def update(self, instance: Constituenta, validated_data) -> Constituenta:
if 'term_raw' in validated_data: schema: RSForm = instance.schema
validated_data['term_resolved'] = validated_data['term_raw'] definition: Optional[str] = validated_data['definition_raw'] if 'definition_raw' in validated_data else None
if 'definition_raw' in validated_data: term: Optional[str] = validated_data['term_raw'] if 'term_raw' in validated_data else None
validated_data['definition_resolved'] = validated_data['definition_raw'] term_changed = False
if definition is not None and definition != instance.definition_raw :
validated_data['definition_resolved'] = schema.resolver().resolve(definition)
if term is not None and term != instance.term_raw:
validated_data['term_resolved'] = schema.resolver().resolve(term)
if validated_data['term_resolved'] != instance.term_resolved:
validated_data['term_forms'] = []
term_changed = validated_data['term_resolved'] != instance.term_resolved
result: Constituenta = super().update(instance, validated_data) result: Constituenta = super().update(instance, validated_data)
instance.schema.save() if term_changed:
schema.on_term_change(result.alias)
result.refresh_from_db()
schema.save()
return result return result
@ -132,13 +142,6 @@ class CstCreateSerializer(serializers.ModelSerializer):
model = Constituenta model = Constituenta
fields = 'alias', 'cst_type', 'convention', 'term_raw', 'definition_raw', 'definition_formal', 'insert_after' fields = 'alias', 'cst_type', 'convention', 'term_raw', 'definition_raw', 'definition_formal', 'insert_after'
def validate(self, attrs):
if 'term_raw' in attrs:
attrs['term_resolved'] = attrs['term_raw']
if 'definition_raw' in attrs:
attrs['definition_resolved'] = attrs['definition_raw']
return attrs
class CstListSerlializer(serializers.Serializer): class CstListSerlializer(serializers.Serializer):
''' Serializer: List of constituents from one origin. ''' ''' Serializer: List of constituents from one origin. '''

View File

@ -28,12 +28,15 @@ class TestConstituentaAPI(APITestCase):
self.rsform_owned = RSForm.objects.create(title='Test', alias='T1', owner=self.user) self.rsform_owned = RSForm.objects.create(title='Test', alias='T1', owner=self.user)
self.rsform_unowned = RSForm.objects.create(title='Test2', alias='T2') self.rsform_unowned = RSForm.objects.create(title='Test2', alias='T2')
self.cst1 = Constituenta.objects.create( self.cst1 = Constituenta.objects.create(
alias='X1', schema=self.rsform_owned, order=1, convention='Test') alias='X1', schema=self.rsform_owned, order=1, convention='Test',
term_raw='Test1', term_resolved='Test1R',
term_forms=[{'text':'form1', 'tags':'sing,datv'}])
self.cst2 = Constituenta.objects.create( self.cst2 = Constituenta.objects.create(
alias='X2', schema=self.rsform_unowned, order=1, convention='Test1') alias='X2', schema=self.rsform_unowned, order=1, convention='Test1',
term_raw='Test2', term_resolved='Test2R')
self.cst3 = Constituenta.objects.create( self.cst3 = Constituenta.objects.create(
alias='X3', schema=self.rsform_owned, order=2, alias='X3', schema=self.rsform_owned, order=2,
term_raw='Test1', term_resolved='Test1', term_raw='Test3', term_resolved='Test3',
definition_raw='Test1', definition_resolved='Test2') definition_raw='Test1', definition_resolved='Test2')
def test_retrieve(self): def test_retrieve(self):
@ -61,7 +64,7 @@ class TestConstituentaAPI(APITestCase):
response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_partial_update_update_resolved(self): def test_update_resolved_norefs(self):
data = json.dumps({ data = json.dumps({
'term_raw': 'New term', 'term_raw': 'New term',
'definition_raw': 'New def' 'definition_raw': 'New def'
@ -72,6 +75,17 @@ class TestConstituentaAPI(APITestCase):
self.assertEqual(self.cst3.term_resolved, 'New term') self.assertEqual(self.cst3.term_resolved, 'New term')
self.assertEqual(self.cst3.definition_resolved, 'New def') self.assertEqual(self.cst3.definition_resolved, 'New def')
def test_update_resolved_refs(self):
data = json.dumps({
'term_raw': '@{X1|nomn,sing}',
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
})
response = self.client.patch(f'/api/constituents/{self.cst3.id}/', data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved)
self.assertEqual(self.cst3.definition_resolved, f'{self.cst1.term_resolved} form1')
def test_readonly_cst_fields(self): def test_readonly_cst_fields(self):
data = json.dumps({'alias': 'X33', 'order': 10}) data = json.dumps({'alias': 'X33', 'order': 10})
response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json')

View File

@ -77,27 +77,15 @@ class RSFormViewSet(viewsets.ModelViewSet):
schema = self._get_schema() schema = self._get_schema()
serializer = serializers.CstCreateSerializer(data=request.data) serializer = serializers.CstCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
if ('insert_after' in serializer.validated_data and serializer.validated_data['insert_after'] is not None): data = serializer.validated_data
cstafter = models.Constituenta.objects.get(pk=serializer.validated_data['insert_after']) new_cst = schema.create_cst(data, data['insert_after'] if 'insert_after' in data else None)
constituenta = schema.insert_at(cstafter.order + 1,
serializer.validated_data['alias'],
serializer.validated_data['cst_type'])
else:
constituenta = schema.insert_last(serializer.validated_data['alias'], serializer.validated_data['cst_type'])
constituenta.convention = serializer.validated_data.get('convention', '')
constituenta.term_raw = serializer.validated_data.get('term_raw', '')
constituenta.term_resolved = serializer.validated_data.get('term_resolved', '')
constituenta.definition_formal = serializer.validated_data.get('definition_formal', '')
constituenta.definition_raw = serializer.validated_data.get('definition_raw', '')
constituenta.definition_resolved = serializer.validated_data.get('definition_resolved', '')
constituenta.save()
schema.refresh_from_db() schema.refresh_from_db()
outSerializer = serializers.RSFormDetailsSerlializer(schema) outSerializer = serializers.RSFormDetailsSerlializer(schema)
response = Response(status=201, data={ response = Response(status=201, data={
'new_cst': serializers.ConstituentaSerializer(constituenta).data, 'new_cst': serializers.ConstituentaSerializer(new_cst).data,
'schema': outSerializer.data}) 'schema': outSerializer.data
response['Location'] = constituenta.get_absolute_url() })
response['Location'] = new_cst.get_absolute_url()
return response return response
@action(detail=True, methods=['patch'], url_path='cst-multidelete') @action(detail=True, methods=['patch'], url_path='cst-multidelete')

View File

@ -1,21 +1,19 @@
''' Term context for reference resolution. ''' ''' Term context for reference resolution. '''
from typing import Iterable, Dict, Optional from typing import Iterable, Dict, Optional, TypedDict
from dataclasses import dataclass
from .conceptapi import inflect from .conceptapi import inflect
@dataclass class TermForm(TypedDict):
class TermForm:
''' Term in a specific form. ''' ''' Term in a specific form. '''
text: str text: str
form: str tags: str
def _search_form(query: str, data: Iterable[TermForm]) -> Optional[str]: def _search_form(query: str, data: Iterable[TermForm]) -> Optional[str]:
for tf in data: for tf in data:
if tf.form == query: if tf['tags'] == query:
return tf.text return tf['text']
return None return None
@ -55,7 +53,7 @@ class Entity:
text = inflect(self._nominal, form) text = inflect(self._nominal, form)
except ValueError as error: except ValueError as error:
text = f'!{error}!'.replace('Unknown grammeme', 'Неизвестная граммема') text = f'!{error}!'.replace('Unknown grammeme', 'Неизвестная граммема')
self._cached.append(TermForm(text=text, form=form)) self._cached.append({'text': text, 'tags': form})
return text return text
# Term context for resolving entity references. # Term context for resolving entity references.

View File

@ -1,7 +1,7 @@
''' Unit tests: context. ''' ''' Unit tests: context. '''
import unittest import unittest
from cctext.context import TermForm, Entity, TermContext from cctext.context import Entity, TermContext
class TestEntity(unittest.TestCase): class TestEntity(unittest.TestCase):
'''Test Entity termform access.''' '''Test Entity termform access.'''
@ -10,12 +10,12 @@ class TestEntity(unittest.TestCase):
self.nominal = 'человек' self.nominal = 'человек'
self.text1 = 'test1' self.text1 = 'test1'
self.form1 = 'sing,datv' self.form1 = 'sing,datv'
self.entity = Entity(self.alias, self.nominal, [TermForm(self.text1, self.form1)]) self.entity = Entity(self.alias, self.nominal, [{'text': self.text1, 'tags': self.form1}])
def test_attributes(self): def test_attributes(self):
self.assertEqual(self.entity.alias, self.alias) self.assertEqual(self.entity.alias, self.alias)
self.assertEqual(self.entity.get_nominal(), self.nominal) self.assertEqual(self.entity.get_nominal(), self.nominal)
self.assertEqual(self.entity.manual, [TermForm(self.text1, self.form1)]) self.assertEqual(self.entity.manual, [{'text': self.text1, 'tags': self.form1}])
def test_get_form(self): def test_get_form(self):
self.assertEqual(self.entity.get_form(''), self.nominal) self.assertEqual(self.entity.get_form(''), self.nominal)