F: Implement RSForm and OSS attribute sync

This commit is contained in:
Ivan 2024-08-05 23:53:07 +03:00
parent c0d01957ff
commit 92a0453b18
14 changed files with 225 additions and 130 deletions

View File

@ -94,9 +94,9 @@ class LibraryItemDetailsSerializer(serializers.ModelSerializer):
class UserTargetSerializer(serializers.Serializer):
''' Serializer: Target single User. '''
user = PKField(many=False, queryset=User.objects.all())
user = PKField(many=False, queryset=User.objects.all().only('pk'))
class UsersListSerializer(serializers.Serializer):
''' Serializer: List of Users. '''
users = PKField(many=True, queryset=User.objects.all())
users = PKField(many=True, queryset=User.objects.all().only('pk'))

View File

@ -183,57 +183,6 @@ class TestLibraryViewset(EndpointTester):
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.location, data['location'])
@decl_endpoint('/api/library/{item}/add-editor', method='patch')
def test_add_editor(self):
time_update = self.owned.time_update
data = {'user': self.invalid_user}
self.executeBadData(data=data, item=self.owned.pk)
data = {'user': self.user.pk}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeOK(data=data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [self.user])
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [self.user])
data = {'user': self.user2.pk}
self.executeOK(data=data)
self.assertEqual(set(self.owned.editors()), set([self.user, self.user2]))
@decl_endpoint('/api/library/{item}/remove-editor', method='patch')
def test_remove_editor(self):
time_update = self.owned.time_update
data = {'user': self.invalid_user}
self.executeBadData(data=data, item=self.owned.pk)
data = {'user': self.user.pk}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeOK(data=data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [])
Editor.add(item=self.owned, user=self.user)
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [])
Editor.add(item=self.owned, user=self.user)
Editor.add(item=self.owned, user=self.user2)
data = {'user': self.user2.pk}
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [self.user])
@decl_endpoint('/api/library/{item}/set-editors', method='patch')
def test_set_editors(self):
time_update = self.owned.time_update

View File

