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", "Upvote",
"Viewset", "Viewset",
"viewsets", "viewsets",
"vkvideo",
"wordform", "wordform",
"Wordforms", "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] [Bugs - PENDING]
- -
[Functionality - PENDING] [Functionality - PENDING]
- Export PDF (Items list, Graph)
- Save react-flow to vector image
- Landing page - Landing page
- Design first user experience - Design first user experience
- Video guides
- Demo sandbox for anonymous users - 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: User profile:
- Settings server persistency - Settings server persistency
- Profile pictures - Profile pictures (avatars)
- Custom LibraryItem lists - Custom LibraryItem lists
- Custom user filters and sharing filters - Custom user filters and sharing filters
- Personal prompt templates
- Static analyzer for RSForm as a whole: check term duplication and empty conventions - 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) - Focus on codemirror editor when label is clicked (need React 19 ref for clean code solution)
- Draggable rows in constituents table - Draggable rows in constituents table
- Search functionality for Help Manuals - use google search integration filtered by site? - 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 - Internationalization - at least english version. Consider react.intl
- Sitemap for better SEO and crawler optimization - Sitemap for better SEO and crawler optimization

View File

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

View File

@ -32,11 +32,11 @@ class TestVersionViews(EndpointTester):
invalid_id = 1338 invalid_id = 1338
data = {'version': '1.0.0', 'description': 'test'} data = {'version': '1.0.0', 'description': 'test'}
self.executeNotFound(data=data, schema=invalid_id) self.executeNotFound(data, schema=invalid_id)
self.executeForbidden(data=data, schema=self.unowned_id) self.executeForbidden(data, schema=self.unowned_id)
self.executeBadData(data=invalid_data, schema=self.owned_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('version' in response.data)
self.assertTrue('schema' in response.data) self.assertTrue('schema' in response.data)
self.assertTrue(response.data['version'] in [v['id'] for v in response.data['schema']['versions']]) 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): def test_create_version_filter(self):
x2 = self.owned.insert_last('X2') x2 = self.owned.insert_last('X2')
data = {'version': '1.0.0', 'description': 'test', 'items': [x2.pk]} 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']) version = Version.objects.get(pk=response.data['version'])
items = version.data['items'] items = version.data['items']
self.assertTrue('version' in response.data) self.assertTrue('version' in response.data)
@ -102,7 +102,7 @@ class TestVersionViews(EndpointTester):
@decl_endpoint('/api/versions/{version}', method='get') @decl_endpoint('/api/versions/{version}', method='get')
def test_access_version(self): def test_access_version(self):
data = {'version': '1.0.0', 'description': 'test'} 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 invalid_id = version_id + 1337
self.executeNotFound(version=invalid_id) self.executeNotFound(version=invalid_id)
@ -116,14 +116,14 @@ class TestVersionViews(EndpointTester):
data = {'version': '1.2.0', 'description': 'test1'} data = {'version': '1.2.0', 'description': 'test1'}
self.method = 'patch' self.method = 'patch'
self.executeForbidden(data=data) self.executeForbidden(data)
self.method = 'delete' self.method = 'delete'
self.executeForbidden() self.executeForbidden()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
self.method = 'patch' self.method = 'patch'
self.executeOK(data=data) self.executeOK(data)
response = self.get() response = self.get()
self.assertEqual(response.data['version'], data['version']) self.assertEqual(response.data['version'], data['version'])
self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['description'], data['description'])
@ -157,7 +157,7 @@ class TestVersionViews(EndpointTester):
x2 = self.owned.insert_last('X2') x2 = self.owned.insert_last('X2')
d1 = self.owned.insert_last('D1', term_raw='TestTerm') d1 = self.owned.insert_last('D1', term_raw='TestTerm')
data = {'version': '1.0.0', 'description': 'test'} 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 invalid_id = version_id + 1337
Constituenta.objects.get(pk=d1.pk).delete() Constituenta.objects.get(pk=d1.pk).delete()
@ -186,7 +186,7 @@ class TestVersionViews(EndpointTester):
def _create_version(self, data) -> int: def _create_version(self, data) -> int:
response = self.client.post( response = self.client.post(
f'/api/library/{self.owned_id}/create-version', f'/api/library/{self.owned_id}/create-version',
data=data, format='json' data, format='json'
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
return response.data['version'] # type: ignore 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 rest_framework.response import Response
from apps.oss.models import Layout, Operation, OperationSchema, PropagationFacade 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.rsform.serializers import RSFormParseSerializer
from apps.users.models import User from apps.users.models import User
from shared import permissions from shared import permissions
@ -157,8 +157,8 @@ class LibraryViewSet(viewsets.ModelViewSet):
serializer = s.LibraryItemCloneSerializer(data=request.data, context={'schema': item}) serializer = s.LibraryItemCloneSerializer(data=request.data, context={'schema': item})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data['item_data'] data = serializer.validated_data['item_data']
with transaction.atomic(): with transaction.atomic():
clone = deepcopy(item) clone = deepcopy(item)
clone.pk = None clone.pk = None
@ -171,12 +171,24 @@ class LibraryViewSet(viewsets.ModelViewSet):
clone.access_policy = data.get('access_policy', m.AccessPolicy.PUBLIC) clone.access_policy = data.get('access_policy', m.AccessPolicy.PUBLIC)
clone.location = data.get('location', m.LocationHead.USER) clone.location = data.get('location', m.LocationHead.USER)
clone.save() 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(): for cst in RSFormCached(item).constituentsQ():
if not need_filter or cst.pk in request.data['items']: if not need_filter or cst.pk in request.data['items']:
old_pk = cst.pk
cst.pk = None cst.pk = None
cst.schema = clone cst.schema = clone
cst.save() 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( return Response(
status=c.HTTP_201_CREATED, status=c.HTTP_201_CREATED,
data=RSFormParseSerializer(clone).data data=RSFormParseSerializer(clone).data
@ -299,7 +311,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
with transaction.atomic(): with transaction.atomic():
added, deleted = m.Editor.set_and_return_diff(item.pk, editors) 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') owned_schemas = OperationSchema.owned_schemasQ(item).only('pk')
if owned_schemas.exists(): if owned_schemas.exists():
m.Editor.objects.filter( m.Editor.objects.filter(

View File

@ -60,9 +60,9 @@ class InheritanceAdmin(admin.ModelAdmin):
search_fields = ['id', 'operation', 'parent', 'child'] search_fields = ['id', 'operation', 'parent', 'child']
@admin.register(models.Reference) @admin.register(models.Replica)
class ReferenceAdmin(admin.ModelAdmin): class ReplicaAdmin(admin.ModelAdmin):
''' Admin model: Reference. ''' ''' Admin model: Replica. '''
ordering = ['reference', 'target'] ordering = ['replica', 'original']
list_display = ['id', 'reference', 'target'] list_display = ['id', 'replica', 'original']
search_fields = ['id', 'reference', 'target'] 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 apps.library.models import LibraryItem
from .Argument import Argument from .Argument import Argument
from .Reference import Reference from .Replica import Replica
from .Substitution import Substitution from .Substitution import Substitution
@ -24,7 +24,7 @@ class OperationType(TextChoices):
''' Type of operation. ''' ''' Type of operation. '''
INPUT = 'input' INPUT = 'input'
SYNTHESIS = 'synthesis' SYNTHESIS = 'synthesis'
REFERENCE = 'reference' REPLICA = 'replica'
class Operation(Model): class Operation(Model):
@ -93,13 +93,13 @@ class Operation(Model):
''' Operation substitutions. ''' ''' Operation substitutions. '''
return Substitution.objects.filter(operation=self) return Substitution.objects.filter(operation=self)
def getQ_references(self) -> QuerySet[Reference]: def getQ_replicas(self) -> QuerySet[Replica]:
''' Operation references. ''' ''' Operation replicas. '''
return Reference.objects.filter(target=self) return Replica.objects.filter(original=self)
def getQ_reference_target(self) -> list['Operation']: def getQ_replica_original(self) -> list['Operation']:
''' Operation target for current reference. ''' ''' Operation source for current replica. '''
return [x.target for x in Reference.objects.filter(reference=self)] return [x.original for x in Replica.objects.filter(replica=self)]
def setQ_result(self, result: Optional[LibraryItem]) -> None: def setQ_result(self, result: Optional[LibraryItem]) -> None:
''' Set result schema. ''' ''' Set result schema. '''
@ -107,12 +107,12 @@ class Operation(Model):
return return
self.result = result self.result = result
self.save(update_fields=['result']) self.save(update_fields=['result'])
for reference in self.getQ_references(): for rep in self.getQ_replicas():
reference.reference.result = result rep.replica.result = result
reference.reference.save(update_fields=['result']) rep.replica.save(update_fields=['result'])
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
''' Delete operation. ''' ''' Delete operation. '''
for ref in self.getQ_references(): for rep in self.getQ_replicas():
ref.reference.delete() rep.replica.delete()
super().delete(*args, **kwargs) super().delete(*args, **kwargs)

View File

@ -11,7 +11,7 @@ from .Block import Block
from .Inheritance import Inheritance from .Inheritance import Inheritance
from .Layout import Layout from .Layout import Layout
from .Operation import Operation, OperationType from .Operation import Operation, OperationType
from .Reference import Reference from .Replica import Replica
from .Substitution import Substitution from .Substitution import Substitution
@ -42,6 +42,22 @@ class OperationSchema:
''' OSS layout. ''' ''' OSS layout. '''
return Layout.objects.get(oss_id=itemID) 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: def refresh_from_db(self) -> None:
''' Model wrapper. ''' ''' Model wrapper. '''
self.model.refresh_from_db() self.model.refresh_from_db()
@ -51,15 +67,15 @@ class OperationSchema:
result = Operation.objects.create(oss=self.model, **kwargs) result = Operation.objects.create(oss=self.model, **kwargs)
return result return result
def create_reference(self, target: Operation) -> Operation: def create_replica(self, target: Operation) -> Operation:
''' Create Reference Operation. ''' ''' Create Replica Operation. '''
result = Operation.objects.create( result = Operation.objects.create(
oss=self.model, oss=self.model,
operation_type=OperationType.REFERENCE, operation_type=OperationType.REPLICA,
result=target.result, result=target.result,
parent=target.parent parent=target.parent
) )
Reference.objects.create(reference=result, target=target) Replica.objects.create(replica=result, original=target)
return result return result
def create_block(self, **kwargs) -> Block: def create_block(self, **kwargs) -> Block:
@ -80,21 +96,6 @@ class OperationSchema:
operation.save(update_fields=['parent']) operation.save(update_fields=['parent'])
target.delete() 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: def set_arguments(self, target: int, arguments: list[Operation]) -> None:
''' Set arguments of target Operation. ''' ''' Set arguments of target Operation. '''
Argument.objects.filter(operation_id=target).delete() Argument.objects.filter(operation_id=target).delete()
@ -128,10 +129,10 @@ class OperationSchema:
.order_by('order') .order_by('order')
if arg.argument.result_id is not None if arg.argument.result_id is not None
] ]
if len(schemas) == 0: if not schemas:
return return
substitutions = operation.getQ_substitutions() substitutions = operation.getQ_substitutions()
receiver = self.create_input(operation) receiver = OperationSchema.create_input(self.model, operation)
parents: dict = {} parents: dict = {}
children: dict = {} children: dict = {}

View File

@ -3,31 +3,17 @@
from typing import Optional from typing import Optional
from cctext import extract_entities from apps.library.models import LibraryItem
from rest_framework.serializers import ValidationError from apps.rsform.models import Attribution, Constituenta, CstType, OrderManager, RSFormCached
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 .Argument import Argument from .Argument import Argument
from .Inheritance import Inheritance from .Inheritance import Inheritance
from .Operation import Operation, OperationType from .Operation import Operation
from .Reference import Reference from .OperationSchema import OperationSchema
from .OssCache import OssCache
from .PropagationEngine import PropagationEngine
from .Substitution import Substitution from .Substitution import Substitution
from .utils import CstMapping, CstSubstitution, create_dependant_mapping, extract_data_references
CstMapping = dict[str, Optional[Constituenta]]
CstSubstitution = list[tuple[Constituenta, Constituenta]]
class OperationSchemaCached: class OperationSchemaCached:
@ -35,19 +21,20 @@ class OperationSchemaCached:
def __init__(self, model: LibraryItem): def __init__(self, model: LibraryItem):
self.model = model 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): def delete_replica(self, target: int, keep_connections: bool = False, keep_constituents: bool = False):
''' Delete Reference Operation. ''' ''' Delete Replica Operation. '''
if not keep_connections: if not keep_connections:
self.delete_operation(target, keep_constituents) self.delete_operation(target, keep_constituents)
return return
self.cache.ensure_loaded_subs() self.cache.ensure_loaded_subs()
operation = self.cache.operation_by_id[target] operation = self.cache.operation_by_id[target]
reference_target = self.cache.reference_target.get(target) original = self.cache.replica_original.get(target)
if reference_target: if original:
for arg in operation.getQ_as_argument(): for arg in operation.getQ_as_argument():
arg.argument_id = reference_target arg.argument_id = original
arg.save() arg.save()
self.cache.remove_operation(target) self.cache.remove_operation(target)
operation.delete() operation.delete()
@ -57,11 +44,11 @@ class OperationSchemaCached:
''' Delete Operation. ''' ''' Delete Operation. '''
self.cache.ensure_loaded_subs() self.cache.ensure_loaded_subs()
operation = self.cache.operation_by_id[target] operation = self.cache.operation_by_id[target]
children = self.cache.graph.outputs[target] children = self.cache.extend_graph.outputs[target]
if operation.result is not None and len(children) > 0: if operation.result is not None and children:
ids = list(Constituenta.objects.filter(schema=operation.result).values_list('pk', flat=True)) ids = list(Constituenta.objects.filter(schema=operation.result).values_list('pk', flat=True))
if not keep_constituents: if not keep_constituents:
self._cascade_delete_inherited(operation.pk, ids) self.engine.on_delete_inherited(operation.pk, ids)
else: else:
inheritance_to_delete: list[Inheritance] = [] inheritance_to_delete: list[Inheritance] = []
for child_id in children: for child_id in children:
@ -69,7 +56,7 @@ class OperationSchemaCached:
child_schema = self.cache.get_schema(child_operation) child_schema = self.cache.get_schema(child_operation)
if child_schema is None: if child_schema is None:
continue 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]: for item in self.cache.inheritance[child_id]:
if item.parent_id in ids: if item.parent_id in ids:
inheritance_to_delete.append(item) inheritance_to_delete.append(item)
@ -82,7 +69,7 @@ class OperationSchemaCached:
def set_input(self, target: int, schema: Optional[LibraryItem]) -> None: def set_input(self, target: int, schema: Optional[LibraryItem]) -> None:
''' Set input schema for operation. ''' ''' Set input schema for operation. '''
operation = self.cache.operation_by_id[target] 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) old_schema = self.cache.get_schema(operation)
if schema is None and old_schema is None or \ 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): (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) processed.append(current.argument)
current.order = arguments.index(current.argument) current.order = arguments.index(current.argument)
updated.append(current) updated.append(current)
if len(deleted) > 0: if deleted:
self.before_delete_arguments(operation, [x.argument for x in deleted]) self.before_delete_arguments(operation, [x.argument for x in deleted])
for deleted_arg in deleted: for deleted_arg in deleted:
self.cache.remove_argument(deleted_arg) self.cache.remove_argument(deleted_arg)
@ -132,7 +119,7 @@ class OperationSchemaCached:
new_arg = Argument.objects.create(operation=operation, argument=arg, order=order) new_arg = Argument.objects.create(operation=operation, argument=arg, order=order)
self.cache.insert_argument(new_arg) self.cache.insert_argument(new_arg)
added.append(arg) added.append(arg)
if len(added) > 0: if added:
self.after_create_arguments(operation, added) self.after_create_arguments(operation, added)
def set_substitutions(self, target: int, substitutes: list[dict]) -> None: def set_substitutions(self, target: int, substitutes: list[dict]) -> None:
@ -147,14 +134,14 @@ class OperationSchemaCached:
x for x in substitutes x for x in substitutes
if x['original'] == current.original and x['substitution'] == current.substitution if x['original'] == current.original and x['substitution'] == current.substitution
] ]
if len(subs) == 0: if not subs:
deleted.append(current) deleted.append(current)
else: else:
processed.append(subs[0]) processed.append(subs[0])
if len(deleted) > 0: if deleted:
if schema is not None: if schema is not None:
for sub in deleted: for sub in deleted:
self._undo_substitution(schema, sub) self.engine.undo_substitution(schema, sub)
else: else:
for sub in deleted: for sub in deleted:
self.cache.remove_substitution(sub) self.cache.remove_substitution(sub)
@ -169,22 +156,7 @@ class OperationSchemaCached:
substitution=sub_item['substitution'] substitution=sub_item['substitution']
) )
added.append(new_sub) added.append(new_sub)
self._process_added_substitutions(schema, added) self._on_add_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
def execute_operation(self, operation: Operation) -> bool: def execute_operation(self, operation: Operation) -> bool:
''' Execute target Operation. ''' ''' Execute target Operation. '''
@ -197,10 +169,11 @@ class OperationSchemaCached:
.order_by('order') .order_by('order')
if arg.argument.result_id is not None if arg.argument.result_id is not None
] ]
if len(schemas) == 0: if not schemas:
return False return False
substitutions = operation.getQ_substitutions() 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 = {} parents: dict = {}
children: dict = {} children: dict = {}
@ -231,7 +204,7 @@ class OperationSchemaCached:
receiver.reset_aliases() receiver.reset_aliases()
receiver.resolve_all_text() 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')) receiver_items = list(Constituenta.objects.filter(schema=receiver.model).order_by('order'))
self.after_create_cst(receiver, receiver_items) self.after_create_cst(receiver, receiver_items)
receiver.model.save(update_fields=['time_update']) receiver.model.save(update_fields=['time_update'])
@ -243,7 +216,7 @@ class OperationSchemaCached:
self.cache.insert_schema(source) self.cache.insert_schema(source)
self.cache.insert_schema(destination) self.cache.insert_schema(destination)
operation = self.cache.get_operation(destination.model.pk) 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] inheritance_to_delete = [item for item in self.cache.inheritance[operation.pk] if item.parent_id in items]
for item in inheritance_to_delete: for item in inheritance_to_delete:
self.cache.remove_inheritance(item) self.cache.remove_inheritance(item)
@ -282,37 +255,27 @@ class OperationSchemaCached:
exclude: Optional[list[int]] = None exclude: Optional[list[int]] = None
) -> None: ) -> None:
''' Trigger cascade resolutions when new Constituenta is created. ''' ''' Trigger cascade resolutions when new Constituenta is created. '''
source.cache.ensure_loaded()
self.cache.insert_schema(source) self.cache.insert_schema(source)
inserted_aliases = [cst.alias for cst in cst_list] alias_mapping = create_dependant_mapping(source, 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
operation = self.cache.get_operation(source.model.pk) 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: def after_change_cst_type(self, schemaID: int, target: int, new_type: CstType) -> None:
''' Trigger cascade resolutions when Constituenta type is changed. ''' ''' Trigger cascade resolutions when Constituenta type is changed. '''
operation = self.cache.get_operation(schemaID) 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: def after_update_cst(self, source: RSFormCached, target: int, data: dict, old_data: dict) -> None:
''' Trigger cascade resolutions when Constituenta data is changed. ''' ''' Trigger cascade resolutions when Constituenta data is changed. '''
self.cache.insert_schema(source) self.cache.insert_schema(source)
operation = self.cache.get_operation(source.model.pk) 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 = {} alias_mapping: CstMapping = {}
for alias in depend_aliases: for alias in depend_aliases:
cst = source.cache.by_alias.get(alias) cst = source.cache.by_alias.get(alias)
if cst is not None: if cst is not None:
alias_mapping[alias] = cst alias_mapping[alias] = cst
self._cascade_update_cst( self.engine.on_update_cst(
operation=operation.pk, operation=operation.pk,
cst_id=target, cst_id=target,
data=data, data=data,
@ -320,15 +283,15 @@ class OperationSchemaCached:
mapping=alias_mapping 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. ''' ''' Trigger cascade resolutions before Constituents are deleted. '''
operation = self.cache.get_operation(sourceID) operation = self.cache.get_operation(operationID)
self._cascade_delete_inherited(operation.pk, target) self.engine.on_delete_inherited(operation.pk, target)
def before_substitute(self, schemaID: int, substitutions: CstSubstitution) -> None: def before_substitute(self, schemaID: int, substitutions: CstSubstitution) -> None:
''' Trigger cascade resolutions before Constituents are substituted. ''' ''' Trigger cascade resolutions before Constituents are substituted. '''
operation = self.cache.get_operation(schemaID) 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: def before_delete_arguments(self, target: Operation, arguments: list[Operation]) -> None:
''' Trigger cascade resolutions before arguments are deleted. ''' ''' Trigger cascade resolutions before arguments are deleted. '''
@ -337,7 +300,7 @@ class OperationSchemaCached:
for argument in arguments: for argument in arguments:
parent_schema = self.cache.get_schema(argument) parent_schema = self.cache.get_schema(argument)
if parent_schema is not None: 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: def after_create_arguments(self, target: Operation, arguments: list[Operation]) -> None:
''' Trigger cascade resolutions after arguments are created. ''' ''' Trigger cascade resolutions after arguments are created. '''
@ -348,336 +311,27 @@ class OperationSchemaCached:
parent_schema = self.cache.get_schema(argument) parent_schema = self.cache.get_schema(argument)
if parent_schema is None: if parent_schema is None:
continue continue
self._execute_inherit_cst( self.engine.inherit_cst(
target_operation=target.pk, target_operation=target.pk,
source=parent_schema, source=parent_schema,
items=list(parent_schema.constituentsQ().order_by('order')), items=list(parent_schema.constituentsQ().order_by('order')),
mapping={} mapping={}
) )
# pylint: disable=too-many-arguments, too-many-positional-arguments def after_create_attribution(self, schemaID: int, associations: list[Attribution],
def _cascade_inherit_cst( exclude: Optional[list[int]] = None) -> None:
self, target_operation: int, ''' Trigger cascade resolutions when Attribution is created. '''
source: RSFormCached, operation = self.cache.get_operation(schemaID)
items: list[Constituenta], self.engine.on_inherit_attribution(operation.pk, associations, exclude)
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 _execute_inherit_cst( def before_delete_attribution(self, schemaID: int, associations: list[Attribution]) -> None:
self, ''' Trigger cascade resolutions when Attribution is deleted. '''
target_operation: int, operation = self.cache.get_operation(schemaID)
source: RSFormCached, self.engine.on_delete_attribution(operation.pk, associations)
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
self.cache.ensure_loaded_subs() def _on_add_substitutions(self, schema: Optional[RSFormCached], added: list[Substitution]) -> None:
new_mapping = self._transform_mapping(mapping, operation, destination) ''' Trigger cascade resolutions when Constituenta substitution is added. '''
alias_mapping = OperationSchemaCached._produce_alias_mapping(new_mapping) if not added:
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:
return return
if schema is None: if schema is None:
for sub in added: for sub in added:
@ -697,185 +351,3 @@ class OperationSchemaCached:
schema.substitute(cst_mapping) schema.substitute(cst_mapping)
for sub in added: for sub in added:
self.cache.insert_substitution(sub) 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 typing import Optional
from apps.library.models import LibraryItem, LibraryItemType 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 from .OperationSchemaCached import CstSubstitution, OperationSchemaCached
@ -60,7 +60,7 @@ class PropagationFacade:
def before_substitute(sourceID: int, substitutions: CstSubstitution, def before_substitute(sourceID: int, substitutions: CstSubstitution,
exclude: Optional[list[int]] = None) -> None: exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before constituents are substituted. ''' ''' Trigger cascade resolutions before constituents are substituted. '''
if len(substitutions) == 0: if not substitutions:
return return
hosts = _get_oss_hosts(sourceID) hosts = _get_oss_hosts(sourceID)
for host in hosts: for host in hosts:
@ -73,8 +73,29 @@ class PropagationFacade:
if item.item_type != LibraryItemType.RSFORM: if item.item_type != LibraryItemType.RSFORM:
return return
hosts = _get_oss_hosts(item.pk) hosts = _get_oss_hosts(item.pk)
if len(hosts) == 0: if not hosts:
return return
ids = list(Constituenta.objects.filter(schema=item).order_by('order').values_list('pk', flat=True)) 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 .OperationSchema import OperationSchema
from .OperationSchemaCached import OperationSchemaCached from .OperationSchemaCached import OperationSchemaCached
from .PropagationFacade import PropagationFacade from .PropagationFacade import PropagationFacade
from .Reference import Reference from .Replica import Replica
from .Substitution import Substitution 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, BlockSerializer,
CloneSchemaSerializer, CloneSchemaSerializer,
CreateBlockSerializer, CreateBlockSerializer,
CreateReferenceSerializer, CreateReplicaSerializer,
CreateSchemaSerializer, CreateSchemaSerializer,
CreateSynthesisSerializer, CreateSynthesisSerializer,
DeleteBlockSerializer, DeleteBlockSerializer,
DeleteOperationSerializer, DeleteOperationSerializer,
DeleteReferenceSerializer, DeleteReplicaSerializer,
ImportSchemaSerializer, ImportSchemaSerializer,
MoveItemsSerializer, MoveItemsSerializer,
OperationSchemaSerializer, OperationSchemaSerializer,
OperationSerializer, OperationSerializer,
ReferenceSerializer,
RelocateConstituentsSerializer, RelocateConstituentsSerializer,
ReplicaSerializer,
SetOperationInputSerializer, SetOperationInputSerializer,
TargetOperationSerializer, TargetOperationSerializer,
UpdateBlockSerializer, UpdateBlockSerializer,

View File

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

View File

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

View File

@ -1,12 +1,12 @@
''' Testing models: Reference. ''' ''' Testing models: Replica. '''
from django.test import TestCase 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 from apps.rsform.models import RSForm
class TestReference(TestCase): class TestReplica(TestCase):
''' Testing Reference model. ''' ''' Testing Replica model. '''
def setUp(self): def setUp(self):
@ -19,26 +19,26 @@ class TestReference(TestCase):
) )
self.operation2 = Operation.objects.create( self.operation2 = Operation.objects.create(
oss=self.oss.model, oss=self.oss.model,
operation_type=OperationType.REFERENCE, operation_type=OperationType.REPLICA,
) )
self.reference = Reference.objects.create( self.replicas = Replica.objects.create(
reference=self.operation2, replica=self.operation2,
target=self.operation1 original=self.operation1
) )
def test_str(self): def test_str(self):
testStr = f'{self.operation2} -> {self.operation1}' testStr = f'{self.operation2} -> {self.operation1}'
self.assertEqual(str(self.reference), testStr) self.assertEqual(str(self.replicas), testStr)
def test_cascade_delete_operation(self): def test_cascade_delete_operation(self):
self.assertEqual(Reference.objects.count(), 1) self.assertEqual(Replica.objects.count(), 1)
self.operation2.delete() self.operation2.delete()
self.assertEqual(Reference.objects.count(), 0) self.assertEqual(Replica.objects.count(), 0)
def test_cascade_delete_target(self): def test_cascade_delete_target(self):
self.assertEqual(Reference.objects.count(), 1) self.assertEqual(Replica.objects.count(), 1)
self.operation1.delete() 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): def test_set_owner(self):
data = {'user': self.user3.pk} 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.owned.model.refresh_from_db()
self.ks1.model.refresh_from_db() self.ks1.model.refresh_from_db()
@ -89,7 +89,7 @@ class TestChangeAttributes(EndpointTester):
def test_set_location(self): def test_set_location(self):
data = {'location': '/U/temp'} 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.owned.model.refresh_from_db()
self.ks1.model.refresh_from_db() self.ks1.model.refresh_from_db()
@ -105,7 +105,7 @@ class TestChangeAttributes(EndpointTester):
def test_set_access_policy(self): def test_set_access_policy(self):
data = {'access_policy': AccessPolicy.PROTECTED} 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.owned.model.refresh_from_db()
self.ks1.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]) Editor.set(self.ks3.model.pk, [self.user2.pk, self.user.pk])
data = {'users': [self.user3.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.owned.model.refresh_from_db()
self.ks1.model.refresh_from_db() self.ks1.model.refresh_from_db()
@ -140,7 +140,7 @@ class TestChangeAttributes(EndpointTester):
def test_sync_from_result(self): def test_sync_from_result(self):
data = {'alias': 'KS111', 'title': 'New Title', 'description': 'New description'} 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.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, self.ks1.model) self.assertEqual(self.operation1.result, self.ks1.model)
@ -161,7 +161,7 @@ class TestChangeAttributes(EndpointTester):
'layout': self.layout_data '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.ks3.model.refresh_from_db()
self.assertEqual(self.ks3.model.alias, data['item_data']['alias']) self.assertEqual(self.ks3.model.alias, data['item_data']['alias'])
self.assertEqual(self.ks3.model.title, data['item_data']['title']) self.assertEqual(self.ks3.model.title, data['item_data']['title'])

View File

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

View File

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

View File

@ -1,6 +1,6 @@
''' Testing API: Propagate changes through references in OSS. ''' ''' 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 apps.rsform.models import Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint from shared.EndpointTester import EndpointTester, decl_endpoint
@ -50,7 +50,7 @@ class ReferencePropagationTestCase(EndpointTester):
operation_type=OperationType.INPUT, operation_type=OperationType.INPUT,
result=self.ks2.model 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( self.operation4 = self.owned.create_operation(
alias='4', alias='4',
@ -79,8 +79,8 @@ class ReferencePropagationTestCase(EndpointTester):
) )
self.owned.set_arguments(self.operation5.pk, [self.operation4, self.operation3]) self.owned.set_arguments(self.operation5.pk, [self.operation4, self.operation3])
self.owned.set_substitutions(self.operation5.pk, [{ self.owned.set_substitutions(self.operation5.pk, [{
'original': self.ks4X1, 'original': self.ks1X2,
'substitution': self.ks1X2 'substitution': self.ks4X1
}]) }])
self.owned.execute_operation(self.operation5) self.owned.execute_operation(self.operation5)
self.operation5.refresh_from_db() 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_arguments(self.operation6.pk, [self.operation2, self.operation3])
self.owned.set_substitutions(self.operation6.pk, [{ self.owned.set_substitutions(self.operation6.pk, [{
'original': self.ks2X1, 'original': self.ks2X2,
'substitution': self.ks1X1 'substitution': self.ks1X2
}]) }])
self.owned.execute_operation(self.operation6) self.owned.execute_operation(self.operation6)
self.operation6.refresh_from_db() self.operation6.refresh_from_db()
@ -139,8 +139,68 @@ class ReferencePropagationTestCase(EndpointTester):
'layout': self.layout_data, 'layout': self.layout_data,
'target': self.operation1.pk '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.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, 'original': self.ks1X1.pk,
'substitution': self.ks1X2.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.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db() self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db() self.ks5D4.refresh_from_db()
@ -159,7 +159,7 @@ class TestChangeSubstitutions(EndpointTester):
'substitution': self.ks2X1.pk '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.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db() self.ks4D2.refresh_from_db()
self.ks5D4.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') @decl_endpoint('/api/rsforms/{schema}/delete-multiple-cst', method='patch')
def test_delete_original(self): def test_delete_original(self):
data = {'items': [self.ks1X1.pk, self.ks1D1.pk]} 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.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db() self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getQ_substitutions() subs1_2 = self.operation4.getQ_substitutions()
@ -194,7 +194,7 @@ class TestChangeSubstitutions(EndpointTester):
@decl_endpoint('/api/rsforms/{schema}/delete-multiple-cst', method='patch') @decl_endpoint('/api/rsforms/{schema}/delete-multiple-cst', method='patch')
def test_delete_substitution(self): def test_delete_substitution(self):
data = {'items': [self.ks2S1.pk, self.ks2X2.pk]} 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.ks4D1.refresh_from_db()
self.ks4D2.refresh_from_db() self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db() self.ks5D4.refresh_from_db()

View File

@ -81,9 +81,9 @@ class TestOssBlocks(EndpointTester):
'children_operations': [], 'children_operations': [],
'children_blocks': [] '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) self.assertEqual(len(response.data['oss']['blocks']), 3)
new_block = response.data['new_block'] new_block = response.data['new_block']
layout = response.data['oss']['layout'] layout = response.data['oss']['layout']
@ -94,9 +94,9 @@ class TestOssBlocks(EndpointTester):
self.assertEqual(block_node['height'], data['position']['height']) self.assertEqual(block_node['height'], data['position']['height'])
self.operation1.refresh_from_db() 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.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') @decl_endpoint('/api/oss/{item}/create-block', method='post')
@ -118,13 +118,13 @@ class TestOssBlocks(EndpointTester):
'children_operations': [], 'children_operations': [],
'children_blocks': [] 'children_blocks': []
} }
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
data['item_data']['parent'] = self.block3.pk data['item_data']['parent'] = self.block3.pk
self.executeBadData(data=data) self.executeBadData(data)
data['item_data']['parent'] = self.block1.pk data['item_data']['parent'] = self.block1.pk
response = self.executeCreated(data=data) response = self.executeCreated(data)
new_block = response.data['new_block'] new_block = response.data['new_block']
block_data = next((block for block in response.data['oss']['blocks'] if block['id'] == new_block), None) 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) self.assertEqual(block_data['parent'], self.block1.pk)
@ -148,20 +148,20 @@ class TestOssBlocks(EndpointTester):
'children_operations': [self.invalid_id], 'children_operations': [self.invalid_id],
'children_blocks': [] 'children_blocks': []
} }
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
data['children_operations'] = [self.operation3.pk] data['children_operations'] = [self.operation3.pk]
self.executeBadData(data=data) self.executeBadData(data)
data['children_operations'] = [self.block1.pk] data['children_operations'] = [self.block1.pk]
self.executeBadData(data=data) self.executeBadData(data)
data['children_operations'] = [self.operation1.pk] data['children_operations'] = [self.operation1.pk]
data['children_blocks'] = [self.operation1.pk] data['children_blocks'] = [self.operation1.pk]
self.executeBadData(data=data) self.executeBadData(data)
data['children_blocks'] = [self.block1.pk] data['children_blocks'] = [self.block1.pk]
response = self.executeCreated(data=data) response = self.executeCreated(data)
new_block = response.data['new_block'] new_block = response.data['new_block']
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.block1.refresh_from_db() self.block1.refresh_from_db()
@ -188,13 +188,13 @@ class TestOssBlocks(EndpointTester):
'children_operations': [], 'children_operations': [],
'children_blocks': [self.block1.pk] '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 data['item_data']['parent'] = self.block1.pk
self.executeBadData(data=data) self.executeBadData(data)
data['children_blocks'] = [self.block2.pk] data['children_blocks'] = [self.block2.pk]
self.executeCreated(data=data) self.executeCreated(data)
@decl_endpoint('/api/oss/{item}/delete-block', method='patch') @decl_endpoint('/api/oss/{item}/delete-block', method='patch')
@ -206,26 +206,26 @@ class TestOssBlocks(EndpointTester):
data = { data = {
'layout': self.layout_data 'layout': self.layout_data
} }
self.executeBadData(data=data) self.executeBadData(data)
data['target'] = self.operation1.pk data['target'] = self.operation1.pk
self.executeBadData(data=data) self.executeBadData(data)
data['target'] = self.block3.pk data['target'] = self.block3.pk
self.executeBadData(data=data) self.executeBadData(data)
data['target'] = self.block2.pk data['target'] = self.block2.pk
self.logout() self.logout()
self.executeForbidden(data=data) self.executeForbidden(data)
self.login() self.login()
response = self.executeOK(data=data) response = self.executeOK(data)
self.operation2.refresh_from_db() self.operation2.refresh_from_db()
self.assertEqual(len(response.data['blocks']), 1) self.assertEqual(len(response.data['blocks']), 1)
self.assertEqual(self.operation2.parent.pk, self.block1.pk) self.assertEqual(self.operation2.parent.pk, self.block1.pk)
data['target'] = self.block1.pk data['target'] = self.block1.pk
response = self.executeOK(data=data) response = self.executeOK(data)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.operation2.refresh_from_db() self.operation2.refresh_from_db()
self.assertEqual(len(response.data['blocks']), 0) self.assertEqual(len(response.data['blocks']), 0)
@ -246,25 +246,25 @@ class TestOssBlocks(EndpointTester):
'parent': None 'parent': None
}, },
} }
self.executeBadData(data=data) self.executeBadData(data)
data['target'] = self.block3.pk data['target'] = self.block3.pk
self.toggle_admin(True) self.toggle_admin(True)
self.executeBadData(data=data) self.executeBadData(data)
data['target'] = self.block2.pk data['target'] = self.block2.pk
self.logout() self.logout()
self.executeForbidden(data=data) self.executeForbidden(data)
self.login() self.login()
response = self.executeOK(data=data) response = self.executeOK(data)
self.block2.refresh_from_db() self.block2.refresh_from_db()
self.assertEqual(self.block2.title, data['item_data']['title']) self.assertEqual(self.block2.title, data['item_data']['title'])
self.assertEqual(self.block2.description, data['item_data']['description']) self.assertEqual(self.block2.description, data['item_data']['description'])
self.assertEqual(self.block2.parent, data['item_data']['parent']) self.assertEqual(self.block2.parent, data['item_data']['parent'])
data['layout'] = self.layout_data data['layout'] = self.layout_data
self.executeOK(data=data) self.executeOK(data)
@decl_endpoint('/api/oss/{item}/update-block', method='patch') @decl_endpoint('/api/oss/{item}/update-block', method='patch')
@ -280,13 +280,13 @@ class TestOssBlocks(EndpointTester):
'parent': self.block2.pk '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 # Create a deeper hierarchy: block1 -> block2 -> block3
self.block3 = self.owned.create_block(title='3', parent=self.block2) self.block3 = self.owned.create_block(title='3', parent=self.block2)
# Try to set block1's parent to block3 (should fail, indirect cycle) # Try to set block1's parent to block3 (should fail, indirect cycle)
data['item_data']['parent'] = self.block3.pk 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) # Setting block2's parent to block1 (valid, as block1 is not a descendant)
data = { data = {
@ -297,4 +297,4 @@ class TestOssBlocks(EndpointTester):
'parent': self.block1.pk '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. ''' ''' Testing API: Operation Schema - operations manipulation. '''
from apps.library.models import AccessPolicy, Editor, LibraryItem, LibraryItemType 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 apps.rsform.models import Constituenta, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint from shared.EndpointTester import EndpointTester, decl_endpoint
@ -95,9 +95,9 @@ class TestOssOperations(EndpointTester):
'height': 50 '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) self.assertEqual(len(response.data['oss']['operations']), 4)
new_operation_id = response.data['new_operation'] new_operation_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id) 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.assertEqual(schema.location, self.owned.model.location)
self.assertIn(self.user2, schema.getQ_editors()) 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.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') @decl_endpoint('/api/oss/{item}/clone-schema', method='post')
@ -141,10 +141,10 @@ class TestOssOperations(EndpointTester):
'height': 60 'height': 60
} }
} }
self.executeNotFound(data=data, item=self.invalid_id) self.executeNotFound(data, item=self.invalid_id)
self.executeForbidden(data=data, item=self.unowned_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('new_operation', response.data)
self.assertIn('oss', response.data) self.assertIn('oss', response.data)
new_operation_id = response.data['new_operation'] new_operation_id = response.data['new_operation']
@ -171,7 +171,7 @@ class TestOssOperations(EndpointTester):
unrelated_data = dict(data) unrelated_data = dict(data)
unrelated_data['source_operation'] = self.unowned_operation.pk 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') @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') block_unowned = self.unowned.create_block(title='TestBlock1')
data['item_data']['parent'] = block_unowned.id 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') block_owned = self.owned.create_block(title='TestBlock2')
data['item_data']['parent'] = block_owned.id 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_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id) 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(len(response.data['oss']['operations']), 4)
self.assertEqual(new_operation['parent'], block_owned.id) self.assertEqual(new_operation['parent'], block_owned.id)
@decl_endpoint('/api/oss/{item}/create-reference', method='post') @decl_endpoint('/api/oss/{item}/create-replica', method='post')
def test_create_reference(self): def test_create_replica(self):
self.populateData() self.populateData()
data = { data = {
'target': self.invalid_id, 'target': self.invalid_id,
@ -222,20 +222,20 @@ class TestOssOperations(EndpointTester):
'height': 40 'height': 40
} }
} }
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
data['target'] = self.unowned_operation.pk 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 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() self.owned.model.refresh_from_db()
new_operation_id = response.data['new_operation'] new_operation_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id) 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['parent'], self.operation1.parent_id)
self.assertEqual(new_operation['result'], self.operation1.result_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.assertIsNotNone(ref)
self.assertTrue(Operation.objects.filter(pk=new_operation_id, oss=self.owned.model).exists()) 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], 'arguments': [self.operation1.pk, self.operation3.pk],
'substitutions': [] 'substitutions': []
} }
response = self.executeCreated(data=data, item=self.owned_id) response = self.executeCreated(data, item=self.owned_id)
self.owned.model.refresh_from_db() self.owned.model.refresh_from_db()
new_operation_id = response.data['new_operation'] new_operation_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id) 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) 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') @decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_operation(self): def test_delete_operation(self):
self.populateData() self.populateData()
@ -279,19 +310,19 @@ class TestOssOperations(EndpointTester):
data = { data = {
'layout': self.layout_data 'layout': self.layout_data
} }
self.executeBadData(data=data) self.executeBadData(data)
data['target'] = self.unowned_operation.pk 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 data['target'] = self.operation1.pk
self.toggle_admin(True) self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id) self.executeBadData(data, item=self.unowned_id)
self.logout() self.logout()
self.executeForbidden(data=data, item=self.owned_id) self.executeForbidden(data, item=self.owned_id)
self.login() self.login()
response = self.executeOK(data=data) response = self.executeOK(data)
layout = response.data['layout'] layout = response.data['layout']
deleted_items = [item for item in layout if item['nodeID'] == 'o' + str(data['target'])] deleted_items = [item for item in layout if item['nodeID'] == 'o' + str(data['target'])]
self.assertEqual(len(response.data['operations']), 2) self.assertEqual(len(response.data['operations']), 2)
@ -301,34 +332,34 @@ class TestOssOperations(EndpointTester):
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch') @decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_reference_operation_invalid(self): def test_delete_reference_operation_invalid(self):
self.populateData() self.populateData()
reference_operation = self.owned.create_reference(self.operation1) reference_operation = self.owned.create_replica(self.operation1)
data = { data = {
'layout': self.layout_data, 'layout': self.layout_data,
'target': reference_operation.pk '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') @decl_endpoint('/api/oss/{item}/delete-replica', method='patch')
def test_delete_reference_operation(self): def test_delete_replica_operation(self):
self.populateData() self.populateData()
data = { data = {
'layout': self.layout_data, 'layout': self.layout_data,
'target': self.invalid_id '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) reference_operation = self.owned.create_replica(self.operation1)
self.assertEqual(len(self.operation1.getQ_references()), 1) self.assertEqual(len(self.operation1.getQ_replicas()), 1)
data['target'] = reference_operation.pk 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 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 data['target'] = reference_operation.pk
self.executeOK(data=data, item=self.owned_id) self.executeOK(data, item=self.owned_id)
self.assertEqual(len(self.operation1.getQ_references()), 0) self.assertEqual(len(self.operation1.getQ_replicas()), 0)
@decl_endpoint('/api/oss/{item}/create-input', method='patch') @decl_endpoint('/api/oss/{item}/create-input', method='patch')
@ -339,22 +370,22 @@ class TestOssOperations(EndpointTester):
data = { data = {
'layout': self.layout_data 'layout': self.layout_data
} }
self.executeBadData(data=data) self.executeBadData(data)
data['target'] = self.operation1.pk data['target'] = self.operation1.pk
self.toggle_admin(True) self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id) self.executeBadData(data, item=self.unowned_id)
self.logout() self.logout()
self.executeForbidden(data=data, item=self.owned_id) self.executeForbidden(data, item=self.owned_id)
self.login() self.login()
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
self.operation1.result = None self.operation1.result = None
self.operation1.description = 'TestComment' self.operation1.description = 'TestComment'
self.operation1.title = 'TestTitle' self.operation1.title = 'TestTitle'
self.operation1.save() self.operation1.save()
response = self.executeOK(data=data) response = self.executeOK(data)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
new_schema = response.data['new_schema'] new_schema = response.data['new_schema']
@ -364,10 +395,10 @@ class TestOssOperations(EndpointTester):
self.assertEqual(new_schema['description'], self.operation1.description) self.assertEqual(new_schema['description'], self.operation1.description)
data['target'] = self.operation3.pk data['target'] = self.operation3.pk
self.executeBadData(data=data) self.executeBadData(data)
data['target'] = self.unowned_operation.pk 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') @decl_endpoint('/api/oss/{item}/set-input', method='patch')
@ -378,17 +409,17 @@ class TestOssOperations(EndpointTester):
data = { data = {
'layout': self.layout_data 'layout': self.layout_data
} }
self.executeBadData(data=data) self.executeBadData(data)
data['target'] = self.operation1.pk data['target'] = self.operation1.pk
data['input'] = None data['input'] = None
self.toggle_admin(True) self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id) self.executeBadData(data, item=self.unowned_id)
self.logout() self.logout()
self.executeForbidden(data=data, item=self.owned_id) self.executeForbidden(data, item=self.owned_id)
self.login() self.login()
response = self.executeOK(data=data) response = self.executeOK(data)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, None) self.assertEqual(self.operation1.result, None)
@ -397,7 +428,7 @@ class TestOssOperations(EndpointTester):
self.ks1.model.title = 'Test421' self.ks1.model.title = 'Test421'
self.ks1.model.description = 'TestComment42' self.ks1.model.description = 'TestComment42'
self.ks1.model.save() self.ks1.model.save()
response = self.executeOK(data=data) response = self.executeOK(data)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, self.ks1.model) self.assertEqual(self.operation1.result, self.ks1.model)
self.assertEqual(self.operation1.alias, self.ks1.model.alias) self.assertEqual(self.operation1.alias, self.ks1.model.alias)
@ -415,7 +446,7 @@ class TestOssOperations(EndpointTester):
'target': self.operation1.pk, 'target': self.operation1.pk,
'input': self.ks2.model.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.visible = False
self.ks2.model.save(update_fields=['visible']) self.ks2.model.save(update_fields=['visible'])
@ -424,7 +455,7 @@ class TestOssOperations(EndpointTester):
'target': self.operation2.pk, 'target': self.operation2.pk,
'input': None 'input': None
} }
self.executeOK(data=data, item=self.owned_id) self.executeOK(data, item=self.owned_id)
self.operation2.refresh_from_db() self.operation2.refresh_from_db()
self.ks2.model.refresh_from_db() self.ks2.model.refresh_from_db()
self.assertEqual(self.operation2.result, None) self.assertEqual(self.operation2.result, None)
@ -435,7 +466,7 @@ class TestOssOperations(EndpointTester):
'target': self.operation1.pk, 'target': self.operation1.pk,
'input': self.ks2.model.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.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, self.ks2.model) 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 data['substitutions'][0]['substitution'] = self.ks2X1.pk
self.toggle_admin(True) self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id) self.executeBadData(data, item=self.unowned_id)
self.logout() self.logout()
self.executeForbidden(data=data, item=self.owned_id) self.executeForbidden(data, item=self.owned_id)
self.login() self.login()
response = self.executeOK(data=data) response = self.executeOK(data)
self.operation3.refresh_from_db() self.operation3.refresh_from_db()
self.assertEqual(self.operation3.alias, data['item_data']['alias']) self.assertEqual(self.operation3.alias, data['item_data']['alias'])
self.assertEqual(self.operation3.title, data['item_data']['title']) 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']) self.assertEqual(sub.substitution.pk, data['substitutions'][0]['substitution'])
data['layout'] = self.layout_data data['layout'] = self.layout_data
self.executeOK(data=data) self.executeOK(data)
data_bad = dict(data) data_bad = dict(data)
data_bad['target'] = self.unowned_operation.pk 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') @decl_endpoint('/api/oss/{item}/update-operation', method='patch')
@ -508,10 +539,10 @@ class TestOssOperations(EndpointTester):
}, },
'layout': self.layout_data 'layout': self.layout_data
} }
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
data['target'] = self.operation1.pk data['target'] = self.operation1.pk
response = self.executeOK(data=data) response = self.executeOK(data)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.assertEqual(self.operation1.alias, data['item_data']['alias']) self.assertEqual(self.operation1.alias, data['item_data']['alias'])
self.assertEqual(self.operation1.title, data['item_data']['title']) 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) # Try to update an operation from an unrelated OSS (should fail)
data_bad = dict(data) data_bad = dict(data)
data_bad['target'] = self.unowned_operation.pk 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') @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') @decl_endpoint('/api/oss/{item}/execute-operation', method='post')
@ -564,19 +595,19 @@ class TestOssOperations(EndpointTester):
'layout': self.layout_data, 'layout': self.layout_data,
'target': self.operation1.pk 'target': self.operation1.pk
} }
self.executeBadData(data=data) self.executeBadData(data)
data['target'] = self.unowned_operation.pk 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 data['target'] = self.operation3.pk
self.toggle_admin(True) self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id) self.executeBadData(data, item=self.unowned_id)
self.logout() self.logout()
self.executeForbidden(data=data, item=self.owned_id) self.executeForbidden(data, item=self.owned_id)
self.login() self.login()
self.executeOK(data=data) self.executeOK(data)
self.operation3.refresh_from_db() self.operation3.refresh_from_db()
schema = self.operation3.result schema = self.operation3.result
self.assertEqual(schema.alias, self.operation3.alias) self.assertEqual(schema.alias, self.operation3.alias)
@ -613,7 +644,7 @@ class TestOssOperations(EndpointTester):
'source': target_ks.model.pk, 'source': target_ks.model.pk,
'clone_source': False '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_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id) new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
layout = response.data['oss']['layout'] layout = response.data['oss']['layout']
@ -653,7 +684,7 @@ class TestOssOperations(EndpointTester):
'source': self.ks2.model.pk, 'source': self.ks2.model.pk,
'clone_source': True '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_id = response.data['new_operation']
new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id) new_operation = next(op for op in response.data['oss']['operations'] if op['id'] == new_operation_id)
layout = response.data['oss']['layout'] layout = response.data['oss']['layout']
@ -694,13 +725,13 @@ class TestOssOperations(EndpointTester):
# 'source' missing # 'source' missing
'clone_source': False 'clone_source': False
} }
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
# Invalid source # Invalid source
data['source'] = self.invalid_id data['source'] = self.invalid_id
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data, item=self.owned_id)
# Invalid OSS # Invalid OSS
data['source'] = self.ks1.model.pk 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') @decl_endpoint('/api/oss/{item}/import-schema', method='post')
def test_import_schema_permissions(self): def test_import_schema_permissions(self):
@ -721,8 +752,8 @@ class TestOssOperations(EndpointTester):
} }
# Not an editor # Not an editor
self.logout() self.logout()
self.executeForbidden(data=data, item=self.owned_id) self.executeForbidden(data, item=self.owned_id)
# As admin # As admin
self.login() self.login()
self.toggle_admin(True) 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) self.executeBadData(item=self.owned_id)
data = {'data': []} data = {'data': []}
self.executeOK(data=data) self.executeOK(data)
data = {'data': [ data = {'data': [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 42.1, 'y': 1337, 'width': 150, 'height': 40}, {'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} {'nodeID': 'o' + str(self.operation3.pk), 'x': 36.1, 'y': 1435, 'width': 150, 'height': 40}
]} ]}
self.toggle_admin(True) self.toggle_admin(True)
self.executeOK(data=data, item=self.unowned_id) self.executeOK(data, item=self.unowned_id)
self.toggle_admin(False) 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.owned.model.refresh_from_db()
self.assertEqual(OperationSchema.layoutQ(self.owned_id).data, data['data']) self.assertEqual(OperationSchema.layoutQ(self.owned_id).data, data['data'])
self.executeForbidden(data=data, item=self.unowned_id) self.executeForbidden(data, item=self.unowned_id)
self.executeForbidden(data=data, item=self.private_id) self.executeForbidden(data, item=self.private_id)
@decl_endpoint('/api/oss/get-predecessor', method='post') @decl_endpoint('/api/oss/get-predecessor', method='post')
@ -155,13 +155,13 @@ class TestOssViewset(EndpointTester):
self.ks3 = RSForm(self.operation3.result) self.ks3 = RSForm(self.operation3.result)
self.ks3X2 = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk) 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['id'], self.ks1X1.pk)
self.assertEqual(response.data['schema'], self.ks1.model.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['id'], self.ks1X2.pk)
self.assertEqual(response.data['schema'], self.ks1.model.pk) self.assertEqual(response.data['schema'], self.ks1.model.pk)
@ -180,10 +180,10 @@ class TestOssViewset(EndpointTester):
'operations': [self.operation1.pk, self.operation2.pk], 'operations': [self.operation1.pk, self.operation2.pk],
'destination': block2.pk 'destination': block2.pk
} }
self.executeBadData(data=data) self.executeBadData(data)
data['destination'] = block1.pk data['destination'] = block1.pk
self.executeOK(data=data) self.executeOK(data)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.operation2.refresh_from_db() self.operation2.refresh_from_db()
block2.refresh_from_db() block2.refresh_from_db()
@ -193,7 +193,7 @@ class TestOssViewset(EndpointTester):
self.assertEqual(block2.parent.pk, block1.pk) self.assertEqual(block2.parent.pk, block1.pk)
data['destination'] = None data['destination'] = None
self.executeOK(data=data) self.executeOK(data)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.operation2.refresh_from_db() self.operation2.refresh_from_db()
block2.refresh_from_db() block2.refresh_from_db()
@ -217,7 +217,7 @@ class TestOssViewset(EndpointTester):
'operations': [], 'operations': [],
'destination': block3.pk 'destination': block3.pk
} }
self.executeBadData(data=data) self.executeBadData(data)
@decl_endpoint('/api/oss/relocate-constituents', method='post') @decl_endpoint('/api/oss/relocate-constituents', method='post')
@ -236,35 +236,35 @@ class TestOssViewset(EndpointTester):
'destination': self.invalid_id, 'destination': self.invalid_id,
'items': [] 'items': []
} }
self.executeBadData(data=data) self.executeBadData(data)
# empty items # empty items
data = { data = {
'destination': self.ks1.model.pk, 'destination': self.ks1.model.pk,
'items': [] 'items': []
} }
self.executeBadData(data=data) self.executeBadData(data)
# source == destination # source == destination
data = { data = {
'destination': self.ks1.model.pk, 'destination': self.ks1.model.pk,
'items': [self.ks1X1.pk] 'items': [self.ks1X1.pk]
} }
self.executeBadData(data=data) self.executeBadData(data)
# moving inherited # moving inherited
data = { data = {
'destination': self.ks1.model.pk, 'destination': self.ks1.model.pk,
'items': [self.ks3X2.pk] 'items': [self.ks3X2.pk]
} }
self.executeBadData(data=data) self.executeBadData(data)
# source and destination are not connected # source and destination are not connected
data = { data = {
'destination': self.ks2.model.pk, 'destination': self.ks2.model.pk,
'items': [self.ks1X1.pk] 'items': [self.ks1X1.pk]
} }
self.executeBadData(data=data) self.executeBadData(data)
data = { data = {
'destination': self.ks3.model.pk, 'destination': self.ks3.model.pk,
@ -272,14 +272,14 @@ class TestOssViewset(EndpointTester):
} }
self.ks3X2.refresh_from_db() self.ks3X2.refresh_from_db()
self.assertEqual(self.ks3X2.convention, 'test') 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()) self.assertFalse(Constituenta.objects.filter(as_child__parent_id=self.ks1X2.pk).exists())
data = { data = {
'destination': self.ks1.model.pk, 'destination': self.ks1.model.pk,
'items': [self.ks3X10.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.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.ks1X3 = Constituenta.objects.get(as_parent__child_id=self.ks3X10.pk)
self.assertEqual(self.ks1X3.convention, 'test2') self.assertEqual(self.ks1X3.convention, 'test2')

View File

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

View File

@ -24,7 +24,7 @@ class TestPromptTemplateViewSet(EndpointTester):
'text': 'prompt text', 'text': 'prompt text',
'is_shared': False 'is_shared': False
} }
response = self.executeCreated(data=data) response = self.executeCreated(data)
self.assertEqual(response.data['label'], 'Test') self.assertEqual(response.data['label'], 'Test')
self.assertEqual(response.data['owner'], self.user.pk) self.assertEqual(response.data['owner'], self.user.pk)
@ -38,7 +38,7 @@ class TestPromptTemplateViewSet(EndpointTester):
'text': 'prompt text', 'text': 'prompt text',
'is_shared': True 'is_shared': True
} }
response = self.executeCreated(data=data) response = self.executeCreated(data)
self.assertTrue(response.data['is_shared']) self.assertTrue(response.data['is_shared'])
@ -50,21 +50,21 @@ class TestPromptTemplateViewSet(EndpointTester):
'text': 'prompt text', 'text': 'prompt text',
'is_shared': True 'is_shared': True
} }
response = self.executeBadData(data=data) response = self.executeBadData(data)
self.assertIn('is_shared', response.data) self.assertIn('is_shared', response.data)
@decl_endpoint('/api/prompts/{item}/', method='patch') @decl_endpoint('/api/prompts/{item}/', method='patch')
def test_update_prompt_owner(self): def test_update_prompt_owner(self):
prompt = PromptTemplate.objects.create(owner=self.user, label='ToUpdate', description='', text='t') 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') self.assertEqual(response.data['label'], 'Updated')
@decl_endpoint('/api/prompts/{item}/', method='patch') @decl_endpoint('/api/prompts/{item}/', method='patch')
def test_update_prompt_not_owner_forbidden(self): def test_update_prompt_not_owner_forbidden(self):
prompt = PromptTemplate.objects.create(owner=self.admin, label='Other', description='', text='t') 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') @decl_endpoint('/api/prompts/{item}/', method='delete')
@ -112,4 +112,4 @@ class TestPromptTemplateViewSet(EndpointTester):
is_shared=True is_shared=True
) )
self.client.force_authenticate(user=self.user) 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'] ordering = ['schema', 'order']
list_display = ['schema', 'order', 'alias', 'term_resolved', 'definition_resolved', 'crucial'] list_display = ['schema', 'order', 'alias', 'term_resolved', 'definition_resolved', 'crucial']
search_fields = ['term_resolved', 'definition_resolved'] 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 = self.topological_order()
order.reverse() order.reverse()
for node_id in order: for node_id in order:
if len(self.inputs[node_id]) == 0: if not self.inputs[node_id]:
continue continue
for parent in self.inputs[node_id]: for parent in self.inputs[node_id]:
result[parent] = result[parent] + [id for id in result[node_id] if id not in result[parent]] 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: if node_id in marked:
continue continue
to_visit: list[ItemType] = [node_id] to_visit: list[ItemType] = [node_id]
while len(to_visit) > 0: while to_visit:
node = to_visit[-1] node = to_visit[-1]
if node in marked: if node in marked:
if node not in result: if node not in result:
@ -132,7 +132,7 @@ class Graph(Generic[ItemType]):
to_visit.remove(node) to_visit.remove(node)
else: else:
marked.add(node) marked.add(node)
if len(self.outputs[node]) <= 0: if not self.outputs[node]:
continue continue
for child_id in self.outputs[node]: for child_id in self.outputs[node]:
if child_id not in marked: 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 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\-].*?)\|.*?}') _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]: def extract_globals(expression: str) -> set[str]:
@ -38,6 +38,7 @@ def replace_entities(expression: str, mapping: dict[str, str]) -> str:
class CstType(TextChoices): class CstType(TextChoices):
''' Type of constituenta. ''' ''' Type of constituenta. '''
NOMINAL = 'nominal'
BASE = 'basic' BASE = 'basic'
CONSTANT = 'constant' CONSTANT = 'constant'
STRUCTURED = 'structure' STRUCTURED = 'structure'

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
''' Django: Models. ''' ''' Django: Models. '''
from .Attribution import Attribution
from .Constituenta import Constituenta, CstType, extract_globals, replace_entities, replace_globals from .Constituenta import Constituenta, CstType, extract_globals, replace_entities, replace_globals
from .OrderManager import OrderManager from .OrderManager import OrderManager
from .RSForm import DELETED_ALIAS, INSERT_LAST, RSForm 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.FUNCTION: return 'F'
case CstType.PREDICATE: return 'P' case CstType.PREDICATE: return 'P'
case CstType.THEOREM: return 'T' case CstType.THEOREM: return 'T'
case CstType.NOMINAL: return 'N'
return 'X' 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]: def _get_structure_prefix(alias: str, expression: str, parse: dict) -> Tuple[str, str]:
''' Generate prefix and alias for structure generation. ''' ''' Generate prefix and alias for structure generation. '''
args = parse['args'] args = parse['args']
if len(args) == 0: if not args:
return (alias, '') return (alias, '')
prefix = expression[0:expression.find(']')] + '] ' prefix = expression[0:expression.find(']')] + '] '
newAlias = alias + '[' + ','.join([arg['alias'] for arg in args]) + ']' newAlias = alias + '[' + ','.join([arg['alias'] for arg in args]) + ']'

