Compare commits

...

54 Commits

Author SHA1 Message Date
Ivan
6b2268a76b B: Fix cst graph visualization
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled
2025-10-15 11:54:21 +03:00
Ivan
22eb2a482c npm update and linter fixes 2025-10-14 01:13:41 +03:00
Ivan
b55f33c17d B: Fix graph selection UI 2025-10-13 15:51:26 +03:00
Ivan
890b1894e8 B: Fix dialog handlers and validators 2025-10-03 01:10:18 +03:00
Ivan
0743cffdd7 npm update 2025-10-03 00:54:38 +03:00
Ivan
de59f7e336 B: Fix pulsing animation for subpixel behaviors 2025-09-29 20:56:28 +03:00
Ivan
3b3cb06a40 M: Fix dialog size for smaller screens 2025-09-29 20:29:34 +03:00
Ivan
ff04d006ea B: Fix isModified flag 2025-09-23 17:20:41 +03:00
Ivan
bed44b57ca B: Make zustand stores indompotent 2025-09-16 19:13:13 +03:00
Ivan
925147299e B: Fix build 2025-09-15 11:57:55 +03:00
Ivan
c3e17549c7 npm update 2025-09-15 11:41:26 +03:00
Ivan
26939c025b B: Try fixing build pt1 2025-09-10 23:49:40 +03:00
Ivan
eb28640513 M: Fix wrapping on small screens 2025-09-10 22:04:57 +03:00
Ivan
2659568e3d npm update 2025-09-10 21:56:30 +03:00
Ivan
9811d0155e F: Do not close dropdown when clicking on interactive child 2025-08-26 14:00:23 +03:00
Ivan
9f1aa2b4a8 M: Minor UI improvements 2025-08-26 13:59:16 +03:00
Ivan
c634ae9700 M: Fix layout shrinking when unfolding items 2025-08-24 11:39:35 +03:00
Ivan
fb1bff055c D: Update db schema 2025-08-24 11:31:43 +03:00
Ivan
f9d0f8556c D: Minor fixes 2025-08-24 11:29:19 +03:00
Ivan
d1fae22f90 npm update 2025-08-24 11:14:25 +03:00
Ivan
99d368fc54 M: Improve ai prompt generation 2025-08-24 11:11:29 +03:00
Ivan
6e17eada27 B: Keep select components managed 2025-08-23 16:19:43 +03:00
Ivan
c358415642 B: Copy attributions when cloning 2025-08-23 16:04:54 +03:00
Ivan
1555a1bf92 F: Rename Nominal relation and improve UI 2025-08-21 21:26:40 +03:00
Ivan
14cda60b0d D: Update docs for RS language 2025-08-18 22:29:05 +03:00
Ivan
1d182f4417 D: Generating help pt1 2025-08-15 21:53:49 +03:00
Ivan
5836e48b22 T: add basic tests for associations 2025-08-15 16:59:38 +03:00
Ivan
21c55c6a43 R: Improve dialogs persistence when data is invalidated 2025-08-13 22:02:45 +03:00
Ivan
eebea31a74 F: Rework video playback, add VK option 2025-08-13 16:20:05 +03:00
Ivan
eadcea566b F: Implement association graph UI 2025-08-13 13:30:08 +03:00
Ivan
d27d7bc31e npm update 2025-08-12 22:59:46 +03:00
Ivan
7d12c12815 F: Implement association editing UI and fix dialogs caching 2025-08-12 22:55:49 +03:00
Ivan
be54dde982 F: Prepare Association backend api 2025-08-12 20:31:35 +03:00
Ivan
866412eb98 F: Update substitute UI with nominal cst 2025-08-12 17:59:04 +03:00
Ivan
a6f36d0d4f F: Implementing Nominal cst_type 2025-08-12 15:39:11 +03:00
Ivan
f8dd26cc4c R: update test calls 2025-08-10 14:08:36 +03:00
Ivan
17f9658e97 R: Use pythonic bool instead of len 2025-08-10 12:41:37 +03:00
Ivan
c6f52ed8f4 F: Implementing Association and Nominal pt1 2025-08-10 12:24:25 +03:00
Ivan
c7ac37411d M: Minor UI fixes 2025-08-08 15:51:58 +03:00
Ivan
e20f969bfd B: Fix cache invalidation logic 2025-08-08 11:25:17 +03:00
Ivan
926b133507 D: Update TODOs 2025-08-07 10:57:42 +03:00
Ivan
78861cd224 D: Add sidebar help documnetation 2025-08-06 19:55:30 +03:00
Ivan
a48f1f406d M: Minor UI fixes 2025-08-06 15:17:44 +03:00
Ivan
bf3df7903e B: Prevent multiple instances of same KS in synthesis 2025-08-06 14:38:24 +03:00
Ivan
7a2b8a0212 R: Rename reference operation -> replica 2025-08-06 12:48:17 +03:00
Ivan
ad0f30b5fd R: Add use client directive 2025-08-05 20:01:18 +03:00
Ivan
1c2d4339fb D: Improve graph help 2025-08-05 19:35:37 +03:00
Ivan
b0481c2cbd B: Fix delete_reference 2025-08-05 19:11:13 +03:00
Ivan
4588cd5f69 F: Improve Graph selection and View retention 2025-08-05 16:54:27 +03:00
Ivan
95ec09a5e0 R: Refactoring propagation mechanism 2025-08-05 15:27:31 +03:00
Ivan
25777b8efc R: Cache refactoring pt3 2025-08-05 14:26:07 +03:00
Ivan
95c9d58f43 M: small ui fix 2025-08-05 14:01:22 +03:00
Ivan
78ccc613bd D: Update db_schema 2025-08-05 12:08:09 +03:00
Ivan
f8a573bbc8 npm update 2025-08-04 23:02:07 +03:00
308 changed files with 8276 additions and 4799 deletions

15
.vscode/settings.json vendored
View File

@ -177,11 +177,16 @@
"Upvote",
"Viewset",
"viewsets",
"vkvideo",
"wordform",
"Wordforms",
"XCSDATN",
"Айзенштат",
"Акименков",
"Астрина",
"Атрибутирование",
"Атрибутирующая",
"Атрибутирующие",
"Ашихмин",
"Биективная",
"биективной",
@ -191,6 +196,8 @@
"Бурбакизатор",
"Версионирование",
"Владельцом",
"генемные",
"дебуль",
"Демешко",
"Десинглетон",
"доксинг",
@ -212,9 +219,16 @@
"Кучкаров",
"Кучкарова",
"мультиграфа",
"мультииндекс",
"Мультииндексы",
"Мультифильтр",
"неинтерпретируемый",
"неитерируемого",
"Никанорова",
"Номиноид",
"номиноида",
"номиноидом",
"Номиноиды",
"операционализации",
"операционализированных",
"Оргтеор",
@ -224,6 +238,7 @@
"подпапках",
"Присакарь",
"ПРОКСИМА",
"родовидовое",
"Родоструктурная",
"родоструктурного",
"Родоструктурное",

View File

@ -1,33 +1,29 @@
!! This is not complete list of TODOs !!
For more specific TODOs see comments in code
[Bugs - PENDING]
-
[Functionality - PENDING]
- Export PDF (Items list, Graph)
- Save react-flow to vector image
- Landing page
- Design first user experience
- Video guides
- Demo sandbox for anonymous users
- Save react-flow to vector image
- Implement rslang and rsmodel functionality in the frontend
- Allow manual setup for typification and value class
User profile:
- Settings server persistency
- Profile pictures
- Profile pictures (avatars)
- Custom LibraryItem lists
- Custom user filters and sharing filters
- Personal prompt templates
- Static analyzer for RSForm as a whole: check term duplication and empty conventions
- OSS clone and versioning
- Clone with saving info connection
- Semantic diff for library items
- Focus on codemirror editor when label is clicked (need React 19 ref for clean code solution)
- Draggable rows in constituents table
- Search functionality for Help Manuals - use google search integration filtered by site?
- Export PDF (Items list, Graph)
- ARIA (accessibility considerations) - for now machine reading not supported
- Internationalization - at least english version. Consider react.intl
- Sitemap for better SEO and crawler optimization

View File

@ -9,7 +9,7 @@ from apps.library.models import (
LibraryTemplate,
LocationHead
)
from apps.rsform.models import RSForm
from apps.rsform.models import Attribution, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
from shared.testing_utils import response_contains
@ -43,7 +43,7 @@ class TestLibraryViewset(EndpointTester):
'title': 'Title',
'alias': 'alias',
}
response = self.executeCreated(data=data)
response = self.executeCreated(data)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['item_type'], LibraryItemType.RSFORM)
self.assertEqual(response.data['title'], data['title'])
@ -57,7 +57,7 @@ class TestLibraryViewset(EndpointTester):
'visible': False,
'read_only': True
}
response = self.executeCreated(data=data)
response = self.executeCreated(data)
oss = LibraryItem.objects.get(pk=response.data['id'])
self.assertEqual(oss.owner, self.user)
self.assertEqual(response.data['owner'], self.user.pk)
@ -70,25 +70,25 @@ class TestLibraryViewset(EndpointTester):
self.logout()
data = {'title': 'Title2'}
self.executeForbidden(data=data)
self.executeForbidden(data)
@decl_endpoint('/api/library/{item}', method='patch')
def test_update(self):
data = {'title': 'New Title'}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.toggle_editor(self.unowned, True)
response = self.executeOK(data=data, item=self.unowned.pk)
response = self.executeOK(data, item=self.unowned.pk)
self.assertEqual(response.data['title'], data['title'])
self.unowned.access_policy = AccessPolicy.PRIVATE
self.unowned.save()
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeForbidden(data, item=self.unowned.pk)
data = {'title': 'New Title'}
response = self.executeOK(data=data, item=self.owned.pk)
response = self.executeOK(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(response.data['alias'], self.owned.alias)
@ -98,7 +98,7 @@ class TestLibraryViewset(EndpointTester):
'access_policy': AccessPolicy.PROTECTED,
'location': LocationHead.LIBRARY
}
response = self.executeOK(data=data, item=self.owned.pk)
response = self.executeOK(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(response.data['owner'], self.owned.owner.pk)
self.assertEqual(response.data['access_policy'], self.owned.access_policy)
@ -111,22 +111,22 @@ class TestLibraryViewset(EndpointTester):
time_update = self.owned.time_update
data = {'user': self.user.pk}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeOK(data=data, item=self.owned.pk)
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.owner, self.user)
data = {'user': self.user2.pk}
self.executeOK(data=data, item=self.owned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.owner, self.user2)
self.assertEqual(self.owned.time_update, time_update)
self.executeForbidden(data=data, item=self.owned.pk)
self.executeForbidden(data, item=self.owned.pk)
self.toggle_admin(True)
data = {'user': self.user.pk}
self.executeOK(data=data, item=self.owned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.owner, self.user)
@ -135,20 +135,20 @@ class TestLibraryViewset(EndpointTester):
time_update = self.owned.time_update
data = {'access_policy': 'invalid'}
self.executeBadData(data=data, item=self.owned.pk)
self.executeBadData(data, item=self.owned.pk)
data = {'access_policy': AccessPolicy.PRIVATE}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeOK(data=data, item=self.owned.pk)
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.access_policy, data['access_policy'])
self.toggle_editor(self.unowned, True)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeForbidden(data, item=self.unowned.pk)
self.toggle_admin(True)
self.executeOK(data=data, item=self.unowned.pk)
self.executeOK(data, item=self.unowned.pk)
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.access_policy, data['access_policy'])
@ -157,29 +157,29 @@ class TestLibraryViewset(EndpointTester):
time_update = self.owned.time_update
data = {'location': 'invalid'}
self.executeBadData(data=data, item=self.owned.pk)
self.executeBadData(data, item=self.owned.pk)
data = {'location': '/U/temp'}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeOK(data=data, item=self.owned.pk)
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.location, data['location'])
data = {'location': LocationHead.LIBRARY}
self.executeForbidden(data=data, item=self.owned.pk)
self.executeForbidden(data, item=self.owned.pk)
data = {'location': '/U/temp'}
self.toggle_editor(self.unowned, True)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeForbidden(data, item=self.unowned.pk)
self.toggle_admin(True)
data = {'location': LocationHead.LIBRARY}
self.executeOK(data=data, item=self.owned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.location, data['location'])
self.executeOK(data=data, item=self.unowned.pk)
self.executeOK(data, item=self.unowned.pk)
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.location, data['location'])
@ -201,12 +201,12 @@ class TestLibraryViewset(EndpointTester):
'new_location': '/S/temp2'
}
self.executeBadData(data={})
self.executeBadData(data={'target:': '/S/temp'})
self.executeBadData(data={'new_location:': '/S/temp'})
self.executeBadData(data={'target:': 'invalid', 'new_location': '/S/temp'})
self.executeBadData(data={'target:': '/S/temp', 'new_location': 'invalid'})
self.executeOK(data=data)
self.executeBadData({})
self.executeBadData({'target:': '/S/temp'})
self.executeBadData({'new_location:': '/S/temp'})
self.executeBadData({'target:': 'invalid', 'new_location': '/S/temp'})
self.executeBadData({'target:': '/S/temp', 'new_location': 'invalid'})
self.executeOK(data)
self.owned.refresh_from_db()
self.unowned.refresh_from_db()
owned2.refresh_from_db()
@ -215,7 +215,7 @@ class TestLibraryViewset(EndpointTester):
self.assertEqual(owned2.location, '/S/temp2/123')
self.toggle_admin(True)
self.executeOK(data=data)
self.executeOK(data)
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.location, '/S/temp2')
@ -232,7 +232,7 @@ class TestLibraryViewset(EndpointTester):
}
self.toggle_admin(True)
self.executeOK(data=data)
self.executeOK(data)
self.owned.refresh_from_db()
self.unowned.refresh_from_db()
self.assertEqual(self.owned.location, '/U/temp2')
@ -243,30 +243,30 @@ class TestLibraryViewset(EndpointTester):
time_update = self.owned.time_update
data = {'users': [self.invalid_user]}
self.executeBadData(data=data, item=self.owned.pk)
self.executeBadData(data, item=self.owned.pk)
data = {'users': [self.user.pk]}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data=data, item=self.owned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(list(self.owned.getQ_editors()), [self.user])
self.executeOK(data=data)
self.executeOK(data)
self.assertEqual(list(self.owned.getQ_editors()), [self.user])
data = {'users': [self.user2.pk]}
self.executeOK(data=data)
self.executeOK(data)
self.assertEqual(list(self.owned.getQ_editors()), [self.user2])
data = {'users': []}
self.executeOK(data=data)
self.executeOK(data)
self.assertEqual(list(self.owned.getQ_editors()), [])
data = {'users': [self.user2.pk, self.user.pk]}
self.executeOK(data=data)
self.executeOK(data)
self.assertEqual(set(self.owned.getQ_editors()), set([self.user2, self.user]))
@ -343,14 +343,16 @@ class TestLibraryViewset(EndpointTester):
term_raw='@{X12|plur}',
term_resolved='люди'
)
Attribution.objects.create(container=d2, attribute=x12)
data = {'item_data': {'title': 'Title1337'}, 'items': []}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeCreated(data=data, item=self.unowned.pk)
self.executeNotFound(data, item=self.invalid_item)
self.executeCreated(data, item=self.unowned.pk)
response = self.executeCreated(data=data, item=self.owned.pk)
response = self.executeCreated(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['item_data']['title'])
self.assertEqual(len(response.data['items']), 2)
self.assertEqual(len(response.data['attribution']), 1)
self.assertEqual(response.data['items'][0]['alias'], x12.alias)
self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw)
self.assertEqual(response.data['items'][0]['term_resolved'], x12.term_resolved)
@ -358,12 +360,12 @@ class TestLibraryViewset(EndpointTester):
self.assertEqual(response.data['items'][1]['term_resolved'], d2.term_resolved)
data = {'item_data': {'title': 'Title1340'}, 'items': []}
response = self.executeCreated(data=data, item=self.owned.pk)
response = self.executeCreated(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['item_data']['title'])
self.assertEqual(len(response.data['items']), 2)
data = {'item_data': {'title': 'Title1341'}, 'items': [x12.pk]}
response = self.executeCreated(data=data, item=self.owned.pk)
response = self.executeCreated(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['item_data']['title'])
self.assertEqual(len(response.data['items']), 1)
self.assertEqual(response.data['items'][0]['alias'], x12.alias)

View File

@ -32,11 +32,11 @@ class TestVersionViews(EndpointTester):
invalid_id = 1338
data = {'version': '1.0.0', 'description': 'test'}
self.executeNotFound(data=data, schema=invalid_id)
self.executeForbidden(data=data, schema=self.unowned_id)
self.executeBadData(data=invalid_data, schema=self.owned_id)
self.executeNotFound(data, schema=invalid_id)
self.executeForbidden(data, schema=self.unowned_id)
self.executeBadData(invalid_data, schema=self.owned_id)
response = self.executeCreated(data=data, schema=self.owned_id)
response = self.executeCreated(data, schema=self.owned_id)
self.assertTrue('version' in response.data)
self.assertTrue('schema' in response.data)
self.assertTrue(response.data['version'] in [v['id'] for v in response.data['schema']['versions']])
@ -46,7 +46,7 @@ class TestVersionViews(EndpointTester):
def test_create_version_filter(self):
x2 = self.owned.insert_last('X2')
data = {'version': '1.0.0', 'description': 'test', 'items': [x2.pk]}
response = self.executeCreated(data=data, schema=self.owned_id)
response = self.executeCreated(data, schema=self.owned_id)
version = Version.objects.get(pk=response.data['version'])
items = version.data['items']
self.assertTrue('version' in response.data)
@ -102,7 +102,7 @@ class TestVersionViews(EndpointTester):
@decl_endpoint('/api/versions/{version}', method='get')
def test_access_version(self):
data = {'version': '1.0.0', 'description': 'test'}
version_id = self._create_version(data=data)
version_id = self._create_version(data)
invalid_id = version_id + 1337
self.executeNotFound(version=invalid_id)
@ -116,14 +116,14 @@ class TestVersionViews(EndpointTester):
data = {'version': '1.2.0', 'description': 'test1'}
self.method = 'patch'
self.executeForbidden(data=data)
self.executeForbidden(data)
self.method = 'delete'
self.executeForbidden()
self.client.force_authenticate(user=self.user)
self.method = 'patch'
self.executeOK(data=data)
self.executeOK(data)
response = self.get()
self.assertEqual(response.data['version'], data['version'])
self.assertEqual(response.data['description'], data['description'])
@ -157,7 +157,7 @@ class TestVersionViews(EndpointTester):
x2 = self.owned.insert_last('X2')
d1 = self.owned.insert_last('D1', term_raw='TestTerm')
data = {'version': '1.0.0', 'description': 'test'}
version_id = self._create_version(data=data)
version_id = self._create_version(data)
invalid_id = version_id + 1337
Constituenta.objects.get(pk=d1.pk).delete()
@ -186,7 +186,7 @@ class TestVersionViews(EndpointTester):
def _create_version(self, data) -> int:
response = self.client.post(
f'/api/library/{self.owned_id}/create-version',
data=data, format='json'
data, format='json'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
return response.data['version'] # type: ignore

View File

@ -14,7 +14,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from apps.oss.models import Layout, Operation, OperationSchema, PropagationFacade
from apps.rsform.models import RSFormCached
from apps.rsform.models import Attribution, RSFormCached
from apps.rsform.serializers import RSFormParseSerializer
from apps.users.models import User
from shared import permissions
@ -157,8 +157,8 @@ class LibraryViewSet(viewsets.ModelViewSet):
serializer = s.LibraryItemCloneSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True)
data = serializer.validated_data['item_data']
with transaction.atomic():
clone = deepcopy(item)
clone.pk = None
@ -171,12 +171,24 @@ class LibraryViewSet(viewsets.ModelViewSet):
clone.access_policy = data.get('access_policy', m.AccessPolicy.PUBLIC)
clone.location = data.get('location', m.LocationHead.USER)
clone.save()
need_filter = 'items' in request.data and len(request.data['items']) > 0
cst_map: dict[int, int] = {}
cst_list: list[int] = []
need_filter = 'items' in request.data and request.data['items']
for cst in RSFormCached(item).constituentsQ():
if not need_filter or cst.pk in request.data['items']:
old_pk = cst.pk
cst.pk = None
cst.schema = clone
cst.save()
cst_map[old_pk] = cst.pk
cst_list.append(old_pk)
for attr in Attribution.objects.filter(container__in=cst_list, attribute__in=cst_list):
attr.pk = None
attr.container_id = cst_map[attr.container_id]
attr.attribute_id = cst_map[attr.attribute_id]
attr.save()
return Response(
status=c.HTTP_201_CREATED,
data=RSFormParseSerializer(clone).data
@ -299,7 +311,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
with transaction.atomic():
added, deleted = m.Editor.set_and_return_diff(item.pk, editors)
if len(added) >= 0 or len(deleted) >= 0:
if added or deleted:
owned_schemas = OperationSchema.owned_schemasQ(item).only('pk')
if owned_schemas.exists():
m.Editor.objects.filter(

View File

@ -60,9 +60,9 @@ class InheritanceAdmin(admin.ModelAdmin):
search_fields = ['id', 'operation', 'parent', 'child']
@admin.register(models.Reference)
class ReferenceAdmin(admin.ModelAdmin):
''' Admin model: Reference. '''
ordering = ['reference', 'target']
list_display = ['id', 'reference', 'target']
search_fields = ['id', 'reference', 'target']
@admin.register(models.Replica)
class ReplicaAdmin(admin.ModelAdmin):
''' Admin model: Replica. '''
ordering = ['replica', 'original']
list_display = ['id', 'replica', 'original']
search_fields = ['id', 'replica', 'original']

View File

@ -0,0 +1,35 @@
# Generated by Django 5.2.4 on 2025-08-06 09:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oss', '0015_reference'),
]
operations = [
migrations.AlterField(
model_name='operation',
name='operation_type',
field=models.CharField(choices=[('input', 'Input'), ('synthesis', 'Synthesis'), ('replica', 'Replica')], default='input', max_length=10, verbose_name='Тип'),
),
migrations.CreateModel(
name='Replica',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('original', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='targets', to='oss.operation', verbose_name='Целевая Операция')),
('replica', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replicas', to='oss.operation', verbose_name='Реплика')),
],
options={
'verbose_name': 'Реплика',
'verbose_name_plural': 'Реплики',
'unique_together': {('replica', 'original')},
},
),
migrations.DeleteModel(
name='Reference',
),
]

View File

@ -16,7 +16,7 @@ from django.db.models import (
from apps.library.models import LibraryItem
from .Argument import Argument
from .Reference import Reference
from .Replica import Replica
from .Substitution import Substitution
@ -24,7 +24,7 @@ class OperationType(TextChoices):
''' Type of operation. '''
INPUT = 'input'
SYNTHESIS = 'synthesis'
REFERENCE = 'reference'
REPLICA = 'replica'
class Operation(Model):
@ -93,13 +93,13 @@ class Operation(Model):
''' Operation substitutions. '''
return Substitution.objects.filter(operation=self)
def getQ_references(self) -> QuerySet[Reference]:
''' Operation references. '''
return Reference.objects.filter(target=self)
def getQ_replicas(self) -> QuerySet[Replica]:
''' Operation replicas. '''
return Replica.objects.filter(original=self)
def getQ_reference_target(self) -> list['Operation']:
''' Operation target for current reference. '''
return [x.target for x in Reference.objects.filter(reference=self)]
def getQ_replica_original(self) -> list['Operation']:
''' Operation source for current replica. '''
return [x.original for x in Replica.objects.filter(replica=self)]
def setQ_result(self, result: Optional[LibraryItem]) -> None:
''' Set result schema. '''
@ -107,12 +107,12 @@ class Operation(Model):
return
self.result = result
self.save(update_fields=['result'])
for reference in self.getQ_references():
reference.reference.result = result
reference.reference.save(update_fields=['result'])
for rep in self.getQ_replicas():
rep.replica.result = result
rep.replica.save(update_fields=['result'])
def delete(self, *args, **kwargs):
''' Delete operation. '''
for ref in self.getQ_references():
ref.reference.delete()
for rep in self.getQ_replicas():
rep.replica.delete()
super().delete(*args, **kwargs)

View File

@ -11,7 +11,7 @@ from .Block import Block
from .Inheritance import Inheritance
from .Layout import Layout
from .Operation import Operation, OperationType
from .Reference import Reference
from .Replica import Replica
from .Substitution import Substitution
@ -42,6 +42,22 @@ class OperationSchema:
''' OSS layout. '''
return Layout.objects.get(oss_id=itemID)
@staticmethod
def create_input(oss: LibraryItem, operation: Operation) -> RSFormCached:
''' Create input RSForm for given Operation. '''
schema = RSFormCached.create(
owner=oss.owner,
alias=operation.alias,
title=operation.title,
description=operation.description,
visible=False,
access_policy=oss.access_policy,
location=oss.location
)
Editor.set(schema.model.pk, oss.getQ_editors().values_list('pk', flat=True))
operation.setQ_result(schema.model)
return schema
def refresh_from_db(self) -> None:
''' Model wrapper. '''
self.model.refresh_from_db()
@ -51,15 +67,15 @@ class OperationSchema:
result = Operation.objects.create(oss=self.model, **kwargs)
return result
def create_reference(self, target: Operation) -> Operation:
''' Create Reference Operation. '''
def create_replica(self, target: Operation) -> Operation:
''' Create Replica Operation. '''
result = Operation.objects.create(
oss=self.model,
operation_type=OperationType.REFERENCE,
operation_type=OperationType.REPLICA,
result=target.result,
parent=target.parent
)
Reference.objects.create(reference=result, target=target)
Replica.objects.create(replica=result, original=target)
return result
def create_block(self, **kwargs) -> Block:
@ -80,21 +96,6 @@ class OperationSchema:
operation.save(update_fields=['parent'])
target.delete()
def create_input(self, operation: Operation) -> RSFormCached:
''' Create input RSForm for given Operation. '''
schema = RSFormCached.create(
owner=self.model.owner,
alias=operation.alias,
title=operation.title,
description=operation.description,
visible=False,
access_policy=self.model.access_policy,
location=self.model.location
)
Editor.set(schema.model.pk, self.model.getQ_editors().values_list('pk', flat=True))
operation.setQ_result(schema.model)
return schema
def set_arguments(self, target: int, arguments: list[Operation]) -> None:
''' Set arguments of target Operation. '''
Argument.objects.filter(operation_id=target).delete()
@ -128,10 +129,10 @@ class OperationSchema:
.order_by('order')
if arg.argument.result_id is not None
]
if len(schemas) == 0:
if not schemas:
return
substitutions = operation.getQ_substitutions()
receiver = self.create_input(operation)
receiver = OperationSchema.create_input(self.model, operation)
parents: dict = {}
children: dict = {}

View File