@ -13,6 +13,7 @@ from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from apps.oss.models import OperationSchema
from apps.rsform.models import RSForm
from apps.rsform.serializers import RSFormParseSerializer
from apps.users.models import User
@ -52,8 +53,6 @@ class LibraryViewSet(viewsets.ModelViewSet):
'set_owner',
'set_access_policy',
'set_location',
'add_editor',
'remove_editor',
'set_editors'
]:
access_level = permissions.ItemOwner
@ -165,29 +164,19 @@ class LibraryViewSet(viewsets.ModelViewSet):
item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_owner = serializer.validated_data['user']
m.LibraryItem.objects.filter(pk=item.pk).update(owner=new_owner)
new_owner = serializer.validated_data['user'].pk
if new_owner == item.owner_id:
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='set AccessPolicy for item',
tags=['Library'],
request=s.AccessPolicySerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='set-access-policy')
def set_access_policy(self, request: Request, pk):
''' Endpoint: Set item AccessPolicy. '''
item = self._get_item()
serializer = s.AccessPolicySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_policy = serializer.validated_data['access_policy']
m.LibraryItem.objects.filter(pk=item.pk).update(access_policy=new_policy)
with transaction.atomic():
if item.item_type == m.LibraryItemType.OPERATION_SCHEMA:
owned_schemas = OperationSchema(item).owned_schemas().only('owner')
for schema in owned_schemas:
schema.owner_id = new_owner
m.LibraryItem.objects.bulk_update(owned_schemas, ['owner'])
item.owner_id = new_owner
item.save(update_fields=['owner'])
return Response(status=c.HTTP_200_OK)
@extend_schema(
@ -208,49 +197,52 @@ class LibraryViewSet(viewsets.ModelViewSet):
serializer = s.LocationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
location: str = serializer.validated_data['location']
if location == item.location:
return Response(status=c.HTTP_200_OK)
if location.startswith(m.LocationHead.LIBRARY) and not self.request.user.is_staff:
return Response(status=c.HTTP_403_FORBIDDEN)
m.LibraryItem.objects.filter(pk=item.pk).update(location=location)
with transaction.atomic():
if item.item_type == m.LibraryItemType.OPERATION_SCHEMA:
owned_schemas = OperationSchema(item).owned_schemas().only('location')
for schema in owned_schemas:
schema.location = location
m.LibraryItem.objects.bulk_update(owned_schemas, ['location'])
item.location = location
item.save(update_fields=['location'])
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='add editor for item',
summary='set AccessPolicy for item',
tags=['Library'],
request=s.UserTargetSerializer,
request=s.AccessPolicySerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='add-editor')
def add_editor(self, request: Request, pk):
''' Endpoint: Add editor for item. '''
@action(detail=True, methods=['patch'], url_path='set-access-policy')
def set_access_policy(self, request: Request, pk):
''' Endpoint: Set item AccessPolicy. '''
item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data)
serializer = s.AccessPolicySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_editor = serializer.validated_data['user']
m.Editor.add(item=item, user=new_editor)
new_policy = serializer.validated_data['access_policy']
if new_policy == item.access_policy:
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='remove editor for item',
tags=['Library'],
request=s.UserTargetSerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='remove-editor')
def remove_editor(self, request: Request, pk):
''' Endpoint: Remove editor for item. '''
item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
editor = serializer.validated_data['user']
m.Editor.remove(item=item, user=editor)
with transaction.atomic():
if item.item_type == m.LibraryItemType.OPERATION_SCHEMA:
owned_schemas = OperationSchema(item).owned_schemas().only('access_policy')
for schema in owned_schemas:
schema.access_policy = new_policy
m.LibraryItem.objects.bulk_update(owned_schemas, ['access_policy'])
item.access_policy = new_policy
item.save(update_fields=['access_policy'])
return Response(status=c.HTTP_200_OK)
@extend_schema(

View File

@ -51,6 +51,14 @@ class OperationSchema:
''' Operation substitutions. '''
return Substitution.objects.filter(operation__oss=self.model)
def owned_schemas(self) -> QuerySet[LibraryItem]:
''' Get QuerySet containing all result schemas owned by current OSS. '''
return LibraryItem.objects.filter(
producer__oss=self.model,
owner_id=self.model.owner_id,
location=self.model.location
)
def update_positions(self, data: list[dict]):
''' Update positions. '''
lookup = {x['id']: x for x in data}

View File

@ -1,2 +1,3 @@
''' Tests for REST API. '''
from .t_change_attributes import *
from .t_oss import *

View File

@ -0,0 +1,107 @@
''' Testing API: Change attributes of OSS and RSForms. '''
from rest_framework import status
from apps.library.models import AccessPolicy, LocationHead
from apps.oss.models import Operation, OperationSchema, OperationType
from apps.rsform.models import RSForm
from apps.users.models import User
from shared.EndpointTester import EndpointTester, decl_endpoint
class TestChangeAttributes(EndpointTester):
''' Testing LibraryItem view when OSS is associated with RSForms. '''
def setUp(self):
super().setUp()
self.user3 = User.objects.create(
username='UserTest3',
email='anotheranother@test.com',
password='password'
)
self.owned = OperationSchema.create(
title='Test',
alias='T1',
owner=self.user,
location=LocationHead.LIBRARY
)
self.owned_id = self.owned.model.pk
self.ks1 = RSForm.create(
alias='KS1',
title='Test1',
owner=self.user,
location=LocationHead.USER
)
self.ks2 = RSForm.create(
alias='KS2',
title='Test2',
owner=self.user2,
location=LocationHead.LIBRARY
)
self.operation1 = self.owned.create_operation(
alias='1',
operation_type=OperationType.INPUT,
result=self.ks1.model
)
self.operation2 = self.owned.create_operation(
alias='2',
operation_type=OperationType.INPUT,
result=self.ks2.model
)
self.operation3 = self.owned.create_operation(
alias='3',
operation_type=OperationType.SYNTHESIS
)
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = self.operation3.result
@decl_endpoint('/api/library/{item}/set-owner', method='patch')
def test_set_owner(self):
data = {'user': self.user3.pk}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(self.owned.model.owner, self.user3)
self.assertEqual(self.ks1.model.owner, self.user)
self.assertEqual(self.ks2.model.owner, self.user2)
self.assertEqual(self.ks3.owner, self.user3)
@decl_endpoint('/api/library/{item}/set-location', method='patch')
def test_set_location(self):
data = {'location': '/U/temp'}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(self.owned.model.location, data['location'])
self.assertNotEqual(self.ks1.model.location, data['location'])
self.assertNotEqual(self.ks2.model.location, data['location'])
self.assertEqual(self.ks3.location, data['location'])
@decl_endpoint('/api/library/{item}/set-access-policy', method='patch')
def test_set_access_policy(self):
data = {'access_policy': AccessPolicy.PROTECTED}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(self.owned.model.access_policy, data['access_policy'])
self.assertNotEqual(self.ks1.model.access_policy, data['access_policy'])
self.assertNotEqual(self.ks2.model.access_policy, data['access_policy'])
self.assertEqual(self.ks3.access_policy, data['access_policy'])

View File

@ -250,9 +250,8 @@ LOGGING = {
'root': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO')
},
}
}
if len(sys.argv) > 1 and sys.argv[1] == 'test':
logging.disable(logging.CRITICAL)

