Add option to clone only selected constituents

This commit is contained in:
IRBorisov 2024-04-04 14:34:25 +03:00
parent 2d707eca72
commit 604578d9da
12 changed files with 188 additions and 70 deletions

View File

@ -1,28 +1,27 @@
!! This is not complete list of todos !!
This list only contains global tech refactorings and tech debt
!! This is not complete list of TODOs !!
For more specific TODOs see comments in code
[Functionality]
- landing page
- home page (user specific)
- export PDF
- блок нотификаций пользователей
- блок синтеза
[Functionality - PROGRESS]
- Landing page
- Home page (user specific)
- статический анализ схемы
- конфигурации правил для разных статусов
- Operational synthesis schema as LibraryItem ?
- Library organization, search and exploration. Consider new user experience
- поиск по содержимому КС в Библиотеке
- private projects and permissions. Consider cooperative editing
- draggable rows in constituents table
- Draggable rows in constituents table
- Clickable IDs in RSEditor tooltips
- ARIA (accessibility considerations) - for now machine reading not supported
- Library organization, search and exploration. Consider new user experience
- Private projects and permissions. Consider cooperative editing
- Rework access setup: project-based, user-based, enable sharing. Prevent enumerating access to private schemas by default
- rework access setup: project-based, user-based, enable sharing. Prevent enumerating access to private schemas by default
[functionality - PLANNING]
- User notifications on edit - consider spam prevention and change aggregation
- Static analyzer for RSForm
- Content based search in Library
- Export PDF (Items list, Graph)
- ARIA (accessibility considerations) - for now machine reading not supported
- Internationalization - at least english version. Consider react.intl
[Tech]
@ -41,7 +40,7 @@ For more specific TODOs see comments in code
[Research]
Research and consider integration
- django-allauth
- django-allauth - consider supporting popular auth providers
- drf-messages
- radix-ui

View File

@ -11,6 +11,7 @@ from .basics import (
)
from .data_access import (
LibraryItemSerializer,
LibraryItemCloneSerializer,
RSFormSerializer,
RSFormParseSerializer,
VersionSerializer,

View File

@ -256,6 +256,11 @@ class CstListSerializer(serializers.Serializer):
return attrs
class LibraryItemCloneSerializer(LibraryItemSerializer):
''' Serializer: LibraryItem cloning. '''
items = PKField(many=True, required=False, queryset=Constituenta.objects.all())
class CstMoveSerializer(CstListSerializer):
''' Serializer: Change constituenta position. '''
move_to = serializers.IntegerField()

View File

@ -80,6 +80,10 @@ class EndpointTester(APITestCase):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def assertCreated(self, data=None, **kwargs):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def assertBadData(self, data=None, **kwargs):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

View File

@ -31,6 +31,7 @@ class TestLibraryViewset(EndpointTester):
alias='T3',
is_common=True
)
self.invalid_item = 1337 + self.common.pk
@decl_endpoint('/api/library', method='post')
@ -49,6 +50,7 @@ class TestLibraryViewset(EndpointTester):
@decl_endpoint('/api/library/{item}', method='patch')
def test_update(self):
data = {'id': self.unowned.id, 'title': 'New title'}
self.assertNotFound(data, item=self.invalid_item)
self.assertForbidden(data, item=self.unowned.id)
data = {'id': self.owned.id, 'title': 'New title'}
@ -71,6 +73,7 @@ class TestLibraryViewset(EndpointTester):
@decl_endpoint('/api/library/{item}/claim', method='post')
def test_claim(self):
self.assertNotFound(item=self.invalid_item)
self.assertForbidden(item=self.owned.id)
self.owned.is_common = True
@ -127,6 +130,7 @@ class TestLibraryViewset(EndpointTester):
@decl_endpoint('/api/library/{item}/subscribe', method='post')
def test_subscriptions(self):
self.assertNotFound(item=self.invalid_item)
response = self.client.delete(f'/api/library/{self.unowned.id}/unsubscribe')
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(self.user in self.unowned.subscribers())
@ -174,11 +178,31 @@ class TestLibraryViewset(EndpointTester):
)
data = {'title': 'Title1337'}
self.assertNotFound(data, item=self.invalid_item)
self.assertCreated(data, item=self.unowned.id)
data = {'title': 'Title1338'}
response = self.execute(data, item=self.owned.id)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(len(response.data['items']), 2)
self.assertEqual(response.data['items'][0]['alias'], x12.alias)
self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw)
self.assertEqual(response.data['items'][0]['term_resolved'], x12.term_resolved)
self.assertEqual(response.data['items'][1]['term_raw'], d2.term_raw)
self.assertEqual(response.data['items'][1]['term_resolved'], d2.term_resolved)
data = {'title': 'Title1340', 'items': []}
response = self.execute(data, item=self.owned.id)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(len(response.data['items']), 0)
data = {'title': 'Title1341', 'items': [x12.pk]}
response = self.execute(data, item=self.owned.id)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(len(response.data['items']), 1)
self.assertEqual(response.data['items'][0]['alias'], x12.alias)
self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw)
self.assertEqual(response.data['items'][0]['term_resolved'], x12.term_resolved)

