From 658de0545befcafedf5b97bae53ceb1583033d80 Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Wed, 21 Aug 2024 16:49:17 +0300 Subject: [PATCH] F: Implement location editing + showSubfolders --- .vscode/settings.json | 1 + .../apps/library/serializers/__init__.py | 2 +- .../apps/library/serializers/basics.py | 18 +++++++ .../apps/library/tests/s_views/t_library.py | 36 +++++++++++++ .../backend/apps/library/views/library.py | 39 +++++++++++++- rsconcept/backend/shared/permissions.py | 2 +- rsconcept/frontend/src/backend/library.ts | 8 +++ .../frontend/src/components/DomainIcons.tsx | 9 ++++ rsconcept/frontend/src/components/Icons.tsx | 2 + .../frontend/src/context/LibraryContext.tsx | 31 ++++++++++- .../src/dialogs/DlgChangeLocation.tsx | 2 +- rsconcept/frontend/src/models/library.ts | 8 +++ .../frontend/src/models/miscellaneous.ts | 1 + .../src/pages/LibraryPage/LibraryPage.tsx | 49 ++++++++++++++---- .../src/pages/LibraryPage/ToolbarSearch.tsx | 4 +- .../pages/LibraryPage/ViewSideLocation.tsx | 51 ++++++++++++++++--- .../ManualsPage/items/ui/HelpLibrary.tsx | 26 +++++++--- rsconcept/frontend/src/styling/overrides.css | 4 ++ rsconcept/frontend/src/utils/codemirror.ts | 13 +++-- rsconcept/frontend/src/utils/constants.ts | 2 +- rsconcept/frontend/src/utils/labels.ts | 1 + 21 files changed, 270 insertions(+), 39 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 27805950..05a9be93 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -200,6 +200,7 @@ "Пакулина", "пересинтез", "Персиц", + "подпапках", "Присакарь", "ПРОКСИМА", "Родоструктурная", diff --git a/rsconcept/backend/apps/library/serializers/__init__.py b/rsconcept/backend/apps/library/serializers/__init__.py index 37a5df78..fbe92549 100644 --- a/rsconcept/backend/apps/library/serializers/__init__.py +++ b/rsconcept/backend/apps/library/serializers/__init__.py @@ -1,6 +1,6 @@ ''' REST API: Serializers. ''' -from .basics import AccessPolicySerializer, LocationSerializer +from .basics import AccessPolicySerializer, LocationSerializer, RenameLocationSerializer from .data_access import ( LibraryItemBaseSerializer, LibraryItemCloneSerializer, diff --git a/rsconcept/backend/apps/library/serializers/basics.py b/rsconcept/backend/apps/library/serializers/basics.py index adf623b8..78fa241a 100644 --- a/rsconcept/backend/apps/library/serializers/basics.py +++ b/rsconcept/backend/apps/library/serializers/basics.py @@ -19,6 +19,24 @@ class LocationSerializer(serializers.Serializer): return attrs +class RenameLocationSerializer(serializers.Serializer): + ''' Serializer: rename location. ''' + target = serializers.CharField(max_length=500) + new_location = serializers.CharField(max_length=500) + + def validate(self, attrs): + attrs = super().validate(attrs) + if not validate_location(attrs['target']): + raise serializers.ValidationError({ + 'target': msg.invalidLocation() + }) + if not validate_location(attrs['target']): + raise serializers.ValidationError({ + 'new_location': msg.invalidLocation() + }) + return attrs + + class AccessPolicySerializer(serializers.Serializer): ''' Serializer: Constituenta renaming. ''' access_policy = serializers.CharField() diff --git a/rsconcept/backend/apps/library/tests/s_views/t_library.py b/rsconcept/backend/apps/library/tests/s_views/t_library.py index fbb94e67..49dd6fd8 100644 --- a/rsconcept/backend/apps/library/tests/s_views/t_library.py +++ b/rsconcept/backend/apps/library/tests/s_views/t_library.py @@ -181,6 +181,42 @@ class TestLibraryViewset(EndpointTester): self.unowned.refresh_from_db() self.assertEqual(self.unowned.location, data['location']) + @decl_endpoint('/api/library/rename-location', method='patch') + def test_rename_location(self): + self.owned.location = '/U/temp' + self.owned.save() + self.unowned.location = '/U/temp' + self.unowned.save() + owned2 = LibraryItem.objects.create( + title='Test3', + alias='T3', + owner=self.user, + location='/U/temp/123' + ) + + data = { + 'target': '/U/temp', + 'new_location': '/U/temp2' + } + + self.executeBadData(data={}) + self.executeBadData(data={'target:': '/U/temp'}) + self.executeBadData(data={'new_location:': '/U/temp'}) + self.executeBadData(data={'target:': 'invalid', 'new_location': '/U/temp'}) + self.executeBadData(data={'target:': '/U/temp', 'new_location': 'invalid'}) + self.executeOK(data=data) + self.owned.refresh_from_db() + self.unowned.refresh_from_db() + owned2.refresh_from_db() + self.assertEqual(self.owned.location, '/U/temp2') + self.assertEqual(self.unowned.location, '/U/temp') + self.assertEqual(owned2.location, '/U/temp2/123') + + self.toggle_admin(True) + self.executeOK(data=data) + self.unowned.refresh_from_db() + self.assertEqual(self.unowned.location, '/U/temp2') + @decl_endpoint('/api/library/{item}/set-editors', method='patch') def test_set_editors(self): time_update = self.owned.time_update diff --git a/rsconcept/backend/apps/library/views/library.py b/rsconcept/backend/apps/library/views/library.py index 08be2ea0..93535250 100644 --- a/rsconcept/backend/apps/library/views/library.py +++ b/rsconcept/backend/apps/library/views/library.py @@ -82,7 +82,8 @@ class LibraryViewSet(viewsets.ModelViewSet): access_level = permissions.ItemOwner elif self.action in [ 'create', - 'clone' + 'clone', + 'rename_location' ]: access_level = permissions.GlobalUser else: @@ -92,6 +93,42 @@ class LibraryViewSet(viewsets.ModelViewSet): def _get_item(self) -> m.LibraryItem: return cast(m.LibraryItem, self.get_object()) + @extend_schema( + summary='rename location', + tags=['Library'], + request=s.RenameLocationSerializer, + responses={ + c.HTTP_200_OK: None, + c.HTTP_403_FORBIDDEN: None, + c.HTTP_404_NOT_FOUND: None + } + ) + @action(detail=False, methods=['patch'], url_path='rename-location') + def rename_location(self, request: Request) -> HttpResponse: + ''' Endpoint: Rename location. ''' + serializer = s.RenameLocationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + target = serializer.validated_data['target'] + new_location = serializer.validated_data['new_location'] + if target == new_location: + return Response(status=c.HTTP_200_OK) + if new_location.startswith(m.LocationHead.LIBRARY) and not self.request.user.is_staff: + return Response(status=c.HTTP_403_FORBIDDEN) + + with transaction.atomic(): + changed: list[m.LibraryItem] = [] + items = m.LibraryItem.objects \ + .filter(Q(location=target) | Q(location__startswith=f'{target}/')) \ + .only('location', 'owner_id') + for item in items: + if item.owner_id == self.request.user.pk or self.request.user.is_staff: + item.location = item.location.replace(target, new_location) + changed.append(item) + if changed: + m.LibraryItem.objects.bulk_update(changed, ['location']) + + return Response(status=c.HTTP_200_OK) + @extend_schema( summary='clone item including contents', tags=['Library'], diff --git a/rsconcept/backend/shared/permissions.py b/rsconcept/backend/shared/permissions.py index 60a23ebf..40c32ca6 100644 --- a/rsconcept/backend/shared/permissions.py +++ b/rsconcept/backend/shared/permissions.py @@ -39,7 +39,7 @@ def can_edit_item(user, obj: Any) -> bool: return True item = _extract_item(obj) - if item.owner == user: + if item.owner_id == user.pk: return True if Editor.objects.filter( diff --git a/rsconcept/frontend/src/backend/library.ts b/rsconcept/frontend/src/backend/library.ts index 6c6fbd71..d401609c 100644 --- a/rsconcept/frontend/src/backend/library.ts +++ b/rsconcept/frontend/src/backend/library.ts @@ -6,6 +6,7 @@ import { ILibraryCreateData, ILibraryItem, ILibraryUpdateData, + IRenameLocationData, ITargetAccessPolicy, ITargetLocation, IVersionData @@ -94,6 +95,13 @@ export function patchSetLocation(target: string, request: FrontPush) { + AxiosPatch({ + endpoint: `/api/library/rename-location`, + request: request + }); +} + export function patchSetEditors(target: string, request: FrontPush) { AxiosPatch({ endpoint: `/api/library/${target}/set-editors`, diff --git a/rsconcept/frontend/src/components/DomainIcons.tsx b/rsconcept/frontend/src/components/DomainIcons.tsx index bdd7a522..c017d332 100644 --- a/rsconcept/frontend/src/components/DomainIcons.tsx +++ b/rsconcept/frontend/src/components/DomainIcons.tsx @@ -24,6 +24,7 @@ import { IconStatusIncalculable, IconStatusOK, IconStatusUnknown, + IconSubfolders, IconTemplates, IconTerm, IconText, @@ -62,6 +63,14 @@ export function VisibilityIcon({ value, size = '1.25rem', className }: DomIconPr } } +export function SubfoldersIcon({ value, size = '1.25rem', className }: DomIconProps) { + if (value) { + return ; + } else { + return ; + } +} + export function LocationIcon({ value, size = '1.25rem', className }: DomIconProps) { switch (value.substring(0, 2) as LocationHead) { case LocationHead.COMMON: diff --git a/rsconcept/frontend/src/components/Icons.tsx b/rsconcept/frontend/src/components/Icons.tsx index ad38a786..f173f330 100644 --- a/rsconcept/frontend/src/components/Icons.tsx +++ b/rsconcept/frontend/src/components/Icons.tsx @@ -34,6 +34,8 @@ export { LuMoon as IconDarkTheme } from 'react-icons/lu'; export { LuSun as IconLightTheme } from 'react-icons/lu'; export { LuFolderTree as IconFolderTree } from 'react-icons/lu'; export { LuFolder as IconFolder } from 'react-icons/lu'; +export { LuFolders as IconSubfolders } from 'react-icons/lu'; +export { LuFolderEdit as IconFolderEdit } from 'react-icons/lu'; export { LuFolderOpen as IconFolderOpened } from 'react-icons/lu'; export { LuFolderClosed as IconFolderClosed } from 'react-icons/lu'; export { LuFolderDot as IconFolderEmpty } from 'react-icons/lu'; diff --git a/rsconcept/frontend/src/context/LibraryContext.tsx b/rsconcept/frontend/src/context/LibraryContext.tsx index 369b45c7..4296fa86 100644 --- a/rsconcept/frontend/src/context/LibraryContext.tsx +++ b/rsconcept/frontend/src/context/LibraryContext.tsx @@ -8,13 +8,14 @@ import { getAdminLibrary, getLibrary, getTemplates, + patchRenameLocation, postCloneLibraryItem, postCreateLibraryItem } from '@/backend/library'; import { getRSFormDetails, postRSFormFromFile } from '@/backend/rsforms'; import { ErrorData } from '@/components/info/InfoError'; import { FolderTree } from '@/models/FolderTree'; -import { ILibraryItem, LibraryItemID, LocationHead } from '@/models/library'; +import { ILibraryItem, IRenameLocationData, LibraryItemID, LocationHead } from '@/models/library'; import { ILibraryCreateData } from '@/models/library'; import { matchLibraryItem, matchLibraryItemLocation } from '@/models/libraryAPI'; import { ILibraryFilter } from '@/models/miscellaneous'; @@ -45,6 +46,7 @@ interface ILibraryContext { createItem: (data: ILibraryCreateData, callback?: DataCallback) => void; cloneItem: (target: LibraryItemID, data: IRSFormCloneData, callback: DataCallback) => void; destroyItem: (target: LibraryItemID, callback?: () => void) => void; + renameLocation: (data: IRenameLocationData, callback?: () => void) => void; localUpdateItem: (data: ILibraryItem) => void; localUpdateTimestamp: (target: LibraryItemID) => void; @@ -92,7 +94,13 @@ export const LibraryState = ({ children }: LibraryStateProps) => { result = result.filter(item => item.location.startsWith(filter.head!)); } if (filter.folderMode && filter.location) { - result = result.filter(item => item.location == filter.location); + if (filter.subfolders) { + result = result.filter( + item => item.location == filter.location || item.location.startsWith(filter.location! + '/') + ); + } else { + result = result.filter(item => item.location == filter.location); + } } if (filter.type) { result = result.filter(item => item.item_type === filter.type); @@ -270,6 +278,23 @@ export const LibraryState = ({ children }: LibraryStateProps) => { [reloadItems, user] ); + const renameLocation = useCallback( + (data: IRenameLocationData, callback?: () => void) => { + setProcessingError(undefined); + patchRenameLocation({ + data: data, + showError: true, + setLoading: setProcessing, + onError: setProcessingError, + onSuccess: () => + reloadItems(() => { + if (callback) callback(); + }) + }); + }, + [reloadItems, user] + ); + return ( { createItem, cloneItem, destroyItem, + renameLocation, + retrieveTemplate, localUpdateItem, localUpdateTimestamp diff --git a/rsconcept/frontend/src/dialogs/DlgChangeLocation.tsx b/rsconcept/frontend/src/dialogs/DlgChangeLocation.tsx index d42da1c1..cd71d0a6 100644 --- a/rsconcept/frontend/src/dialogs/DlgChangeLocation.tsx +++ b/rsconcept/frontend/src/dialogs/DlgChangeLocation.tsx @@ -45,7 +45,7 @@ function DlgChangeLocation({ hideWindow, initial, onChangeLocation }: DlgChangeL onSubmit={() => onChangeLocation(location)} className={clsx('w-[35rem]', 'pb-3 px-6 flex gap-3')} > -
+