View File

@ -1,4 +1,7 @@
''' Utils: base tester class for endpoints. '''
import logging
from django.db import connection
from rest_framework import status
from rest_framework.test import APIClient, APIRequestFactory, APITestCase
@ -40,6 +43,9 @@ class EndpointTester(APITestCase):
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.logger = logging.getLogger('django.db.backends')
self.logger.setLevel(logging.DEBUG)
def setUpFullUsers(self):
self.factory = APIRequestFactory()
self.user = User.objects.create_user(
@ -71,6 +77,16 @@ class EndpointTester(APITestCase):
def logout(self):
self.client.logout()
def start_db_log(self):
''' Warning! Do not use this second time before calling stop_db_log. '''
''' Warning! Do not forget to enable global logging in settings. '''
logging.disable(logging.NOTSET)
connection.force_debug_cursor = True
def stop_db_log(self):
connection.force_debug_cursor = False
logging.disable(logging.CRITICAL)
def set_params(self, **kwargs):
''' Given named argument values resolve current endpoint_mask. '''
if self.endpoint_mask and len(kwargs) > 0:

View File

@ -17,14 +17,24 @@ interface MiniSelectorOSSProps {
function MiniSelectorOSS({ items, onSelect }: MiniSelectorOSSProps) {
const ossMenu = useDropdown();
function onToggle(event: CProps.EventMouse) {
if (items.length > 1) {
ossMenu.toggle();
} else {
onSelect(event, items[0]);
}
}
return (
<div ref={ossMenu.ref} className='flex items-center'>
<MiniButton
icon={<IconOSS size='1.25rem' className='icon-primary' />}
title='Связанные операционные схемы'
title='Операционные схемы'
hideTitle={ossMenu.isOpen}
onClick={() => ossMenu.toggle()}
onClick={onToggle}
/>
{items.length > 1 ? (
<Dropdown isOpen={ossMenu.isOpen}>
<Label text='Список ОСС' className='border-b px-3 py-1' />
{items.map((reference, index) => (
@ -36,6 +46,7 @@ function MiniSelectorOSS({ items, onSelect }: MiniSelectorOSSProps) {
/>
))}
</Dropdown>
) : null}
</div>
);
}

View File

@ -103,6 +103,7 @@ export interface ILibraryItemEditor {
isMutable: boolean;
isProcessing: boolean;
isAttachedToOSS: boolean;
setOwner: (newOwner: UserID) => void;
setAccessPolicy: (newPolicy: AccessPolicy) => void;

View File

@ -15,7 +15,7 @@ import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCreateOperation from '@/dialogs/DlgCreateOperation';
import DlgEditEditors from '@/dialogs/DlgEditEditors';
import DlgEditOperation from '@/dialogs/DlgEditOperation';
import { AccessPolicy, LibraryItemID } from '@/models/library';
import { AccessPolicy, ILibraryItemEditor, LibraryItemID } from '@/models/library';
import { Position2D } from '@/models/miscellaneous';
import {
IOperationCreateData,
@ -37,12 +37,13 @@ export interface ICreateOperationPrompt {
callback: (newID: OperationID) => void;
}
export interface IOssEditContext {
export interface IOssEditContext extends ILibraryItemEditor {
schema?: IOperationSchema;
selected: OperationID[];
isMutable: boolean;
isProcessing: boolean;
isAttachedToOSS: boolean;
showTooltip: boolean;
setShowTooltip: React.Dispatch<React.SetStateAction<boolean>>;
@ -319,6 +320,7 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
isMutable,
isProcessing: model.processing,
isAttachedToOSS: false,
toggleSubscribe,
setOwner,

View File

@ -4,6 +4,7 @@ import { useIntl } from 'react-intl';
import { IconEdit } from '@/components/Icons';
import InfoUsers from '@/components/info/InfoUsers';
import SelectUser from '@/components/select/SelectUser';
import LabeledValue from '@/components/ui/LabeledValue';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import Tooltip from '@/components/ui/Tooltip';
@ -15,8 +16,6 @@ import { UserID, UserLevel } from '@/models/user';
import { prefixes } from '@/utils/constants';
import { prompts } from '@/utils/labels';
import LabeledValue from '@/components/ui/LabeledValue';
interface EditorLibraryItemProps {
item?: ILibraryItemData;
isModified?: boolean;
@ -48,11 +47,11 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
{accessLevel >= UserLevel.OWNER ? (
<Overlay position='top-[-0.5rem] left-[2.3rem] cc-icons'>
<MiniButton
title='Изменить путь'
title={controller.isAttachedToOSS ? 'Путь наследуется от ОСС' : 'Изменить путь'}
noHover
onClick={() => controller.promptLocation()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing}
disabled={isModified || controller.isProcessing || controller.isAttachedToOSS}
/>
</Overlay>
) : null}
@ -66,11 +65,11 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
<Overlay position='top-[-0.5rem] left-[5.5rem] cc-icons'>
<div className='flex items-start'>
<MiniButton
title='Изменить владельца'
title={controller.isAttachedToOSS ? 'Владелец наследуется от ОСС' : 'Изменить владельца'}
noHover
onClick={() => ownerSelector.toggle()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing}
disabled={isModified || controller.isProcessing || controller.isAttachedToOSS}
/>
{ownerSelector.isOpen ? (
<SelectUser

View File

@ -33,7 +33,7 @@ function ToolbarItemAccess({ visible, toggleVisible, readOnly, toggleReadOnly, c
<Label text='Доступ' className='self-center select-none' />
<div className='ml-auto cc-icons'>
<SelectAccessPolicy
disabled={accessLevel <= UserLevel.EDITOR || controller.isProcessing}
disabled={accessLevel <= UserLevel.EDITOR || controller.isProcessing || controller.isAttachedToOSS}
value={policy}
onChange={newPolicy => controller.setAccessPolicy(newPolicy)}
/>

View File

@ -26,6 +26,7 @@ import DlgSubstituteCst from '@/dialogs/DlgSubstituteCst';
import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm';
import {
AccessPolicy,
ILibraryItemEditor,
ILibraryUpdateData,
IVersionData,
LibraryItemID,
@ -55,13 +56,14 @@ import { promptUnsaved } from '@/utils/utils';
import { RSTabID } from './RSTabs';
export interface IRSEditContext {
export interface IRSEditContext extends ILibraryItemEditor {
schema?: IRSForm;
selected: ConstituentaID[];
isMutable: boolean;
isContentEditable: boolean;
isProcessing: boolean;
isAttachedToOSS: boolean;
canProduceStructure: boolean;
nothingSelected: boolean;
canDeleteSelected: boolean;
@ -153,6 +155,13 @@ export const RSEditState = ({
() => !nothingSelected && selected.every(id => !model.schema?.cstByID.get(id)?.is_inherited),
[selected, nothingSelected, model.schema]
);
const isAttachedToOSS = useMemo(
() =>
!!model.schema &&
model.schema.oss.length > 0 &&
(model.schema.stats.count_inherited > 0 || model.schema.items.length === 0),
[model.schema]
);
const [showUpload, setShowUpload] = useState(false);
const [showClone, setShowClone] = useState(false);
@ -618,6 +627,7 @@ export const RSEditState = ({
isMutable,
isContentEditable,
isProcessing: model.processing,
isAttachedToOSS,
canProduceStructure,
nothingSelected,
canDeleteSelected,