View File

@ -81,9 +81,11 @@ class LibraryViewSet(viewsets.ModelViewSet):
@extend_schema(
summary='clone item including contents',
tags=['Library'],
request=s.LibraryItemSerializer,
request=s.LibraryItemCloneSerializer,
responses={
c.HTTP_201_CREATED: s.RSFormParseSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@ -91,7 +93,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post'], url_path='clone')
def clone(self, request: Request, pk):
''' Endpoint: Create deep copy of library item. '''
serializer = s.LibraryItemSerializer(data=request.data)
serializer = s.LibraryItemCloneSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
item = self._get_item()
if item.item_type == m.LibraryItemType.RSFORM:
@ -104,6 +106,13 @@ class LibraryViewSet(viewsets.ModelViewSet):
clone_data['comment'] = serializer.validated_data.get('comment', '')
clone_data['is_common'] = serializer.validated_data.get('is_common', False)
clone_data['is_canonical'] = serializer.validated_data.get('is_canonical', False)
if 'items' in request.data:
filtered_items = []
for cst in clone_data['items']:
if cst['entityUID'] in request.data['items']:
filtered_items.append(cst)
clone_data['items'] = filtered_items
clone = s.RSFormTRSSerializer(data=clone_data, context={'load_meta': True})
clone.is_valid(raise_exception=True)
new_schema = clone.save()
@ -111,13 +120,17 @@ class LibraryViewSet(viewsets.ModelViewSet):
status=c.HTTP_201_CREATED,
data=s.RSFormParseSerializer(new_schema.item).data
)
return Response(status=c.HTTP_404_NOT_FOUND)
return Response(status=c.HTTP_400_BAD_REQUEST)
@extend_schema(
summary='claim item',
tags=['Library'],
request=None,
responses={c.HTTP_200_OK: s.LibraryItemSerializer}
responses={
c.HTTP_200_OK: s.LibraryItemSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@transaction.atomic
@action(detail=True, methods=['post'])
@ -139,7 +152,11 @@ class LibraryViewSet(viewsets.ModelViewSet):
summary='subscribe to item',
tags=['Library'],
request=None,
responses={c.HTTP_204_NO_CONTENT: None}
responses={
c.HTTP_204_NO_CONTENT: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'])
def subscribe(self, request: Request, pk):
@ -152,7 +169,11 @@ class LibraryViewSet(viewsets.ModelViewSet):
summary='unsubscribe from item',
tags=['Library'],
request=None,
responses={c.HTTP_204_NO_CONTENT: None},
responses={
c.HTTP_204_NO_CONTENT: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
},
)
@action(detail=True, methods=['delete'])
def unsubscribe(self, request: Request, pk):

View File

@ -41,7 +41,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='create constituenta',
tags=['Constituenta'],
request=s.CstCreateSerializer,
responses={c.HTTP_201_CREATED: s.NewCstResponse}
responses={
c.HTTP_201_CREATED: s.NewCstResponse,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'], url_path='cst-create')
def cst_create(self, request: Request, pk):
@ -69,7 +73,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='produce the structure of a given constituenta',
tags=['RSForm'],
request=s.CstTargetSerializer,
responses={c.HTTP_200_OK: s.NewMultiCstResponse}
responses={
c.HTTP_200_OK: s.NewMultiCstResponse,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='cst-produce-structure')
def produce_structure(self, request: Request, pk):
@ -101,7 +109,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='rename constituenta',
tags=['Constituenta'],
request=s.CstRenameSerializer,
responses={c.HTTP_200_OK: s.NewCstResponse}
responses={
c.HTTP_200_OK: s.NewCstResponse,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@transaction.atomic
@action(detail=True, methods=['patch'], url_path='cst-rename')
@ -135,7 +147,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='substitute constituenta',
tags=['RSForm'],
request=s.CstSubstituteSerializer,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@transaction.atomic
@action(detail=True, methods=['patch'], url_path='cst-substitute')
@ -161,7 +177,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='delete constituents',
tags=['RSForm'],
request=s.CstListSerializer,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='cst-delete-multiple')
def cst_delete_multiple(self, request: Request, pk):
@ -183,7 +203,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='move constituenta',
tags=['RSForm'],
request=s.CstMoveSerializer,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='cst-moveto')
def cst_moveto(self, request: Request, pk):
@ -208,7 +232,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='reset aliases, update expressions and references',
tags=['RSForm'],
request=None,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='reset-aliases')
def reset_aliases(self, request: Request, pk):
@ -224,7 +252,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='load data from TRS file',
tags=['RSForm'],
request=s.RSFormUploadSerializer,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='load-trs')
def load_trs(self, request: Request, pk):
@ -251,7 +283,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='get all constituents data from DB',
tags=['RSForm'],
request=None,
responses={c.HTTP_200_OK: s.RSFormSerializer}
responses={
c.HTTP_200_OK: s.RSFormSerializer,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['get'])
def contents(self, request: Request, pk):
@ -266,7 +301,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='get all constituents data and parses',
tags=['RSForm'],
request=None,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['get'])
def details(self, request: Request, pk):
@ -281,7 +319,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='check RSLang expression',
tags=['RSForm', 'FormalLanguage'],
request=s.ExpressionSerializer,
responses={c.HTTP_200_OK: s.ExpressionParseSerializer},
responses={
c.HTTP_200_OK: s.ExpressionParseSerializer,
c.HTTP_404_NOT_FOUND: None
},
)
@action(detail=True, methods=['post'])
def check(self, request: Request, pk):
@ -300,7 +341,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='resolve text with references',
tags=['RSForm', 'NaturalLanguage'],
request=s.TextSerializer,
responses={c.HTTP_200_OK: s.ResolverSerializer}
responses={
c.HTTP_200_OK: s.ResolverSerializer,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'])
def resolve(self, request: Request, pk):
@ -319,7 +363,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
summary='export as TRS file',
tags=['RSForm'],
request=None,
responses={(c.HTTP_200_OK, 'application/zip'): bytes}
responses={
(c.HTTP_200_OK, 'application/zip'): bytes,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['get'], url_path='export-trs')
def export_trs(self, request: Request, pk):
@ -341,7 +388,10 @@ class TrsImportView(views.APIView):
summary='import TRS file into RSForm',
tags=['RSForm'],
request=s.FileSerializer,
responses={c.HTTP_201_CREATED: s.LibraryItemSerializer}
responses={
c.HTTP_201_CREATED: s.LibraryItemSerializer,
c.HTTP_403_FORBIDDEN: None
}
)
def post(self, request: Request):
data = utils.read_zipped_json(request.FILES['file'].file, utils.EXTEOR_INNER_FILENAME)
@ -364,7 +414,10 @@ class TrsImportView(views.APIView):
summary='create new RSForm empty or from file',
tags=['RSForm'],
request=s.LibraryItemSerializer,
responses={c.HTTP_201_CREATED: s.LibraryItemSerializer}
responses={
c.HTTP_201_CREATED: s.LibraryItemSerializer,
c.HTTP_403_FORBIDDEN: None
}
)
@api_view(['POST'])
def create_rsform(request: Request):