View File

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

View File

@ -133,7 +133,7 @@ class ReferenceSerializer(StrictSerializer):
class InheritanceDataSerializer(StrictSerializer): class InheritanceDataSerializer(StrictSerializer):
''' Serializer: inheritance data. ''' ''' Serializer: Inheritance data. '''
child = serializers.IntegerField() child = serializers.IntegerField()
child_source = serializers.IntegerField() child_source = serializers.IntegerField()
parent = serializers.IntegerField() # type: ignore 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 import messages as msg
from shared.serializers import StrictModelSerializer, StrictSerializer 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 .basics import CstParseSerializer, InheritanceDataSerializer
from .io_pyconcept import PyConceptAdapter 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): class CstBaseSerializer(StrictModelSerializer):
''' Serializer: Constituenta all data. ''' ''' Serializer: Constituenta all data. '''
class Meta: class Meta:
@ -122,6 +166,15 @@ class CstCreateSerializer(StrictModelSerializer):
'term_raw', 'definition_raw', 'definition_formal', \ 'term_raw', 'definition_raw', 'definition_formal', \
'insert_after', 'term_forms' '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): class RSFormSerializer(StrictModelSerializer):
''' Serializer: Detailed data for RSForm. ''' ''' Serializer: Detailed data for RSForm. '''
@ -134,6 +187,9 @@ class RSFormSerializer(StrictModelSerializer):
inheritance = serializers.ListField( inheritance = serializers.ListField(
child=InheritanceDataSerializer() child=InheritanceDataSerializer()
) )
attribution = serializers.ListField(
child=AttributionSerializer()
)
oss = serializers.ListField( oss = serializers.ListField(
child=LibraryItemReferenceSerializer() child=LibraryItemReferenceSerializer()
) )
@ -164,6 +220,7 @@ class RSFormSerializer(StrictModelSerializer):
result['items'] = [] result['items'] = []
result['oss'] = [] result['oss'] = []
result['inheritance'] = [] result['inheritance'] = []
result['attribution'] = []
for cst in Constituenta.objects.filter(schema=instance).defer('order').order_by('order'): for cst in Constituenta.objects.filter(schema=instance).defer('order').order_by('order'):
result['items'].append(CstInfoSerializer(cst).data) result['items'].append(CstInfoSerializer(cst).data)
for oss in LibraryItem.objects.filter(operations__result=instance).only('alias'): for oss in LibraryItem.objects.filter(operations__result=instance).only('alias'):
@ -171,6 +228,11 @@ class RSFormSerializer(StrictModelSerializer):
'id': oss.pk, 'id': oss.pk,
'alias': oss.alias '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 return result
def to_versioned_data(self) -> dict: def to_versioned_data(self) -> dict:
@ -200,37 +262,38 @@ class RSFormSerializer(StrictModelSerializer):
instance = cast(LibraryItem, self.instance) instance = cast(LibraryItem, self.instance)
schema = RSForm(instance) schema = RSForm(instance)
items: list[dict] = data['items'] items: list[dict] = data['items']
ids: list[int] = [item['id'] for item in items] stored_ids: list[int] = [item['id'] for item in items]
processed: list[int] = [] id_map: dict[int, int] = {}
for cst in schema.constituentsQ(): for existing_cst in schema.constituentsQ():
if not cst.pk in ids: if not existing_cst.pk in stored_ids:
cst.delete() existing_cst.delete()
else: 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 cst_data['schema'] = instance.pk
new_cst = CstBaseSerializer(data=cst_data) cst_serializer = CstBaseSerializer(data=cst_data)
new_cst.is_valid(raise_exception=True) cst_serializer.is_valid(raise_exception=True)
new_cst.validated_data['order'] = ids.index(cst.pk) cst_serializer.validated_data['order'] = stored_ids.index(existing_cst.pk)
new_cst.update( cst_serializer.update(
instance=cst, instance=existing_cst,
validated_data=new_cst.validated_data validated_data=cst_serializer.validated_data
) )
processed.append(cst.pk) id_map[cst_data['id']] = existing_cst.pk
for cst_data in items: for cst_data in items:
if cst_data['id'] not in processed: if cst_data['id'] not in id_map:
cst = schema.insert_last(cst_data['alias'])
old_id = cst_data['id'] 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 cst_data['schema'] = instance.pk
new_cst = CstBaseSerializer(data=cst_data) cst_serializer = CstBaseSerializer(data=cst_data)
new_cst.is_valid(raise_exception=True) cst_serializer.is_valid(raise_exception=True)
new_cst.validated_data['order'] = ids.index(old_id) cst_serializer.validated_data['order'] = stored_ids.index(old_id)
new_cst.update( cst_serializer.update(
instance=cst, instance=inserted_cst,
validated_data=new_cst.validated_data validated_data=cst_serializer.validated_data
) )
id_map[old_id] = inserted_cst.pk
loaded_item = LibraryItemBaseNonStrictSerializer(data=data) loaded_item = LibraryItemBaseNonStrictSerializer(data=data)
loaded_item.is_valid(raise_exception=True) loaded_item.is_valid(raise_exception=True)
@ -239,6 +302,23 @@ class RSFormSerializer(StrictModelSerializer):
validated_data=loaded_item.validated_data 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): class RSFormParseSerializer(StrictModelSerializer):
''' Serializer: Detailed data for RSForm including parse. ''' ''' Serializer: Detailed data for RSForm including parse. '''
@ -267,6 +347,8 @@ class RSFormParseSerializer(StrictModelSerializer):
def _parse_data(self, data: dict) -> dict: def _parse_data(self, data: dict) -> dict:
parse = PyConceptAdapter(data).parse() parse = PyConceptAdapter(data).parse()
for cst_data in data['items']: for cst_data in data['items']:
if cst_data['cst_type'] == CstType.NOMINAL:
continue
cst_data['parse'] = next( cst_data['parse'] = next(
cst['parse'] for cst in parse['items'] cst['parse'] for cst in parse['items']
if cst['id'] == cst_data['id'] 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 import messages as msg
from shared.serializers import StrictSerializer from shared.serializers import StrictSerializer
from ..models import Constituenta, RSFormCached from ..models import Constituenta, CstType, RSFormCached
from ..utils import fix_old_references from ..utils import fix_old_references
_CST_TYPE = 'constituenta' _ENTITY_CONSTITUENTA = 'constituenta'
_TRS_TYPE = 'rsform' _ENTITY_SCHEMA = 'rsform'
_TRS_VERSION_MIN = 16 _TRS_VERSION_MIN = 16
_TRS_VERSION = 16 _TRS_VERSION = 16
_TRS_HEADER = 'Exteor 4.8.13.1000 - 30/05/2022' _TRS_HEADER = 'Exteor 4.8.13.1000 - 30/05/2022'
@ -30,11 +30,11 @@ class RSFormUploadSerializer(StrictSerializer):
def generate_trs(schema: LibraryItem) -> dict: def generate_trs(schema: LibraryItem) -> dict:
''' Generate TRS file for RSForm. ''' ''' Generate TRS file for RSForm. '''
items = [] 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( items.append(
{ {
'entityUID': cst.pk, 'entityUID': cst.pk,
'type': _CST_TYPE, 'type': _ENTITY_CONSTITUENTA,
'cstType': cst.cst_type, 'cstType': cst.cst_type,
'alias': cst.alias, 'alias': cst.alias,
'convention': cst.convention, 'convention': cst.convention,
@ -53,7 +53,7 @@ def generate_trs(schema: LibraryItem) -> dict:
} }
) )
return { return {
'type': _TRS_TYPE, 'type': _ENTITY_SCHEMA,
'title': schema.title, 'title': schema.title,
'alias': schema.alias, 'alias': schema.alias,
'comment': schema.description, 'comment': schema.description,
@ -72,7 +72,7 @@ class RSFormTRSSerializer(serializers.Serializer):
def load_versioned_data(data: dict) -> dict: def load_versioned_data(data: dict) -> dict:
''' Load data from version. ''' ''' Load data from version. '''
result = { result = {
'type': _TRS_TYPE, 'type': _ENTITY_SCHEMA,
'title': data['title'], 'title': data['title'],
'alias': data['alias'], 'alias': data['alias'],
'comment': data['description'], 'comment': data['description'],
@ -85,7 +85,7 @@ class RSFormTRSSerializer(serializers.Serializer):
for cst in data['items']: for cst in data['items']:
result['items'].append({ result['items'].append({
'entityUID': cst['id'], 'entityUID': cst['id'],
'type': _CST_TYPE, 'type': _ENTITY_CONSTITUENTA,
'cstType': cst['cst_type'], 'cstType': cst['cst_type'],
'alias': cst['alias'], 'alias': cst['alias'],
'convention': cst['convention'], 'convention': cst['convention'],

View File

@ -6,7 +6,7 @@ import pyconcept
from shared import messages as msg from shared import messages as msg
from ..models import Constituenta from ..models import Constituenta, CstType
class PyConceptAdapter: class PyConceptAdapter:
@ -34,7 +34,7 @@ class PyConceptAdapter:
result: dict = { result: dict = {
'items': [] '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: for cst in items:
result['items'].append({ result['items'].append({
'entityUID': cst.pk, 'entityUID': cst.pk,
@ -51,6 +51,8 @@ class PyConceptAdapter:
'items': [] 'items': []
} }
for cst in data['items']: for cst in data['items']:
if cst['cst_type'] == CstType.NOMINAL:
continue
result['items'].append({ result['items'].append({
'entityUID': cst['id'], 'entityUID': cst['id'],
'cstType': cst['cst_type'], 'cstType': cst['cst_type'],

View File

@ -1,4 +1,5 @@
''' Tests for Django Models. ''' ''' Tests for Django Models. '''
from .t_Attribution import *
from .t_Constituenta import * from .t_Constituenta import *
from .t_RSForm import * from .t_RSForm import *
from .t_RSFormCached 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') x1 = self.schema.insert_last('X1')
x2 = self.schema.insert_last('X2') 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() x2.refresh_from_db()
self.assertEqual(x3.alias, data['alias']) self.assertEqual(x3.alias, data['alias'])

View File

@ -1,4 +1,6 @@
''' Tests for REST API. ''' ''' Tests for REST API. '''
from .t_attribtuions import *
from .t_cctext import * from .t_cctext import *
from .t_constituenta import *
from .t_rsforms import * from .t_rsforms import *
from .t_rslang 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') @decl_endpoint(endpoint='/api/cctext/parse', method='post')
def test_parse_text(self): def test_parse_text(self):
data = {'text': 'синим слонам'} data = {'text': 'синим слонам'}
response = self.executeOK(data=data) response = self.executeOK(data)
self._assert_tags(response.data['result'], 'datv,NOUN,plur,anim,masc') self._assert_tags(response.data['result'], 'datv,NOUN,plur,anim,masc')
@decl_endpoint(endpoint='/api/cctext/inflect', method='post') @decl_endpoint(endpoint='/api/cctext/inflect', method='post')
def test_inflect(self): def test_inflect(self):
data = {'text': 'синий слон', 'grams': 'plur,datv'} data = {'text': 'синий слон', 'grams': 'plur,datv'}
response = self.executeOK(data=data) response = self.executeOK(data)
self.assertEqual(response.data['result'], 'синим слонам') self.assertEqual(response.data['result'], 'синим слонам')
@decl_endpoint(endpoint='/api/cctext/generate-lexeme', method='post') @decl_endpoint(endpoint='/api/cctext/generate-lexeme', method='post')
def test_generate_lexeme(self): def test_generate_lexeme(self):
data = {'text': 'синий слон'} data = {'text': 'синий слон'}
response = self.executeOK(data=data) response = self.executeOK(data)
self.assertEqual(len(response.data['items']), 12) self.assertEqual(len(response.data['items']), 12)
self.assertEqual(response.data['items'][0]['text'], 'синий слон') 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, 'access_policy': AccessPolicy.PROTECTED,
'visible': False 'visible': False
} }
self.executeBadData(data=data) self.executeBadData(data)
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file: with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data['file'] = 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.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['owner'], self.user.pk) self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['title'], data['title']) self.assertEqual(response.data['title'], data['title'])
@ -117,21 +117,21 @@ class TestRSFormViewset(EndpointTester):
def test_check_expression(self): def test_check_expression(self):
self.owned.insert_last('X1') self.owned.insert_last('X1')
data = {'expression': 'X1=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['parseResult'], True)
self.assertEqual(response.data['syntax'], 'math') self.assertEqual(response.data['syntax'], 'math')
self.assertEqual(response.data['astText'], '[=[X1][X1]]') self.assertEqual(response.data['astText'], '[=[X1][X1]]')
self.assertEqual(response.data['typification'], 'LOGIC') self.assertEqual(response.data['typification'], 'LOGIC')
self.assertEqual(response.data['valueClass'], 'value') 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') @decl_endpoint('/api/rsforms/{item}/check-constituenta', method='post')
def test_check_constituenta(self): def test_check_constituenta(self):
self.owned.insert_last('X1') self.owned.insert_last('X1')
data = {'definition_formal': 'X1=X1', 'alias': 'A111', 'cst_type': CstType.AXIOM} 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['parseResult'], True)
self.assertEqual(response.data['syntax'], 'math') self.assertEqual(response.data['syntax'], 'math')
self.assertEqual(response.data['astText'], '[:==[A111][=[X1][X1]]]') self.assertEqual(response.data['astText'], '[:==[A111][=[X1][X1]]]')
@ -143,7 +143,7 @@ class TestRSFormViewset(EndpointTester):
def test_check_constituenta_error(self): def test_check_constituenta_error(self):
self.owned.insert_last('X1') self.owned.insert_last('X1')
data = {'definition_formal': 'X1=X1', 'alias': 'D111', 'cst_type': CstType.TERM} 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) self.assertEqual(response.data['parseResult'], False)
@ -155,7 +155,7 @@ class TestRSFormViewset(EndpointTester):
) )
data = {'text': '@{1|редкий} @{X1|plur,datv}'} 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['input'], '@{1|редкий} @{X1|plur,datv}')
self.assertEqual(response.data['output'], 'редким синим слонам') self.assertEqual(response.data['output'], 'редким синим слонам')
self.assertEqual(len(response.data['refs']), 2) self.assertEqual(len(response.data['refs']), 2)
@ -182,7 +182,7 @@ class TestRSFormViewset(EndpointTester):
work_dir = os.path.dirname(os.path.abspath(__file__)) work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file: with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': 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.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['owner'], self.user.pk) self.assertEqual(response.data['owner'], self.user.pk)
self.assertTrue(response.data['title'] != '') self.assertTrue(response.data['title'] != '')
@ -200,57 +200,10 @@ class TestRSFormViewset(EndpointTester):
self.assertIn('document.json', zipped_file.namelist()) 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') @decl_endpoint('/api/rsforms/{item}/substitute', method='patch')
def test_substitute_multiple(self): def test_substitute_multiple(self):
self.set_params(item=self.owned_id) self.set_params(item=self.owned_id)
x1 = self.owned.insert_last('X1') x1 = self.owned.insert_last('X1')
x2 = self.owned.insert_last('X2') x2 = self.owned.insert_last('X2')
d1 = self.owned.insert_last('D1') d1 = self.owned.insert_last('D1')
@ -261,7 +214,7 @@ class TestRSFormViewset(EndpointTester):
) )
data = {'substitutions': []} data = {'substitutions': []}
self.executeBadData(data=data) self.executeBadData(data)
data = {'substitutions': [ data = {'substitutions': [
{ {
@ -273,7 +226,7 @@ class TestRSFormViewset(EndpointTester):
'substitution': d2.pk 'substitution': d2.pk
} }
]} ]}
self.executeBadData(data=data) self.executeBadData(data)
data = {'substitutions': [ data = {'substitutions': [
{ {
@ -285,7 +238,7 @@ class TestRSFormViewset(EndpointTester):
'substitution': d2.pk 'substitution': d2.pk
} }
]} ]}
response = self.executeOK(data=data, item=self.owned_id) response = self.executeOK(data, item=self.owned_id)
d3.refresh_from_db() d3.refresh_from_db()
self.assertEqual(d3.definition_formal, r'D1 \ D2') self.assertEqual(d3.definition_formal, r'D1 \ D2')
@ -300,7 +253,7 @@ class TestRSFormViewset(EndpointTester):
'definition_formal': '3', 'definition_formal': '3',
'definition_raw': '4' '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']['alias'], 'X3')
self.assertEqual(response.data['new_cst']['cst_type'], CstType.BASE) self.assertEqual(response.data['new_cst']['cst_type'], CstType.BASE)
self.assertEqual(response.data['new_cst']['convention'], '1') self.assertEqual(response.data['new_cst']['convention'], '1')
@ -316,13 +269,13 @@ class TestRSFormViewset(EndpointTester):
self.set_params(item=self.owned_id) self.set_params(item=self.owned_id)
data = {'items': [1337]} data = {'items': [1337]}
self.executeBadData(data=data) self.executeBadData(data, item=self.owned_id)
x1 = self.owned.insert_last('X1') x1 = self.owned.insert_last('X1')
x2 = self.owned.insert_last('X2') x2 = self.owned.insert_last('X2')
data = {'items': [x1.pk]} data = {'items': [x1.pk]}
response = self.executeOK(data=data) response = self.executeOK(data)
x2.refresh_from_db() x2.refresh_from_db()
self.owned.model.refresh_from_db() self.owned.model.refresh_from_db()
self.assertEqual(len(response.data['items']), 1) self.assertEqual(len(response.data['items']), 1)
@ -332,7 +285,7 @@ class TestRSFormViewset(EndpointTester):
x3 = self.unowned.insert_last('X1') x3 = self.unowned.insert_last('X1')
data = {'items': [x3.pk]} 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') @decl_endpoint('/api/rsforms/{item}/move-cst', method='patch')
@ -340,13 +293,13 @@ class TestRSFormViewset(EndpointTester):
self.set_params(item=self.owned_id) self.set_params(item=self.owned_id)
data = {'items': [1337], 'move_to': 0} data = {'items': [1337], 'move_to': 0}
self.executeBadData(data=data) self.executeBadData(data)
x1 = self.owned.insert_last('X1') x1 = self.owned.insert_last('X1')
x2 = self.owned.insert_last('X2') x2 = self.owned.insert_last('X2')
data = {'items': [x2.pk], 'move_to': 0} data = {'items': [x2.pk], 'move_to': 0}
response = self.executeOK(data=data) response = self.executeOK(data)
x1.refresh_from_db() x1.refresh_from_db()
x2.refresh_from_db() x2.refresh_from_db()
self.assertEqual(response.data['id'], self.owned_id) self.assertEqual(response.data['id'], self.owned_id)
@ -355,7 +308,7 @@ class TestRSFormViewset(EndpointTester):
x3 = self.unowned.insert_last('X1') x3 = self.unowned.insert_last('X1')
data = {'items': [x3.pk], 'move_to': 0} data = {'items': [x3.pk], 'move_to': 0}
self.executeBadData(data=data) self.executeBadData(data)
@decl_endpoint('/api/rsforms/{item}/reset-aliases', method='patch') @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__)) work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file: with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file, 'load_metadata': False} 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.owned.model.refresh_from_db()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(self.owned.model.title, 'Test11') self.assertEqual(self.owned.model.title, 'Test11')
@ -432,7 +385,7 @@ class TestRSFormViewset(EndpointTester):
self.executeBadData({'target': s2.pk}) self.executeBadData({'target': s2.pk})
# Testing simple structure # Testing simple structure
response = self.executeOK(data={'target': s1.pk}) response = self.executeOK({'target': s1.pk})
result = response.data['schema'] result = response.data['schema']
items = [item for item in result['items'] if item['id'] in response.data['cst_list']] items = [item for item in result['items'] if item['id'] in response.data['cst_list']]
self.assertEqual(len(items), 2) self.assertEqual(len(items), 2)
@ -441,7 +394,7 @@ class TestRSFormViewset(EndpointTester):
# Testing complex structure # Testing complex structure
s3.refresh_from_db() s3.refresh_from_db()
response = self.executeOK(data={'target': s3.pk}) response = self.executeOK({'target': s3.pk})
result = response.data['schema'] result = response.data['schema']
items = [item for item in result['items'] if item['id'] in response.data['cst_list']] items = [item for item in result['items'] if item['id'] in response.data['cst_list']]
self.assertEqual(len(items), 8) self.assertEqual(len(items), 8)
@ -449,151 +402,15 @@ class TestRSFormViewset(EndpointTester):
# Testing function # Testing function
f1.refresh_from_db() f1.refresh_from_db()
response = self.executeOK(data={'target': f1.pk}) response = self.executeOK({'target': f1.pk})
result = response.data['schema'] result = response.data['schema']
items = [item for item in result['items'] if item['id'] in response.data['cst_list']] items = [item for item in result['items'] if item['id'] in response.data['cst_list']]
self.assertEqual(len(items), 2) self.assertEqual(len(items), 2)
self.assertEqual(items[0]['definition_formal'], '[α∈X1, β∈X1] Pr1(F10[α,β])') 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): class TestInlineSynthesis(EndpointTester):
''' Testing Operations endpoints. ''' ''' Testing Inline synthesis. '''
@decl_endpoint('/api/rsforms/inline-synthesis', method='patch') @decl_endpoint('/api/rsforms/inline-synthesis', method='patch')
@ -612,20 +429,20 @@ class TestInlineSynthesis(EndpointTester):
'items': [], 'items': [],
'substitutions': [] 'substitutions': []
} }
self.executeForbidden(data=data) self.executeForbidden(data)
data['receiver'] = invalid_id data['receiver'] = invalid_id
self.executeBadData(data=data) self.executeBadData(data)
data['receiver'] = self.schema1.model.pk data['receiver'] = self.schema1.model.pk
data['source'] = invalid_id data['source'] = invalid_id
self.executeBadData(data=data) self.executeBadData(data)
data['source'] = self.schema1.model.pk data['source'] = self.schema1.model.pk
self.executeOK(data=data) self.executeOK(data)
data['items'] = [invalid_id] data['items'] = [invalid_id]
self.executeBadData(data=data) self.executeBadData(data)
def test_inline_synthesis(self): 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']} result = {item['alias']: item for item in response.data['items']}
self.assertEqual(len(result), 6) self.assertEqual(len(result), 6)
self.assertEqual(result['S1']['definition_formal'], 'X2') self.assertEqual(result['S1']['definition_formal'], 'X2')