@ -3,31 +3,17 @@
from typing import Optional
from cctext import extract_entities
from rest_framework.serializers import ValidationError
from apps.library.models import Editor, LibraryItem
from apps.rsform.graph import Graph
from apps.rsform.models import (
DELETED_ALIAS,
INSERT_LAST,
Constituenta,
CstType,
OrderManager,
RSFormCached,
extract_globals,
replace_entities,
replace_globals
)
from apps.library.models import LibraryItem
from apps.rsform.models import Attribution, Constituenta, CstType, OrderManager, RSFormCached
from .Argument import Argument
from .Inheritance import Inheritance
from .Operation import Operation, OperationType
from .Reference import Reference
from .Operation import Operation
from .OperationSchema import OperationSchema
from .OssCache import OssCache
from .PropagationEngine import PropagationEngine
from .Substitution import Substitution
CstMapping = dict[str, Optional[Constituenta]]
CstSubstitution = list[tuple[Constituenta, Constituenta]]
from .utils import CstMapping, CstSubstitution, create_dependant_mapping, extract_data_references
class OperationSchemaCached:
@ -35,19 +21,20 @@ class OperationSchemaCached:
def __init__(self, model: LibraryItem):
self.model = model
self.cache = OssCache(self)
self.cache = OssCache(model.pk)
self.engine = PropagationEngine(self.cache)
def delete_reference(self, target: int, keep_connections: bool = False, keep_constituents: bool = False):
''' Delete Reference Operation. '''
def delete_replica(self, target: int, keep_connections: bool = False, keep_constituents: bool = False):
''' Delete Replica Operation. '''
if not keep_connections:
self.delete_operation(target, keep_constituents)
return
self.cache.ensure_loaded_subs()
operation = self.cache.operation_by_id[target]
reference_target = self.cache.reference_target.get(target)
if reference_target:
original = self.cache.replica_original.get(target)
if original:
for arg in operation.getQ_as_argument():
arg.argument_id = reference_target
arg.argument_id = original
arg.save()
self.cache.remove_operation(target)
operation.delete()
@ -57,11 +44,11 @@ class OperationSchemaCached:
''' Delete Operation. '''
self.cache.ensure_loaded_subs()
operation = self.cache.operation_by_id[target]
children = self.cache.graph.outputs[target]
if operation.result is not None and len(children) > 0:
children = self.cache.extend_graph.outputs[target]
if operation.result is not None and children:
ids = list(Constituenta.objects.filter(schema=operation.result).values_list('pk', flat=True))
if not keep_constituents:
self._cascade_delete_inherited(operation.pk, ids)
self.engine.on_delete_inherited(operation.pk, ids)
else:
inheritance_to_delete: list[Inheritance] = []
for child_id in children:
@ -69,7 +56,7 @@ class OperationSchemaCached:
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
continue
self._undo_substitutions_cst(ids, child_operation, child_schema)
self.engine.undo_substitutions_cst(ids, child_operation, child_schema)
for item in self.cache.inheritance[child_id]:
if item.parent_id in ids:
inheritance_to_delete.append(item)
@ -82,7 +69,7 @@ class OperationSchemaCached:
def set_input(self, target: int, schema: Optional[LibraryItem]) -> None:
''' Set input schema for operation. '''
operation = self.cache.operation_by_id[target]
has_children = len(self.cache.graph.outputs[target]) > 0
has_children = bool(self.cache.extend_graph.outputs[target])
old_schema = self.cache.get_schema(operation)
if schema is None and old_schema is None or \
(schema is not None and old_schema is not None and schema.pk == old_schema.model.pk):
@ -118,7 +105,7 @@ class OperationSchemaCached:
processed.append(current.argument)
current.order = arguments.index(current.argument)
updated.append(current)
if len(deleted) > 0:
if deleted:
self.before_delete_arguments(operation, [x.argument for x in deleted])
for deleted_arg in deleted:
self.cache.remove_argument(deleted_arg)
@ -132,7 +119,7 @@ class OperationSchemaCached:
new_arg = Argument.objects.create(operation=operation, argument=arg, order=order)
self.cache.insert_argument(new_arg)
added.append(arg)
if len(added) > 0:
if added:
self.after_create_arguments(operation, added)
def set_substitutions(self, target: int, substitutes: list[dict]) -> None:
@ -147,14 +134,14 @@ class OperationSchemaCached:
x for x in substitutes
if x['original'] == current.original and x['substitution'] == current.substitution
]
if len(subs) == 0:
if not subs:
deleted.append(current)
else:
processed.append(subs[0])
if len(deleted) > 0:
if deleted:
if schema is not None:
for sub in deleted:
self._undo_substitution(schema, sub)
self.engine.undo_substitution(schema, sub)
else:
for sub in deleted:
self.cache.remove_substitution(sub)
@ -169,22 +156,7 @@ class OperationSchemaCached:
substitution=sub_item['substitution']
)
added.append(new_sub)
self._process_added_substitutions(schema, added)
def _create_input(self, operation: Operation) -> RSFormCached:
''' Create input RSForm for given Operation. '''
schema = RSFormCached.create(
owner=self.model.owner,
alias=operation.alias,
title=operation.title,
description=operation.description,
visible=False,
access_policy=self.model.access_policy,
location=self.model.location
)
Editor.set(schema.model.pk, self.model.getQ_editors().values_list('pk', flat=True))
operation.setQ_result(schema.model)
return schema
self._on_add_substitutions(schema, added)
def execute_operation(self, operation: Operation) -> bool:
''' Execute target Operation. '''
@ -197,10 +169,11 @@ class OperationSchemaCached:
.order_by('order')
if arg.argument.result_id is not None
]
if len(schemas) == 0:
if not schemas:
return False
substitutions = operation.getQ_substitutions()
receiver = self._create_input(self.cache.operation_by_id[operation.pk])
receiver = OperationSchema.create_input(self.model, self.cache.operation_by_id[operation.pk])
self.cache.insert_schema(receiver)
parents: dict = {}
children: dict = {}
@ -231,7 +204,7 @@ class OperationSchemaCached:
receiver.reset_aliases()
receiver.resolve_all_text()
if len(self.cache.graph.outputs[operation.pk]) > 0:
if self.cache.extend_graph.outputs[operation.pk]:
receiver_items = list(Constituenta.objects.filter(schema=receiver.model).order_by('order'))
self.after_create_cst(receiver, receiver_items)
receiver.model.save(update_fields=['time_update'])
@ -243,7 +216,7 @@ class OperationSchemaCached:
self.cache.insert_schema(source)
self.cache.insert_schema(destination)
operation = self.cache.get_operation(destination.model.pk)
self._undo_substitutions_cst(items, operation, destination)
self.engine.undo_substitutions_cst(items, operation, destination)
inheritance_to_delete = [item for item in self.cache.inheritance[operation.pk] if item.parent_id in items]
for item in inheritance_to_delete:
self.cache.remove_inheritance(item)
@ -282,37 +255,27 @@ class OperationSchemaCached:
exclude: Optional[list[int]] = None
) -> None:
''' Trigger cascade resolutions when new Constituenta is created. '''
source.cache.ensure_loaded()
self.cache.insert_schema(source)
inserted_aliases = [cst.alias for cst in cst_list]
depend_aliases: set[str] = set()
for new_cst in cst_list:
depend_aliases.update(new_cst.extract_references())
depend_aliases.difference_update(inserted_aliases)
alias_mapping: CstMapping = {}
for alias in depend_aliases:
cst = source.cache.by_alias.get(alias)
if cst is not None:
alias_mapping[alias] = cst
alias_mapping = create_dependant_mapping(source, cst_list)
operation = self.cache.get_operation(source.model.pk)
self._cascade_inherit_cst(operation.pk, source, cst_list, alias_mapping, exclude)
self.engine.on_inherit_cst(operation.pk, source, cst_list, alias_mapping, exclude)
def after_change_cst_type(self, schemaID: int, target: int, new_type: CstType) -> None:
''' Trigger cascade resolutions when Constituenta type is changed. '''
operation = self.cache.get_operation(schemaID)
self._cascade_change_cst_type(operation.pk, target, new_type)
self.engine.on_change_cst_type(operation.pk, target, new_type)
def after_update_cst(self, source: RSFormCached, target: int, data: dict, old_data: dict) -> None:
''' Trigger cascade resolutions when Constituenta data is changed. '''
self.cache.insert_schema(source)
operation = self.cache.get_operation(source.model.pk)
depend_aliases = self._extract_data_references(data, old_data)
depend_aliases = extract_data_references(data, old_data)
alias_mapping: CstMapping = {}
for alias in depend_aliases:
cst = source.cache.by_alias.get(alias)
if cst is not None:
alias_mapping[alias] = cst
self._cascade_update_cst(
self.engine.on_update_cst(
operation=operation.pk,
cst_id=target,
data=data,
@ -320,15 +283,15 @@ class OperationSchemaCached:
mapping=alias_mapping
)
def before_delete_cst(self, sourceID: int, target: list[int]) -> None:
def before_delete_cst(self, operationID: int, target: list[int]) -> None:
''' Trigger cascade resolutions before Constituents are deleted. '''
operation = self.cache.get_operation(sourceID)
self._cascade_delete_inherited(operation.pk, target)
operation = self.cache.get_operation(operationID)
self.engine.on_delete_inherited(operation.pk, target)
def before_substitute(self, schemaID: int, substitutions: CstSubstitution) -> None:
''' Trigger cascade resolutions before Constituents are substituted. '''
operation = self.cache.get_operation(schemaID)
self._cascade_before_substitute(substitutions, operation)
self.engine.on_before_substitute(operation.pk, substitutions)
def before_delete_arguments(self, target: Operation, arguments: list[Operation]) -> None:
''' Trigger cascade resolutions before arguments are deleted. '''
@ -337,7 +300,7 @@ class OperationSchemaCached:
for argument in arguments:
parent_schema = self.cache.get_schema(argument)
if parent_schema is not None:
self._execute_delete_inherited(target.pk, [cst.pk for cst in parent_schema.cache.constituents])
self.engine.delete_inherited(target.pk, [cst.pk for cst in parent_schema.cache.constituents])
def after_create_arguments(self, target: Operation, arguments: list[Operation]) -> None:
''' Trigger cascade resolutions after arguments are created. '''
@ -348,336 +311,27 @@ class OperationSchemaCached:
parent_schema = self.cache.get_schema(argument)
if parent_schema is None:
continue
self._execute_inherit_cst(
self.engine.inherit_cst(
target_operation=target.pk,
source=parent_schema,
items=list(parent_schema.constituentsQ().order_by('order')),
mapping={}
)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def _cascade_inherit_cst(
self, target_operation: int,
source: RSFormCached,
items: list[Constituenta],
mapping: CstMapping,
exclude: Optional[list[int]] = None
) -> None:
children = self.cache.graph.outputs[target_operation]
if len(children) == 0:
return
for child_id in children:
if not exclude or child_id not in exclude:
self._execute_inherit_cst(child_id, source, items, mapping)
def after_create_attribution(self, schemaID: int, associations: list[Attribution],
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when Attribution is created. '''
operation = self.cache.get_operation(schemaID)
self.engine.on_inherit_attribution(operation.pk, associations, exclude)
def _execute_inherit_cst(
self,
target_operation: int,
source: RSFormCached,
items: list[Constituenta],
mapping: CstMapping
) -> None:
operation = self.cache.operation_by_id[target_operation]
destination = self.cache.get_schema(operation)
if destination is None:
return
def before_delete_attribution(self, schemaID: int, associations: list[Attribution]) -> None:
''' Trigger cascade resolutions when Attribution is deleted. '''
operation = self.cache.get_operation(schemaID)
self.engine.on_delete_attribution(operation.pk, associations)
self.cache.ensure_loaded_subs()
new_mapping = self._transform_mapping(mapping, operation, destination)
alias_mapping = OperationSchemaCached._produce_alias_mapping(new_mapping)
insert_where = self._determine_insert_position(items[0].pk, operation, source, destination)
new_cst_list = destination.insert_copy(items, insert_where, alias_mapping)
for index, cst in enumerate(new_cst_list):
new_inheritance = Inheritance.objects.create(
operation=operation,
child=cst,
parent=items[index]
)
self.cache.insert_inheritance(new_inheritance)
new_mapping = {alias_mapping[alias]: cst for alias, cst in new_mapping.items()}
self._cascade_inherit_cst(operation.pk, destination, new_cst_list, new_mapping)
def _cascade_change_cst_type(self, operation_id: int, cst_id: int, ctype: CstType) -> None:
children = self.cache.graph.outputs[operation_id]
if len(children) == 0:
return
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
successor_id = self.cache.get_inheritor(cst_id, child_id)
if successor_id is None:
continue
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
continue
if child_schema.change_cst_type(successor_id, ctype):
self._cascade_change_cst_type(child_id, successor_id, ctype)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def _cascade_update_cst(
self,
operation: int,
cst_id: int,
data: dict, old_data: dict,
mapping: CstMapping
) -> None:
children = self.cache.graph.outputs[operation]
if len(children) == 0:
return
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
successor_id = self.cache.get_inheritor(cst_id, child_id)
if successor_id is None:
continue
child_schema = self.cache.get_schema(child_operation)
assert child_schema is not None
new_mapping = self._transform_mapping(mapping, child_operation, child_schema)
alias_mapping = OperationSchemaCached._produce_alias_mapping(new_mapping)
successor = child_schema.cache.by_id.get(successor_id)
if successor is None:
continue
new_data = self._prepare_update_data(successor, data, old_data, alias_mapping)
if len(new_data) == 0:
continue
new_old_data = child_schema.update_cst(successor.pk, new_data)
if len(new_old_data) == 0:
continue
new_mapping = {alias_mapping[alias]: cst for alias, cst in new_mapping.items()}
self._cascade_update_cst(
operation=child_id,
cst_id=successor_id,
data=new_data,
old_data=new_old_data,
mapping=new_mapping
)
def _cascade_delete_inherited(self, operation: int, target: list[int]) -> None:
children = self.cache.graph.outputs[operation]
if len(children) == 0:
return
self.cache.ensure_loaded_subs()
for child_id in children:
self._execute_delete_inherited(child_id, target)
def _execute_delete_inherited(self, operation_id: int, parent_ids: list[int]) -> None:
operation = self.cache.operation_by_id[operation_id]
schema = self.cache.get_schema(operation)
if schema is None:
return
self._undo_substitutions_cst(parent_ids, operation, schema)
target_ids = self.cache.get_inheritors_list(parent_ids, operation_id)
self._cascade_delete_inherited(operation_id, target_ids)
if len(target_ids) > 0:
self.cache.remove_cst(operation_id, target_ids)
schema.delete_cst(target_ids)
def _cascade_before_substitute(self, substitutions: CstSubstitution, operation: Operation) -> None:
children = self.cache.graph.outputs[operation.pk]
if len(children) == 0:
return
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
continue
new_substitutions = self._transform_substitutions(substitutions, child_id, child_schema)
if len(new_substitutions) == 0:
continue
self._cascade_before_substitute(new_substitutions, child_operation)
child_schema.substitute(new_substitutions)
def _cascade_partial_mapping(
self,
mapping: CstMapping,
target: list[int],
operation: int,
schema: RSFormCached
) -> None:
alias_mapping = OperationSchemaCached._produce_alias_mapping(mapping)
schema.apply_partial_mapping(alias_mapping, target)
children = self.cache.graph.outputs[operation]
if len(children) == 0:
return
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
continue
new_mapping = self._transform_mapping(mapping, child_operation, child_schema)
if not new_mapping:
continue
new_target = self.cache.get_inheritors_list(target, child_id)
if len(new_target) == 0:
continue
self._cascade_partial_mapping(new_mapping, new_target, child_id, child_schema)
@staticmethod
def _produce_alias_mapping(mapping: CstMapping) -> dict[str, str]:
result: dict[str, str] = {}
for alias, cst in mapping.items():
if cst is None:
result[alias] = DELETED_ALIAS
else:
result[alias] = cst.alias
return result
def _transform_mapping(self, mapping: CstMapping, operation: Operation, schema: RSFormCached) -> CstMapping:
if len(mapping) == 0:
return mapping
result: CstMapping = {}
for alias, cst in mapping.items():
if cst is None:
result[alias] = None
continue
successor_id = self.cache.get_successor(cst.pk, operation.pk)
if successor_id is None:
continue
successor = schema.cache.by_id.get(successor_id)
if successor is None:
continue
result[alias] = successor
return result
def _determine_insert_position(
self, prototype_id: int,
operation: Operation,
source: RSFormCached,
destination: RSFormCached
) -> int:
''' Determine insert_after for new constituenta. '''
prototype = source.cache.by_id[prototype_id]
prototype_index = source.cache.constituents.index(prototype)
if prototype_index == 0:
return 0
prev_cst = source.cache.constituents[prototype_index - 1]
inherited_prev_id = self.cache.get_successor(prev_cst.pk, operation.pk)
if inherited_prev_id is None:
return INSERT_LAST
prev_cst = destination.cache.by_id[inherited_prev_id]
prev_index = destination.cache.constituents.index(prev_cst)
return prev_index + 1
def _extract_data_references(self, data: dict, old_data: dict) -> set[str]:
result: set[str] = set()
if 'definition_formal' in data:
result.update(extract_globals(data['definition_formal']))
result.update(extract_globals(old_data['definition_formal']))
if 'term_raw' in data:
result.update(extract_entities(data['term_raw']))
result.update(extract_entities(old_data['term_raw']))
if 'definition_raw' in data:
result.update(extract_entities(data['definition_raw']))
result.update(extract_entities(old_data['definition_raw']))
return result
def _prepare_update_data(self, cst: Constituenta, data: dict, old_data: dict, mapping: dict[str, str]) -> dict:
new_data = {}
if 'term_forms' in data:
if old_data['term_forms'] == cst.term_forms:
new_data['term_forms'] = data['term_forms']
if 'convention' in data:
new_data['convention'] = data['convention']
if 'definition_formal' in data:
new_data['definition_formal'] = replace_globals(data['definition_formal'], mapping)
if 'term_raw' in data:
if replace_entities(old_data['term_raw'], mapping) == cst.term_raw:
new_data['term_raw'] = replace_entities(data['term_raw'], mapping)
if 'definition_raw' in data:
if replace_entities(old_data['definition_raw'], mapping) == cst.definition_raw:
new_data['definition_raw'] = replace_entities(data['definition_raw'], mapping)
return new_data
def _transform_substitutions(
self,
target: CstSubstitution,
operation: int,
schema: RSFormCached
) -> CstSubstitution:
result: CstSubstitution = []
for current_sub in target:
sub_replaced = False
new_substitution_id = self.cache.get_inheritor(current_sub[1].pk, operation)
if new_substitution_id is None:
for sub in self.cache.substitutions[operation]:
if sub.original_id == current_sub[1].pk:
sub_replaced = True
new_substitution_id = self.cache.get_inheritor(sub.original_id, operation)
break
new_original_id = self.cache.get_inheritor(current_sub[0].pk, operation)
original_replaced = False
if new_original_id is None:
for sub in self.cache.substitutions[operation]:
if sub.original_id == current_sub[0].pk:
original_replaced = True
sub.original_id = current_sub[1].pk
sub.save()
new_original_id = new_substitution_id
new_substitution_id = self.cache.get_inheritor(sub.substitution_id, operation)
break
if sub_replaced and original_replaced:
raise ValidationError({'propagation': 'Substitution breaks OSS substitutions.'})
for sub in self.cache.substitutions[operation]:
if sub.substitution_id == current_sub[0].pk:
sub.substitution_id = current_sub[1].pk
sub.save()
if new_original_id is not None and new_substitution_id is not None:
result.append((schema.cache.by_id[new_original_id], schema.cache.by_id[new_substitution_id]))
return result
def _undo_substitutions_cst(self, target_ids: list[int], operation: Operation, schema: RSFormCached) -> None:
to_process = []
for sub in self.cache.substitutions[operation.pk]:
if sub.original_id in target_ids or sub.substitution_id in target_ids:
to_process.append(sub)
for sub in to_process:
self._undo_substitution(schema, sub, target_ids)
def _undo_substitution(
self,
schema: RSFormCached,
target: Substitution,
ignore_parents: Optional[list[int]] = None
) -> None:
if ignore_parents is None:
ignore_parents = []
operation_id = target.operation_id
original_schema, _, original_cst, substitution_cst = self.cache.unfold_sub(target)
dependant = []
for cst_id in original_schema.get_dependant([original_cst.pk]):
if cst_id not in ignore_parents:
inheritor_id = self.cache.get_inheritor(cst_id, operation_id)
if inheritor_id is not None:
dependant.append(inheritor_id)
self.cache.substitutions[operation_id].remove(target)
target.delete()
new_original: Optional[Constituenta] = None
if original_cst.pk not in ignore_parents:
full_cst = Constituenta.objects.get(pk=original_cst.pk)
self.after_create_cst(original_schema, [full_cst])
new_original_id = self.cache.get_inheritor(original_cst.pk, operation_id)
assert new_original_id is not None
new_original = schema.cache.by_id[new_original_id]
if len(dependant) == 0:
return
substitution_id = self.cache.get_inheritor(substitution_cst.pk, operation_id)
assert substitution_id is not None
substitution_inheritor = schema.cache.by_id[substitution_id]
mapping = {substitution_inheritor.alias: new_original}
self._cascade_partial_mapping(mapping, dependant, operation_id, schema)
def _process_added_substitutions(self, schema: Optional[RSFormCached], added: list[Substitution]) -> None:
if len(added) == 0:
def _on_add_substitutions(self, schema: Optional[RSFormCached], added: list[Substitution]) -> None:
''' Trigger cascade resolutions when Constituenta substitution is added. '''
if not added:
return
if schema is None:
for sub in added:
@ -697,185 +351,3 @@ class OperationSchemaCached:
schema.substitute(cst_mapping)
for sub in added:
self.cache.insert_substitution(sub)
class OssCache:
''' Cache for OSS data. '''
def __init__(self, oss: OperationSchemaCached):
self._oss = oss
self._schemas: list[RSFormCached] = []
self._schema_by_id: dict[int, RSFormCached] = {}
self.operations = list(Operation.objects.filter(oss=oss.model).only('result_id', 'operation_type'))
self.operation_by_id = {operation.pk: operation for operation in self.operations}
self.graph = Graph[int]()
for operation in self.operations:
self.graph.add_node(operation.pk)
references = Reference.objects.filter(reference__oss=self._oss.model).only('reference_id', 'target_id')
self.reference_target = {ref.reference_id: ref.target_id for ref in references}
arguments = Argument.objects \
.filter(operation__oss=self._oss.model) \
.only('operation_id', 'argument_id') \
.order_by('order')
for argument in arguments:
self.graph.add_edge(argument.argument_id, argument.operation_id)
target = self.reference_target.get(argument.argument_id)
if target is not None:
self.graph.add_edge(target, argument.operation_id)
self.is_loaded_subs = False
self.substitutions: dict[int, list[Substitution]] = {}
self.inheritance: dict[int, list[Inheritance]] = {}
def ensure_loaded_subs(self) -> None:
''' Ensure cache is fully loaded. '''
if self.is_loaded_subs:
return
self.is_loaded_subs = True
for operation in self.operations:
self.inheritance[operation.pk] = []
self.substitutions[operation.pk] = []
for sub in Substitution.objects.filter(operation__oss=self._oss.model).only(
'operation_id', 'original_id', 'substitution_id'):
self.substitutions[sub.operation_id].append(sub)
for item in Inheritance.objects.filter(operation__oss=self._oss.model).only(
'operation_id', 'parent_id', 'child_id'):
self.inheritance[item.operation_id].append(item)
def get_schema(self, operation: Operation) -> Optional[RSFormCached]:
''' Get schema by Operation. '''
if operation.result_id is None:
return None
if operation.result_id in self._schema_by_id:
return self._schema_by_id[operation.result_id]
else:
schema = RSFormCached.from_id(operation.result_id)
schema.cache.ensure_loaded()
self._insert_new(schema)
return schema
def get_operation(self, schemaID: int) -> Operation:
''' Get operation by schema. '''
for operation in self.operations:
if operation.result_id == schemaID and operation.operation_type != OperationType.REFERENCE:
return operation
raise ValueError(f'Operation for schema {schemaID} not found')
def get_inheritor(self, parent_cst: int, operation: int) -> Optional[int]:
''' Get child for parent inside target RSFrom. '''
for item in self.inheritance[operation]:
if item.parent_id == parent_cst:
return item.child_id
return None
def get_inheritors_list(self, target: list[int], operation: int) -> list[int]:
''' Get child for parent inside target RSFrom. '''
result = []
for item in self.inheritance[operation]:
if item.parent_id in target:
result.append(item.child_id)
return result
def get_successor(self, parent_cst: int, operation: int) -> Optional[int]:
''' Get child for parent inside target RSFrom including substitutions. '''
for sub in self.substitutions[operation]:
if sub.original_id == parent_cst:
return self.get_inheritor(sub.substitution_id, operation)
return self.get_inheritor(parent_cst, operation)
def insert_schema(self, schema: RSFormCached) -> None:
''' Insert new schema. '''
if not self._schema_by_id.get(schema.model.pk):
schema.cache.ensure_loaded()
self._insert_new(schema)
def insert_argument(self, argument: Argument) -> None:
''' Insert new argument. '''
self.graph.add_edge(argument.argument_id, argument.operation_id)
target = self.reference_target.get(argument.argument_id)
if target is not None:
self.graph.add_edge(target, argument.operation_id)
def insert_inheritance(self, inheritance: Inheritance) -> None:
''' Insert new inheritance. '''
self.inheritance[inheritance.operation_id].append(inheritance)
def insert_substitution(self, sub: Substitution) -> None:
''' Insert new substitution. '''
self.substitutions[sub.operation_id].append(sub)
def remove_cst(self, operation: int, target: list[int]) -> None:
''' Remove constituents from operation. '''
subs_to_delete = [
sub for sub in self.substitutions[operation]
if sub.original_id in target or sub.substitution_id in target
]
for sub in subs_to_delete:
self.substitutions[operation].remove(sub)
inherit_to_delete = [item for item in self.inheritance[operation] if item.child_id in target]
for item in inherit_to_delete:
self.inheritance[operation].remove(item)
def remove_schema(self, schema: RSFormCached) -> None:
''' Remove schema from cache. '''
self._schemas.remove(schema)
del self._schema_by_id[schema.model.pk]
def remove_operation(self, operation: int) -> None:
''' Remove operation from cache. '''
target = self.operation_by_id[operation]
self.graph.remove_node(operation)
if target.result_id in self._schema_by_id:
self._schemas.remove(self._schema_by_id[target.result_id])
del self._schema_by_id[target.result_id]
self.operations.remove(self.operation_by_id[operation])
del self.operation_by_id[operation]
if operation in self.reference_target:
del self.reference_target[operation]
if self.is_loaded_subs:
del self.substitutions[operation]
del self.inheritance[operation]
def remove_argument(self, argument: Argument) -> None:
''' Remove argument from cache. '''
self.graph.remove_edge(argument.argument_id, argument.operation_id)
target = self.reference_target.get(argument.argument_id)
if target is not None:
if not Argument.objects.filter(argument_id=target, operation_id=argument.operation_id).exists():
self.graph.remove_edge(target, argument.operation_id)
def remove_substitution(self, target: Substitution) -> None:
''' Remove substitution from cache. '''
self.substitutions[target.operation_id].remove(target)
def remove_inheritance(self, target: Inheritance) -> None:
''' Remove inheritance from cache. '''
self.inheritance[target.operation_id].remove(target)
def unfold_sub(self, sub: Substitution) -> tuple[RSFormCached, RSFormCached, Constituenta, Constituenta]:
''' Unfold substitution into original and substitution forms. '''
operation = self.operation_by_id[sub.operation_id]
parents = self.graph.inputs[operation.pk]
original_cst = None
substitution_cst = None
original_schema = None
substitution_schema = None
for parent_id in parents:
parent_schema = self.get_schema(self.operation_by_id[parent_id])
if parent_schema is None:
continue
if sub.original_id in parent_schema.cache.by_id:
original_schema = parent_schema
original_cst = original_schema.cache.by_id[sub.original_id]
if sub.substitution_id in parent_schema.cache.by_id:
substitution_schema = parent_schema
substitution_cst = substitution_schema.cache.by_id[sub.substitution_id]
if original_schema is None or substitution_schema is None or original_cst is None or substitution_cst is None:
raise ValueError(f'Parent schema for Substitution-{sub.pk} not found.')
return original_schema, substitution_schema, original_cst, substitution_cst
def _insert_new(self, schema: RSFormCached) -> None:
self._schemas.append(schema)
self._schema_by_id[schema.model.pk] = schema

View File

@ -0,0 +1,188 @@
''' Models: OSS API. '''
from typing import Optional
from apps.rsform.graph import Graph
from apps.rsform.models import RSFormCached
from .Argument import Argument
from .Inheritance import Inheritance
from .Operation import Operation, OperationType
from .Replica import Replica
from .Substitution import Substitution
class OssCache:
''' Cache for OSS data. '''
def __init__(self, item_id: int):
self._item_id = item_id
self._schemas: list[RSFormCached] = []
self._schema_by_id: dict[int, RSFormCached] = {}
self.operations = list(Operation.objects.filter(oss_id=item_id).only('result_id', 'operation_type'))
self.operation_by_id = {operation.pk: operation for operation in self.operations}
self.graph = Graph[int]()
self.extend_graph = Graph[int]()
for operation in self.operations:
self.graph.add_node(operation.pk)
self.extend_graph.add_node(operation.pk)
replicas = Replica.objects.filter(replica__oss_id=self._item_id).only('replica_id', 'original_id')
self.replica_original = {rep.replica_id: rep.original_id for rep in replicas}
arguments = Argument.objects \
.filter(operation__oss_id=self._item_id) \
.only('operation_id', 'argument_id') \
.order_by('order')
for argument in arguments:
self.graph.add_edge(argument.argument_id, argument.operation_id)
self.extend_graph.add_edge(argument.argument_id, argument.operation_id)
original = self.replica_original.get(argument.argument_id)
if original is not None:
self.extend_graph.add_edge(original, argument.operation_id)
self.is_loaded_subs = False
self.substitutions: dict[int, list[Substitution]] = {}
self.inheritance: dict[int, list[Inheritance]] = {}
def ensure_loaded_subs(self) -> None:
''' Ensure cache is fully loaded. '''
if self.is_loaded_subs:
return
self.is_loaded_subs = True
for operation in self.operations:
self.inheritance[operation.pk] = []
self.substitutions[operation.pk] = []
for sub in Substitution.objects.filter(operation__oss_id=self._item_id).only(
'operation_id', 'original_id', 'substitution_id', 'original__schema_id'):
self.substitutions[sub.operation_id].append(sub)
for item in Inheritance.objects.filter(operation__oss_id=self._item_id).only(
'operation_id', 'parent_id', 'child_id'):
self.inheritance[item.operation_id].append(item)
def get_schema(self, operation: Operation) -> Optional[RSFormCached]:
''' Get schema by Operation. '''
if operation.result_id is None:
return None
if operation.result_id in self._schema_by_id:
return self._schema_by_id[operation.result_id]
else:
schema = RSFormCached.from_id(operation.result_id)
schema.cache.ensure_loaded()
self._insert_new(schema)
return schema
def get_schema_by_id(self, target: int) -> RSFormCached:
''' Get schema by Operation. '''
if target in self._schema_by_id:
return self._schema_by_id[target]
else:
schema = RSFormCached.from_id(target)
schema.cache.ensure_loaded()
self._insert_new(schema)
return schema
def get_operation(self, schemaID: int) -> Operation:
''' Get operation by schema. '''
for operation in self.operations:
if operation.result_id == schemaID and operation.operation_type != OperationType.REPLICA:
return operation
raise ValueError(f'Operation for schema {schemaID} not found')
def get_inheritor(self, parent_cst: int, operation: int) -> Optional[int]:
''' Get child for parent inside target RSFrom. '''
for item in self.inheritance[operation]:
if item.parent_id == parent_cst:
return item.child_id
return None
def get_inheritors_list(self, target: list[int], operation: int) -> list[int]:
''' Get child for parent inside target RSFrom. '''
result = []
for item in self.inheritance[operation]:
if item.parent_id in target:
result.append(item.child_id)
return result
def get_successor(self, parent_cst: int, operation: int) -> Optional[int]:
''' Get child for parent inside target RSFrom including substitutions. '''
for sub in self.substitutions[operation]:
if sub.original_id == parent_cst:
return self.get_inheritor(sub.substitution_id, operation)
return self.get_inheritor(parent_cst, operation)
def insert_schema(self, schema: RSFormCached) -> None:
''' Insert new schema. '''
if not self._schema_by_id.get(schema.model.pk):
schema.cache.ensure_loaded()
self._insert_new(schema)
def insert_argument(self, argument: Argument) -> None:
''' Insert new argument. '''
self.graph.add_edge(argument.argument_id, argument.operation_id)
self.extend_graph.add_edge(argument.argument_id, argument.operation_id)
target = self.replica_original.get(argument.argument_id)
if target is not None:
self.extend_graph.add_edge(target, argument.operation_id)
def insert_inheritance(self, inheritance: Inheritance) -> None:
''' Insert new inheritance. '''
self.inheritance[inheritance.operation_id].append(inheritance)
def insert_substitution(self, sub: Substitution) -> None:
''' Insert new substitution. '''
self.substitutions[sub.operation_id].append(sub)
def remove_cst(self, operation: int, target: list[int]) -> None:
''' Remove constituents from operation. '''
subs_to_delete = [
sub for sub in self.substitutions[operation]
if sub.original_id in target or sub.substitution_id in target
]
for sub in subs_to_delete:
self.substitutions[operation].remove(sub)
inherit_to_delete = [item for item in self.inheritance[operation] if item.child_id in target]
for item in inherit_to_delete:
self.inheritance[operation].remove(item)
def remove_schema(self, schema: RSFormCached) -> None:
''' Remove schema from cache. '''
self._schemas.remove(schema)
del self._schema_by_id[schema.model.pk]
def remove_operation(self, operation: int) -> None:
''' Remove operation from cache. '''
target = self.operation_by_id[operation]
self.graph.remove_node(operation)
self.extend_graph.remove_node(operation)
if target.result_id in self._schema_by_id:
self._schemas.remove(self._schema_by_id[target.result_id])
del self._schema_by_id[target.result_id]
self.operations.remove(self.operation_by_id[operation])
del self.operation_by_id[operation]
if operation in self.replica_original:
del self.replica_original[operation]
if self.is_loaded_subs:
del self.substitutions[operation]
del self.inheritance[operation]
def remove_argument(self, argument: Argument) -> None:
''' Remove argument from cache. '''
self.graph.remove_edge(argument.argument_id, argument.operation_id)
self.extend_graph.remove_edge(argument.argument_id, argument.operation_id)
target = self.replica_original.get(argument.argument_id)
if target is not None:
if not Argument.objects.filter(argument_id=target, operation_id=argument.operation_id).exists():
self.extend_graph.remove_edge(target, argument.operation_id)
def remove_substitution(self, target: Substitution) -> None:
''' Remove substitution from cache. '''
self.substitutions[target.operation_id].remove(target)
def remove_inheritance(self, target: Inheritance) -> None:
''' Remove inheritance from cache. '''
self.inheritance[target.operation_id].remove(target)
def _insert_new(self, schema: RSFormCached) -> None:
self._schemas.append(schema)
self._schema_by_id[schema.model.pk] = schema

View File

@ -0,0 +1,386 @@
''' Models: Change propagation engine. '''
from typing import Optional
from rest_framework.serializers import ValidationError
from apps.rsform.models import INSERT_LAST, Attribution, Constituenta, CstType, RSFormCached
from .Inheritance import Inheritance
from .Operation import Operation
from .OssCache import OssCache
from .Substitution import Substitution
from .utils import (
CstMapping,
CstSubstitution,
create_dependant_mapping,
cst_mapping_to_alias,
map_cst_update_data
)
class PropagationEngine:
''' OSS changes propagation engine. '''
def __init__(self, cache: OssCache):
self.cache = cache
def on_change_cst_type(self, operation_id: int, cst_id: int, ctype: CstType) -> None:
''' Trigger cascade resolutions when Constituenta type is changed. '''
children = self.cache.extend_graph.outputs[operation_id]
if not children:
return
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
successor_id = self.cache.get_inheritor(cst_id, child_id)
if successor_id is None:
continue
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
continue
if child_schema.change_cst_type(successor_id, ctype):
self.on_change_cst_type(child_id, successor_id, ctype)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def on_inherit_cst(
self,
target_operation: int,
source: RSFormCached,
items: list[Constituenta],
mapping: CstMapping,
exclude: Optional[list[int]] = None
) -> None:
''' Trigger cascade resolutions when Constituenta is inherited. '''
children = self.cache.extend_graph.outputs[target_operation]
if not children:
return
for child_id in children:
if not exclude or child_id not in exclude:
self.inherit_cst(child_id, source, items, mapping)
def inherit_cst(
self,
target_operation: int,
source: RSFormCached,
items: list[Constituenta],
mapping: CstMapping
) -> None:
''' Execute inheritance of Constituenta. '''
operation = self.cache.operation_by_id[target_operation]
destination = self.cache.get_schema(operation)
if destination is None:
return
self.cache.ensure_loaded_subs()
new_mapping = self._transform_mapping(mapping, operation, destination)
alias_mapping = cst_mapping_to_alias(new_mapping)
insert_where = self._determine_insert_position(items[0].pk, operation, source, destination)
new_cst_list = destination.insert_copy(items, insert_where, alias_mapping)
for index, cst in enumerate(new_cst_list):
new_inheritance = Inheritance.objects.create(
operation=operation,
child=cst,
parent=items[index]
)
self.cache.insert_inheritance(new_inheritance)
new_mapping = {alias_mapping[alias]: cst for alias, cst in new_mapping.items()}
self.on_inherit_cst(operation.pk, destination, new_cst_list, new_mapping)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def on_update_cst(
self,
operation: int,
cst_id: int,
data: dict, old_data: dict,
mapping: CstMapping
) -> None:
''' Trigger cascade resolutions when Constituenta data is changed. '''
children = self.cache.extend_graph.outputs[operation]
if not children:
return
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
successor_id = self.cache.get_inheritor(cst_id, child_id)
if successor_id is None:
continue
child_schema = self.cache.get_schema(child_operation)
assert child_schema is not None
new_mapping = self._transform_mapping(mapping, child_operation, child_schema)
alias_mapping = cst_mapping_to_alias(new_mapping)
successor = child_schema.cache.by_id.get(successor_id)
if successor is None:
continue
new_data = map_cst_update_data(successor, data, old_data, alias_mapping)
if not new_data:
continue
new_old_data = child_schema.update_cst(successor.pk, new_data)
if not new_old_data:
continue
new_mapping = {alias_mapping[alias]: cst for alias, cst in new_mapping.items()}
self.on_update_cst(
operation=child_id,
cst_id=successor_id,
data=new_data,
old_data=new_old_data,
mapping=new_mapping
)
def on_inherit_attribution(self, operationID: int,
items: list[Attribution],
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when Attribution is inherited. '''
children = self.cache.extend_graph.outputs[operationID]
if not children:
return
for child_id in children:
if not exclude or child_id not in exclude:
self.inherit_association(child_id, items)
def inherit_association(self, target: int, items: list[Attribution]) -> None:
''' Execute inheritance of Associations. '''
operation = self.cache.operation_by_id[target]
if operation.result is None or not items:
return
self.cache.ensure_loaded_subs()
existing_associations = set(
Attribution.objects.filter(
container__schema_id=operation.result_id,
).values_list('container_id', 'attribute_id')
)
new_associations: list[Attribution] = []
for assoc in items:
new_container = self.cache.get_inheritor(assoc.container_id, target)
new_attribute = self.cache.get_inheritor(assoc.attribute_id, target)
if new_container is None or new_attribute is None \
or new_attribute == new_container \
or (new_container, new_attribute) in existing_associations:
continue
new_associations.append(Attribution(
container_id=new_container,
attribute_id=new_attribute
))
if new_associations:
new_associations = Attribution.objects.bulk_create(new_associations)
self.on_inherit_attribution(target, new_associations)
def on_before_substitute(self, operationID: int, substitutions: CstSubstitution) -> None:
''' Trigger cascade resolutions when Constituenta substitution is executed. '''
children = self.cache.extend_graph.outputs[operationID]
if not children:
return
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
continue
new_substitutions = self._transform_substitutions(substitutions, child_id, child_schema)
if not new_substitutions:
continue
self.on_before_substitute(child_operation.pk, new_substitutions)
child_schema.substitute(new_substitutions)
def on_delete_attribution(self, operationID: int, associations: list[Attribution]) -> None:
''' Trigger cascade resolutions when Attribution is deleted. '''
children = self.cache.extend_graph.outputs[operationID]
if not children:
return
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
continue
deleted: list[Attribution] = []
for attr in associations:
new_container = self.cache.get_inheritor(attr.container_id, child_id)
new_attribute = self.cache.get_inheritor(attr.attribute_id, child_id)
if new_container is None or new_attribute is None:
continue
deleted_assoc = Attribution.objects.filter(
container=new_container,
attribute=new_attribute
)
if deleted_assoc.exists():
deleted.append(deleted_assoc[0])
if deleted:
self.on_delete_attribution(child_id, deleted)
Attribution.objects.filter(pk__in=[assoc.pk for assoc in deleted]).delete()
def on_delete_inherited(self, operation: int, target: list[int]) -> None:
''' Trigger cascade resolutions when Constituenta inheritance is deleted. '''
children = self.cache.extend_graph.outputs[operation]
if not children:
return
self.cache.ensure_loaded_subs()
for child_id in children:
self.delete_inherited(child_id, target)
def delete_inherited(self, operation_id: int, parent_ids: list[int]) -> None:
''' Execute deletion of Constituenta inheritance. '''
operation = self.cache.operation_by_id[operation_id]
schema = self.cache.get_schema(operation)
if schema is None:
return
self.undo_substitutions_cst(parent_ids, operation, schema)
target_ids = self.cache.get_inheritors_list(parent_ids, operation_id)
self.on_delete_inherited(operation_id, target_ids)
if target_ids:
self.cache.remove_cst(operation_id, target_ids)
schema.delete_cst(target_ids)
def undo_substitutions_cst(self, target_ids: list[int], operation: Operation, schema: RSFormCached) -> None:
''' Undo substitutions for Constituents. '''
to_process = []
for sub in self.cache.substitutions[operation.pk]:
if sub.original_id in target_ids or sub.substitution_id in target_ids:
to_process.append(sub)
for sub in to_process:
self.undo_substitution(schema, sub, target_ids)
def undo_substitution(
self,
schema: RSFormCached,
target: Substitution,
ignore_parents: Optional[list[int]] = None
) -> None:
''' Undo target substitution. '''
if ignore_parents is None:
ignore_parents = []
operation_id = target.operation_id
original_schema = self.cache.get_schema_by_id(target.original.schema_id)
dependant = []
for cst_id in original_schema.get_dependant([target.original_id]):
if cst_id not in ignore_parents:
inheritor_id = self.cache.get_inheritor(cst_id, operation_id)
if inheritor_id is not None:
dependant.append(inheritor_id)
self.cache.substitutions[operation_id].remove(target)
target.delete()
new_original: Optional[Constituenta] = None
if target.original_id not in ignore_parents:
full_cst = Constituenta.objects.get(pk=target.original_id)
cst_mapping = create_dependant_mapping(original_schema, [full_cst])
self.inherit_cst(operation_id, original_schema, [full_cst], cst_mapping)
new_original_id = self.cache.get_inheritor(target.original_id, operation_id)
assert new_original_id is not None
new_original = schema.cache.by_id[new_original_id]
if dependant:
substitution_id = self.cache.get_inheritor(target.substitution_id, operation_id)
assert substitution_id is not None
substitution_inheritor = schema.cache.by_id[substitution_id]
mapping = {substitution_inheritor.alias: new_original}
self._on_partial_mapping(mapping, dependant, operation_id, schema)
def _determine_insert_position(
self, prototype_id: int,
operation: Operation,
source: RSFormCached,
destination: RSFormCached
) -> int:
''' Determine insert_after for new constituenta. '''
prototype = source.cache.by_id[prototype_id]
prototype_index = source.cache.constituents.index(prototype)
if prototype_index == 0:
return 0
prev_cst = source.cache.constituents[prototype_index - 1]
inherited_prev_id = self.cache.get_successor(prev_cst.pk, operation.pk)
if inherited_prev_id is None:
return INSERT_LAST
prev_cst = destination.cache.by_id[inherited_prev_id]
prev_index = destination.cache.constituents.index(prev_cst)
return prev_index + 1
def _transform_mapping(self, mapping: CstMapping, operation: Operation, schema: RSFormCached) -> CstMapping:
if not mapping:
return mapping
result: CstMapping = {}
for alias, cst in mapping.items():
if cst is None:
result[alias] = None
continue
successor_id = self.cache.get_successor(cst.pk, operation.pk)
if successor_id is None:
continue
successor = schema.cache.by_id.get(successor_id)
if successor is None:
continue
result[alias] = successor
return result
def _transform_substitutions(
self,
target: CstSubstitution,
operation: int,
schema: RSFormCached
) -> CstSubstitution:
result: CstSubstitution = []
for current_sub in target:
sub_replaced = False
new_substitution_id = self.cache.get_inheritor(current_sub[1].pk, operation)
if new_substitution_id is None:
for sub in self.cache.substitutions[operation]:
if sub.original_id == current_sub[1].pk:
sub_replaced = True
new_substitution_id = self.cache.get_inheritor(sub.original_id, operation)
break
new_original_id = self.cache.get_inheritor(current_sub[0].pk, operation)
original_replaced = False
if new_original_id is None:
for sub in self.cache.substitutions[operation]:
if sub.original_id == current_sub[0].pk:
original_replaced = True
sub.original_id = current_sub[1].pk
sub.save()
new_original_id = new_substitution_id
new_substitution_id = self.cache.get_inheritor(sub.substitution_id, operation)
break
if sub_replaced and original_replaced:
raise ValidationError({'propagation': 'Substitution breaks OSS substitutions.'})
for sub in self.cache.substitutions[operation]:
if sub.substitution_id == current_sub[0].pk:
sub.substitution_id = current_sub[1].pk
sub.save()
if new_original_id is not None and new_substitution_id is not None:
result.append((schema.cache.by_id[new_original_id], schema.cache.by_id[new_substitution_id]))
return result
def _on_partial_mapping(
self,
mapping: CstMapping,
target: list[int],
operation: int,
schema: RSFormCached
) -> None:
''' Trigger cascade resolutions when Constituents are partially mapped. '''
alias_mapping = cst_mapping_to_alias(mapping)
schema.apply_partial_mapping(alias_mapping, target)
children = self.cache.extend_graph.outputs[operation]
if not children:
return
self.cache.ensure_loaded_subs()
for child_id in children:
child_operation = self.cache.operation_by_id[child_id]
child_schema = self.cache.get_schema(child_operation)
if child_schema is None:
continue
new_mapping = self._transform_mapping(mapping, child_operation, child_schema)
if not new_mapping:
continue
new_target = self.cache.get_inheritors_list(target, child_id)
if not new_target:
continue
self._on_partial_mapping(new_mapping, new_target, child_id, child_schema)

View File

@ -2,7 +2,7 @@
from typing import Optional
from apps.library.models import LibraryItem, LibraryItemType
from apps.rsform.models import Constituenta, CstType, RSFormCached
from apps.rsform.models import Attribution, Constituenta, CstType, RSFormCached
from .OperationSchemaCached import CstSubstitution, OperationSchemaCached
@ -60,7 +60,7 @@ class PropagationFacade:
def before_substitute(sourceID: int, substitutions: CstSubstitution,
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before constituents are substituted. '''
if len(substitutions) == 0:
if not substitutions:
return
hosts = _get_oss_hosts(sourceID)
for host in hosts:
@ -73,8 +73,29 @@ class PropagationFacade:
if item.item_type != LibraryItemType.RSFORM:
return
hosts = _get_oss_hosts(item.pk)
if len(hosts) == 0:
if not hosts:
return
ids = list(Constituenta.objects.filter(schema=item).order_by('order').values_list('pk', flat=True))
PropagationFacade.before_delete_cst(item.pk, ids, exclude)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).before_delete_cst(item.pk, ids)
@staticmethod
def after_create_attribution(sourceID: int, associations: list[Attribution],
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when Attribution is created. '''
hosts = _get_oss_hosts(sourceID)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).after_create_attribution(sourceID, associations)
@staticmethod
def before_delete_attribution(sourceID: int,
associations: list[Attribution],
exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before Attribution is deleted. '''
hosts = _get_oss_hosts(sourceID)
for host in hosts:
if exclude is None or host.pk not in exclude:
OperationSchemaCached(host).before_delete_attribution(sourceID, associations)

View File

@ -1,27 +0,0 @@
''' Models: Operation Reference in OSS. '''
from django.db.models import CASCADE, ForeignKey, Model
class Reference(Model):
''' Operation Reference. '''
reference = ForeignKey(
verbose_name='Отсылка',
to='oss.Operation',
on_delete=CASCADE,
related_name='references'
)
target = ForeignKey(
verbose_name='Целевая Операция',
to='oss.Operation',
on_delete=CASCADE,
related_name='targets'
)
class Meta:
''' Model metadata. '''
verbose_name = 'Отсылка'
verbose_name_plural = 'Отсылки'
unique_together = [['reference', 'target']]
def __str__(self) -> str:
return f'{self.reference} -> {self.target}'

View File

@ -0,0 +1,27 @@
''' Models: Operation Replica in OSS. '''
from django.db.models import CASCADE, ForeignKey, Model
class Replica(Model):
''' Operation Replica. '''
replica = ForeignKey(
verbose_name='Реплика',
to='oss.Operation',
on_delete=CASCADE,
related_name='replicas'
)
original = ForeignKey(
verbose_name='Целевая Операция',
to='oss.Operation',
on_delete=CASCADE,
related_name='targets'
)
class Meta:
''' Model metadata. '''
verbose_name = 'Реплика'
verbose_name_plural = 'Реплики'
unique_together = [['replica', 'original']]
def __str__(self) -> str:
return f'{self.replica} -> {self.original}'

View File

@ -8,5 +8,5 @@ from .Operation import Operation, OperationType
from .OperationSchema import OperationSchema
from .OperationSchemaCached import OperationSchemaCached
from .PropagationFacade import PropagationFacade
from .Reference import Reference
from .Replica import Replica
from .Substitution import Substitution

View File

@ -0,0 +1,79 @@
''' Utils for OSS models. '''
from typing import Optional
from cctext import extract_entities
from apps.rsform.models import (
DELETED_ALIAS,
Constituenta,
RSFormCached,
extract_globals,
replace_entities,
replace_globals
)
CstMapping = dict[str, Optional[Constituenta]]
CstSubstitution = list[tuple[Constituenta, Constituenta]]
def cst_mapping_to_alias(mapping: CstMapping) -> dict[str, str]:
''' Convert constituenta mapping to alias mapping. '''
result: dict[str, str] = {}
for alias, cst in mapping.items():
if cst is None:
result[alias] = DELETED_ALIAS
else:
result[alias] = cst.alias
return result
def map_cst_update_data(cst: Constituenta, data: dict, old_data: dict, mapping: dict[str, str]) -> dict:
''' Map data for constituenta update. '''
new_data = {}
if 'term_forms' in data:
if old_data['term_forms'] == cst.term_forms:
new_data['term_forms'] = data['term_forms']
if 'convention' in data:
new_data['convention'] = data['convention']
if 'definition_formal' in data:
new_data['definition_formal'] = replace_globals(data['definition_formal'], mapping)
if 'term_raw' in data:
if replace_entities(old_data['term_raw'], mapping) == cst.term_raw:
new_data['term_raw'] = replace_entities(data['term_raw'], mapping)
if 'definition_raw' in data:
if replace_entities(old_data['definition_raw'], mapping) == cst.definition_raw:
new_data['definition_raw'] = replace_entities(data['definition_raw'], mapping)
return new_data
def extract_data_references(data: dict, old_data: dict) -> set[str]:
''' Extract references from data. '''
result: set[str] = set()
if 'definition_formal' in data:
result.update(extract_globals(data['definition_formal']))
result.update(extract_globals(old_data['definition_formal']))
if 'term_raw' in data:
result.update(extract_entities(data['term_raw']))
result.update(extract_entities(old_data['term_raw']))
if 'definition_raw' in data:
result.update(extract_entities(data['definition_raw']))
result.update(extract_entities(old_data['definition_raw']))
return result
def create_dependant_mapping(source: RSFormCached, cst_list: list[Constituenta]) -> CstMapping:
''' Create mapping for dependant Constituents. '''
if len(cst_list) == len(source.cache.constituents):
return {c.alias: c for c in source.cache.constituents}
inserted_aliases = [cst.alias for cst in cst_list]
depend_aliases: set[str] = set()
for item in cst_list:
depend_aliases.update(item.extract_references())
depend_aliases.difference_update(inserted_aliases)
alias_mapping: CstMapping = {}
for alias in depend_aliases:
cst = source.cache.by_alias.get(alias)
if cst is not None:
alias_mapping[alias] = cst
return alias_mapping

View File

@ -6,18 +6,18 @@ from .data_access import (
BlockSerializer,
CloneSchemaSerializer,
CreateBlockSerializer,
CreateReferenceSerializer,
CreateReplicaSerializer,
CreateSchemaSerializer,
CreateSynthesisSerializer,
DeleteBlockSerializer,
DeleteOperationSerializer,
DeleteReferenceSerializer,
DeleteReplicaSerializer,
ImportSchemaSerializer,
MoveItemsSerializer,
OperationSchemaSerializer,
OperationSerializer,
ReferenceSerializer,
RelocateConstituentsSerializer,
ReplicaSerializer,
SetOperationInputSerializer,
TargetOperationSerializer,
UpdateBlockSerializer,

View File

@ -20,7 +20,7 @@ from ..models import (
Layout,
Operation,
OperationType,
Reference,
Replica,
Substitution
)
from .basics import NodeSerializer, PositionSerializer, SubstitutionExSerializer
@ -47,19 +47,19 @@ class BlockSerializer(StrictModelSerializer):
class ArgumentSerializer(StrictModelSerializer):
''' Serializer: Operation data. '''
''' Serializer: Operation arguments. '''
class Meta:
''' serializer metadata. '''
model = Argument
fields = ('operation', 'argument')
class ReferenceSerializer(StrictModelSerializer):
''' Serializer: Reference data. '''
class ReplicaSerializer(StrictModelSerializer):
''' Serializer: Replica relation. '''
class Meta:
''' serializer metadata. '''
model = Reference
fields = ('reference', 'target')
model = Replica
fields = ('replica', 'original')
class CreateBlockSerializer(StrictSerializer):
@ -251,15 +251,15 @@ class CloneSchemaSerializer(StrictSerializer):
raise serializers.ValidationError({
'source_operation': msg.operationResultEmpty(source_operation.alias)
})
if source_operation.operation_type == OperationType.REFERENCE:
if source_operation.operation_type == OperationType.REPLICA:
raise serializers.ValidationError({
'source_operation': msg.referenceTypeNotAllowed()
'source_operation': msg.replicaNotAllowed()
})
return attrs
class CreateReferenceSerializer(StrictSerializer):
''' Serializer: Create reference operation. '''
class CreateReplicaSerializer(StrictSerializer):
''' Serializer: Create Replica operation. '''
layout = serializers.ListField(child=NodeSerializer())
target = PKField(many=False, queryset=Operation.objects.all())
position = PositionSerializer()
@ -269,11 +269,11 @@ class CreateReferenceSerializer(StrictSerializer):
target = cast(Operation, attrs['target'])
if target.oss_id != oss.pk:
raise serializers.ValidationError({
'target_operation': msg.operationNotInOSS()
'target': msg.operationNotInOSS()
})
if target.operation_type == OperationType.REFERENCE:
if target.operation_type == OperationType.REPLICA:
raise serializers.ValidationError({
'target_operation': msg.referenceTypeNotAllowed()
'target': msg.replicaNotAllowed()
})
return attrs
@ -328,6 +328,10 @@ class CreateSynthesisSerializer(StrictSerializer):
})
schemas = [arg.result_id for arg in attrs['arguments'] if arg.result is not None]
if len(schemas) != len(set(schemas)):
raise serializers.ValidationError({
'arguments': msg.duplicateSchemasInArguments()
})
substitutions = attrs['substitutions']
to_delete = {x['original'].pk for x in substitutions}
deleted = set()
@ -375,6 +379,7 @@ class UpdateOperationSerializer(StrictSerializer):
required=False
)
# pylint: disable=too-many-branches
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
parent = attrs['item_data'].get('parent')
@ -405,6 +410,10 @@ class UpdateOperationSerializer(StrictSerializer):
if 'substitutions' not in attrs:
return attrs
schemas = [arg.result_id for arg in attrs['arguments'] if arg.result is not None]
if len(schemas) != len(set(schemas)):
raise serializers.ValidationError({
'arguments': msg.duplicateSchemasInArguments()
})
substitutions = attrs['substitutions']
to_delete = {x['original'].pk for x in substitutions}
deleted = set()
@ -432,7 +441,7 @@ class UpdateOperationSerializer(StrictSerializer):
class DeleteOperationSerializer(StrictSerializer):
''' Serializer: Delete non-reference operation. '''
''' Serializer: Delete non-replica operation. '''
layout = serializers.ListField(
child=NodeSerializer()
)
@ -447,15 +456,15 @@ class DeleteOperationSerializer(StrictSerializer):
raise serializers.ValidationError({
'target': msg.operationNotInOSS()
})
if operation.operation_type == OperationType.REFERENCE:
if operation.operation_type == OperationType.REPLICA:
raise serializers.ValidationError({
'target': msg.referenceTypeNotAllowed()
'target': msg.replicaNotAllowed()
})
return attrs
class DeleteReferenceSerializer(StrictSerializer):
''' Serializer: Delete reference operation. '''
class DeleteReplicaSerializer(StrictSerializer):
''' Serializer: Delete Replica operation. '''
layout = serializers.ListField(
child=NodeSerializer()
)
@ -470,9 +479,9 @@ class DeleteReferenceSerializer(StrictSerializer):
raise serializers.ValidationError({
'target': msg.operationNotInOSS()
})
if operation.operation_type != OperationType.REFERENCE:
if operation.operation_type != OperationType.REPLICA:
raise serializers.ValidationError({
'target': msg.referenceTypeRequired()
'target': msg.replicaRequired()
})
return attrs
@ -535,8 +544,8 @@ class OperationSchemaSerializer(StrictModelSerializer):
substitutions = serializers.ListField(
child=SubstitutionExSerializer()
)
references = serializers.ListField(
child=ReferenceSerializer()
replicas = serializers.ListField(
child=ReplicaSerializer()
)
layout = serializers.ListField(
child=NodeSerializer()
@ -555,7 +564,7 @@ class OperationSchemaSerializer(StrictModelSerializer):
result['blocks'] = []
result['arguments'] = []
result['substitutions'] = []
result['references'] = []
result['replicas'] = []
for operation in Operation.objects.filter(oss=instance).order_by('pk'):
operation_data = OperationSerializer(operation).data
operation_result = operation.result
@ -578,8 +587,8 @@ class OperationSchemaSerializer(StrictModelSerializer):
substitution_term=F('substitution__term_resolved'),
).order_by('pk'):
result['substitutions'].append(substitution)
for reference in Reference.objects.filter(target__oss=instance).order_by('pk'):
result['references'].append(ReferenceSerializer(reference).data)
for replication in Replica.objects.filter(original__oss=instance).order_by('pk'):
result['replicas'].append(ReplicaSerializer(replication).data)
return result

View File

@ -3,5 +3,5 @@ from .t_Argument import *
from .t_Inheritance import *
from .t_Layout import *
from .t_Operation import *
from .t_Reference import *
from .t_Replica import *
from .t_Substitution import *

View File

@ -1,12 +1,12 @@
''' Testing models: Reference. '''
''' Testing models: Replica. '''
from django.test import TestCase
from apps.oss.models import Operation, OperationSchema, OperationType, Reference
from apps.oss.models import Operation, OperationSchema, OperationType, Replica
from apps.rsform.models import RSForm
class TestReference(TestCase):
''' Testing Reference model. '''
class TestReplica(TestCase):
''' Testing Replica model. '''
def setUp(self):
@ -19,26 +19,26 @@ class TestReference(TestCase):
)
self.operation2 = Operation.objects.create(
oss=self.oss.model,
operation_type=OperationType.REFERENCE,
operation_type=OperationType.REPLICA,
)
self.reference = Reference.objects.create(
reference=self.operation2,
target=self.operation1
self.replicas = Replica.objects.create(
replica=self.operation2,
original=self.operation1
)
def test_str(self):
testStr = f'{self.operation2} -> {self.operation1}'
self.assertEqual(str(self.reference), testStr)
self.assertEqual(str(self.replicas), testStr)
def test_cascade_delete_operation(self):
self.assertEqual(Reference.objects.count(), 1)
self.assertEqual(Replica.objects.count(), 1)
self.operation2.delete()
self.assertEqual(Reference.objects.count(), 0)
self.assertEqual(Replica.objects.count(), 0)
def test_cascade_delete_target(self):
self.assertEqual(Reference.objects.count(), 1)
self.assertEqual(Replica.objects.count(), 1)
self.operation1.delete()
self.assertEqual(Reference.objects.count(), 0)
self.assertEqual(Replica.objects.count(), 0)

View File

@ -73,7 +73,7 @@ class TestChangeAttributes(EndpointTester):
def test_set_owner(self):
data = {'user': self.user3.pk}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.owned.model.refresh_from_db()
self.ks1.model.refresh_from_db()
@ -89,7 +89,7 @@ class TestChangeAttributes(EndpointTester):
def test_set_location(self):
data = {'location': '/U/temp'}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.owned.model.refresh_from_db()
self.ks1.model.refresh_from_db()
@ -105,7 +105,7 @@ class TestChangeAttributes(EndpointTester):
def test_set_access_policy(self):
data = {'access_policy': AccessPolicy.PROTECTED}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.owned.model.refresh_from_db()
self.ks1.model.refresh_from_db()
@ -124,7 +124,7 @@ class TestChangeAttributes(EndpointTester):
Editor.set(self.ks3.model.pk, [self.user2.pk, self.user.pk])
data = {'users': [self.user3.pk]}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.owned.model.refresh_from_db()
self.ks1.model.refresh_from_db()
@ -140,7 +140,7 @@ class TestChangeAttributes(EndpointTester):
def test_sync_from_result(self):
data = {'alias': 'KS111', 'title': 'New Title', 'description': 'New description'}
self.executeOK(data=data, item=self.ks1.model.pk)
self.executeOK(data, item=self.ks1.model.pk)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, self.ks1.model)
@ -161,7 +161,7 @@ class TestChangeAttributes(EndpointTester):
'layout': self.layout_data
}
response = self.executeOK(data=data, item=self.owned_id)
response = self.executeOK(data, item=self.owned_id)
self.ks3.model.refresh_from_db()
self.assertEqual(self.ks3.model.alias, data['item_data']['alias'])
self.assertEqual(self.ks3.model.title, data['item_data']['title'])

View File

@ -102,7 +102,7 @@ class TestChangeConstituents(EndpointTester):
'cst_type': CstType.BASE,
'definition_formal': 'X4 = X5'
}
response = self.executeCreated(data=data, schema=self.ks1.model.pk)
response = self.executeCreated(data, schema=self.ks1.model.pk)
new_cst = Constituenta.objects.get(pk=response.data['new_cst']['id'])
inherited_cst = Constituenta.objects.get(as_child__parent_id=new_cst.pk)
self.assertEqual(self.ks1.constituentsQ().count(), 3)
@ -125,7 +125,7 @@ class TestChangeConstituents(EndpointTester):
'crucial': True,
}
}
response = self.executeOK(data=data, schema=self.ks1.model.pk)
response = self.executeOK(data, schema=self.ks1.model.pk)
self.ks1X1.refresh_from_db()
d2.refresh_from_db()
inherited_cst = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk)
@ -145,7 +145,7 @@ class TestChangeConstituents(EndpointTester):
@decl_endpoint('/api/rsforms/{schema}/delete-multiple-cst', method='patch')
def test_delete_constituenta(self):
data = {'items': [self.ks2X1.pk]}
response = self.executeOK(data=data, schema=self.ks2.model.pk)
response = self.executeOK(data, schema=self.ks2.model.pk)
inherited_cst = Constituenta.objects.get(as_child__parent_id=self.ks2D1.pk)
self.ks2D1.refresh_from_db()
self.assertEqual(self.ks2.constituentsQ().count(), 1)
@ -161,7 +161,7 @@ class TestChangeConstituents(EndpointTester):
'original': self.ks1X1.pk,
'substitution': self.ks1X2.pk
}]}
self.executeOK(data=data, schema=self.ks1.model.pk)
self.executeOK(data, schema=self.ks1.model.pk)
self.ks1X2.refresh_from_db()
d2.refresh_from_db()
self.assertEqual(self.ks1.constituentsQ().count(), 1)

View File

@ -133,7 +133,7 @@ class TestChangeOperations(EndpointTester):
'layout': self.layout_data,
'target': self.operation2.pk
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
@ -155,7 +155,7 @@ class TestChangeOperations(EndpointTester):
'target': self.operation2.pk,
'input': None
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
@ -188,7 +188,7 @@ class TestChangeOperations(EndpointTester):
'target': self.operation2.pk,
'input': ks6.model.pk
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
ks4Dks6 = Constituenta.objects.get(as_child__parent_id=ks6D1.pk)
self.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db()
@ -234,7 +234,7 @@ class TestChangeOperations(EndpointTester):
'delete_schema': True
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getQ_substitutions()
@ -256,7 +256,7 @@ class TestChangeOperations(EndpointTester):
'delete_schema': True
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getQ_substitutions()
@ -278,7 +278,7 @@ class TestChangeOperations(EndpointTester):
'delete_schema': False
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.ks1.model.refresh_from_db()
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
@ -317,7 +317,7 @@ class TestChangeOperations(EndpointTester):
]
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getQ_substitutions()
@ -343,7 +343,7 @@ class TestChangeOperations(EndpointTester):
'arguments': [self.operation1.pk],
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getQ_substitutions()
@ -356,7 +356,7 @@ class TestChangeOperations(EndpointTester):
self.assertEqual(self.ks5D4.definition_formal, r'DEL DEL X3 DEL D1 D2 D3')
data['arguments'] = [self.operation1.pk, self.operation2.pk]
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getQ_substitutions()
@ -381,7 +381,7 @@ class TestChangeOperations(EndpointTester):
'target': self.operation4.pk,
'layout': self.layout_data
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.operation4.refresh_from_db()
self.ks5.model.refresh_from_db()
self.assertNotEqual(self.operation4.result, None)
@ -408,7 +408,7 @@ class TestChangeOperations(EndpointTester):
'items': [ks6A1.pk]
}
self.executeOK(data=data)
self.executeOK(data)
ks6.model.refresh_from_db()
self.ks1.model.refresh_from_db()
self.ks4.model.refresh_from_db()
@ -438,7 +438,7 @@ class TestChangeOperations(EndpointTester):
'items': [self.ks1X2.pk]
}
self.executeOK(data=data)
self.executeOK(data)
ks6.model.refresh_from_db()
self.ks1.model.refresh_from_db()
self.ks4.model.refresh_from_db()

View File

@ -1,6 +1,6 @@
''' Testing API: Propagate changes through references in OSS. '''
from apps.oss.models import OperationSchema, OperationType
from apps.oss.models import Inheritance, OperationSchema, OperationType
from apps.rsform.models import Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
@ -50,7 +50,7 @@ class ReferencePropagationTestCase(EndpointTester):
operation_type=OperationType.INPUT,
result=self.ks2.model
)
self.operation3 = self.owned.create_reference(self.operation1)
self.operation3 = self.owned.create_replica(self.operation1)
self.operation4 = self.owned.create_operation(
alias='4',
@ -79,8 +79,8 @@ class ReferencePropagationTestCase(EndpointTester):
)
self.owned.set_arguments(self.operation5.pk, [self.operation4, self.operation3])
self.owned.set_substitutions(self.operation5.pk, [{
'original': self.ks4X1,
'substitution': self.ks1X2
'original': self.ks1X2,
'substitution': self.ks4X1
}])
self.owned.execute_operation(self.operation5)
self.operation5.refresh_from_db()
@ -97,8 +97,8 @@ class ReferencePropagationTestCase(EndpointTester):
)
self.owned.set_arguments(self.operation6.pk, [self.operation2, self.operation3])
self.owned.set_substitutions(self.operation6.pk, [{
'original': self.ks2X1,
'substitution': self.ks1X1
'original': self.ks2X2,
'substitution': self.ks1X2
}])
self.owned.execute_operation(self.operation6)
self.operation6.refresh_from_db()
@ -139,8 +139,68 @@ class ReferencePropagationTestCase(EndpointTester):
'layout': self.layout_data,
'target': self.operation1.pk
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.assertEqual(self.ks6.constituentsQ().count(), 4)
# self.assertEqual(self.ks5.constituentsQ().count(), 5)
self.assertEqual(self.ks5.constituentsQ().count(), 5)
# TODO: add more tests
@decl_endpoint('/api/rsforms/{schema}/create-cst', method='post')
def test_create_constituenta(self):
data = {
'alias': 'X3',
'cst_type': CstType.BASE,
}
response = self.executeCreated(data, schema=self.ks1.model.pk)
new_cst = Constituenta.objects.get(pk=response.data['new_cst']['id'])
inherited = Constituenta.objects.filter(as_child__parent_id=new_cst.pk)
self.assertEqual(self.ks1.constituentsQ().count(), 4)
self.assertEqual(self.ks4.constituentsQ().count(), 7)
self.assertEqual(self.ks5.constituentsQ().count(), 11)
self.assertEqual(self.ks6.constituentsQ().count(), 7)
self.assertEqual(inherited.count(), 3)
@decl_endpoint('/api/rsforms/{schema}/delete-multiple-cst', method='patch')
def test_delete_constituenta(self):
data = {'items': [self.ks1X1.pk]}
response = self.executeOK(data, schema=self.ks1.model.pk)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
self.ks6D2.refresh_from_db()
self.assertEqual(self.ks4D2.definition_formal, r'X1 X2 X3 S1 D1')
self.assertEqual(self.ks5D4.definition_formal, r'X1 X2 X3 DEL S1 D1 D2 D3')
self.assertEqual(self.ks6D2.definition_formal, r'X1 DEL X3 S1 D1')
self.assertEqual(self.ks4.constituentsQ().count(), 6)
self.assertEqual(self.ks5.constituentsQ().count(), 8)
self.assertEqual(self.ks6.constituentsQ().count(), 5)
@decl_endpoint('/api/oss/{item}/delete-replica', method='patch')
def test_delete_replica_redirection(self):
data = {
'layout': self.layout_data,
'target': self.operation3.pk,
'keep_connections': True,
'keep_constituents': False
}
self.executeOK(data, item=self.owned_id)
self.assertEqual(self.ks4.constituentsQ().count(), 6)
self.assertEqual(self.ks5.constituentsQ().count(), 9)
self.assertEqual(self.ks6.constituentsQ().count(), 6)
@decl_endpoint('/api/oss/{item}/delete-replica', method='patch')
def test_delete_replica_constituents(self):
data = {
'layout': self.layout_data,
'target': self.operation3.pk,
'keep_connections': False,
'keep_constituents': True
}
ks5X4 = Constituenta.objects.get(schema=self.ks5.model, alias='X4')
self.assertEqual(Inheritance.objects.filter(child=ks5X4).count(), 1)
self.executeOK(data, item=self.owned_id)
self.assertEqual(self.ks4.constituentsQ().count(), 6)
self.assertEqual(self.ks5.constituentsQ().count(), 9)
self.assertEqual(self.ks6.constituentsQ().count(), 7)
self.assertEqual(Inheritance.objects.filter(child=ks5X4).count(), 0)

View File

@ -134,7 +134,7 @@ class TestChangeSubstitutions(EndpointTester):
'original': self.ks1X1.pk,
'substitution': self.ks1X2.pk
}]}
self.executeOK(data=data, schema=self.ks1.model.pk)
self.executeOK(data, schema=self.ks1.model.pk)
self.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
@ -159,7 +159,7 @@ class TestChangeSubstitutions(EndpointTester):
'substitution': self.ks2X1.pk
}]
}
self.executeOK(data=data, schema=self.ks2.model.pk)
self.executeOK(data, schema=self.ks2.model.pk)
self.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
@ -179,7 +179,7 @@ class TestChangeSubstitutions(EndpointTester):
@decl_endpoint('/api/rsforms/{schema}/delete-multiple-cst', method='patch')
def test_delete_original(self):
data = {'items': [self.ks1X1.pk, self.ks1D1.pk]}
self.executeOK(data=data, schema=self.ks1.model.pk)
self.executeOK(data, schema=self.ks1.model.pk)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getQ_substitutions()
@ -194,7 +194,7 @@ class TestChangeSubstitutions(EndpointTester):
@decl_endpoint('/api/rsforms/{schema}/delete-multiple-cst', method='patch')
def test_delete_substitution(self):
data = {'items': [self.ks2S1.pk, self.ks2X2.pk]}
self.executeOK(data=data, schema=self.ks2.model.pk)
self.executeOK(data, schema=self.ks2.model.pk)
self.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()

View File

@ -81,9 +81,9 @@ class TestOssBlocks(EndpointTester):
'children_operations': [],
'children_blocks': []
}
self.executeNotFound(data=data, item=self.invalid_id)
self.executeNotFound(data, item=self.invalid_id)
response = self.executeCreated(data=data, item=self.owned_id)
response = self.executeCreated(data, item=self.owned_id)
self.assertEqual(len(response.data['oss']['blocks']), 3)
new_block = response.data['new_block']
layout = response.data['oss']['layout']
@ -94,9 +94,9 @@ class TestOssBlocks(EndpointTester):
self.assertEqual(block_node['height'], data['position']['height'])
self.operation1.refresh_from_db()
self.executeForbidden(data=data, item=self.unowned_id)
self.executeForbidden(data, item=self.unowned_id)
self.toggle_admin(True)
self.executeCreated(data=data, item=self.unowned_id)
self.executeCreated(data, item=self.unowned_id)
@decl_endpoint('/api/oss/{item}/create-block', method='post')
@ -118,13 +118,13 @@ class TestOssBlocks(EndpointTester):
'children_operations': [],
'children_blocks': []
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
data['item_data']['parent'] = self.block3.pk
self.executeBadData(data=data)
self.executeBadData(data)
data['item_data']['parent'] = self.block1.pk
response = self.executeCreated(data=data)
response = self.executeCreated(data)
new_block = response.data['new_block']
block_data = next((block for block in response.data['oss']['blocks'] if block['id'] == new_block), None)
self.assertEqual(block_data['parent'], self.block1.pk)
@ -148,20 +148,20 @@ class TestOssBlocks(EndpointTester):
'children_operations': [self.invalid_id],
'children_blocks': []
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
data['children_operations'] = [self.operation3.pk]
self.executeBadData(data=data)
self.executeBadData(data)
data['children_operations'] = [self.block1.pk]
self.executeBadData(data=data)
self.executeBadData(data)
data['children_operations'] = [self.operation1.pk]
data['children_blocks'] = [self.operation1.pk]
self.executeBadData(data=data)
self.executeBadData(data)
data['children_blocks'] = [self.block1.pk]
response = self.executeCreated(data=data)
response = self.executeCreated(data)
new_block = response.data['new_block']
self.operation1.refresh_from_db()
self.block1.refresh_from_db()
@ -188,13 +188,13 @@ class TestOssBlocks(EndpointTester):
'children_operations': [],
'children_blocks': [self.block1.pk]
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
data['item_data']['parent'] = self.block1.pk
self.executeBadData(data=data)
self.executeBadData(data)
data['children_blocks'] = [self.block2.pk]
self.executeCreated(data=data)
self.executeCreated(data)
@decl_endpoint('/api/oss/{item}/delete-block', method='patch')
@ -206,26 +206,26 @@ class TestOssBlocks(EndpointTester):
data = {
'layout': self.layout_data
}
self.executeBadData(data=data)
self.executeBadData(data)
data['target'] = self.operation1.pk
self.executeBadData(data=data)
self.executeBadData(data)
data['target'] = self.block3.pk
self.executeBadData(data=data)
self.executeBadData(data)
data['target'] = self.block2.pk
self.logout()
self.executeForbidden(data=data)
self.executeForbidden(data)
self.login()
response = self.executeOK(data=data)
response = self.executeOK(data)
self.operation2.refresh_from_db()
self.assertEqual(len(response.data['blocks']), 1)
self.assertEqual(self.operation2.parent.pk, self.block1.pk)
data['target'] = self.block1.pk
response = self.executeOK(data=data)
response = self.executeOK(data)
self.operation1.refresh_from_db()
self.operation2.refresh_from_db()
self.assertEqual(len(response.data['blocks']), 0)
@ -246,25 +246,25 @@ class TestOssBlocks(EndpointTester):
'parent': None
},
}
self.executeBadData(data=data)
self.executeBadData(data)
data['target'] = self.block3.pk
self.toggle_admin(True)
self.executeBadData(data=data)
self.executeBadData(data)
data['target'] = self.block2.pk
self.logout()
self.executeForbidden(data=data)
self.executeForbidden(data)
self.login()
response = self.executeOK(data=data)
response = self.executeOK(data)
self.block2.refresh_from_db()
self.assertEqual(self.block2.title, data['item_data']['title'])
self.assertEqual(self.block2.description, data['item_data']['description'])
self.assertEqual(self.block2.parent, data['item_data']['parent'])
data['layout'] = self.layout_data
self.executeOK(data=data)
self.executeOK(data)
@decl_endpoint('/api/oss/{item}/update-block', method='patch')
@ -280,13 +280,13 @@ class TestOssBlocks(EndpointTester):
'parent': self.block2.pk
},
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
# Create a deeper hierarchy: block1 -> block2 -> block3
self.block3 = self.owned.create_block(title='3', parent=self.block2)
# Try to set block1's parent to block3 (should fail, indirect cycle)
data['item_data']['parent'] = self.block3.pk
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
# Setting block2's parent to block1 (valid, as block1 is not a descendant)
data = {
@ -297,4 +297,4 @@ class TestOssBlocks(EndpointTester):
'parent': self.block1.pk
},
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)

View File

@ -1,6 +1,6 @@
''' Testing API: Operation Schema - operations manipulation. '''
from apps.library.models import AccessPolicy, Editor, LibraryItem, LibraryItemType
from apps.oss.models import Argument, Operation, OperationSchema, OperationType, Reference
from apps.oss.models import Argument, Operation, OperationSchema, OperationType, Replica
from apps.rsform.models import Constituenta, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
@ -95,9 +95,9 @@ class TestOssOperations(EndpointTester):
'height': 50
}
}
self.executeNotFound(data=data, item=self.invalid_id)
self.executeNotFound(data, item=self.invalid_id)
response = self.executeCreated(data=data, item=self.owned_id)
response = self.executeCreated(data, item=self.owned_id)
self.assertEqual(len(response.data['oss']['operations']), 4)
new_operation_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
@ -122,9 +122,9 @@ class TestOssOperations(EndpointTester):
self.assertEqual(schema.location, self.owned.model.location)
self.assertIn(self.user2, schema.getQ_editors())
self.executeForbidden(data=data, item=self.unowned_id)
self.executeForbidden(data, item=self.unowned_id)
self.toggle_admin(True)
self.executeCreated(data=data, item=self.unowned_id)
self.executeCreated(data, item=self.unowned_id)
@decl_endpoint('/api/oss/{item}/clone-schema', method='post')
@ -141,10 +141,10 @@ class TestOssOperations(EndpointTester):
'height': 60
}
}
self.executeNotFound(data=data, item=self.invalid_id)
self.executeForbidden(data=data, item=self.unowned_id)
self.executeNotFound(data, item=self.invalid_id)
self.executeForbidden(data, item=self.unowned_id)
response = self.executeCreated(data=data, item=self.owned_id)
response = self.executeCreated(data, item=self.owned_id)
self.assertIn('new_operation', response.data)
self.assertIn('oss', response.data)
new_operation_id = response.data['new_operation']
@ -171,7 +171,7 @@ class TestOssOperations(EndpointTester):
unrelated_data = dict(data)
unrelated_data['source_operation'] = self.unowned_operation.pk
self.executeBadData(data=unrelated_data, item=self.owned_id)
self.executeBadData(unrelated_data, item=self.owned_id)
@decl_endpoint('/api/oss/{item}/create-schema', method='post')
@ -194,23 +194,23 @@ class TestOssOperations(EndpointTester):
}
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
block_unowned = self.unowned.create_block(title='TestBlock1')
data['item_data']['parent'] = block_unowned.id
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
block_owned = self.owned.create_block(title='TestBlock2')
data['item_data']['parent'] = block_owned.id
response = self.executeCreated(data=data, item=self.owned_id)
response = self.executeCreated(data, item=self.owned_id)
new_operation_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
self.assertEqual(len(response.data['oss']['operations']), 4)
self.assertEqual(new_operation['parent'], block_owned.id)
@decl_endpoint('/api/oss/{item}/create-reference', method='post')
def test_create_reference(self):
@decl_endpoint('/api/oss/{item}/create-replica', method='post')
def test_create_replica(self):
self.populateData()
data = {
'target': self.invalid_id,
@ -222,20 +222,20 @@ class TestOssOperations(EndpointTester):
'height': 40
}
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
data['target'] = self.unowned_operation.pk
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
data['target'] = self.operation1.pk
response = self.executeCreated(data=data, item=self.owned_id)
response = self.executeCreated(data, item=self.owned_id)
self.owned.model.refresh_from_db()
new_operation_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
self.assertEqual(new_operation['operation_type'], OperationType.REFERENCE)
self.assertEqual(new_operation['operation_type'], OperationType.REPLICA)
self.assertEqual(new_operation['parent'], self.operation1.parent_id)
self.assertEqual(new_operation['result'], self.operation1.result_id)
ref = Reference.objects.filter(reference_id=new_operation_id, target_id=self.operation1.pk).first()
ref = Replica.objects.filter(replica_id=new_operation_id, original_id=self.operation1.pk).first()
self.assertIsNotNone(ref)
self.assertTrue(Operation.objects.filter(pk=new_operation_id, oss=self.owned.model).exists())
@ -260,7 +260,7 @@ class TestOssOperations(EndpointTester):
'arguments': [self.operation1.pk, self.operation3.pk],
'substitutions': []
}
response = self.executeCreated(data=data, item=self.owned_id)
response = self.executeCreated(data, item=self.owned_id)
self.owned.model.refresh_from_db()
new_operation_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
@ -270,6 +270,37 @@ class TestOssOperations(EndpointTester):
self.assertNotEqual(new_operation['result'], None)
@decl_endpoint('/api/oss/{item}/create-synthesis', method='post')
def test_create_synthesis_replicas(self):
self.populateData()
operation4 = self.owned.create_replica(self.operation1)
operation5 = self.owned.create_replica(self.operation1)
data = {
'item_data': {
'alias': 'Test5',
'title': 'Test title',
'description': '',
'parent': None
},
'layout': self.layout_data,
'position': {
'x': 1,
'y': 1,
'width': 500,
'height': 50
},
'arguments': [self.operation1.pk, operation4.pk],
'substitutions': []
}
self.executeBadData(data, item=self.owned_id)
data['arguments'] = [operation4.pk, operation5.pk]
self.executeBadData(data, item=self.owned_id)
data['arguments'] = [operation4.pk, self.operation3.pk]
self.executeCreated(data, item=self.owned_id)
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_operation(self):
self.populateData()
@ -279,19 +310,19 @@ class TestOssOperations(EndpointTester):
data = {
'layout': self.layout_data
}
self.executeBadData(data=data)
self.executeBadData(data)
data['target'] = self.unowned_operation.pk
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
data['target'] = self.operation1.pk
self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id)
self.executeBadData(data, item=self.unowned_id)
self.logout()
self.executeForbidden(data=data, item=self.owned_id)
self.executeForbidden(data, item=self.owned_id)
self.login()
response = self.executeOK(data=data)
response = self.executeOK(data)
layout = response.data['layout']
deleted_items = [item for item in layout if item['nodeID'] == 'o' + str(data['target'])]
self.assertEqual(len(response.data['operations']), 2)
@ -301,34 +332,34 @@ class TestOssOperations(EndpointTester):
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_reference_operation_invalid(self):
self.populateData()
reference_operation = self.owned.create_reference(self.operation1)
reference_operation = self.owned.create_replica(self.operation1)
data = {
'layout': self.layout_data,
'target': reference_operation.pk
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
@decl_endpoint('/api/oss/{item}/delete-reference', method='patch')
def test_delete_reference_operation(self):
@decl_endpoint('/api/oss/{item}/delete-replica', method='patch')
def test_delete_replica_operation(self):
self.populateData()
data = {
'layout': self.layout_data,
'target': self.invalid_id
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
reference_operation = self.owned.create_reference(self.operation1)
self.assertEqual(len(self.operation1.getQ_references()), 1)
reference_operation = self.owned.create_replica(self.operation1)
self.assertEqual(len(self.operation1.getQ_replicas()), 1)
data['target'] = reference_operation.pk
self.executeForbidden(data=data, item=self.unowned_id)
self.executeForbidden(data, item=self.unowned_id)
data['target'] = self.operation1.pk
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
data['target'] = reference_operation.pk
self.executeOK(data=data, item=self.owned_id)
self.assertEqual(len(self.operation1.getQ_references()), 0)
self.executeOK(data, item=self.owned_id)
self.assertEqual(len(self.operation1.getQ_replicas()), 0)
@decl_endpoint('/api/oss/{item}/create-input', method='patch')
@ -339,22 +370,22 @@ class TestOssOperations(EndpointTester):
data = {
'layout': self.layout_data
}
self.executeBadData(data=data)
self.executeBadData(data)
data['target'] = self.operation1.pk
self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id)
self.executeBadData(data, item=self.unowned_id)
self.logout()
self.executeForbidden(data=data, item=self.owned_id)
self.executeForbidden(data, item=self.owned_id)
self.login()
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
self.operation1.result = None
self.operation1.description = 'TestComment'
self.operation1.title = 'TestTitle'
self.operation1.save()
response = self.executeOK(data=data)
response = self.executeOK(data)
self.operation1.refresh_from_db()
new_schema = response.data['new_schema']
@ -364,10 +395,10 @@ class TestOssOperations(EndpointTester):
self.assertEqual(new_schema['description'], self.operation1.description)
data['target'] = self.operation3.pk
self.executeBadData(data=data)
self.executeBadData(data)
data['target'] = self.unowned_operation.pk
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
@decl_endpoint('/api/oss/{item}/set-input', method='patch')
@ -378,17 +409,17 @@ class TestOssOperations(EndpointTester):
data = {
'layout': self.layout_data
}
self.executeBadData(data=data)
self.executeBadData(data)
data['target'] = self.operation1.pk
data['input'] = None
self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id)
self.executeBadData(data, item=self.unowned_id)
self.logout()
self.executeForbidden(data=data, item=self.owned_id)
self.executeForbidden(data, item=self.owned_id)
self.login()
response = self.executeOK(data=data)
response = self.executeOK(data)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, None)
@ -397,7 +428,7 @@ class TestOssOperations(EndpointTester):
self.ks1.model.title = 'Test421'
self.ks1.model.description = 'TestComment42'
self.ks1.model.save()
response = self.executeOK(data=data)
response = self.executeOK(data)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, self.ks1.model)
self.assertEqual(self.operation1.alias, self.ks1.model.alias)
@ -415,7 +446,7 @@ class TestOssOperations(EndpointTester):
'target': self.operation1.pk,
'input': self.ks2.model.pk
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
self.ks2.model.visible = False
self.ks2.model.save(update_fields=['visible'])
@ -424,7 +455,7 @@ class TestOssOperations(EndpointTester):
'target': self.operation2.pk,
'input': None
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.operation2.refresh_from_db()
self.ks2.model.refresh_from_db()
self.assertEqual(self.operation2.result, None)
@ -435,7 +466,7 @@ class TestOssOperations(EndpointTester):
'target': self.operation1.pk,
'input': self.ks2.model.pk
}
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, self.ks2.model)
@ -463,16 +494,16 @@ class TestOssOperations(EndpointTester):
}
]
}
self.executeBadData(data=data)
self.executeBadData(data)
data['substitutions'][0]['substitution'] = self.ks2X1.pk
self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id)
self.executeBadData(data, item=self.unowned_id)
self.logout()
self.executeForbidden(data=data, item=self.owned_id)
self.executeForbidden(data, item=self.owned_id)
self.login()
response = self.executeOK(data=data)
response = self.executeOK(data)
self.operation3.refresh_from_db()
self.assertEqual(self.operation3.alias, data['item_data']['alias'])
self.assertEqual(self.operation3.title, data['item_data']['title'])
@ -487,11 +518,11 @@ class TestOssOperations(EndpointTester):
self.assertEqual(sub.substitution.pk, data['substitutions'][0]['substitution'])
data['layout'] = self.layout_data
self.executeOK(data=data)
self.executeOK(data)
data_bad = dict(data)
data_bad['target'] = self.unowned_operation.pk
self.executeBadData(data=data_bad, item=self.owned_id)
self.executeBadData(data_bad, item=self.owned_id)
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
@ -508,10 +539,10 @@ class TestOssOperations(EndpointTester):
},
'layout': self.layout_data
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
data['target'] = self.operation1.pk
response = self.executeOK(data=data)
response = self.executeOK(data)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.alias, data['item_data']['alias'])
self.assertEqual(self.operation1.title, data['item_data']['title'])
@ -523,7 +554,7 @@ class TestOssOperations(EndpointTester):
# Try to update an operation from an unrelated OSS (should fail)
data_bad = dict(data)
data_bad['target'] = self.unowned_operation.pk
self.executeBadData(data=data_bad, item=self.owned_id)
self.executeBadData(data_bad, item=self.owned_id)
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
@ -552,7 +583,7 @@ class TestOssOperations(EndpointTester):
}
]
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
@decl_endpoint('/api/oss/{item}/execute-operation', method='post')
@ -564,19 +595,19 @@ class TestOssOperations(EndpointTester):
'layout': self.layout_data,
'target': self.operation1.pk
}
self.executeBadData(data=data)
self.executeBadData(data)
data['target'] = self.unowned_operation.pk
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
data['target'] = self.operation3.pk
self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id)
self.executeBadData(data, item=self.unowned_id)
self.logout()
self.executeForbidden(data=data, item=self.owned_id)
self.executeForbidden(data, item=self.owned_id)
self.login()
self.executeOK(data=data)
self.executeOK(data)
self.operation3.refresh_from_db()
schema = self.operation3.result
self.assertEqual(schema.alias, self.operation3.alias)
@ -613,7 +644,7 @@ class TestOssOperations(EndpointTester):
'source': target_ks.model.pk,
'clone_source': False
}
response = self.executeCreated(data=data, item=self.owned_id)
response = self.executeCreated(data, item=self.owned_id)
new_operation_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
layout = response.data['oss']['layout']
@ -653,7 +684,7 @@ class TestOssOperations(EndpointTester):
'source': self.ks2.model.pk,
'clone_source': True
}
response = self.executeCreated(data=data, item=self.owned_id)
response = self.executeCreated(data, item=self.owned_id)
new_operation_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
layout = response.data['oss']['layout']
@ -694,13 +725,13 @@ class TestOssOperations(EndpointTester):
# 'source' missing
'clone_source': False
}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
# Invalid source
data['source'] = self.invalid_id
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
# Invalid OSS
data['source'] = self.ks1.model.pk
self.executeNotFound(data=data, item=self.invalid_id)
self.executeNotFound(data, item=self.invalid_id)
@decl_endpoint('/api/oss/{item}/import-schema', method='post')
def test_import_schema_permissions(self):
@ -721,8 +752,8 @@ class TestOssOperations(EndpointTester):
}
# Not an editor
self.logout()
self.executeForbidden(data=data, item=self.owned_id)
self.executeForbidden(data, item=self.owned_id)
# As admin
self.login()
self.toggle_admin(True)
self.executeCreated(data=data, item=self.owned_id)
self.executeCreated(data, item=self.owned_id)

View File

@ -126,7 +126,7 @@ class TestOssViewset(EndpointTester):
self.executeBadData(item=self.owned_id)
data = {'data': []}
self.executeOK(data=data)
self.executeOK(data)
data = {'data': [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 42.1, 'y': 1337, 'width': 150, 'height': 40},
@ -134,15 +134,15 @@ class TestOssViewset(EndpointTester):
{'nodeID': 'o' + str(self.operation3.pk), 'x': 36.1, 'y': 1435, 'width': 150, 'height': 40}
]}
self.toggle_admin(True)
self.executeOK(data=data, item=self.unowned_id)
self.executeOK(data, item=self.unowned_id)
self.toggle_admin(False)
self.executeOK(data=data, item=self.owned_id)
self.executeOK(data, item=self.owned_id)
self.owned.model.refresh_from_db()
self.assertEqual(OperationSchema.layoutQ(self.owned_id).data, data['data'])
self.executeForbidden(data=data, item=self.unowned_id)
self.executeForbidden(data=data, item=self.private_id)
self.executeForbidden(data, item=self.unowned_id)
self.executeForbidden(data, item=self.private_id)
@decl_endpoint('/api/oss/get-predecessor', method='post')
@ -155,13 +155,13 @@ class TestOssViewset(EndpointTester):
self.ks3 = RSForm(self.operation3.result)
self.ks3X2 = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
self.executeBadData(data={'target': self.invalid_id})
self.executeBadData({'target': self.invalid_id})
response = self.executeOK(data={'target': self.ks1X1.pk})
response = self.executeOK({'target': self.ks1X1.pk})
self.assertEqual(response.data['id'], self.ks1X1.pk)
self.assertEqual(response.data['schema'], self.ks1.model.pk)
response = self.executeOK(data={'target': self.ks3X2.pk})
response = self.executeOK({'target': self.ks3X2.pk})
self.assertEqual(response.data['id'], self.ks1X2.pk)
self.assertEqual(response.data['schema'], self.ks1.model.pk)
@ -180,10 +180,10 @@ class TestOssViewset(EndpointTester):
'operations': [self.operation1.pk, self.operation2.pk],
'destination': block2.pk
}
self.executeBadData(data=data)
self.executeBadData(data)
data['destination'] = block1.pk
self.executeOK(data=data)
self.executeOK(data)
self.operation1.refresh_from_db()
self.operation2.refresh_from_db()
block2.refresh_from_db()
@ -193,7 +193,7 @@ class TestOssViewset(EndpointTester):
self.assertEqual(block2.parent.pk, block1.pk)
data['destination'] = None
self.executeOK(data=data)
self.executeOK(data)
self.operation1.refresh_from_db()
self.operation2.refresh_from_db()
block2.refresh_from_db()
@ -217,7 +217,7 @@ class TestOssViewset(EndpointTester):
'operations': [],
'destination': block3.pk
}
self.executeBadData(data=data)
self.executeBadData(data)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
@ -236,35 +236,35 @@ class TestOssViewset(EndpointTester):
'destination': self.invalid_id,
'items': []
}
self.executeBadData(data=data)
self.executeBadData(data)
# empty items
data = {
'destination': self.ks1.model.pk,
'items': []
}
self.executeBadData(data=data)
self.executeBadData(data)
# source == destination
data = {
'destination': self.ks1.model.pk,
'items': [self.ks1X1.pk]
}
self.executeBadData(data=data)
self.executeBadData(data)
# moving inherited
data = {
'destination': self.ks1.model.pk,
'items': [self.ks3X2.pk]
}
self.executeBadData(data=data)
self.executeBadData(data)
# source and destination are not connected
data = {
'destination': self.ks2.model.pk,
'items': [self.ks1X1.pk]
}
self.executeBadData(data=data)
self.executeBadData(data)
data = {
'destination': self.ks3.model.pk,
@ -272,14 +272,14 @@ class TestOssViewset(EndpointTester):
}
self.ks3X2.refresh_from_db()
self.assertEqual(self.ks3X2.convention, 'test')
self.executeOK(data=data)
self.executeOK(data)
self.assertFalse(Constituenta.objects.filter(as_child__parent_id=self.ks1X2.pk).exists())
data = {
'destination': self.ks1.model.pk,
'items': [self.ks3X10.pk]
}
self.executeOK(data=data)
self.executeOK(data)
self.assertTrue(Constituenta.objects.filter(as_parent__child_id=self.ks3X10.pk).exists())
self.ks1X3 = Constituenta.objects.get(as_parent__child_id=self.ks3X10.pk)
self.assertEqual(self.ks1X3.convention, 'test2')

View File

@ -65,11 +65,11 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'create_schema',
'clone_schema',
'import_schema',
'create_reference',
'create_replica',
'create_synthesis',
'update_operation',
'delete_operation',
'delete_reference',
'delete_replica',
'create_input',
'set_input',
'execute_operation',
@ -140,10 +140,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def create_block(self, request: Request, pk) -> HttpResponse:
''' Create Block. '''
item = self._get_item()
serializer = s.CreateBlockSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.CreateBlockSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
layout = serializer.validated_data['layout']
position = serializer.validated_data['position']
@ -161,11 +158,11 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'height': position['height'],
})
m.Layout.update_data(pk, layout)
if len(children_blocks) > 0:
if children_blocks:
for block in children_blocks:
block.parent = new_block
m.Block.objects.bulk_update(children_blocks, ['parent'])
if len(children_operations) > 0:
if children_operations:
for operation in children_operations:
operation.parent = new_block
m.Operation.objects.bulk_update(children_operations, ['parent'])
@ -194,10 +191,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def update_block(self, request: Request, pk) -> HttpResponse:
''' Update Block. '''
item = self._get_item()
serializer = s.UpdateBlockSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.UpdateBlockSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
block: m.Block = cast(m.Block, serializer.validated_data['target'])
@ -234,10 +228,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def delete_block(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete Block. '''
item = self._get_item()
serializer = s.DeleteBlockSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.DeleteBlockSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
block = cast(m.Block, serializer.validated_data['target'])
layout = serializer.validated_data['layout']
@ -269,10 +260,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def move_items(self, request: Request, pk) -> HttpResponse:
''' Move items to another parent. '''
item = self._get_item()
serializer = s.MoveItemsSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.MoveItemsSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
layout = serializer.validated_data['layout']
@ -306,10 +294,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def create_schema(self, request: Request, pk) -> HttpResponse:
''' Create schema. '''
item = self._get_item()
serializer = s.CreateSchemaSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.CreateSchemaSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
layout = serializer.validated_data['layout']
position = serializer.validated_data['position']
@ -327,7 +312,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'height': position['height']
})
m.Layout.update_data(pk, layout)
oss.create_input(new_operation)
m.OperationSchema.create_input(item, new_operation)
item.save(update_fields=['time_update'])
return Response(
@ -354,10 +339,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def clone_schema(self, request: Request, pk) -> HttpResponse:
''' Clone schema. '''
item = self._get_item()
serializer = s.CloneSchemaSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.CloneSchemaSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
layout = serializer.validated_data['layout']
position = serializer.validated_data['position']
@ -424,10 +406,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def import_schema(self, request: Request, pk) -> HttpResponse:
''' Create operation with existing schema. '''
item = self._get_item()
serializer = s.ImportSchemaSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.ImportSchemaSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
layout = serializer.validated_data['layout']
position = serializer.validated_data['position']
@ -465,9 +444,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
@extend_schema(
summary='create reference for operation',
summary='create replica for operation',
tags=['OSS'],
request=s.CreateReferenceSerializer(),
request=s.CreateReplicaSerializer(),
responses={
c.HTTP_201_CREATED: s.OperationCreatedResponse,
c.HTTP_400_BAD_REQUEST: None,
@ -475,14 +454,11 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'], url_path='create-reference')
def create_reference(self, request: Request, pk) -> HttpResponse:
''' Clone schema. '''
@action(detail=True, methods=['post'], url_path='create-replica')
def create_replica(self, request: Request, pk) -> HttpResponse:
''' Replicate schema. '''
item = self._get_item()
serializer = s.CreateReferenceSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.CreateReplicaSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
layout = serializer.validated_data['layout']
position = serializer.validated_data['position']
@ -490,7 +466,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
with transaction.atomic():
oss = m.OperationSchema(item)
target = cast(m.Operation, serializer.validated_data['target'])
new_operation = oss.create_reference(target)
new_operation = oss.create_replica(target)
layout.append({
'nodeID': 'o' + str(new_operation.pk),
'x': position['x'],
@ -524,10 +500,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def create_synthesis(self, request: Request, pk) -> HttpResponse:
''' Create Synthesis operation from arguments. '''
item = self._get_item()
serializer = s.CreateSynthesisSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.CreateSynthesisSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
layout = serializer.validated_data['layout']
position = serializer.validated_data['position']
@ -573,10 +546,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def update_operation(self, request: Request, pk) -> HttpResponse:
''' Update Operation arguments and parameters. '''
item = self._get_item()
serializer = s.UpdateOperationSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.UpdateOperationSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
@ -628,10 +598,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def delete_operation(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete Operation. '''
item = self._get_item()
serializer = s.DeleteOperationSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.DeleteOperationSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
operation = cast(m.Operation, serializer.validated_data['target'])
old_schema = operation.result
@ -657,9 +624,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
)
@extend_schema(
summary='delete reference',
summary='delete replica',
tags=['OSS'],
request=s.DeleteReferenceSerializer(),
request=s.DeleteReplicaSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
@ -667,23 +634,22 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='delete-reference')
def delete_reference(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete Reference Operation. '''
@action(detail=True, methods=['patch'], url_path='delete-replica')
def delete_replica(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete Replica Operation. '''
item = self._get_item()
serializer = s.DeleteReferenceSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.DeleteReplicaSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
operation = cast(m.Operation, serializer.validated_data['target'])
keep_connections = serializer.validated_data['keep_connections']
keep_constituents = serializer.validated_data['keep_constituents']
layout = serializer.validated_data['layout']
layout = [x for x in layout if x['nodeID'] != 'o' + str(operation.pk)]
with transaction.atomic():
oss = m.OperationSchemaCached(item)
m.Layout.update_data(pk, layout)
oss.delete_reference(operation.pk, serializer.validated_data['keep_connections'])
oss.delete_replica(operation.pk, keep_connections, keep_constituents)
item.save(update_fields=['time_update'])
return Response(
@ -706,10 +672,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def create_input(self, request: Request, pk) -> HttpResponse:
''' Create input RSForm. '''
item = self._get_item()
serializer = s.TargetOperationSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.TargetOperationSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
if len(operation.getQ_arguments()) > 0:
@ -723,9 +686,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
layout = serializer.validated_data['layout']
with transaction.atomic():
oss = m.OperationSchema(item)
m.Layout.update_data(pk, layout)
schema = oss.create_input(operation)
schema = m.OperationSchema.create_input(item, operation)
item.save(update_fields=['time_update'])
return Response(
@ -751,10 +713,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def set_input(self, request: Request, pk) -> HttpResponse:
''' Set input schema for target operation. '''
item = self._get_item()
serializer = s.SetOperationInputSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.SetOperationInputSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
layout = serializer.validated_data['layout']
@ -803,10 +762,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
def execute_operation(self, request: Request, pk) -> HttpResponse:
''' Execute operation. '''
item = self._get_item()
serializer = s.TargetOperationSerializer(
data=request.data,
context={'oss': item}
)
serializer = s.TargetOperationSerializer(data=request.data, context={'oss': item})
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
if operation.operation_type != m.OperationType.SYNTHESIS:

View File

@ -24,7 +24,7 @@ class TestPromptTemplateViewSet(EndpointTester):
'text': 'prompt text',
'is_shared': False
}
response = self.executeCreated(data=data)
response = self.executeCreated(data)
self.assertEqual(response.data['label'], 'Test')
self.assertEqual(response.data['owner'], self.user.pk)
@ -38,7 +38,7 @@ class TestPromptTemplateViewSet(EndpointTester):
'text': 'prompt text',
'is_shared': True
}
response = self.executeCreated(data=data)
response = self.executeCreated(data)
self.assertTrue(response.data['is_shared'])
@ -50,21 +50,21 @@ class TestPromptTemplateViewSet(EndpointTester):
'text': 'prompt text',
'is_shared': True
}
response = self.executeBadData(data=data)
response = self.executeBadData(data)
self.assertIn('is_shared', response.data)
@decl_endpoint('/api/prompts/{item}/', method='patch')
def test_update_prompt_owner(self):
prompt = PromptTemplate.objects.create(owner=self.user, label='ToUpdate', description='', text='t')
response = self.executeOK(data={'label': 'Updated'}, item=prompt.id)
response = self.executeOK({'label': 'Updated'}, item=prompt.id)
self.assertEqual(response.data['label'], 'Updated')
@decl_endpoint('/api/prompts/{item}/', method='patch')
def test_update_prompt_not_owner_forbidden(self):
prompt = PromptTemplate.objects.create(owner=self.admin, label='Other', description='', text='t')
response = self.executeForbidden(data={'label': 'Updated'}, item=prompt.id)
response = self.executeForbidden({'label': 'Updated'}, item=prompt.id)
@decl_endpoint('/api/prompts/{item}/', method='delete')
@ -112,4 +112,4 @@ class TestPromptTemplateViewSet(EndpointTester):
is_shared=True
)
self.client.force_authenticate(user=self.user)
response = self.executeForbidden(data={'label': 'Nope'}, item=prompt.id)
response = self.executeForbidden({'label': 'Nope'}, item=prompt.id)

View File

@ -10,3 +10,11 @@ class ConstituentaAdmin(admin.ModelAdmin):
ordering = ['schema', 'order']
list_display = ['schema', 'order', 'alias', 'term_resolved', 'definition_resolved', 'crucial']
search_fields = ['term_resolved', 'definition_resolved']
@admin.register(models.Attribution)
class AttributionAdmin(admin.ModelAdmin):
''' Admin model: Attribution. '''
ordering = ['container__schema', 'container', 'attribute']
list_display = ['container__schema__alias', 'container__alias', 'attribute__alias']
search_fields = ['container', 'attribute']

View File

@ -110,7 +110,7 @@ class Graph(Generic[ItemType]):
order = self.topological_order()
order.reverse()
for node_id in order:
if len(self.inputs[node_id]) == 0:
if not self.inputs[node_id]:
continue
for parent in self.inputs[node_id]:
result[parent] = result[parent] + [id for id in result[node_id] if id not in result[parent]]
@ -124,7 +124,7 @@ class Graph(Generic[ItemType]):
if node_id in marked:
continue
to_visit: list[ItemType] = [node_id]
while len(to_visit) > 0:
while to_visit:
node = to_visit[-1]
if node in marked:
if node not in result:
@ -132,7 +132,7 @@ class Graph(Generic[ItemType]):
to_visit.remove(node)
else:
marked.add(node)
if len(self.outputs[node]) <= 0:
if not self.outputs[node]:
continue
for child_id in self.outputs[node]:
if child_id not in marked:

View File

@ -0,0 +1,32 @@
# Generated by Django 5.2.4 on 2025-08-09 10:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rsform', '0004_constituenta_crucial'),
]
operations = [
migrations.AlterField(
model_name='constituenta',
name='cst_type',
field=models.CharField(choices=[('nominal', 'Nominal'), ('basic', 'Base'), ('constant', 'Constant'), ('structure', 'Structured'), ('axiom', 'Axiom'), ('term', 'Term'), ('function', 'Function'), ('predicate', 'Predicate'), ('theorem', 'Theorem')], default='basic', max_length=10, verbose_name='Тип'),
),
migrations.CreateModel(
name='Association',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('associate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_associate', to='rsform.constituenta', verbose_name='Ассоциированная конституента')),
('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_container', to='rsform.constituenta', verbose_name='Составная конституента')),
],
options={
'verbose_name': 'Ассоциация конституент',
'verbose_name_plural': 'Ассоциации конституент',
'unique_together': {('container', 'associate')},
},
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.4 on 2025-08-21 11:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rsform', '0005_alter_constituenta_cst_type_association'),
]
operations = [
migrations.CreateModel(
name='Attribution',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_attribute', to='rsform.constituenta', verbose_name='Атрибутирующая конституента')),
('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_container', to='rsform.constituenta', verbose_name='Составная конституента')),
],
options={
'verbose_name': 'Атрибутирование конституент',
'verbose_name_plural': 'Атрибутирования конституент',
'unique_together': {('container', 'attribute')},
},
),
migrations.DeleteModel(
name='Association',
),
]

View File

@ -0,0 +1,28 @@
''' Models: Attribution of nominal constituents. '''
from django.db.models import CASCADE, ForeignKey, Model
class Attribution(Model):
''' Attribution links nominal constituent to its content.'''
container = ForeignKey(
verbose_name='Составная конституента',
to='rsform.Constituenta',
on_delete=CASCADE,
related_name='as_container'
)
attribute = ForeignKey(
verbose_name='Атрибутирующая конституента',
to='rsform.Constituenta',
on_delete=CASCADE,
related_name='as_attribute'
)
class Meta:
''' Model metadata. '''
verbose_name = 'Атрибутирование конституент'
verbose_name_plural = 'Атрибутирования конституент'
unique_together = [['container', 'attribute']]
def __str__(self) -> str:
return f'{self.container} -> {self.attribute}'

View File

@ -16,9 +16,9 @@ from django.db.models import (
from ..utils import apply_pattern
_RE_GLOBALS = r'[XCSADFPT]\d+' # cspell:disable-line
_RE_GLOBALS = r'[XCSADFPTN]\d+' # cspell:disable-line
_REF_ENTITY_PATTERN = re.compile(r'@{([^0-9\-].*?)\|.*?}')
_GLOBAL_ID_PATTERN = re.compile(r'([XCSADFPT][0-9]+)') # cspell:disable-line
_GLOBAL_ID_PATTERN = re.compile(r'([XCSADFPTN][0-9]+)') # cspell:disable-line
def extract_globals(expression: str) -> set[str]:
@ -38,6 +38,7 @@ def replace_entities(expression: str, mapping: dict[str, str]) -> str:
class CstType(TextChoices):
''' Type of constituenta. '''
NOMINAL = 'nominal'
BASE = 'basic'
CONSTANT = 'constant'
STRUCTURED = 'structure'

View File

@ -48,7 +48,7 @@ class OrderManager:
continue
result.append(cst)
children = self._semantic[cst.pk]['children']
if len(children) == 0:
if not children:
continue
for child in self._items:
if child.pk in children:

View File

@ -158,12 +158,12 @@ class RSForm:
graph_terms = RSForm.graph_term(cst_list, cst_by_alias)
expansion = graph_terms.expand_outputs(changed)
expanded_change = changed + expansion
update_list: list[Constituenta] = []
if resolver is None:
resolver = RSForm.resolver_from_list(cst_list)
if len(expansion) > 0:
if expansion:
resolved_terms: list[Constituenta] = []
for cst_id in graph_terms.topological_order():
if cst_id not in expansion:
continue
@ -172,21 +172,20 @@ class RSForm:
if resolved == resolver.context[cst.alias].get_nominal():
continue
cst.set_term_resolved(resolved)
update_list.append(cst)
resolved_terms.append(cst)
resolver.context[cst.alias] = Entity(cst.alias, resolved)
Constituenta.objects.bulk_update(update_list, ['term_resolved'])
Constituenta.objects.bulk_update(resolved_terms, ['term_resolved'])
graph_defs = RSForm.graph_text(cst_list, cst_by_alias)
update_defs = set(expansion + graph_defs.expand_outputs(expanded_change)).union(changed)
update_list = []
if len(update_defs) == 0:
return
if update_defs:
resolved_defs: list[Constituenta] = []
for cst_id in update_defs:
cst = cst_by_id[cst_id]
resolved = resolver.resolve(cst.definition_raw)
cst.definition_resolved = resolved
update_list.append(cst)
Constituenta.objects.bulk_update(update_list, ['definition_resolved'])
resolved_defs.append(cst)
Constituenta.objects.bulk_update(resolved_defs, ['definition_resolved'])
def constituentsQ(self) -> QuerySet[Constituenta]:
''' Get QuerySet containing all constituents of current RSForm. '''
@ -263,7 +262,7 @@ class RSForm:
def substitute(self, substitutions: list[tuple[Constituenta, Constituenta]]) -> None:
''' Execute constituenta substitution. '''
if len(substitutions) < 1:
if not substitutions:
return
mapping = {}
deleted: list[int] = []

View File

@ -223,7 +223,7 @@ class RSFormCached:
def substitute(self, substitutions: list[tuple[Constituenta, Constituenta]]) -> None:
''' Execute constituenta substitution. '''
if len(substitutions) < 1:
if not substitutions:
return
self.cache.ensure_loaded_terms()
mapping = {}

View File

@ -126,7 +126,7 @@ class SemanticInfo:
return sources
def _need_check_head(self, sources: set[int], head: str) -> bool:
if len(sources) == 0:
if not sources:
return True
elif len(sources) != 1:
return False

View File

@ -1,5 +1,6 @@
''' Django: Models. '''
from .Attribution import Attribution
from .Constituenta import Constituenta, CstType, extract_globals, replace_entities, replace_globals
from .OrderManager import OrderManager
from .RSForm import DELETED_ALIAS, INSERT_LAST, RSForm

View File

@ -37,6 +37,7 @@ def get_type_prefix(cst_type: str) -> str:
case CstType.FUNCTION: return 'F'
case CstType.PREDICATE: return 'P'
case CstType.THEOREM: return 'T'
case CstType.NOMINAL: return 'N'
return 'X'
@ -78,7 +79,7 @@ def guess_type(alias: str) -> CstType:
def _get_structure_prefix(alias: str, expression: str, parse: dict) -> Tuple[str, str]:
''' Generate prefix and alias for structure generation. '''
args = parse['args']
if len(args) == 0:
if not args:
return (alias, '')
prefix = expression[0:expression.find(']')] + '] '
newAlias = alias + '[' + ','.join([arg['alias'] for arg in args]) + ']'

View File

@ -12,6 +12,8 @@ from .basics import (
WordFormSerializer
)
from .data_access import (
AttributionCreateSerializer,
AttributionDataSerializer,
CrucialUpdateSerializer,
CstCreateSerializer,
CstInfoSerializer,

View File

@ -133,7 +133,7 @@ class ReferenceSerializer(StrictSerializer):
class InheritanceDataSerializer(StrictSerializer):
''' Serializer: inheritance data. '''
''' Serializer: Inheritance data. '''
child = serializers.IntegerField()
child_source = serializers.IntegerField()
parent = serializers.IntegerField() # type: ignore

View File

@ -17,11 +17,55 @@ from apps.oss.models import Inheritance
from shared import messages as msg
from shared.serializers import StrictModelSerializer, StrictSerializer
from ..models import Constituenta, CstType, RSForm
from ..models import Attribution, Constituenta, CstType, RSForm
from .basics import CstParseSerializer, InheritanceDataSerializer
from .io_pyconcept import PyConceptAdapter
class AttributionSerializer(StrictModelSerializer):
''' Serializer: Attribution relation. '''
class Meta:
''' serializer metadata. '''
model = Attribution
fields = ('container', 'attribute')
class AttributionDataSerializer(StrictSerializer):
''' Serializer: Attribution data. '''
container = PKField(many=False, queryset=Constituenta.objects.all().only('schema_id'))
attribute = PKField(many=False, queryset=Constituenta.objects.all().only('schema_id'))
def validate(self, attrs):
schema = cast(LibraryItem, self.context['schema'])
if schema and attrs['container'].schema_id != schema.id:
raise serializers.ValidationError({
'container': msg.constituentaNotInRSform(schema.title)
})
if schema and attrs['attribute'].schema_id != schema.id:
raise serializers.ValidationError({
'attribute': msg.constituentaNotInRSform(schema.title)
})
return attrs
class AttributionCreateSerializer(AttributionDataSerializer):
''' Serializer: Data for creating new Attribution. '''
def validate(self, attrs):
attrs = super().validate(attrs)
if attrs['container'].pk == attrs['attribute'].pk:
raise serializers.ValidationError({
'container': msg.associationSelf()
})
if Attribution.objects.filter(container=attrs['container'], attribute=attrs['attribute']).exists():
raise serializers.ValidationError({
'attribute': msg.associationAlreadyExists()
})
return attrs
class CstBaseSerializer(StrictModelSerializer):
''' Serializer: Constituenta all data. '''
class Meta:
@ -122,6 +166,15 @@ class CstCreateSerializer(StrictModelSerializer):
'term_raw', 'definition_raw', 'definition_formal', \
'insert_after', 'term_forms'
def validate(self, attrs):
schema = cast(LibraryItem, self.context['schema'])
insert_after = attrs.get('insert_after')
if insert_after and insert_after.schema_id != schema.pk:
raise serializers.ValidationError({
'insert_after': msg.constituentaNotInRSform(schema.title)
})
return attrs
class RSFormSerializer(StrictModelSerializer):
''' Serializer: Detailed data for RSForm. '''
@ -134,6 +187,9 @@ class RSFormSerializer(StrictModelSerializer):
inheritance = serializers.ListField(
child=InheritanceDataSerializer()
)
attribution = serializers.ListField(
child=AttributionSerializer()
)
oss = serializers.ListField(
child=LibraryItemReferenceSerializer()
)
@ -164,6 +220,7 @@ class RSFormSerializer(StrictModelSerializer):
result['items'] = []
result['oss'] = []
result['inheritance'] = []
result['attribution'] = []
for cst in Constituenta.objects.filter(schema=instance).defer('order').order_by('order'):
result['items'].append(CstInfoSerializer(cst).data)
for oss in LibraryItem.objects.filter(operations__result=instance).only('alias'):
@ -171,6 +228,11 @@ class RSFormSerializer(StrictModelSerializer):
'id': oss.pk,
'alias': oss.alias
})
for assoc in Attribution.objects.filter(container__schema=instance).only('container_id', 'attribute_id'):
result['attribution'].append({
'container': assoc.container_id,
'attribute': assoc.attribute_id
})
return result
def to_versioned_data(self) -> dict:
@ -200,37 +262,38 @@ class RSFormSerializer(StrictModelSerializer):
instance = cast(LibraryItem, self.instance)
schema = RSForm(instance)
items: list[dict] = data['items']
ids: list[int] = [item['id'] for item in items]
processed: list[int] = []
stored_ids: list[int] = [item['id'] for item in items]
id_map: dict[int, int] = {}
for cst in schema.constituentsQ():
if not cst.pk in ids:
cst.delete()
for existing_cst in schema.constituentsQ():
if not existing_cst.pk in stored_ids:
existing_cst.delete()
else:
cst_data = next(x for x in items if x['id'] == cst.pk)
cst_data = next(x for x in items if x['id'] == existing_cst.pk)
cst_data['schema'] = instance.pk
new_cst = CstBaseSerializer(data=cst_data)
new_cst.is_valid(raise_exception=True)
new_cst.validated_data['order'] = ids.index(cst.pk)
new_cst.update(
instance=cst,
validated_data=new_cst.validated_data
cst_serializer = CstBaseSerializer(data=cst_data)
cst_serializer.is_valid(raise_exception=True)
cst_serializer.validated_data['order'] = stored_ids.index(existing_cst.pk)
cst_serializer.update(
instance=existing_cst,
validated_data=cst_serializer.validated_data
)
processed.append(cst.pk)
id_map[cst_data['id']] = existing_cst.pk
for cst_data in items:
if cst_data['id'] not in processed:
cst = schema.insert_last(cst_data['alias'])
if cst_data['id'] not in id_map:
old_id = cst_data['id']
cst_data['id'] = cst.pk
inserted_cst = schema.insert_last(cst_data['alias'])
cst_data['id'] = inserted_cst.pk
cst_data['schema'] = instance.pk
new_cst = CstBaseSerializer(data=cst_data)
new_cst.is_valid(raise_exception=True)
new_cst.validated_data['order'] = ids.index(old_id)
new_cst.update(
instance=cst,
validated_data=new_cst.validated_data
cst_serializer = CstBaseSerializer(data=cst_data)
cst_serializer.is_valid(raise_exception=True)
cst_serializer.validated_data['order'] = stored_ids.index(old_id)
cst_serializer.update(
instance=inserted_cst,
validated_data=cst_serializer.validated_data
)
id_map[old_id] = inserted_cst.pk
loaded_item = LibraryItemBaseNonStrictSerializer(data=data)
loaded_item.is_valid(raise_exception=True)
@ -239,6 +302,23 @@ class RSFormSerializer(StrictModelSerializer):
validated_data=loaded_item.validated_data
)
Attribution.objects.filter(container__schema=instance).delete()
attributions_to_create: list[Attribution] = []
for assoc in data.get('attribution', []):
old_container_id = assoc['container']
old_attribute_id = assoc['attribute']
container_id = id_map.get(old_container_id)
attribute_id = id_map.get(old_attribute_id)
if container_id and attribute_id:
attributions_to_create.append(
Attribution(
container_id=container_id,
attribute_id=attribute_id
)
)
if attributions_to_create:
Attribution.objects.bulk_create(attributions_to_create)
class RSFormParseSerializer(StrictModelSerializer):
''' Serializer: Detailed data for RSForm including parse. '''
@ -267,6 +347,8 @@ class RSFormParseSerializer(StrictModelSerializer):
def _parse_data(self, data: dict) -> dict:
parse = PyConceptAdapter(data).parse()
for cst_data in data['items']:
if cst_data['cst_type'] == CstType.NOMINAL:
continue
cst_data['parse'] = next(
cst['parse'] for cst in parse['items']
if cst['id'] == cst_data['id']

View File

@ -6,11 +6,11 @@ from apps.library.models import LibraryItem
from shared import messages as msg
from shared.serializers import StrictSerializer
from ..models import Constituenta, RSFormCached
from ..models import Constituenta, CstType, RSFormCached
from ..utils import fix_old_references
_CST_TYPE = 'constituenta'
_TRS_TYPE = 'rsform'
_ENTITY_CONSTITUENTA = 'constituenta'
_ENTITY_SCHEMA = 'rsform'
_TRS_VERSION_MIN = 16
_TRS_VERSION = 16
_TRS_HEADER = 'Exteor 4.8.13.1000 - 30/05/2022'
@ -30,11 +30,11 @@ class RSFormUploadSerializer(StrictSerializer):
def generate_trs(schema: LibraryItem) -> dict:
''' Generate TRS file for RSForm. '''
items = []
for cst in Constituenta.objects.filter(schema=schema).order_by('order'):
for cst in Constituenta.objects.filter(schema=schema).exclude(cst_type=CstType.NOMINAL).order_by('order'):
items.append(
{
'entityUID': cst.pk,
'type': _CST_TYPE,
'type': _ENTITY_CONSTITUENTA,
'cstType': cst.cst_type,
'alias': cst.alias,
'convention': cst.convention,
@ -53,7 +53,7 @@ def generate_trs(schema: LibraryItem) -> dict:
}
)
return {
'type': _TRS_TYPE,
'type': _ENTITY_SCHEMA,
'title': schema.title,
'alias': schema.alias,
'comment': schema.description,
@ -72,7 +72,7 @@ class RSFormTRSSerializer(serializers.Serializer):
def load_versioned_data(data: dict) -> dict:
''' Load data from version. '''
result = {
'type': _TRS_TYPE,
'type': _ENTITY_SCHEMA,
'title': data['title'],
'alias': data['alias'],
'comment': data['description'],
@ -85,7 +85,7 @@ class RSFormTRSSerializer(serializers.Serializer):
for cst in data['items']:
result['items'].append({
'entityUID': cst['id'],
'type': _CST_TYPE,
'type': _ENTITY_CONSTITUENTA,
'cstType': cst['cst_type'],
'alias': cst['alias'],
'convention': cst['convention'],

View File

@ -6,7 +6,7 @@ import pyconcept
from shared import messages as msg
from ..models import Constituenta
from ..models import Constituenta, CstType
class PyConceptAdapter:
@ -34,7 +34,7 @@ class PyConceptAdapter:
result: dict = {
'items': []
}
items = Constituenta.objects.filter(schema_id=schemaID).order_by('order')
items = Constituenta.objects.filter(schema_id=schemaID).exclude(cst_type=CstType.NOMINAL).order_by('order')
for cst in items:
result['items'].append({
'entityUID': cst.pk,
@ -51,6 +51,8 @@ class PyConceptAdapter:
'items': []
}
for cst in data['items']:
if cst['cst_type'] == CstType.NOMINAL:
continue
result['items'].append({
'entityUID': cst['id'],
'cstType': cst['cst_type'],

View File

@ -1,4 +1,5 @@
''' Tests for Django Models. '''
from .t_Attribution import *
from .t_Constituenta import *
from .t_RSForm import *
from .t_RSFormCached import *

View File

@ -0,0 +1,175 @@
''' Testing models: Attribution. '''
from django.db.utils import IntegrityError
from django.test import TestCase
from apps.rsform.models import Attribution, Constituenta, CstType, RSForm
class TestAttribution(TestCase):
''' Testing Attribution model. '''
def setUp(self):
self.schema = RSForm.create(title='Test1')
# Create test constituents
self.container1 = Constituenta.objects.create(
alias='C1',
schema=self.schema.model,
order=1,
cst_type=CstType.NOMINAL
)
self.attribute1 = Constituenta.objects.create(
alias='A1',
schema=self.schema.model,
order=2,
cst_type=CstType.BASE
)
def test_str(self):
''' Test string representation. '''
attribution = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
expected_str = f'{self.container1} -> {self.attribute1}'
self.assertEqual(str(attribution), expected_str)
def test_create_attribution(self):
''' Test basic Attribution creation. '''
attr = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
self.assertEqual(attr.container, self.container1)
self.assertEqual(attr.attribute, self.attribute1)
self.assertIsNotNone(attr.id)
def test_unique_constraint(self):
''' Test unique constraint on container and attribute. '''
# Create first Attribution
Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
# Try to create duplicate Attribution
with self.assertRaises(IntegrityError):
Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
def test_container_not_null(self):
''' Test container field cannot be null. '''
with self.assertRaises(IntegrityError):
Attribution.objects.create(
container=None,
attribute=self.attribute1
)
def test_attribute_not_null(self):
''' Test attribute field cannot be null. '''
with self.assertRaises(IntegrityError):
Attribution.objects.create(
container=self.container1,
attribute=None
)
def test_cascade_delete_container(self):
''' Test cascade delete when container is deleted. '''
attribution = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
association_id = attribution.id
# Delete the container
self.container1.delete()
# Attribution should be deleted due to CASCADE
with self.assertRaises(Attribution.DoesNotExist):
Attribution.objects.get(id=association_id)
def test_cascade_delete_attribute(self):
''' Test cascade delete when attribute is deleted. '''
attribution = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
association_id = attribution.id
# Delete the attribute
self.attribute1.delete()
# Attribution should be deleted due to CASCADE
with self.assertRaises(Attribution.DoesNotExist):
Attribution.objects.get(id=association_id)
def test_related_names(self):
''' Test related names for foreign key relationships. '''
attribution = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
# Test container related name
container_associations = self.container1.as_container.all()
self.assertEqual(len(container_associations), 1)
self.assertEqual(container_associations[0], attribution)
# Test attribute related name
attribute_associations = self.attribute1.as_attribute.all()
self.assertEqual(len(attribute_associations), 1)
self.assertEqual(attribute_associations[0], attribution)
def test_multiple_attributions_same_container(self):
''' Test multiple Attributions with same container. '''
attribute3 = Constituenta.objects.create(
alias='A3',
schema=self.schema.model,
order=3,
cst_type=CstType.BASE
)
attr1 = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
attr2 = Attribution.objects.create(
container=self.container1,
attribute=attribute3
)
container_associations = self.container1.as_container.all()
self.assertEqual(len(container_associations), 2)
self.assertIn(attr1, container_associations)
self.assertIn(attr2, container_associations)
def test_multiple_attributions_same_attribute(self):
''' Test multiple Attributions with same attribute. '''
container3 = Constituenta.objects.create(
alias='C3',
schema=self.schema.model,
order=3,
cst_type=CstType.NOMINAL
)
attr1 = Attribution.objects.create(
container=self.container1,
attribute=self.attribute1
)
attr2 = Attribution.objects.create(
container=container3,
attribute=self.attribute1
)
attribute_associations = self.attribute1.as_attribute.all()
self.assertEqual(len(attribute_associations), 2)
self.assertIn(attr1, attribute_associations)
self.assertIn(attr2, attribute_associations)
def test_meta_unique_together(self):
''' Test Meta class unique_together constraint. '''
unique_together = Attribution._meta.unique_together
self.assertEqual(len(unique_together), 1)
self.assertIn(('container', 'attribute'), unique_together)

View File

@ -48,7 +48,7 @@ class TestRSFormCached(DBTester):
x1 = self.schema.insert_last('X1')
x2 = self.schema.insert_last('X2')
x3 = self.schema.create_cst(data=data, insert_after=x1)
x3 = self.schema.create_cst(data, insert_after=x1)
x2.refresh_from_db()
self.assertEqual(x3.alias, data['alias'])

View File

@ -1,4 +1,6 @@
''' Tests for REST API. '''
from .t_attribtuions import *
from .t_cctext import *
from .t_constituenta import *
from .t_rsforms import *
from .t_rslang import *

View File

@ -0,0 +1,100 @@
''' Testing API: Attribution. '''
import io
import os
from zipfile import ZipFile
from cctext import ReferenceType
from rest_framework import status
from apps.library.models import AccessPolicy, LibraryItem, LibraryItemType, LocationHead
from apps.rsform.models import Attribution, Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
from shared.testing_utils import response_contains
class TestAttributionsEndpoints(EndpointTester):
''' Testing basic Attribution API. '''
def setUp(self):
super().setUp()
self.owned = RSForm.create(title='Test', alias='T1', owner=self.user)
self.owned_id = self.owned.model.pk
self.unowned = RSForm.create(title='Test2', alias='T2')
self.unowned_id = self.unowned.model.pk
self.n1 = self.owned.insert_last('N1')
self.x1 = self.owned.insert_last('X1')
self.n2 = self.owned.insert_last('N2')
self.unowned_cst = self.unowned.insert_last('C1')
self.invalid_id = self.n2.pk + 1337
@decl_endpoint('/api/rsforms/{item}/create-attribution', method='post')
def test_create_attribution(self):
self.executeBadData({}, item=self.owned_id)
data = {'container': self.n1.pk, 'attribute': self.invalid_id}
self.executeBadData(data, item=self.owned_id)
data['attribute'] = self.unowned_cst.pk
self.executeBadData(data, item=self.owned_id)
data['attribute'] = data['container']
self.executeBadData(data, item=self.owned_id)
data = {'container': self.n1.pk, 'attribute': self.x1.pk}
self.executeBadData(data, item=self.unowned_id)
response = self.executeCreated(data, item=self.owned_id)
associations = response.data['attribution']
self.assertEqual(len(associations), 1)
self.assertEqual(associations[0]['container'], self.n1.pk)
self.assertEqual(associations[0]['attribute'], self.x1.pk)
@decl_endpoint('/api/rsforms/{item}/create-attribution', method='post')
def test_create_attribution_duplicate(self):
data = {'container': self.n1.pk, 'attribute': self.x1.pk}
self.executeCreated(data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
@decl_endpoint('/api/rsforms/{item}/delete-attribution', method='patch')
def test_delete_attribution(self):
data = {'container': self.n1.pk, 'attribute': self.x1.pk}
self.executeForbidden(data, item=self.unowned_id)
self.executeBadData(data, item=self.owned_id)
Attribution.objects.create(
container=self.n1,
attribute=self.x1
)
self.executeForbidden(data, item=self.unowned_id)
response = self.executeOK(data, item=self.owned_id)
attributions = response.data['attribution']
self.assertEqual(len(attributions), 0)
@decl_endpoint('/api/rsforms/{item}/clear-attributions', method='patch')
def test_clear_attributions(self):
data = {'target': self.n1.pk}
self.executeForbidden(data, item=self.unowned_id)
self.executeNotFound(data, item=self.invalid_id)
self.executeOK(data, item=self.owned_id)
Attribution.objects.create(
container=self.n1,
attribute=self.x1
)
Attribution.objects.create(
container=self.n1,
attribute=self.n2
)
Attribution.objects.create(
container=self.n2,
attribute=self.n1
)
response = self.executeOK(data, item=self.owned_id)
associations = response.data['attribution']
self.assertEqual(len(associations), 1)
self.assertEqual(associations[0]['container'], self.n2.pk)
self.assertEqual(associations[0]['attribute'], self.n1.pk)

View File

@ -14,20 +14,20 @@ class TestNaturalLanguageViews(EndpointTester):
@decl_endpoint(endpoint='/api/cctext/parse', method='post')
def test_parse_text(self):
data = {'text': 'синим слонам'}
response = self.executeOK(data=data)
response = self.executeOK(data)
self._assert_tags(response.data['result'], 'datv,NOUN,plur,anim,masc')
@decl_endpoint(endpoint='/api/cctext/inflect', method='post')
def test_inflect(self):
data = {'text': 'синий слон', 'grams': 'plur,datv'}
response = self.executeOK(data=data)
response = self.executeOK(data)
self.assertEqual(response.data['result'], 'синим слонам')
@decl_endpoint(endpoint='/api/cctext/generate-lexeme', method='post')
def test_generate_lexeme(self):
data = {'text': 'синий слон'}
response = self.executeOK(data=data)
response = self.executeOK(data)
self.assertEqual(len(response.data['items']), 12)
self.assertEqual(response.data['items'][0]['text'], 'синий слон')

View File

@ -0,0 +1,195 @@
''' Testing API: Constituenta editing. '''
from apps.rsform.models import Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
class TestConstituentaAPI(EndpointTester):
''' Testing Constituenta view. '''
def setUp(self):
super().setUp()
self.owned = RSForm.create(title='Test', alias='T1', owner=self.user)
self.owned_id = self.owned.model.pk
self.unowned = RSForm.create(title='Test2', alias='T2')
self.unowned_id = self.unowned.model.pk
self.x1 = Constituenta.objects.create(
alias='X1',
cst_type=CstType.BASE,
schema=self.owned.model,
order=0,
convention='Test',
term_raw='Test1',
term_resolved='Test1R',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}])
self.x2 = Constituenta.objects.create(
alias='X2',
cst_type=CstType.BASE,
schema=self.owned.model,
order=1,
convention='Test1',
term_raw='Test2',
term_resolved='Test2R'
)
self.x3 = Constituenta.objects.create(
alias='X3',
schema=self.owned.model,
order=2,
term_raw='Test3',
term_resolved='Test3',
definition_raw='Test1',
definition_resolved='Test2'
)
self.unowned_cst = self.unowned.insert_last(alias='X1', cst_type=CstType.BASE)
self.invalid_cst = self.x3.pk + 1337
@decl_endpoint('/api/rsforms/{item}/update-cst', method='patch')
def test_partial_update(self):
data = {'target': self.x1.pk, 'item_data': {'convention': 'tt'}}
self.executeForbidden(data, item=self.unowned_id)
self.logout()
self.executeForbidden(data, item=self.owned_id)
self.login()
self.executeOK(data)
self.x1.refresh_from_db()
self.assertEqual(self.x1.convention, 'tt')
self.executeOK(data)
@decl_endpoint('/api/rsforms/{item}/update-cst', method='patch')
def test_partial_update_rename(self):
data = {'target': self.x1.pk, 'item_data': {'alias': self.x3.alias}}
self.executeBadData(data, item=self.owned_id)
d1 = self.owned.insert_last(
alias='D1',
term_raw='@{X1|plur}',
definition_formal='X1'
)
self.assertEqual(self.x1.order, 0)
self.assertEqual(self.x1.alias, 'X1')
self.assertEqual(self.x1.cst_type, CstType.BASE)
data = {'target': self.x1.pk, 'item_data': {'alias': 'D2', 'cst_type': CstType.TERM}}
self.executeOK(data, item=self.owned_id)
d1.refresh_from_db()
self.x1.refresh_from_db()
self.assertEqual(d1.term_resolved, '')
self.assertEqual(d1.term_raw, '@{D2|plur}')
self.assertEqual(self.x1.order, 0)
self.assertEqual(self.x1.alias, 'D2')
self.assertEqual(self.x1.cst_type, CstType.TERM)
@decl_endpoint('/api/rsforms/{item}/update-cst', method='patch')
def test_update_resolved_no_refs(self):
data = {
'target': self.x3.pk,
'item_data': {
'term_raw': 'New term',
'definition_raw': 'New def'
}
}
self.executeOK(data, item=self.owned_id)
self.x3.refresh_from_db()
self.assertEqual(self.x3.term_resolved, 'New term')
self.assertEqual(self.x3.definition_resolved, 'New def')
@decl_endpoint('/api/rsforms/{item}/update-cst', method='patch')
def test_update_resolved_refs(self):
data = {
'target': self.x3.pk,
'item_data': {
'term_raw': '@{X1|nomn,sing}',
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
}
}
self.executeOK(data, item=self.owned_id)
self.x3.refresh_from_db()
self.assertEqual(self.x3.term_resolved, self.x1.term_resolved)
self.assertEqual(self.x3.definition_resolved, f'{self.x1.term_resolved} form1')
@decl_endpoint('/api/rsforms/{item}/update-cst', method='patch')
def test_update_term_forms(self):
data = {
'target': self.x3.pk,
'item_data': {
'definition_raw': '@{X3|sing,datv}',
'term_forms': [{'text': 'form1', 'tags': 'sing,datv'}]
}
}
self.executeOK(data, item=self.owned_id)
self.x3.refresh_from_db()
self.assertEqual(self.x3.definition_resolved, 'form1')
self.assertEqual(self.x3.term_forms, data['item_data']['term_forms'])
@decl_endpoint('/api/rsforms/{item}/update-crucial', method='patch')
def test_update_crucial(self):
data = {'target': [self.x1.pk], 'value': True}
self.executeForbidden(data, item=self.unowned_id)
self.logout()
self.executeForbidden(data, item=self.owned_id)
self.login()
self.executeOK(data, item=self.owned_id)
self.x1.refresh_from_db()
self.assertEqual(self.x1.crucial, True)
@decl_endpoint('/api/rsforms/{item}/create-cst', method='post')
def test_create_constituenta(self):
data = {'alias': 'X4', 'cst_type': CstType.BASE}
self.executeForbidden(data, item=self.unowned_id)
data = {'alias': 'X4'}
self.executeBadData(item=self.owned_id)
self.executeBadData(data)
data['cst_type'] = 'invalid'
self.executeBadData(data)
data = {
'alias': 'X4',
'cst_type': CstType.BASE,
'term_raw': 'test',
'term_forms': [{'text': 'form1', 'tags': 'sing,datv'}],
'definition_formal': 'invalid',
'crucial': True
}
response = self.executeCreated(data)
self.assertEqual(response.data['new_cst']['alias'], data['alias'])
x4 = Constituenta.objects.get(alias=response.data['new_cst']['alias'])
self.assertEqual(x4.order, 3)
self.assertEqual(x4.term_raw, data['term_raw'])
self.assertEqual(x4.term_forms, data['term_forms'])
self.assertEqual(x4.definition_formal, data['definition_formal'])
self.assertEqual(x4.crucial, data['crucial'])
@decl_endpoint('/api/rsforms/{item}/create-cst', method='post')
def test_create_constituenta_after(self):
self.set_params(item=self.owned_id)
data = {'alias': 'X4', 'cst_type': CstType.BASE, 'insert_after': self.invalid_cst}
self.executeBadData(data)
data['insert_after'] = self.unowned_cst.pk
self.executeBadData(data)
data = {
'alias': 'X4',
'cst_type': CstType.BASE,
'insert_after': self.x2.pk,
}
response = self.executeCreated(data)
self.assertEqual(response.data['new_cst']['alias'], data['alias'])
x4 = Constituenta.objects.get(alias=response.data['new_cst']['alias'])
self.x3.refresh_from_db()
self.assertEqual(x4.order, 2)
self.assertEqual(self.x3.order, 3)

View File

@ -36,11 +36,11 @@ class TestRSFormViewset(EndpointTester):
'access_policy': AccessPolicy.PROTECTED,
'visible': False
}
self.executeBadData(data=data)
self.executeBadData(data)
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data['file'] = file
response = self.client.post(self.endpoint, data=data, format='multipart')
response = self.client.post(self.endpoint, data, format='multipart')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['title'], data['title'])
@ -117,21 +117,21 @@ class TestRSFormViewset(EndpointTester):
def test_check_expression(self):
self.owned.insert_last('X1')
data = {'expression': 'X1=X1'}
response = self.executeOK(data=data, item=self.owned_id)
response = self.executeOK(data, item=self.owned_id)
self.assertEqual(response.data['parseResult'], True)
self.assertEqual(response.data['syntax'], 'math')
self.assertEqual(response.data['astText'], '[=[X1][X1]]')
self.assertEqual(response.data['typification'], 'LOGIC')
self.assertEqual(response.data['valueClass'], 'value')
self.executeOK(data=data, item=self.unowned_id)
self.executeOK(data, item=self.unowned_id)
@decl_endpoint('/api/rsforms/{item}/check-constituenta', method='post')
def test_check_constituenta(self):
self.owned.insert_last('X1')
data = {'definition_formal': 'X1=X1', 'alias': 'A111', 'cst_type': CstType.AXIOM}
response = self.executeOK(data=data, item=self.owned_id)
response = self.executeOK(data, item=self.owned_id)
self.assertEqual(response.data['parseResult'], True)
self.assertEqual(response.data['syntax'], 'math')
self.assertEqual(response.data['astText'], '[:==[A111][=[X1][X1]]]')
@ -143,7 +143,7 @@ class TestRSFormViewset(EndpointTester):
def test_check_constituenta_error(self):
self.owned.insert_last('X1')
data = {'definition_formal': 'X1=X1', 'alias': 'D111', 'cst_type': CstType.TERM}
response = self.executeOK(data=data, item=self.owned_id)
response = self.executeOK(data, item=self.owned_id)
self.assertEqual(response.data['parseResult'], False)
@ -155,7 +155,7 @@ class TestRSFormViewset(EndpointTester):
)
data = {'text': '@{1|редкий} @{X1|plur,datv}'}
response = self.executeOK(data=data, item=self.owned_id)
response = self.executeOK(data, item=self.owned_id)
self.assertEqual(response.data['input'], '@{1|редкий} @{X1|plur,datv}')
self.assertEqual(response.data['output'], 'редким синим слонам')
self.assertEqual(len(response.data['refs']), 2)
@ -182,7 +182,7 @@ class TestRSFormViewset(EndpointTester):
work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file}
response = self.client.post(self.endpoint, data=data, format='multipart')
response = self.client.post(self.endpoint, data, format='multipart')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertTrue(response.data['title'] != '')
@ -200,57 +200,10 @@ class TestRSFormViewset(EndpointTester):
self.assertIn('document.json', zipped_file.namelist())
@decl_endpoint('/api/rsforms/{item}/create-cst', method='post')
def test_create_constituenta(self):
data = {'alias': 'X3', 'cst_type': CstType.BASE}
self.executeForbidden(data=data, item=self.unowned_id)
data = {'alias': 'X3'}
self.owned.insert_last('X1')
x2 = self.owned.insert_last('X2')
self.executeBadData(item=self.owned_id)
self.executeBadData(data=data, item=self.owned_id)
data['cst_type'] = 'invalid'
self.executeBadData(data=data, item=self.owned_id)
data['cst_type'] = CstType.BASE
response = self.executeCreated(data=data, item=self.owned_id)
self.assertEqual(response.data['new_cst']['alias'], 'X3')
x3 = Constituenta.objects.get(alias=response.data['new_cst']['alias'])
self.assertEqual(x3.order, 2)
data = {
'alias': 'X4',
'cst_type': CstType.BASE,
'insert_after': x2.pk,
'term_raw': 'test',
'term_forms': [{'text': 'form1', 'tags': 'sing,datv'}],
'definition_formal': 'invalid',
'crucial': True
}
response = self.executeCreated(data=data, item=self.owned_id)
self.assertEqual(response.data['new_cst']['alias'], data['alias'])
x4 = Constituenta.objects.get(alias=response.data['new_cst']['alias'])
self.assertEqual(x4.order, 2)
self.assertEqual(x4.term_raw, data['term_raw'])
self.assertEqual(x4.term_forms, data['term_forms'])
self.assertEqual(x4.definition_formal, data['definition_formal'])
self.assertEqual(x4.crucial, data['crucial'])
data = {
'alias': 'X5',
'cst_type': CstType.BASE,
'insert_after': None,
'term_raw': 'test5'
}
response = self.executeCreated(data=data, item=self.owned_id)
self.assertEqual(response.data['new_cst']['alias'], data['alias'])
@decl_endpoint('/api/rsforms/{item}/substitute', method='patch')
def test_substitute_multiple(self):
self.set_params(item=self.owned_id)
x1 = self.owned.insert_last('X1')
x2 = self.owned.insert_last('X2')
d1 = self.owned.insert_last('D1')
@ -261,7 +214,7 @@ class TestRSFormViewset(EndpointTester):
)
data = {'substitutions': []}
self.executeBadData(data=data)
self.executeBadData(data)
data = {'substitutions': [
{
@ -273,7 +226,7 @@ class TestRSFormViewset(EndpointTester):
'substitution': d2.pk
}
]}
self.executeBadData(data=data)
self.executeBadData(data)
data = {'substitutions': [
{
@ -285,7 +238,7 @@ class TestRSFormViewset(EndpointTester):
'substitution': d2.pk
}
]}
response = self.executeOK(data=data, item=self.owned_id)
response = self.executeOK(data, item=self.owned_id)
d3.refresh_from_db()
self.assertEqual(d3.definition_formal, r'D1 \ D2')
@ -300,7 +253,7 @@ class TestRSFormViewset(EndpointTester):
'definition_formal': '3',
'definition_raw': '4'
}
response = self.executeCreated(data=data, item=self.owned_id)
response = self.executeCreated(data, item=self.owned_id)
self.assertEqual(response.data['new_cst']['alias'], 'X3')
self.assertEqual(response.data['new_cst']['cst_type'], CstType.BASE)
self.assertEqual(response.data['new_cst']['convention'], '1')
@ -316,13 +269,13 @@ class TestRSFormViewset(EndpointTester):
self.set_params(item=self.owned_id)
data = {'items': [1337]}
self.executeBadData(data=data)
self.executeBadData(data, item=self.owned_id)
x1 = self.owned.insert_last('X1')
x2 = self.owned.insert_last('X2')
data = {'items': [x1.pk]}
response = self.executeOK(data=data)
response = self.executeOK(data)
x2.refresh_from_db()
self.owned.model.refresh_from_db()
self.assertEqual(len(response.data['items']), 1)
@ -332,7 +285,7 @@ class TestRSFormViewset(EndpointTester):
x3 = self.unowned.insert_last('X1')
data = {'items': [x3.pk]}
self.executeBadData(data=data, item=self.owned_id)
self.executeBadData(data, item=self.owned_id)
@decl_endpoint('/api/rsforms/{item}/move-cst', method='patch')
@ -340,13 +293,13 @@ class TestRSFormViewset(EndpointTester):
self.set_params(item=self.owned_id)
data = {'items': [1337], 'move_to': 0}
self.executeBadData(data=data)
self.executeBadData(data)
x1 = self.owned.insert_last('X1')
x2 = self.owned.insert_last('X2')
data = {'items': [x2.pk], 'move_to': 0}
response = self.executeOK(data=data)
response = self.executeOK(data)
x1.refresh_from_db()
x2.refresh_from_db()
self.assertEqual(response.data['id'], self.owned_id)
@ -355,7 +308,7 @@ class TestRSFormViewset(EndpointTester):
x3 = self.unowned.insert_last('X1')
data = {'items': [x3.pk], 'move_to': 0}
self.executeBadData(data=data)
self.executeBadData(data)
@decl_endpoint('/api/rsforms/{item}/reset-aliases', method='patch')
@ -392,7 +345,7 @@ class TestRSFormViewset(EndpointTester):
work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file, 'load_metadata': False}
response = self.client.patch(self.endpoint, data=data, format='multipart')
response = self.client.patch(self.endpoint, data, format='multipart')
self.owned.model.refresh_from_db()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(self.owned.model.title, 'Test11')
@ -432,7 +385,7 @@ class TestRSFormViewset(EndpointTester):
self.executeBadData({'target': s2.pk})
# Testing simple structure
response = self.executeOK(data={'target': s1.pk})
response = self.executeOK({'target': s1.pk})
result = response.data['schema']
items = [item for item in result['items'] if item['id'] in response.data['cst_list']]
self.assertEqual(len(items), 2)
@ -441,7 +394,7 @@ class TestRSFormViewset(EndpointTester):
# Testing complex structure
s3.refresh_from_db()
response = self.executeOK(data={'target': s3.pk})
response = self.executeOK({'target': s3.pk})
result = response.data['schema']
items = [item for item in result['items'] if item['id'] in response.data['cst_list']]
self.assertEqual(len(items), 8)
@ -449,151 +402,15 @@ class TestRSFormViewset(EndpointTester):
# Testing function
f1.refresh_from_db()
response = self.executeOK(data={'target': f1.pk})
response = self.executeOK({'target': f1.pk})
result = response.data['schema']
items = [item for item in result['items'] if item['id'] in response.data['cst_list']]
self.assertEqual(len(items), 2)
self.assertEqual(items[0]['definition_formal'], '[α∈X1, β∈X1] Pr1(F10[α,β])')
class TestConstituentaAPI(EndpointTester):
''' Testing Constituenta view. '''
def setUp(self):
super().setUp()
self.owned = RSForm.create(title='Test', alias='T1', owner=self.user)
self.owned_id = self.owned.model.pk
self.unowned = RSForm.create(title='Test2', alias='T2')
self.unowned_id = self.unowned.model.pk
self.cst1 = Constituenta.objects.create(
alias='X1',
cst_type=CstType.BASE,
schema=self.owned.model,
order=0,
convention='Test',
term_raw='Test1',
term_resolved='Test1R',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}])
self.cst2 = Constituenta.objects.create(
alias='X2',
cst_type=CstType.BASE,
schema=self.unowned.model,
order=0,
convention='Test1',
term_raw='Test2',
term_resolved='Test2R'
)
self.cst3 = Constituenta.objects.create(
alias='X3',
schema=self.owned.model,
order=1,
term_raw='Test3',
term_resolved='Test3',
definition_raw='Test1',
definition_resolved='Test2'
)
self.invalid_cst = self.cst3.pk + 1337
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_partial_update(self):
data = {'target': self.cst1.pk, 'item_data': {'convention': 'tt'}}
self.executeForbidden(data=data, schema=self.unowned_id)
self.logout()
self.executeForbidden(data=data, schema=self.owned_id)
self.login()
self.executeOK(data=data, schema=self.owned_id)
self.cst1.refresh_from_db()
self.assertEqual(self.cst1.convention, 'tt')
self.executeOK(data=data, schema=self.owned_id)
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_partial_update_rename(self):
data = {'target': self.cst1.pk, 'item_data': {'alias': self.cst3.alias}}
self.executeBadData(data=data, schema=self.owned_id)
d1 = self.owned.insert_last(
alias='D1',
term_raw='@{X1|plur}',
definition_formal='X1'
)
self.assertEqual(self.cst1.order, 0)
self.assertEqual(self.cst1.alias, 'X1')
self.assertEqual(self.cst1.cst_type, CstType.BASE)
data = {'target': self.cst1.pk, 'item_data': {'alias': 'D2', 'cst_type': CstType.TERM}}
self.executeOK(data=data, schema=self.owned_id)
d1.refresh_from_db()
self.cst1.refresh_from_db()
self.assertEqual(d1.term_resolved, '')
self.assertEqual(d1.term_raw, '@{D2|plur}')
self.assertEqual(self.cst1.order, 0)
self.assertEqual(self.cst1.alias, 'D2')
self.assertEqual(self.cst1.cst_type, CstType.TERM)
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_update_resolved_no_refs(self):
data = {
'target': self.cst3.pk,
'item_data': {
'term_raw': 'New term',
'definition_raw': 'New def'
}
}
self.executeOK(data=data, schema=self.owned_id)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.term_resolved, 'New term')
self.assertEqual(self.cst3.definition_resolved, 'New def')
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_update_resolved_refs(self):
data = {
'target': self.cst3.pk,
'item_data': {
'term_raw': '@{X1|nomn,sing}',
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
}
}
self.executeOK(data=data, schema=self.owned_id)
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')
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_update_term_forms(self):
data = {
'target': self.cst3.pk,
'item_data': {
'definition_raw': '@{X3|sing,datv}',
'term_forms': [{'text': 'form1', 'tags': 'sing,datv'}]
}
}
self.executeOK(data=data, schema=self.owned_id)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.definition_resolved, 'form1')
self.assertEqual(self.cst3.term_forms, data['item_data']['term_forms'])
@decl_endpoint('/api/rsforms/{schema}/update-crucial', method='patch')
def test_update_crucial(self):
data = {'target': [self.cst1.pk], 'value': True}
self.executeForbidden(data=data, schema=self.unowned_id)
self.logout()
self.executeForbidden(data=data, schema=self.owned_id)
self.login()
self.executeOK(data=data, schema=self.owned_id)
self.cst1.refresh_from_db()
self.assertEqual(self.cst1.crucial, True)
class TestInlineSynthesis(EndpointTester):
''' Testing Operations endpoints. '''
''' Testing Inline synthesis. '''
@decl_endpoint('/api/rsforms/inline-synthesis', method='patch')
@ -612,20 +429,20 @@ class TestInlineSynthesis(EndpointTester):
'items': [],
'substitutions': []
}
self.executeForbidden(data=data)
self.executeForbidden(data)
data['receiver'] = invalid_id
self.executeBadData(data=data)
self.executeBadData(data)
data['receiver'] = self.schema1.model.pk
data['source'] = invalid_id
self.executeBadData(data=data)
self.executeBadData(data)
data['source'] = self.schema1.model.pk
self.executeOK(data=data)
self.executeOK(data)
data['items'] = [invalid_id]
self.executeBadData(data=data)
self.executeBadData(data)
def test_inline_synthesis(self):
@ -654,7 +471,7 @@ class TestInlineSynthesis(EndpointTester):
}
]
}
response = self.executeOK(data=data)
response = self.executeOK(data)
result = {item['alias']: item for item in response.data['items']}
self.assertEqual(len(result), 6)
self.assertEqual(result['S1']['definition_formal'], 'X2')

View File

@ -8,30 +8,30 @@ class TestRSLanguageViews(EndpointTester):
@decl_endpoint('/api/rslang/to-ascii', method='post')
def test_convert_to_ascii(self):
data = {'data': '1=1'}
self.executeBadData(data=data)
self.executeBadData(data)
data = {'expression': '1=1'}
response = self.executeOK(data=data)
response = self.executeOK(data)
self.assertEqual(response.data['result'], r'1 \eq 1')
@decl_endpoint('/api/rslang/to-math', method='post')
def test_convert_to_math(self):
data = {'data': r'1 \eq 1'}
self.executeBadData(data=data)
self.executeBadData(data)
data = {'expression': r'1 \eq 1'}
response = self.executeOK(data=data)
response = self.executeOK(data)
self.assertEqual(response.data['result'], r'1=1')
@decl_endpoint('/api/rslang/parse-expression', method='post')
def test_parse_expression(self):
data = {'data': r'1=1'}
self.executeBadData(data=data)
self.executeBadData(data)
data = {'expression': r'1=1'}
response = self.executeOK(data=data)
response = self.executeOK(data)
self.assertEqual(response.data['parseResult'], True)
self.assertEqual(response.data['syntax'], 'math')
self.assertEqual(response.data['astText'], '[=[1][1]]')

View File

@ -49,6 +49,9 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
'restore_order',
'reset_aliases',
'produce_structure',
'add_attribution',
'delete_attribution',
'clear_attributions'
]:
permission_list = [permissions.ItemEditor]
elif self.action in [
@ -79,7 +82,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
def create_cst(self, request: Request, pk) -> HttpResponse:
''' Create Constituenta. '''
item = self._get_item()
serializer = s.CstCreateSerializer(data=request.data)
serializer = s.CstCreateSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
if 'insert_after' not in data:
@ -232,10 +235,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
def substitute(self, request: Request, pk) -> HttpResponse:
''' Substitute occurrences of constituenta with another one. '''
item = self._get_item()
serializer = s.CstSubstituteSerializer(
data=request.data,
context={'schema': item}
)
serializer = s.CstSubstituteSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True)
substitutions: list[tuple[m.Constituenta, m.Constituenta]] = []
@ -269,10 +269,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
def delete_multiple_cst(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete multiple Constituents. '''
item = self._get_item()
serializer = s.CstListSerializer(
data=request.data,
context={'schema': item}
)
serializer = s.CstListSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True)
cst_list: list[m.Constituenta] = serializer.validated_data['items']
@ -287,6 +284,106 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
data=s.RSFormParseSerializer(item).data
)
@extend_schema(
summary='create Attribution',
tags=['Constituenta'],
request=s.AttributionCreateSerializer,
responses={
c.HTTP_201_CREATED: s.RSFormParseSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'], url_path='create-attribution')
def create_attribution(self, request: Request, pk) -> HttpResponse:
''' Create Attribution. '''
item = self._get_item()
serializer = s.AttributionCreateSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True)
container = serializer.validated_data['container']
attribute = serializer.validated_data['attribute']
with transaction.atomic():
new_association = m.Attribution.objects.create(
container=container,
attribute=attribute
)
PropagationFacade.after_create_attribution(item.pk, [new_association])
item.save(update_fields=['time_update'])
return Response(
status=c.HTTP_201_CREATED,
data=s.RSFormParseSerializer(item).data
)
@extend_schema(
summary='delete Association',
tags=['RSForm'],
request=s.AttributionDataSerializer,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='delete-attribution')
def delete_attribution(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete Attribution. '''
item = self._get_item()
serializer = s.AttributionDataSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True)
with transaction.atomic():
target = list(m.Attribution.objects.filter(
container=serializer.validated_data['container'],
attribute=serializer.validated_data['attribute']
))
if not target:
raise ValidationError({
'container': msg.invalidAssociation()
})
PropagationFacade.before_delete_attribution(item.pk, target)
m.Attribution.objects.filter(pk__in=[assoc.pk for assoc in target]).delete()
item.save(update_fields=['time_update'])
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(item).data
)
@extend_schema(
summary='delete all Attributions for target constituenta',
tags=['RSForm'],
request=s.CstTargetSerializer,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='clear-attributions')
def clear_attributions(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete Associations for target Constituenta. '''
item = self._get_item()
serializer = s.CstTargetSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True)
with transaction.atomic():
target = list(m.Attribution.objects.filter(container=serializer.validated_data['target']))
if target:
PropagationFacade.before_delete_attribution(item.pk, target)
m.Attribution.objects.filter(pk__in=[assoc.pk for assoc in target]).delete()
item.save(update_fields=['time_update'])
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(item).data
)
@extend_schema(
summary='move constituenta',
tags=['RSForm'],
@ -302,10 +399,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
def move_cst(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Move multiple Constituents. '''
item = self._get_item()
serializer = s.CstMoveSerializer(
data=request.data,
context={'schema': item}
)
serializer = s.CstMoveSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True)
with transaction.atomic():
@ -397,10 +491,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
)
data['id'] = item.pk
serializer = s.RSFormTRSSerializer(
data=data,
context={'load_meta': load_metadata}
)
serializer = s.RSFormTRSSerializer(data=data, context={'load_meta': load_metadata})
serializer.is_valid(raise_exception=True)
result: m.RSForm = serializer.save()
return Response(
@ -558,10 +649,7 @@ class TrsImportView(views.APIView):
)
owner = cast(User, self.request.user)
_prepare_rsform_data(data, request, owner)
serializer = s.RSFormTRSSerializer(
data=data,
context={'load_meta': True}
)
serializer = s.RSFormTRSSerializer(data=data, context={'load_meta': True})
serializer.is_valid(raise_exception=True)
schema: m.RSForm = serializer.save()
return Response(
@ -640,15 +728,12 @@ def _prepare_rsform_data(data: dict, request: Request, owner: Union[User, None])
@api_view(['PATCH'])
def inline_synthesis(request: Request) -> HttpResponse:
''' Endpoint: Inline synthesis. '''
serializer = s.InlineSynthesisSerializer(
data=request.data,
context={'user': request.user}
)
serializer = s.InlineSynthesisSerializer(data=request.data, context={'user': request.user})
serializer.is_valid(raise_exception=True)
receiver = m.RSFormCached(serializer.validated_data['receiver'])
items = cast(list[m.Constituenta], serializer.validated_data['items'])
if len(items) == 0:
if not items:
source = cast(LibraryItem, serializer.validated_data['source'])
items = list(m.Constituenta.objects.filter(schema=source).order_by('order'))

View File

@ -16,15 +16,15 @@ class TestUserAPIViews(EndpointTester):
def test_login(self):
self.logout()
data = {'username': self.user.username, 'password': 'invalid'}
self.executeBadData(data=data)
self.executeBadData(data)
data = {'username': self.user.username, 'password': 'password'}
self.executeAccepted(data=data)
self.executeAccepted(data=data)
self.executeAccepted(data)
self.executeAccepted(data)
self.logout()
data = {'username': self.user.email, 'password': 'password'}
self.executeAccepted(data=data)
self.executeAccepted(data)
@decl_endpoint('/users/api/logout', method='post')
@ -82,7 +82,7 @@ class TestUserUserProfileAPIView(EndpointTester):
'first_name': 'firstName',
'last_name': 'lastName',
}
response = self.executeOK(data=data)
response = self.executeOK(data)
self.user.refresh_from_db()
self.assertEqual(response.data['email'], '123@mail.ru')
self.assertEqual(self.user.email, '123@mail.ru')
@ -96,13 +96,13 @@ class TestUserUserProfileAPIView(EndpointTester):
'first_name': 'new',
'last_name': 'new2',
}
self.executeOK(data=data)
self.executeOK(data)
data = {'email': self.user2.email}
self.executeBadData(data=data)
self.executeBadData(data)
data = {'username': 'new_username'}
response = self.executeOK(data=data)
response = self.executeOK(data)
self.assertNotEqual(response.data['username'], data['username'])
self.logout()
@ -115,14 +115,14 @@ class TestUserUserProfileAPIView(EndpointTester):
'old_password': 'invalid',
'new_password': 'password2'
}
self.executeBadData(data=data)
self.executeBadData(data)
data = {
'old_password': 'password',
'new_password': 'password2'
}
oldHash = self.user.password
response = self.executeNoContent(data=data)
response = self.executeNoContent(data)
self.user.refresh_from_db()
self.assertNotEqual(self.user.password, oldHash)
self.assertTrue(self.client.login(username=self.user.username, password='password2'))
@ -155,7 +155,7 @@ class TestSignupAPIView(EndpointTester):
'first_name': 'firstName',
'last_name': 'lastName'
}
self.executeBadData(data=data)
self.executeBadData(data)
data = {
'username': 'NewUser',
@ -165,7 +165,7 @@ class TestSignupAPIView(EndpointTester):
'first_name': 'firstName',
'last_name': 'lastName'
}
response = self.executeCreated(data=data)
response = self.executeCreated(data)
self.assertTrue('id' in response.data)
self.assertEqual(response.data['username'], data['username'])
self.assertEqual(response.data['email'], data['email'])
@ -180,7 +180,7 @@ class TestSignupAPIView(EndpointTester):
'first_name': 'firstName',
'last_name': 'lastName'
}
self.executeBadData(data=data)
self.executeBadData(data)
data = {
'username': 'NewUser2',
@ -190,4 +190,4 @@ class TestSignupAPIView(EndpointTester):
'first_name': 'firstName',
'last_name': 'lastName'
}
self.executeBadData(data=data)
self.executeBadData(data)

View File

@ -10,6 +10,14 @@ def constituentsInvalid(constituents: list[int]):
return f'некорректные конституенты для схемы: {constituents}'
def associationSelf():
return 'Рефлексивная ассоциация не допускается'
def associationAlreadyExists():
return 'Отношение уже существует'
def constituentaNotInRSform(title: str):
return f'Конституента не принадлежит схеме: {title}'
@ -22,6 +30,10 @@ def operationNotInOSS():
return 'Операция не принадлежит ОСС'
def duplicateSchemasInArguments():
return 'Аргументы не должны содержать повторяющиеся КС'
def blockNotInOSS():
return 'Блок не принадлежит ОСС'
@ -86,12 +98,12 @@ def operationInputAlreadyConnected():
return 'Схема уже подключена к другой операции'
def referenceTypeNotAllowed():
return 'Ссылки не поддерживаются'
def replicaNotAllowed():
return 'Реплики не поддерживаются'
def referenceTypeRequired():
return 'Операция должна быть ссылкой'
def replicaRequired():
return 'Операция должна быть репликацией'
def operationNotSynthesis(title: str):
@ -138,6 +150,10 @@ def typificationInvalidStr():
return 'Invalid typification string'
def invalidAssociation():
return f'Ассоциация не найдена'
def exteorFileVersionNotSupported():
return 'Некорректный формат файла Экстеор. Сохраните файл в новой версии'

View File

@ -21,5 +21,5 @@ def write_zipped_json(json_data: dict, json_filename: str) -> bytes:
content = BytesIO()
data = json.dumps(json_data, indent=4, ensure_ascii=False)
with ZipFile(content, 'w') as archive:
archive.writestr(json_filename, data=data)
archive.writestr(json_filename, data)
return content.getvalue()

File diff suppressed because it is too large Load Diff

View File

@ -10,77 +10,77 @@
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "stylelint \"src/**/*.css\" && eslint . --report-unused-disable-directives --max-warnings 0",
"lintFix": "eslint . --report-unused-disable-directives --max-warnings 0 --fix",
"lintFix": "eslint . --report-unused-disable-directives --max-warnings 1 --fix",
"preview": "vite preview --port 3000"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.5",
"@hookform/resolvers": "^5.2.1",
"@hookform/resolvers": "^5.2.2",
"@lezer/lr": "^1.4.2",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query-devtools": "^5.83.0",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-themes": "^4.24.1",
"@uiw/react-codemirror": "^4.24.1",
"axios": "^1.11.0",
"@uiw/codemirror-themes": "^4.25.2",
"@uiw/react-codemirror": "^4.25.2",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"global": "^4.4.0",
"js-file-download": "^0.4.12",
"lucide-react": "^0.533.0",
"lucide-react": "^0.545.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.61.1",
"react-hook-form": "^7.65.0",
"react-icons": "^5.5.0",
"react-intl": "^7.1.11",
"react-router": "^7.7.1",
"react-intl": "^7.1.14",
"react-router": "^7.9.4",
"react-scan": "^0.4.3",
"react-tabs": "^6.1.0",
"react-toastify": "^11.0.5",
"react-tooltip": "^5.29.1",
"react-tooltip": "^5.30.0",
"react-zoom-pan-pinch": "^3.7.0",
"reactflow": "^11.11.4",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.6",
"use-debounce": "^10.0.5",
"zod": "^4.0.13",
"zustand": "^5.0.6"
"tw-animate-css": "^1.3.7",
"use-debounce": "^10.0.6",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@lezer/generator": "^1.8.0",
"@playwright/test": "^1.54.1",
"@tailwindcss/vite": "^4.1.11",
"@playwright/test": "^1.56.0",
"@tailwindcss/vite": "^4.1.14",
"@types/jest": "^30.0.0",
"@types/node": "^24.1.0",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/node": "^24.7.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.7.0",
"babel-plugin-react-compiler": "^19.1.0-rc.1",
"eslint": "^9.32.0",
"@vitejs/plugin-react": "^5.0.4",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.37.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-playwright": "^2.2.1",
"eslint-plugin-playwright": "^2.2.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^16.3.0",
"jest": "^30.0.5",
"stylelint": "^16.23.0",
"globals": "^16.4.0",
"jest": "^30.2.0",
"stylelint": "^16.25.0",
"stylelint-config-recommended": "^16.0.0",
"stylelint-config-standard": "^38.0.0",
"stylelint-config-tailwindcss": "^1.0.0",
"tailwindcss": "^4.0.7",
"ts-jest": "^29.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^7.0.6"
"ts-jest": "^29.4.5",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.0",
"vite": "^7.1.9"
},
"jest": {
"preset": "ts-jest",

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View File

@ -1,3 +1,5 @@
'use client';
import { Suspense } from 'react';
import { Outlet } from 'react-router';
import clsx from 'clsx';

View File

@ -1,3 +1,5 @@
'use client';
import { useNavigate, useRouteError } from 'react-router';
import { Button } from '@/components/control';

View File

@ -4,6 +4,9 @@ import React from 'react';
import { DialogType, useDialogsStore } from '@/stores/dialogs';
const DlgShowVideo = React.lazy(() =>
import('@/features/help/dialogs/dlg-show-video').then(module => ({ default: module.DlgShowVideo }))
);
const DlgChangeInputSchema = React.lazy(() =>
import('@/features/oss/dialogs/dlg-change-input-schema').then(module => ({ default: module.DlgChangeInputSchema }))
);
@ -46,8 +49,8 @@ const DlgDeleteOperation = React.lazy(() =>
}))
);
const DlgDeleteReference = React.lazy(() =>
import('@/features/oss/dialogs/dlg-delete-reference').then(module => ({
default: module.DlgDeleteReference
import('@/features/oss/dialogs/dlg-delete-replica').then(module => ({
default: module.DlgDeleteReplica
}))
);
const DlgEditEditors = React.lazy(() =>
@ -161,6 +164,8 @@ export const GlobalDialogs = () => {
return null;
}
switch (active) {
case DialogType.SHOW_VIDEO:
return <DlgShowVideo />;
case DialogType.CONSTITUENTA_TEMPLATE:
return <DlgCstTemplate />;
case DialogType.CREATE_CONSTITUENTA:

View File

@ -1,3 +1,5 @@
'use client';
import { useNavigation } from 'react-router';
import { useDebounce } from 'use-debounce';

View File

@ -1,5 +1,3 @@
'use client';
import { IntlProvider } from 'react-intl';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

View File

@ -1,3 +1,5 @@
'use client';
import { ToastContainer, type ToastContainerProps } from 'react-toastify';
import { usePreferencesStore } from '@/stores/preferences';

View File

@ -1,5 +1,3 @@
'use client';
import { Tooltip } from '@/components/container';
import { globalIDs } from '@/utils/constants';

View File

@ -1,3 +1,5 @@
'use client';
import { useWindowSize } from '@/hooks/use-window-size';
import { usePreferencesStore } from '@/stores/preferences';

View File

@ -1,3 +1,5 @@
'use client';
import { useAuth } from '@/features/auth/backend/use-auth';
import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown';
@ -12,34 +14,40 @@ import { useConceptNavigation } from './navigation-context';
export function MenuAI() {
const router = useConceptNavigation();
const menu = useDropdown();
const {
elementRef: menuRef,
isOpen: isMenuOpen,
toggle: toggleMenu,
handleBlur: handleMenuBlur,
hide: hideMenu
} = useDropdown();
const { user } = useAuth();
const showAIPrompt = useDialogsStore(state => state.showAIPrompt);
function navigateTemplates(event: React.MouseEvent<Element>) {
menu.hide();
hideMenu();
router.push({ path: urls.prompt_templates, newTab: event.ctrlKey || event.metaKey });
}
function handleCreatePrompt(event: React.MouseEvent<Element>) {
event.preventDefault();
event.stopPropagation();
menu.hide();
hideMenu();
showAIPrompt();
}
return (
<div ref={menu.ref} onBlur={menu.handleBlur} className='flex items-center justify-start relative h-full'>
<div ref={menuRef} onBlur={handleMenuBlur} className='flex items-center justify-start relative h-full'>
<NavigationButton
title='ИИ помощник' //
hideTitle={menu.isOpen}
aria-expanded={menu.isOpen}
hideTitle={isMenuOpen}
aria-expanded={isMenuOpen}
aria-controls={globalIDs.ai_dropdown}
icon={<IconAssistant size='1.5rem' />}
onClick={menu.toggle}
onClick={toggleMenu}
/>
<Dropdown id={globalIDs.ai_dropdown} className='min-w-[12ch] max-w-48' stretchLeft isOpen={menu.isOpen}>
<Dropdown id={globalIDs.ai_dropdown} className='min-w-[12ch] max-w-48' stretchLeft isOpen={isMenuOpen}>
<DropdownButton
text='Запрос'
title='Создать запрос'

View File

@ -1,3 +1,5 @@
'use client';
import { Suspense } from 'react';
import { useDropdown } from '@/components/dropdown';
@ -11,17 +13,23 @@ import { UserDropdown } from './user-dropdown';
export function MenuUser() {
const router = useConceptNavigation();
const menu = useDropdown();
const {
elementRef: menuRef,
isOpen: isMenuOpen,
toggle: toggleMenu,
handleBlur: handleMenuBlur,
hide: hideMenu
} = useDropdown();
return (
<div ref={menu.ref} onBlur={menu.handleBlur} className='flex items-center justify-start relative h-full'>
<div ref={menuRef} onBlur={handleMenuBlur} className='flex items-center justify-start relative h-full'>
<Suspense fallback={<Loader circular scale={1.5} />}>
<UserButton
onLogin={() => router.push({ path: urls.login, force: true })}
onClickUser={menu.toggle}
isOpen={menu.isOpen}
onClickUser={toggleMenu}
isOpen={isMenuOpen}
/>
</Suspense>
<UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} />
<UserDropdown isOpen={isMenuOpen} hideDropdown={() => hideMenu()} />
</div>
);
}

View File

@ -1,3 +1,5 @@
'use client';
import clsx from 'clsx';
import { IconLibrary2, IconManuals, IconNewItem2 } from '@/components/icons';

View File

@ -1,3 +1,5 @@
'use client';
import { useAuthSuspense } from '@/features/auth';
import { IconLogin, IconUser2 } from '@/components/icons';

View File

@ -1,3 +1,5 @@
'use client';
import { useAuthSuspense } from '@/features/auth';
import { useLogout } from '@/features/auth/backend/use-logout';

View File

@ -33,7 +33,7 @@ export function ExportDropdown<T extends object = object>({
filename = 'export',
className
}: ExportDropdownProps<T>) {
const { ref, isOpen, toggle, handleBlur, hide } = useDropdown();
const { elementRef: ref, isOpen, toggle, handleBlur, hide } = useDropdown();
function handleExport(format: 'csv' | 'json') {
if (!data || data.length === 0) {

View File

@ -1,5 +1,4 @@
'use no memo';
'use client';
import { type Table } from '@tanstack/react-table';

View File

@ -27,7 +27,7 @@ export function SelectPagination<TData>({ id, table, paginationOptions, onChange
);
return (
<Select onValueChange={handlePaginationOptionsChange} defaultValue={String(table.getState().pagination.pageSize)}>
<Select onValueChange={handlePaginationOptionsChange} value={String(table.getState().pagination.pageSize)}>
<SelectTrigger
id={id}
aria-label='Выбор количества строчек на странице'

View File

@ -1,5 +1,4 @@
'use no memo';
'use client';
import { flexRender, type Header, type HeaderGroup, type Table } from '@tanstack/react-table';
import clsx from 'clsx';

View File

@ -1,3 +1,4 @@
'use client';
'use no memo';
import { useCallback } from 'react';

View File

@ -1,4 +1,5 @@
'use client';
'use no memo';
import { useState } from 'react';
import {

View File

@ -45,7 +45,6 @@ export function Dropdown({
margin,
className
)}
aria-hidden={!isOpen}
inert={!isOpen}
{...restProps}
>

View File

@ -4,17 +4,26 @@ import { useRef, useState } from 'react';
export function useDropdown() {
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const elementRef = useRef<HTMLDivElement>(null);
function handleBlur(event: React.FocusEvent<HTMLDivElement>) {
if (ref.current?.contains(event.relatedTarget as Node)) {
const nextTarget = event.relatedTarget as Node | null;
if (nextTarget && elementRef.current?.contains(nextTarget)) {
return;
}
// Keep open when focus moves into a popover (e.g., ComboBox menu rendered via portal)
if (
nextTarget instanceof Element &&
(nextTarget.closest("[data-slot='popover-content']") || nextTarget.closest("[data-slot='popover-trigger']"))
) {
return;
}
setIsOpen(false);
}
return {
ref,
elementRef,
isOpen,
setIsOpen,
handleBlur,

View File

@ -12,8 +12,8 @@ export { BiX as IconRemove } from 'react-icons/bi';
export { BiTrash as IconDestroy } from 'react-icons/bi';
export { BiReset as IconReset } from 'react-icons/bi';
export { TbArrowsDiagonal2 as IconResize } from 'react-icons/tb';
export { LiaEdit as IconEdit } from 'react-icons/lia';
export { FiEdit as IconEdit2 } from 'react-icons/fi';
export { FiEdit as IconEdit } from 'react-icons/fi';
export { AiOutlineEdit as IconEdit2 } from 'react-icons/ai';
export { BiSearchAlt2 as IconSearch } from 'react-icons/bi';
export { BiDownload as IconDownload } from 'react-icons/bi';
export { BiUpload as IconUpload } from 'react-icons/bi';
@ -39,6 +39,7 @@ export { RiMenuFoldFill as IconMenuFold } from 'react-icons/ri';
export { RiMenuUnfoldFill as IconMenuUnfold } from 'react-icons/ri';
export { LuMoon as IconDarkTheme } from 'react-icons/lu';
export { LuSun as IconLightTheme } from 'react-icons/lu';
export { IoVideocamOutline as IconVideo } from 'react-icons/io5';
export { LuFolderTree as IconFolderTree } from 'react-icons/lu';
export { LuFolder as IconFolder } from 'react-icons/lu';
export { LuFolderSearch as IconFolderSearch } from 'react-icons/lu';
@ -87,6 +88,7 @@ export { MdOutlineSelectAll as IconConceptBlock } from 'react-icons/md';
export { TbHexagon as IconRSForm } from 'react-icons/tb';
export { TbAssembly as IconRSFormOwned } from 'react-icons/tb';
export { TbBallFootball as IconRSFormImported } from 'react-icons/tb';
export { TbHexagonLetterN as IconCstNominal } from 'react-icons/tb';
export { TbHexagonLetterX as IconCstBaseSet } from 'react-icons/tb';
export { TbHexagonLetterC as IconCstConstSet } from 'react-icons/tb';
export { TbHexagonLetterS as IconCstStructured } from 'react-icons/tb';
@ -125,7 +127,7 @@ export { RiOpenSourceLine as IconPublic } from 'react-icons/ri';
export { RiShieldLine as IconProtected } from 'react-icons/ri';
export { RiShieldKeyholeLine as IconPrivate } from 'react-icons/ri';
export { BiBug as IconStatusError } from 'react-icons/bi';
export { BiCheckCircle as IconStatusOK } from 'react-icons/bi';
export { LuThumbsUp as IconStatusOK } from 'react-icons/lu';
export { BiHelpCircle as IconStatusUnknown } from 'react-icons/bi';
export { BiStopCircle as IconStatusIncalculable } from 'react-icons/bi';
export { BiPauseCircle as IconStatusProperty } from 'react-icons/bi';
@ -160,7 +162,6 @@ export { BiGitBranch as IconGraphInputs } from 'react-icons/bi';
export { TbEarScan as IconGraphInverse } from 'react-icons/tb';
export { BiGitMerge as IconGraphOutputs } from 'react-icons/bi';
export { LuAtom as IconGraphCore } from 'react-icons/lu';
export { LuRotate3D as IconRotate3D } from 'react-icons/lu';
export { MdOutlineFitScreen as IconFitImage } from 'react-icons/md';
export { RiFocus3Line as IconFocus } from 'react-icons/ri';
export { LuSparkles as IconClustering } from 'react-icons/lu';

View File

@ -1,5 +1,7 @@
'use client';
import assert from 'assert';
import { useRef, useState } from 'react';
import { ChevronDownIcon } from 'lucide-react';
@ -9,20 +11,32 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { cn } from '../utils';
interface ComboMultiProps<Option> extends Styling {
interface ComboMultiPropsBase<Option> extends Styling {
id?: string;
items?: Option[];
value: Option[];
onChange: (newValue: Option[]) => void;
idFunc: (item: Option) => string;
labelValueFunc: (item: Option) => string;
labelOptionFunc: (item: Option) => string;
disabled?: boolean;
placeholder?: string;
noSearch?: boolean;
}
interface ComboMultiPropsFull<Option> extends ComboMultiPropsBase<Option> {
onChange: (newValue: Option[]) => void;
}
interface ComboMultiPropsSplit<Option> extends ComboMultiPropsBase<Option> {
onClear: () => void;
onAdd: (item: Option) => void;
onRemove: (item: Option) => void;
}
type ComboMultiProps<Option> = ComboMultiPropsFull<Option> | ComboMultiPropsSplit<Option>;
/**
* Displays a combo-box component with multiple selection.
*/
@ -30,14 +44,15 @@ export function ComboMulti<Option>({
id,
items,
value,
onChange,
labelValueFunc,
labelOptionFunc,
idFunc,
placeholder,
className,
style,
noSearch
disabled,
noSearch,
...restProps
}: ComboMultiProps<Option>) {
const [open, setOpen] = useState(false);
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
@ -54,19 +69,34 @@ export function ComboMulti<Option>({
if (value.includes(newValue)) {
handleRemoveValue(newValue);
} else {
onChange([...value, newValue]);
if ('onAdd' in restProps && typeof restProps.onAdd === 'function') {
restProps.onAdd(newValue);
} else {
assert('onChange' in restProps);
restProps.onChange([...value, newValue]);
}
setOpen(false);
}
}
function handleRemoveValue(delValue: Option) {
onChange(value.filter(v => v !== delValue));
if ('onRemove' in restProps && typeof restProps.onRemove === 'function') {
restProps.onRemove(delValue);
} else {
assert('onChange' in restProps);
restProps.onChange(value.filter(v => v !== delValue));
}
setOpen(false);
}
function handleClear(event: React.MouseEvent<SVGElement>) {
event.stopPropagation();
onChange([]);
if ('onClear' in restProps && typeof restProps.onClear === 'function') {
restProps.onClear();
} else {
assert('onChange' in restProps);
restProps.onChange([]);
}
setOpen(false);
}
@ -81,7 +111,7 @@ export function ComboMulti<Option>({
className={cn(
'relative h-9',
'flex gap-2 px-3 py-2 items-center justify-between',
'bg-input disabled:opacity-50',
'bg-input disabled:bg-transparent',
'cursor-pointer disabled:cursor-auto',
'whitespace-nowrap',
'focus-outline border',
@ -91,32 +121,39 @@ export function ComboMulti<Option>({
className
)}
style={style}
disabled={disabled}
>
<div className='flex flex-wrap gap-1 items-center'>
<div className='flex flex-wrap gap-2 items-center'>
{value.length === 0 ? <div className='text-muted-foreground'>{placeholder}</div> : null}
{value.map(item => (
<div key={idFunc(item)} className='flex px-1 items-center border rounded-lg bg-accent text-sm'>
{labelValueFunc(item)}
{!disabled ? (
<IconRemove
tabIndex={-1}
size='1rem'
className='cc-remove cc-hover-pulse'
onClick={event => {
onClick={
disabled
? undefined
: event => {
event.stopPropagation();
handleRemoveValue(item);
}}
}
}
/>
) : null}
</div>
))}
</div>
<ChevronDownIcon className={cn('text-muted-foreground', !!value && 'opacity-0')} />
{!!value ? (
{!!value && !disabled ? (
<IconRemove
tabIndex={-1}
size='1rem'
className='cc-remove absolute pointer-events-auto right-3 cc-hover-pulse hover:text-primary'
onClick={handleClear}
onClick={value.length === 0 ? undefined : handleClear}
/>
) : null}
</button>
@ -127,11 +164,14 @@ export function ComboMulti<Option>({
<CommandList>
<CommandEmpty>Список пуст</CommandEmpty>
<CommandGroup>
{items?.map(item => (
{items
?.filter(item => !value.includes(item))
.map(item => (
<CommandItem
key={idFunc(item)}
value={labelOptionFunc(item)}
onSelect={() => handleAddValue(item)}
disabled={disabled}
className={cn(value === item && 'bg-selected text-selected-foreground')}
>
{labelOptionFunc(item)}

View File

@ -1,3 +1,5 @@
'use client';
import { useEffect, useState } from 'react';
import clsx from 'clsx';

View File

@ -0,0 +1,42 @@
interface EmbedVKVideoProps {
/** Video ID to embed. */
videoID: string;
/** Display height in pixels. */
pxHeight: number;
/** Display width in pixels. */
pxWidth?: number;
}
/**
* Embeds a YouTube video into the page using the given video ID and dimensions.
*/
export function EmbedVKVideo({ videoID, pxHeight, pxWidth }: EmbedVKVideoProps) {
if (!pxWidth) {
pxWidth = (pxHeight * 16) / 9;
}
return (
<div
className='relative h-0 mt-1'
style={{
paddingBottom: `${pxHeight}px`,
paddingLeft: `${pxWidth}px`
}}
>
<iframe
allowFullScreen
title='Встроенное видео ВКонтакте'
allow='autoplay; encrypted-media; fullscreen; picture-in-picture; screen-wake-lock;'
className='absolute top-0 left-0 border'
style={{
minHeight: `${pxHeight}px`,
minWidth: `${pxWidth}px`
}}
width={`${pxWidth}px`}
height={`${pxHeight}px`}
src={`https://vk.com/video_ext.php?${videoID}&hd=1`}
/>
</div>
);
}

View File

@ -1,3 +1,5 @@
'use client';
import { Suspense, useState } from 'react';
import { HelpTopic } from '@/features/help';
@ -18,7 +20,7 @@ export function DlgAIPromptDialog() {
return (
<ModalView
header='Генератор запросом LLM'
className='w-100 sm:w-160 px-6 flex flex-col h-120'
className='w-100 sm:w-160 px-6 flex flex-col h-110'
helpTopic={HelpTopic.ASSISTANT}
>
<ComboBox

View File

@ -5,7 +5,7 @@ import { toast } from 'react-toastify';
import { urls, useConceptNavigation } from '@/app';
import { MiniButton } from '@/components/control';
import { IconClone, IconEdit2 } from '@/components/icons';
import { IconClone, IconEdit } from '@/components/icons';
import { useDialogsStore } from '@/stores/dialogs';
import { infoMsg } from '@/utils/labels';
@ -36,7 +36,7 @@ export function MenuAIPrompt({ promptID, generatedPrompt }: MenuAIPromptProps) {
title='Редактировать шаблон'
noHover
noPadding
icon={<IconEdit2 size='1.25rem' />}
icon={<IconEdit size='1.25rem' />}
className='h-full pl-2 text-muted-foreground hover:text-primary cc-animate-color bg-transparent'
onClick={navigatePrompt}
/>

View File

@ -1,3 +1,5 @@
'use client';
import { TextArea } from '@/components/input';
import { PromptInput } from '../../components/prompt-input';

View File

@ -11,7 +11,7 @@ export function TabPromptResult({ prompt }: TabPromptResultProps) {
value={prompt}
placeholder='Текст шаблона пуст'
disabled
className='w-full h-100'
className='w-full h-88'
/>
);
}

View File

@ -1,3 +1,5 @@
'use client';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@ -39,7 +41,7 @@ export function DlgCreatePromptTemplate() {
mode: 'onChange'
});
const label = useWatch({ control, name: 'label' });
const isValid = !!label && !templates.find(template => template.label === label);
const canSubmit = !!label && !templates.find(template => template.label === label);
function onSubmit(data: ICreatePromptTemplateDTO) {
void createPromptTemplate(data).then(onCreate);
@ -49,7 +51,7 @@ export function DlgCreatePromptTemplate() {
<ModalForm
header='Создание шаблона'
submitText='Создать'
canSubmit={isValid}
canSubmit={canSubmit}
onSubmit={event => void handleSubmit(onSubmit)(event)}
submitInvalidTooltip='Введите уникальное название шаблона'
className='cc-column w-140 max-h-120 py-2 px-6'

View File

@ -4,6 +4,7 @@ import { labelCstTypification } from '@/features/rsform/labels';
import { isBasicConcept } from '@/features/rsform/models/rsform-api';
import { TypificationGraph } from '@/features/rsform/models/typification-graph';
import { type Graph } from '@/models/graph';
import { PARAMETER } from '@/utils/constants';
import { mockPromptVariable } from '../labels';
@ -38,30 +39,24 @@ export function generateSample(target: string): string {
/** Generates a prompt for a schema variable. */
export function varSchema(schema: IRSForm): string {
let result = `Название концептуальной схемы: ${schema.title}\n`;
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`;
result += 'Конституенты:\n';
let result = stringifySchemaIntro(schema);
result += '\n\nКонституенты:';
schema.items.forEach(item => {
result += `\n${item.alias} - "${labelCstTypification(item)}" - "${item.term_resolved}" - "${
item.definition_formal
}" - "${item.definition_resolved}" - "${item.convention}"`;
});
if (schema.stats.count_crucial > 0) {
result +=
'\nКлючевые конституенты: ' +
schema.items
.filter(cst => cst.crucial)
.map(cst => cst.alias)
.join(', ');
}
result += `\n${stringifyCrucial(schema.items.filter(cst => cst.crucial))}`;
result += '\n\nСвязи "атрибутирован":';
const attributionGraph = stringifyGraph(schema.attribution_graph, schema);
result += attributionGraph ? attributionGraph : ' отсутствуют';
return result;
}
/** Generates a prompt for a schema thesaurus variable. */
export function varSchemaThesaurus(schema: IRSForm): string {
let result = `Название концептуальной схемы: ${schema.title}\n`;
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`;
result += 'Термины:\n';
let result = stringifySchemaIntro(schema);
result += '\n\nТермины:';
schema.items.forEach(item => {
if (item.cst_type === CstType.AXIOM || item.cst_type === CstType.THEOREM) {
return;
@ -77,48 +72,62 @@ export function varSchemaThesaurus(schema: IRSForm): string {
/** Generates a prompt for a schema graph variable. */
export function varSchemaGraph(schema: IRSForm): string {
let result = `Название концептуальной схемы: ${schema.title}\n`;
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`;
result += 'Узлы графа\n';
result += JSON.stringify(schema.items, null, PARAMETER.indentJSON);
result += '\n\nСвязи графа';
schema.graph.nodes.forEach(node => (result += `\n${node.id} -> ${node.outputs.join(', ')}`));
let result = stringifySchemaIntro(schema);
result += '\n\nУзлы графа\n';
result += JSON.stringify(
schema.items.map(cst => ({
alias: cst.alias,
term: cst.term_resolved,
definition: cst.definition_resolved,
convention: cst.convention,
crucial: cst.crucial
})),
null,
PARAMETER.indentJSON
);
result += '\n\nСвязи "входит в определение"';
const definitionGraph = stringifyGraph(schema.graph, schema);
result += definitionGraph ? definitionGraph : ' отсутствуют';
result += '\n\nСвязи "атрибутирован"';
const attributionGraph = stringifyGraph(schema.attribution_graph, schema);
result += attributionGraph ? attributionGraph : ' отсутствуют';
return result;
}
/** Generates a prompt for a schema type graph variable. */
export function varSchemaTypeGraph(schema: IRSForm): string {
const graph = new TypificationGraph();
schema.items.forEach(item => graph.addConstituenta(item.alias, item.parse.typification, item.parse.args));
schema.items.forEach(item => {
if (item.parse) graph.addConstituenta(item.alias, item.parse.typification, item.parse.args);
});
let result = `Название концептуальной схемы: ${schema.title}\n`;
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`;
result += 'Ступени\n';
let result = stringifySchemaIntro(schema);
result += '\n\nСтупени\n';
result += JSON.stringify(graph.nodes, null, PARAMETER.indentJSON);
return result;
}
/** Generates a prompt for a OSS variable. */
export function varOSS(oss: IOperationSchema): string {
let result = `Название операционной схемы: ${oss.title}\n`;
result += `Сокращение: ${oss.alias}\n`;
result += `Описание: ${oss.description}\n`;
result += `Блоки: ${oss.blocks.length}\n`;
let result = stringifyOSSIntro(oss);
result += `\n\nБлоки: ${oss.blocks.length}\n`;
oss.hierarchy.topologicalOrder().forEach(blockID => {
const block = oss.itemByNodeID.get(blockID);
if (block?.nodeType !== NodeType.BLOCK) {
return;
}
result += `\nБлок ${block.id}: ${block.title}\n`;
result += `Описание: ${block.description}\n`;
result += `Предок: "${block.parent}"\n`;
result += `\n\nБлок ${block.id}: ${block.title}`;
result += `\nОписание: ${block.description}`;
result += `\nПредок: "${block.parent ?? 'отсутствует'}"`;
});
result += `Операции: ${oss.operations.length}\n`;
result += `\n\nОперации: ${oss.operations.length}`;
oss.operations.forEach(operation => {
result += `\nОперация ${operation.id}: ${operation.alias}\n`;
result += `Название: ${operation.title}\n`;
result += `Описание: ${operation.description}\n`;
result += `Блок: ${operation.parent}`;
result += `\n\nОперация ${operation.id}: ${operation.alias}`;
result += `\nНазвание: ${operation.title}`;
result += `\nОписание: ${operation.description}`;
result += `\nБлок: ${operation.parent ?? 'отсутствует'}`;
});
return result;
}
@ -127,19 +136,19 @@ export function varOSS(oss: IOperationSchema): string {
export function varBlock(target: IBlock, oss: IOperationSchema): string {
const blocks = oss.blocks.filter(block => block.parent === target.id);
const operations = oss.operations.filter(operation => operation.parent === target.id);
let result = `Название блока: ${target.title}\n`;
result += `Описание: "${target.description}"\n`;
result += '\nСодержание\n';
result += `Блоки: ${blocks.length}\n`;
let result = `Название блока: ${target.title}`;
result += `\nОписание: "${target.description}"`;
result += '\n\nСодержание';
result += `\nБлоки: ${blocks.length}`;
blocks.forEach(block => {
result += `\nБлок ${block.id}: ${block.title}\n`;
result += `Описание: "${block.description}"\n`;
result += `\n\nБлок ${block.id}: ${block.title}`;
result += `\nОписание: "${block.description}"`;
});
result += `Операции: ${operations.length}\n`;
result += `\n\nОперации: ${operations.length}`;
operations.forEach(operation => {
result += `\nОперация ${operation.id}: ${operation.alias}\n`;
result += `Название: "${operation.title}"\n`;
result += `Описание: "${operation.description}"`;
result += `\n\nОперация ${operation.id}: ${operation.alias}`;
result += `\nНазвание: "${operation.title}"`;
result += `\nОписание: "${operation.description}"`;
});
return result;
}
@ -151,9 +160,49 @@ export function varConstituenta(cst: IConstituenta): string {
/** Generates a prompt for a constituenta syntax tree variable. */
export function varSyntaxTree(cst: IConstituenta): string {
let result = `Конституента: ${cst.alias}\n`;
result += `Формальное выражение: ${cst.definition_formal}\n`;
result += `Дерево синтаксического разбора:\n`;
result += JSON.stringify(cst.parse.syntaxTree, null, PARAMETER.indentJSON);
let result = `Конституента: ${cst.alias}`;
result += `\ормальное выражение: ${cst.definition_formal}`;
result += `\ерево синтаксического разбора:\n`;
result += cst.parse ? JSON.stringify(cst.parse.syntaxTree, null, PARAMETER.indentJSON) : 'не определено';
return result;
}
// ==== Internal functions ====
function stringifyGraph(graph: Graph<number>, schema: IRSForm): string {
let result = '';
graph.nodes.forEach(node => {
if (node.outputs.length > 0) {
result += `\n${schema.items.find(cst => cst.id === node.id)!.alias} -> ${node.outputs
.map(id => schema.items.find(cst => cst.id === id)!.alias)
.join(', ')}`;
}
});
return result;
}
function stringifySchemaIntro(schema: IRSForm): string {
let result = `Концептуальная схема: ${schema.title}`;
result += `\nКраткое название: ${schema.alias}`;
if (schema.description) {
result += `\nОписание: "${schema.description}"`;
}
return result;
}
function stringifyOSSIntro(schema: IOperationSchema): string {
let result = `Операционная схема: ${schema.title}`;
result += `\nКраткое название: ${schema.alias}`;
if (schema.description) {
result += `\nОписание: "${schema.description}"`;
}
return result;
}
function stringifyCrucial(cstList: IConstituenta[]): string {
let result = 'Ключевые конституенты: ';
if (cstList.length === 0) {
return result + 'отсутствуют';
}
result += cstList.map(cst => cst.alias).join(', ');
return result;
}

View File

@ -1,3 +1,5 @@
'use client';
import { urls, useConceptNavigation } from '@/app';
import { MiniButton } from '@/components/control';

View File

@ -1,3 +1,5 @@
'use client';
import { ErrorBoundary } from 'react-error-boundary';
import { isAxiosError } from 'axios';
import { z } from 'zod';

View File

@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client';
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx';
@ -63,6 +63,8 @@ export function FormPromptTemplate({ promptTemplate, className, isMutable, toggl
const prevReset = useRef(toggleReset);
const prevTemplate = useRef(promptTemplate);
useEffect(() => {
if (prevTemplate.current !== promptTemplate || prevReset.current !== toggleReset) {
prevTemplate.current = promptTemplate;
prevReset.current = toggleReset;
@ -73,14 +75,13 @@ export function FormPromptTemplate({ promptTemplate, className, isMutable, toggl
text: promptTemplate.text,
is_shared: promptTemplate.is_shared
});
setSampleResult(null);
return () => setSampleResult(null);
}
}, [promptTemplate, toggleReset, reset, setSampleResult]);
const prevDirty = useRef(isDirty);
if (prevDirty.current !== isDirty) {
prevDirty.current = isDirty;
useEffect(() => {
setIsModified(isDirty);
}
}, [isDirty, setIsModified]);
function onSubmit(data: IUpdatePromptTemplateDTO) {
return updatePromptTemplate({ id: promptTemplate.id, data }).then(() => {

View File

@ -1,3 +1,5 @@
'use client';
import { useState } from 'react';
import clsx from 'clsx';

View File

@ -1,3 +1,5 @@
'use client';
import { urls, useConceptNavigation } from '@/app';
import { useDeletePromptTemplate } from '@/features/ai/backend/use-delete-prompt-template';
import { useMutatingPrompts } from '@/features/ai/backend/use-mutating-prompts';

Some files were not shown because too many files have changed in this diff Show More