View File

@ -6,7 +6,7 @@ import { ErrorData } from '@/components/info/InfoError';
import { ILibraryItem } from '@/models/library';
import { matchLibraryItem } from '@/models/libraryAPI';
import { ILibraryFilter } from '@/models/miscellaneous';
import { IRSForm, IRSFormCreateData, IRSFormData } from '@/models/rsform';
import { IRSForm, IRSFormCloneData, IRSFormCreateData, IRSFormData } from '@/models/rsform';
import { loadRSFormData } from '@/models/rsformAPI';
import {
DataCallback,
@ -31,7 +31,7 @@ interface ILibraryContext {
applyFilter: (params: ILibraryFilter) => ILibraryItem[];
retrieveTemplate: (templateID: number, callback: (schema: IRSForm) => void) => void;
createItem: (data: IRSFormCreateData, callback?: DataCallback<ILibraryItem>) => void;
cloneItem: (target: number, data: IRSFormCreateData, callback: DataCallback<IRSFormData>) => void;
cloneItem: (target: number, data: IRSFormCloneData, callback: DataCallback<IRSFormData>) => void;
destroyItem: (target: number, callback?: () => void) => void;
localUpdateItem: (data: ILibraryItem) => void;
@ -194,7 +194,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
);
const cloneItem = useCallback(
(target: number, data: IRSFormCreateData, callback: DataCallback<IRSFormData>) => {
(target: number, data: IRSFormCloneData, callback: DataCallback<IRSFormData>) => {
if (!user) {
return;
}

View File

@ -1,7 +1,7 @@
'use client';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { urls } from '@/app/urls';
@ -13,43 +13,38 @@ import { useLibrary } from '@/context/LibraryContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { ILibraryItem } from '@/models/library';
import { cloneTitle } from '@/models/libraryAPI';
import { IRSFormCreateData } from '@/models/rsform';
import { ConstituentaID, IRSFormCloneData } from '@/models/rsform';
interface DlgCloneLibraryItemProps extends Pick<ModalProps, 'hideWindow'> {
base: ILibraryItem;
selected: ConstituentaID[];
totalCount: number;
}
function DlgCloneLibraryItem({ hideWindow, base }: DlgCloneLibraryItemProps) {
function DlgCloneLibraryItem({ hideWindow, base, selected, totalCount }: DlgCloneLibraryItemProps) {
const router = useConceptNavigation();
const [title, setTitle] = useState('');
const [alias, setAlias] = useState('');
const [comment, setComment] = useState('');
const [common, setCommon] = useState(false);
const [canonical, setCanonical] = useState(false);
const [title, setTitle] = useState(cloneTitle(base));
const [alias, setAlias] = useState(base.alias);
const [comment, setComment] = useState(base.comment);
const [common, setCommon] = useState(base.is_common);
const [onlySelected, setOnlySelected] = useState(false);
const { cloneItem } = useLibrary();
const canSubmit = useMemo(() => title !== '' && alias !== '', [title, alias]);
useEffect(() => {
if (base) {
setTitle(cloneTitle(base));
setAlias(base.alias);
setComment(base.comment);
setCommon(base.is_common);
setCanonical(false);
}
}, [base, base?.title, base?.alias, base?.comment, base?.is_common]);
function handleSubmit() {
const data: IRSFormCreateData = {
const data: IRSFormCloneData = {
item_type: base.item_type,
title: title,
alias: alias,
comment: comment,
is_common: common,
is_canonical: canonical
is_canonical: false
};
if (onlySelected) {
data.items = selected;
}
cloneItem(base.id, data, newSchema => {
toast.success(`Копия создана: ${newSchema.alias}`);
router.push(urls.schema(newSchema.id));
@ -78,11 +73,12 @@ function DlgCloneLibraryItem({ hideWindow, base }: DlgCloneLibraryItemProps) {
className='max-w-sm'
onChange={event => setAlias(event.target.value)}
/>
<TextArea
id='dlg_comment'
label='Комментарий'
value={comment}
onChange={event => setComment(event.target.value)}
<TextArea id='dlg_comment' label='Описание' value={comment} onChange={event => setComment(event.target.value)} />
<Checkbox
id='dlg_only_selected'
label={`Только выбранные конституенты [${selected.length} из ${totalCount}]`}
value={onlySelected}
setValue={value => setOnlySelected(value)}
/>
<Checkbox id='dlg_is_common' label='Общедоступная схема' value={common} setValue={value => setCommon(value)} />
</Modal>

View File

@ -234,6 +234,13 @@ export interface IRSFormCreateData extends ILibraryUpdateData {
fileName?: string;
}
/**
* Represents data, used for cloning {@link IRSForm}.
*/
export interface IRSFormCloneData extends ILibraryUpdateData {
items?: ConstituentaID[];
}
/**
* Represents data, used for uploading {@link IRSForm} as file.
*/

View File

@ -527,7 +527,14 @@ export const RSEditState = ({
{model.schema ? (
<AnimatePresence>
{showUpload ? <DlgUploadRSForm hideWindow={() => setShowUpload(false)} /> : null}
{showClone ? <DlgCloneLibraryItem base={model.schema} hideWindow={() => setShowClone(false)} /> : null}
{showClone ? (
<DlgCloneLibraryItem
base={model.schema}
hideWindow={() => setShowClone(false)}
selected={selected}
totalCount={model.schema.items.length}
/>
) : null}
{showCreateCst ? (
<DlgCreateCst
hideWindow={() => setShowCreateCst(false)}

View File

@ -34,6 +34,7 @@ import {
ICstUpdateData,
IInlineSynthesisData,
IProduceStructureResponse,
IRSFormCloneData,
IRSFormCreateData,
IRSFormData,
IRSFormUploadData,
@ -212,7 +213,7 @@ export function postNewRSForm(request: FrontExchange<IRSFormCreateData, ILibrary
});
}
export function postCloneLibraryItem(target: string, request: FrontExchange<IRSFormCreateData, IRSFormData>) {
export function postCloneLibraryItem(target: string, request: FrontExchange<IRSFormCloneData, IRSFormData>) {
AxiosPost({
title: 'Clone RSForm',
endpoint: `/api/library/${target}/clone`,