View File

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

View File

@ -49,6 +49,9 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
'restore_order', 'restore_order',
'reset_aliases', 'reset_aliases',
'produce_structure', 'produce_structure',
'add_attribution',
'delete_attribution',
'clear_attributions'
]: ]:
permission_list = [permissions.ItemEditor] permission_list = [permissions.ItemEditor]
elif self.action in [ elif self.action in [
@ -79,7 +82,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
def create_cst(self, request: Request, pk) -> HttpResponse: def create_cst(self, request: Request, pk) -> HttpResponse:
''' Create Constituenta. ''' ''' Create Constituenta. '''
item = self._get_item() 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) serializer.is_valid(raise_exception=True)
data = serializer.validated_data data = serializer.validated_data
if 'insert_after' not in 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: def substitute(self, request: Request, pk) -> HttpResponse:
''' Substitute occurrences of constituenta with another one. ''' ''' Substitute occurrences of constituenta with another one. '''
item = self._get_item() item = self._get_item()
serializer = s.CstSubstituteSerializer( serializer = s.CstSubstituteSerializer(data=request.data, context={'schema': item})
data=request.data,
context={'schema': item}
)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
substitutions: list[tuple[m.Constituenta, m.Constituenta]] = [] 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: def delete_multiple_cst(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete multiple Constituents. ''' ''' Endpoint: Delete multiple Constituents. '''
item = self._get_item() item = self._get_item()
serializer = s.CstListSerializer( serializer = s.CstListSerializer(data=request.data, context={'schema': item})
data=request.data,
context={'schema': item}
)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
cst_list: list[m.Constituenta] = serializer.validated_data['items'] 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 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( @extend_schema(
summary='move constituenta', summary='move constituenta',
tags=['RSForm'], tags=['RSForm'],
@ -302,10 +399,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
def move_cst(self, request: Request, pk) -> HttpResponse: def move_cst(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Move multiple Constituents. ''' ''' Endpoint: Move multiple Constituents. '''
item = self._get_item() item = self._get_item()
serializer = s.CstMoveSerializer( serializer = s.CstMoveSerializer(data=request.data, context={'schema': item})
data=request.data,
context={'schema': item}
)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
with transaction.atomic(): with transaction.atomic():
@ -397,10 +491,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
) )
data['id'] = item.pk data['id'] = item.pk
serializer = s.RSFormTRSSerializer( serializer = s.RSFormTRSSerializer(data=data, context={'load_meta': load_metadata})
data=data,
context={'load_meta': load_metadata}
)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
result: m.RSForm = serializer.save() result: m.RSForm = serializer.save()
return Response( return Response(
@ -558,10 +649,7 @@ class TrsImportView(views.APIView):
) )
owner = cast(User, self.request.user) owner = cast(User, self.request.user)
_prepare_rsform_data(data, request, owner) _prepare_rsform_data(data, request, owner)
serializer = s.RSFormTRSSerializer( serializer = s.RSFormTRSSerializer(data=data, context={'load_meta': True})
data=data,
context={'load_meta': True}
)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema: m.RSForm = serializer.save() schema: m.RSForm = serializer.save()
return Response( return Response(
@ -640,15 +728,12 @@ def _prepare_rsform_data(data: dict, request: Request, owner: Union[User, None])
@api_view(['PATCH']) @api_view(['PATCH'])
def inline_synthesis(request: Request) -> HttpResponse: def inline_synthesis(request: Request) -> HttpResponse:
''' Endpoint: Inline synthesis. ''' ''' Endpoint: Inline synthesis. '''
serializer = s.InlineSynthesisSerializer( serializer = s.InlineSynthesisSerializer(data=request.data, context={'user': request.user})
data=request.data,
context={'user': request.user}
)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
receiver = m.RSFormCached(serializer.validated_data['receiver']) receiver = m.RSFormCached(serializer.validated_data['receiver'])
items = cast(list[m.Constituenta], serializer.validated_data['items']) items = cast(list[m.Constituenta], serializer.validated_data['items'])
if len(items) == 0: if not items:
source = cast(LibraryItem, serializer.validated_data['source']) source = cast(LibraryItem, serializer.validated_data['source'])
items = list(m.Constituenta.objects.filter(schema=source).order_by('order')) items = list(m.Constituenta.objects.filter(schema=source).order_by('order'))

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,17 +4,26 @@ import { useRef, useState } from 'react';
export function useDropdown() { export function useDropdown() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const elementRef = useRef<HTMLDivElement>(null);
function handleBlur(event: React.FocusEvent<HTMLDivElement>) { 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; return;
} }
setIsOpen(false); setIsOpen(false);
} }
return { return {
ref, elementRef,
isOpen, isOpen,
setIsOpen, setIsOpen,
handleBlur, handleBlur,

View File

@ -12,8 +12,8 @@ export { BiX as IconRemove } from 'react-icons/bi';
export { BiTrash as IconDestroy } from 'react-icons/bi'; export { BiTrash as IconDestroy } from 'react-icons/bi';
export { BiReset as IconReset } from 'react-icons/bi'; export { BiReset as IconReset } from 'react-icons/bi';
export { TbArrowsDiagonal2 as IconResize } from 'react-icons/tb'; export { TbArrowsDiagonal2 as IconResize } from 'react-icons/tb';
export { LiaEdit as IconEdit } from 'react-icons/lia'; export { FiEdit as IconEdit } from 'react-icons/fi';
export { FiEdit as IconEdit2 } from 'react-icons/fi'; export { AiOutlineEdit as IconEdit2 } from 'react-icons/ai';
export { BiSearchAlt2 as IconSearch } from 'react-icons/bi'; export { BiSearchAlt2 as IconSearch } from 'react-icons/bi';
export { BiDownload as IconDownload } from 'react-icons/bi'; export { BiDownload as IconDownload } from 'react-icons/bi';
export { BiUpload as IconUpload } 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 { RiMenuUnfoldFill as IconMenuUnfold } from 'react-icons/ri';
export { LuMoon as IconDarkTheme } from 'react-icons/lu'; export { LuMoon as IconDarkTheme } from 'react-icons/lu';
export { LuSun as IconLightTheme } 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 { LuFolderTree as IconFolderTree } from 'react-icons/lu';
export { LuFolder as IconFolder } from 'react-icons/lu'; export { LuFolder as IconFolder } from 'react-icons/lu';
export { LuFolderSearch as IconFolderSearch } 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 { TbHexagon as IconRSForm } from 'react-icons/tb';
export { TbAssembly as IconRSFormOwned } from 'react-icons/tb'; export { TbAssembly as IconRSFormOwned } from 'react-icons/tb';
export { TbBallFootball as IconRSFormImported } 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 { TbHexagonLetterX as IconCstBaseSet } from 'react-icons/tb';
export { TbHexagonLetterC as IconCstConstSet } from 'react-icons/tb'; export { TbHexagonLetterC as IconCstConstSet } from 'react-icons/tb';
export { TbHexagonLetterS as IconCstStructured } 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 { RiShieldLine as IconProtected } from 'react-icons/ri';
export { RiShieldKeyholeLine as IconPrivate } from 'react-icons/ri'; export { RiShieldKeyholeLine as IconPrivate } from 'react-icons/ri';
export { BiBug as IconStatusError } from 'react-icons/bi'; 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 { BiHelpCircle as IconStatusUnknown } from 'react-icons/bi';
export { BiStopCircle as IconStatusIncalculable } from 'react-icons/bi'; export { BiStopCircle as IconStatusIncalculable } from 'react-icons/bi';
export { BiPauseCircle as IconStatusProperty } 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 { TbEarScan as IconGraphInverse } from 'react-icons/tb';
export { BiGitMerge as IconGraphOutputs } from 'react-icons/bi'; export { BiGitMerge as IconGraphOutputs } from 'react-icons/bi';
export { LuAtom as IconGraphCore } from 'react-icons/lu'; 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 { MdOutlineFitScreen as IconFitImage } from 'react-icons/md';
export { RiFocus3Line as IconFocus } from 'react-icons/ri'; export { RiFocus3Line as IconFocus } from 'react-icons/ri';
export { LuSparkles as IconClustering } from 'react-icons/lu'; export { LuSparkles as IconClustering } from 'react-icons/lu';

View File

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

View File

@ -1,3 +1,5 @@
'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import clsx from 'clsx'; 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 { Suspense, useState } from 'react';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
@ -18,7 +20,7 @@ export function DlgAIPromptDialog() {
return ( return (
<ModalView <ModalView
header='Генератор запросом LLM' 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} helpTopic={HelpTopic.ASSISTANT}
> >
<ComboBox <ComboBox

View File

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

View File

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

View File

@ -11,7 +11,7 @@ export function TabPromptResult({ prompt }: TabPromptResultProps) {
value={prompt} value={prompt}
placeholder='Текст шаблона пуст' placeholder='Текст шаблона пуст'
disabled 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 { Controller, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@ -39,7 +41,7 @@ export function DlgCreatePromptTemplate() {
mode: 'onChange' mode: 'onChange'
}); });
const label = useWatch({ control, name: 'label' }); 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) { function onSubmit(data: ICreatePromptTemplateDTO) {
void createPromptTemplate(data).then(onCreate); void createPromptTemplate(data).then(onCreate);
@ -49,7 +51,7 @@ export function DlgCreatePromptTemplate() {
<ModalForm <ModalForm
header='Создание шаблона' header='Создание шаблона'
submitText='Создать' submitText='Создать'
canSubmit={isValid} canSubmit={canSubmit}
onSubmit={event => void handleSubmit(onSubmit)(event)} onSubmit={event => void handleSubmit(onSubmit)(event)}
submitInvalidTooltip='Введите уникальное название шаблона' submitInvalidTooltip='Введите уникальное название шаблона'
className='cc-column w-140 max-h-120 py-2 px-6' 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 { isBasicConcept } from '@/features/rsform/models/rsform-api';
import { TypificationGraph } from '@/features/rsform/models/typification-graph'; import { TypificationGraph } from '@/features/rsform/models/typification-graph';
import { type Graph } from '@/models/graph';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { mockPromptVariable } from '../labels'; import { mockPromptVariable } from '../labels';
@ -38,30 +39,24 @@ export function generateSample(target: string): string {
/** Generates a prompt for a schema variable. */ /** Generates a prompt for a schema variable. */
export function varSchema(schema: IRSForm): string { export function varSchema(schema: IRSForm): string {
let result = `Название концептуальной схемы: ${schema.title}\n`; let result = stringifySchemaIntro(schema);
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`; result += '\n\nКонституенты:';
result += 'Конституенты:\n';
schema.items.forEach(item => { schema.items.forEach(item => {
result += `\n${item.alias} - "${labelCstTypification(item)}" - "${item.term_resolved}" - "${ result += `\n${item.alias} - "${labelCstTypification(item)}" - "${item.term_resolved}" - "${
item.definition_formal item.definition_formal
}" - "${item.definition_resolved}" - "${item.convention}"`; }" - "${item.definition_resolved}" - "${item.convention}"`;
}); });
if (schema.stats.count_crucial > 0) { result += `\n${stringifyCrucial(schema.items.filter(cst => cst.crucial))}`;
result += result += '\n\nСвязи "атрибутирован":';
'\nКлючевые конституенты: ' + const attributionGraph = stringifyGraph(schema.attribution_graph, schema);
schema.items result += attributionGraph ? attributionGraph : ' отсутствуют';
.filter(cst => cst.crucial)
.map(cst => cst.alias)
.join(', ');
}
return result; return result;
} }
/** Generates a prompt for a schema thesaurus variable. */ /** Generates a prompt for a schema thesaurus variable. */
export function varSchemaThesaurus(schema: IRSForm): string { export function varSchemaThesaurus(schema: IRSForm): string {
let result = `Название концептуальной схемы: ${schema.title}\n`; let result = stringifySchemaIntro(schema);
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`; result += '\n\nТермины:';
result += 'Термины:\n';
schema.items.forEach(item => { schema.items.forEach(item => {
if (item.cst_type === CstType.AXIOM || item.cst_type === CstType.THEOREM) { if (item.cst_type === CstType.AXIOM || item.cst_type === CstType.THEOREM) {
return; return;
@ -77,48 +72,62 @@ export function varSchemaThesaurus(schema: IRSForm): string {
/** Generates a prompt for a schema graph variable. */ /** Generates a prompt for a schema graph variable. */
export function varSchemaGraph(schema: IRSForm): string { export function varSchemaGraph(schema: IRSForm): string {
let result = `Название концептуальной схемы: ${schema.title}\n`; let result = stringifySchemaIntro(schema);
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`; result += '\n\nУзлы графа\n';
result += 'Узлы графа\n'; result += JSON.stringify(
result += JSON.stringify(schema.items, null, PARAMETER.indentJSON); schema.items.map(cst => ({
result += '\n\nСвязи графа'; alias: cst.alias,
schema.graph.nodes.forEach(node => (result += `\n${node.id} -> ${node.outputs.join(', ')}`)); 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; return result;
} }
/** Generates a prompt for a schema type graph variable. */ /** Generates a prompt for a schema type graph variable. */
export function varSchemaTypeGraph(schema: IRSForm): string { export function varSchemaTypeGraph(schema: IRSForm): string {
const graph = new TypificationGraph(); 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`; let result = stringifySchemaIntro(schema);
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`; result += '\n\nСтупени\n';
result += 'Ступени\n';
result += JSON.stringify(graph.nodes, null, PARAMETER.indentJSON); result += JSON.stringify(graph.nodes, null, PARAMETER.indentJSON);
return result; return result;
} }
/** Generates a prompt for a OSS variable. */ /** Generates a prompt for a OSS variable. */
export function varOSS(oss: IOperationSchema): string { export function varOSS(oss: IOperationSchema): string {
let result = `Название операционной схемы: ${oss.title}\n`; let result = stringifyOSSIntro(oss);
result += `Сокращение: ${oss.alias}\n`; result += `\n\nБлоки: ${oss.blocks.length}\n`;
result += `Описание: ${oss.description}\n`;
result += `Блоки: ${oss.blocks.length}\n`;
oss.hierarchy.topologicalOrder().forEach(blockID => { oss.hierarchy.topologicalOrder().forEach(blockID => {
const block = oss.itemByNodeID.get(blockID); const block = oss.itemByNodeID.get(blockID);
if (block?.nodeType !== NodeType.BLOCK) { if (block?.nodeType !== NodeType.BLOCK) {
return; return;
} }
result += `\nБлок ${block.id}: ${block.title}\n`; result += `\n\nБлок ${block.id}: ${block.title}`;
result += `Описание: ${block.description}\n`; result += `\nОписание: ${block.description}`;
result += `Предок: "${block.parent}"\n`; result += `\nПредок: "${block.parent ?? 'отсутствует'}"`;
}); });
result += `Операции: ${oss.operations.length}\n`; result += `\n\nОперации: ${oss.operations.length}`;
oss.operations.forEach(operation => { oss.operations.forEach(operation => {
result += `\nОперация ${operation.id}: ${operation.alias}\n`; result += `\n\nОперация ${operation.id}: ${operation.alias}`;
result += `Название: ${operation.title}\n`; result += `\nНазвание: ${operation.title}`;
result += `Описание: ${operation.description}\n`; result += `\nОписание: ${operation.description}`;
result += `Блок: ${operation.parent}`; result += `\nБлок: ${operation.parent ?? 'отсутствует'}`;
}); });
return result; return result;
} }
@ -127,19 +136,19 @@ export function varOSS(oss: IOperationSchema): string {
export function varBlock(target: IBlock, oss: IOperationSchema): string { export function varBlock(target: IBlock, oss: IOperationSchema): string {
const blocks = oss.blocks.filter(block => block.parent === target.id); const blocks = oss.blocks.filter(block => block.parent === target.id);
const operations = oss.operations.filter(operation => operation.parent === target.id); const operations = oss.operations.filter(operation => operation.parent === target.id);
let result = `Название блока: ${target.title}\n`; let result = `Название блока: ${target.title}`;
result += `Описание: "${target.description}"\n`; result += `\nОписание: "${target.description}"`;
result += '\nСодержание\n'; result += '\n\nСодержание';
result += `Блоки: ${blocks.length}\n`; result += `\nБлоки: ${blocks.length}`;
blocks.forEach(block => { blocks.forEach(block => {
result += `\nБлок ${block.id}: ${block.title}\n`; result += `\n\nБлок ${block.id}: ${block.title}`;
result += `Описание: "${block.description}"\n`; result += `\nОписание: "${block.description}"`;
}); });
result += `Операции: ${operations.length}\n`; result += `\n\nОперации: ${operations.length}`;
operations.forEach(operation => { operations.forEach(operation => {
result += `\nОперация ${operation.id}: ${operation.alias}\n`; result += `\n\nОперация ${operation.id}: ${operation.alias}`;
result += `Название: "${operation.title}"\n`; result += `\nНазвание: "${operation.title}"`;
result += `Описание: "${operation.description}"`; result += `\nОписание: "${operation.description}"`;
}); });
return result; return result;
} }
@ -151,9 +160,49 @@ export function varConstituenta(cst: IConstituenta): string {
/** Generates a prompt for a constituenta syntax tree variable. */ /** Generates a prompt for a constituenta syntax tree variable. */
export function varSyntaxTree(cst: IConstituenta): string { export function varSyntaxTree(cst: IConstituenta): string {
let result = `Конституента: ${cst.alias}\n`; let result = `Конституента: ${cst.alias}`;
result += `Формальное выражение: ${cst.definition_formal}\n`; result += `\ормальное выражение: ${cst.definition_formal}`;
result += `Дерево синтаксического разбора:\n`; result += `\ерево синтаксического разбора:\n`;
result += JSON.stringify(cst.parse.syntaxTree, null, PARAMETER.indentJSON); 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; return result;
} }

View File

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

View File

@ -1,3 +1,5 @@
'use client';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { isAxiosError } from 'axios'; import { isAxiosError } from 'axios';
import { z } from 'zod'; 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 no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form'; import { Controller, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx'; import clsx from 'clsx';
@ -63,24 +63,25 @@ export function FormPromptTemplate({ promptTemplate, className, isMutable, toggl
const prevReset = useRef(toggleReset); const prevReset = useRef(toggleReset);
const prevTemplate = useRef(promptTemplate); const prevTemplate = useRef(promptTemplate);
if (prevTemplate.current !== promptTemplate || prevReset.current !== toggleReset) {
prevTemplate.current = promptTemplate;
prevReset.current = toggleReset;
reset({
owner: promptTemplate.owner,
label: promptTemplate.label,
description: promptTemplate.description,
text: promptTemplate.text,
is_shared: promptTemplate.is_shared
});
setSampleResult(null);
}
const prevDirty = useRef(isDirty); useEffect(() => {
if (prevDirty.current !== isDirty) { if (prevTemplate.current !== promptTemplate || prevReset.current !== toggleReset) {
prevDirty.current = isDirty; prevTemplate.current = promptTemplate;
prevReset.current = toggleReset;
reset({
owner: promptTemplate.owner,
label: promptTemplate.label,
description: promptTemplate.description,
text: promptTemplate.text,
is_shared: promptTemplate.is_shared
});
return () => setSampleResult(null);
}
}, [promptTemplate, toggleReset, reset, setSampleResult]);
useEffect(() => {
setIsModified(isDirty); setIsModified(isDirty);
} }, [isDirty, setIsModified]);
function onSubmit(data: IUpdatePromptTemplateDTO) { function onSubmit(data: IUpdatePromptTemplateDTO) {
return updatePromptTemplate({ id: promptTemplate.id, data }).then(() => { return updatePromptTemplate({ id: promptTemplate.id, data }).then(() => {

View File

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

View File

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