diff --git a/rsconcept/backend/apps/rsform/models.py b/rsconcept/backend/apps/rsform/models.py index e47dd36e..106274e6 100644 --- a/rsconcept/backend/apps/rsform/models.py +++ b/rsconcept/backend/apps/rsform/models.py @@ -92,6 +92,7 @@ class RSForm(models.Model): csttype=type ) self._recreate_order() + self.save() return Constituenta.objects.get(pk=result.pk) @transaction.atomic @@ -107,8 +108,17 @@ class RSForm(models.Model): csttype=type ) self._recreate_order() + self.save() return Constituenta.objects.get(pk=result.pk) + @transaction.atomic + def delete_cst(self, listCst): + ''' Delete multiple constituents. Do not check if listCst are from this schema ''' + for cst in listCst: + cst.delete() + self._recreate_order() + self.save() + @staticmethod @transaction.atomic def import_json(owner: User, data: dict, is_common: bool = True) -> 'RSForm': diff --git a/rsconcept/backend/apps/rsform/serializers.py b/rsconcept/backend/apps/rsform/serializers.py index 139f4b2b..281a199c 100644 --- a/rsconcept/backend/apps/rsform/serializers.py +++ b/rsconcept/backend/apps/rsform/serializers.py @@ -7,6 +7,12 @@ class FileSerializer(serializers.Serializer): file = serializers.FileField(allow_empty_file=False) +class ItemsListSerlializer(serializers.Serializer): + items = serializers.ListField( + child=serializers.IntegerField() + ) + + class ExpressionSerializer(serializers.Serializer): expression = serializers.CharField() diff --git a/rsconcept/backend/apps/rsform/tests/t_models.py b/rsconcept/backend/apps/rsform/tests/t_models.py index 41bc773d..5c158882 100644 --- a/rsconcept/backend/apps/rsform/tests/t_models.py +++ b/rsconcept/backend/apps/rsform/tests/t_models.py @@ -177,6 +177,20 @@ class TestRSForm(TestCase): self.assertEqual(cst2.schema, schema) self.assertEqual(cst1.order, 1) + def test_delete_cst(self): + schema = RSForm.objects.create(title='Test') + x1 = schema.insert_last('X1', CstType.BASE) + x2 = schema.insert_last('X2', CstType.BASE) + d1 = schema.insert_last('D1', CstType.TERM) + d2 = schema.insert_last('D2', CstType.TERM) + schema.delete_cst([x2, d1]) + x1.refresh_from_db() + d2.refresh_from_db() + schema.refresh_from_db() + self.assertEqual(schema.constituents().count(), 2) + self.assertEqual(x1.order, 1) + self.assertEqual(d2.order, 2) + def test_to_json(self): schema = RSForm.objects.create(title='Test', alias='KS1', comment='Test') x1 = schema.insert_at(4, 'X1', CstType.BASE) diff --git a/rsconcept/backend/apps/rsform/tests/t_views.py b/rsconcept/backend/apps/rsform/tests/t_views.py index 072bcdd0..2376ae5e 100644 --- a/rsconcept/backend/apps/rsform/tests/t_views.py +++ b/rsconcept/backend/apps/rsform/tests/t_views.py @@ -173,14 +173,14 @@ class TestRSFormViewset(APITestCase): def test_create_constituenta(self): data = json.dumps({'alias': 'X3', 'csttype': 'basic'}) - response = self.client.post(f'/api/rsforms/{self.rsform_unowned.id}/new-constituenta/', + response = self.client.post(f'/api/rsforms/{self.rsform_unowned.id}/cst-create/', data=data, content_type='application/json') self.assertEqual(response.status_code, 403) schema = self.rsform_owned Constituenta.objects.create(schema=schema, alias='X1', csttype='basic', order=1) x2 = Constituenta.objects.create(schema=schema, alias='X2', csttype='basic', order=2) - response = self.client.post(f'/api/rsforms/{schema.id}/new-constituenta/', + response = self.client.post(f'/api/rsforms/{schema.id}/cst-create/', data=data, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['alias'], 'X3') @@ -188,13 +188,38 @@ class TestRSFormViewset(APITestCase): self.assertEqual(x3.order, 3) data = json.dumps({'alias': 'X4', 'csttype': 'basic', 'insert_after': x2.id}) - response = self.client.post(f'/api/rsforms/{schema.id}/new-constituenta/', + response = self.client.post(f'/api/rsforms/{schema.id}/cst-create/', data=data, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['alias'], 'X4') x4 = Constituenta.objects.get(alias=response.data['alias']) self.assertEqual(x4.order, 3) + def test_delete_constituenta(self): + schema = self.rsform_owned + data = json.dumps({'items': [1337]}) + response = self.client.post(f'/api/rsforms/{schema.id}/cst-multidelete/', + data=data, content_type='application/json') + self.assertEqual(response.status_code, 404) + + x1 = Constituenta.objects.create(schema=schema, alias='X1', csttype='basic', order=1) + x2 = Constituenta.objects.create(schema=schema, alias='X2', csttype='basic', order=2) + data = json.dumps({'items': [x1.id]}) + response = self.client.post(f'/api/rsforms/{schema.id}/cst-multidelete/', + data=data, content_type='application/json') + x2.refresh_from_db() + schema.refresh_from_db() + self.assertEqual(response.status_code, 202) + self.assertEqual(schema.constituents().count(), 1) + self.assertEqual(x2.alias, 'X2') + self.assertEqual(x2.order, 1) + + x3 = Constituenta.objects.create(schema=self.rsform_unowned, alias='X1', csttype='basic', order=1) + data = json.dumps({'items': [x3.id]}) + response = self.client.post(f'/api/rsforms/{schema.id}/cst-multidelete/', + data=data, content_type='application/json') + self.assertEqual(response.status_code, 400) + class TestFunctionalViews(APITestCase): def setUp(self): diff --git a/rsconcept/backend/apps/rsform/views.py b/rsconcept/backend/apps/rsform/views.py index 15c2c20a..efb18aa4 100644 --- a/rsconcept/backend/apps/rsform/views.py +++ b/rsconcept/backend/apps/rsform/views.py @@ -42,7 +42,7 @@ class RSFormViewSet(viewsets.ModelViewSet): def get_permissions(self): if self.action in ['update', 'destroy', 'partial_update', - 'new_constituenta']: + 'cst_create', 'cst_multidelete']: permission_classes = [utils.ObjectOwnerOrAdmin] elif self.action in ['create', 'claim']: permission_classes = [permissions.IsAuthenticated] @@ -50,9 +50,9 @@ class RSFormViewSet(viewsets.ModelViewSet): permission_classes = [permissions.AllowAny] return [permission() for permission in permission_classes] - @action(detail=True, methods=['post'], url_path='new-constituenta') - def new_constituenta(self, request, pk): - ''' View schema contents (including constituents) ''' + @action(detail=True, methods=['post'], url_path='cst-create') + def cst_create(self, request, pk): + ''' Create new constituenta ''' schema: models.RSForm = self.get_object() serializer = serializers.NewConstituentaSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -65,6 +65,26 @@ class RSFormViewSet(viewsets.ModelViewSet): constituenta = schema.insert_last(serializer.validated_data['alias'], serializer.validated_data['csttype']) return Response(status=201, data=constituenta.to_json()) + @action(detail=True, methods=['post'], url_path='cst-multidelete') + def cst_multidelete(self, request, pk): + ''' Delete multiple constituents ''' + schema: models.RSForm = self.get_object() + serializer = serializers.ItemsListSerlializer(data=request.data) + serializer.is_valid(raise_exception=True) + listCst = [] + # TODO: consider moving validation to serializer + try: + for id in serializer.validated_data['items']: + cst = models.Constituenta.objects.get(pk=id) + if (cst.schema != schema): + return Response({'error', 'Конституенты должны относиться к данной схеме'}, status=400) + listCst.append(cst) + except models.Constituenta.DoesNotExist: + return Response(status=404) + + schema.delete_cst(listCst) + return Response(status=202) + @action(detail=True, methods=['post']) def claim(self, request, pk=None): schema: models.RSForm = self.get_object() diff --git a/rsconcept/frontend/src/context/AuthContext.tsx b/rsconcept/frontend/src/context/AuthContext.tsx index c88cc108..273e3339 100644 --- a/rsconcept/frontend/src/context/AuthContext.tsx +++ b/rsconcept/frontend/src/context/AuthContext.tsx @@ -7,9 +7,9 @@ import { getAuth, postLogin, postLogout, postSignup } from '../utils/backendAPI' interface IAuthContext { user: ICurrentUser | undefined - login: (username: string, password: string, onSuccess?: () => void) => void - logout: (onSuccess?: () => void) => void - signup: (data: IUserSignupData, onSuccess?: () => void) => void + login: (username: string, password: string) => Promise + logout: (onSuccess?: () => void) => Promise + signup: (data: IUserSignupData) => Promise loading: boolean error: ErrorInfo setError: (error: ErrorInfo) => void @@ -17,9 +17,9 @@ interface IAuthContext { export const AuthContext = createContext({ user: undefined, - login: () => {}, - logout: () => {}, - signup: () => {}, + login: async () => {}, + logout: async () => {}, + signup: async () => {}, loading: false, error: '', setError: () => {} @@ -63,18 +63,17 @@ export const AuthState = ({ children }: AuthStateProps) => { }); } - async function logout(onSuccess?: () => void) { + async function logout() { setError(undefined); postLogout({ showError: true, onSucccess: response => { loadCurrentUser(); - if(onSuccess) onSuccess(); } }); } - async function signup(data: IUserSignupData, onSuccess?: () => void) { + async function signup(data: IUserSignupData) { setError(undefined); postSignup({ data: data, @@ -83,7 +82,6 @@ export const AuthState = ({ children }: AuthStateProps) => { onError: error => setError(error), onSucccess: response => { loadCurrentUser(); - if(onSuccess) onSuccess(); } }); } diff --git a/rsconcept/frontend/src/context/RSFormContext.tsx b/rsconcept/frontend/src/context/RSFormContext.tsx index 837f7e5c..08eeaee6 100644 --- a/rsconcept/frontend/src/context/RSFormContext.tsx +++ b/rsconcept/frontend/src/context/RSFormContext.tsx @@ -3,7 +3,7 @@ import { IConstituenta, IRSForm } from '../utils/models'; import { useRSFormDetails } from '../hooks/useRSFormDetails'; import { ErrorInfo } from '../components/BackendError'; import { useAuth } from './AuthContext'; -import { BackendCallback, deleteRSForm, getTRSFile, patchConstituenta, patchRSForm, postClaimRSForm, postNewConstituenta } from '../utils/backendAPI'; +import { BackendCallback, deleteRSForm, getTRSFile, patchConstituenta, patchRSForm, postClaimRSForm, postDeleteConstituenta, postNewConstituenta } from '../utils/backendAPI'; import { toast } from 'react-toastify'; interface IRSFormContext { @@ -23,14 +23,16 @@ interface IRSFormContext { toggleForceAdmin: () => void toggleReadonly: () => void toggleTracking: () => void - reload: () => void - update: (data: any, callback?: BackendCallback) => void - destroy: (callback: BackendCallback) => void - claim: (callback: BackendCallback) => void - download: (callback: BackendCallback) => void - cstUpdate: (data: any, callback: BackendCallback) => void - cstCreate: (data: any, callback: BackendCallback) => void + reload: () => Promise + update: (data: any, callback?: BackendCallback) => Promise + destroy: (callback?: BackendCallback) => Promise + claim: (callback?: BackendCallback) => Promise + download: (callback: BackendCallback) => Promise + + cstUpdate: (data: any, callback?: BackendCallback) => Promise + cstCreate: (data: any, callback?: BackendCallback) => Promise + cstDelete: (data: any, callback?: BackendCallback) => Promise } export const RSFormContext = createContext({ @@ -50,14 +52,16 @@ export const RSFormContext = createContext({ toggleForceAdmin: () => {}, toggleReadonly: () => {}, toggleTracking: () => {}, - reload: () => {}, - update: () => {}, - destroy: () => {}, - claim: () => {}, - download: () => {}, - cstUpdate: () => {}, - cstCreate: () => {}, + reload: async () => {}, + update: async () => {}, + destroy: async () => {}, + claim: async () => {}, + download: async () => {}, + + cstUpdate: async () => {}, + cstCreate: async () => {}, + cstDelete: async () => {}, }) interface RSFormStateProps { @@ -76,22 +80,26 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => { const isOwned = useMemo(() => user?.id === schema?.owner || false, [user, schema]); const isClaimable = useMemo(() => (user?.id !== schema?.owner || false), [user, schema]); - const isEditable = useMemo(() => { + const isEditable = useMemo( + () => { return ( - !readonly && + !loading && !readonly && (isOwned || (forceAdmin && user?.is_staff) || false) ) - }, [user, readonly, forceAdmin, isOwned]); + }, [user, readonly, forceAdmin, isOwned, loading]); - const isTracking = useMemo(() => { + const isTracking = useMemo( + () => { return true; }, []); - const toggleTracking = useCallback(() => { + const toggleTracking = useCallback( + () => { toast('not implemented yet'); }, []); - async function update(data: any, callback?: BackendCallback) { + const update = useCallback( + async (data: any, callback?: BackendCallback) => { setError(undefined); patchRSForm(schemaID, { data: data, @@ -100,9 +108,10 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => { onError: error => setError(error), onSucccess: callback }); - } + }, [schemaID, setError]); - async function destroy(callback: BackendCallback) { + const destroy = useCallback( + async (callback?: BackendCallback) => { setError(undefined); deleteRSForm(schemaID, { showError: true, @@ -110,9 +119,10 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => { onError: error => setError(error), onSucccess: callback }); - } + }, [schemaID, setError]); - async function claim(callback: BackendCallback) { + const claim = useCallback( + async (callback?: BackendCallback) => { setError(undefined); postClaimRSForm(schemaID, { showError: true, @@ -120,9 +130,10 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => { onError: error => setError(error), onSucccess: callback }); - } + }, [schemaID, setError]); - async function download(callback: BackendCallback) { + const download = useCallback( + async (callback: BackendCallback) => { setError(undefined); getTRSFile(schemaID, { showError: true, @@ -130,9 +141,10 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => { onError: error => setError(error), onSucccess: callback }); - } + }, [schemaID, setError]); - async function cstUpdate(data: any, callback?: BackendCallback) { + const cstUpdate = useCallback( + async (data: any, callback?: BackendCallback) => { setError(undefined); patchConstituenta(String(active!.entityUID), { data: data, @@ -141,9 +153,10 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => { onError: error => setError(error), onSucccess: callback }); - } + }, [active, setError]); - async function cstCreate(data: any, callback?: BackendCallback) { + const cstCreate = useCallback( + async (data: any, callback?: BackendCallback) => { setError(undefined); postNewConstituenta(schemaID, { data: data, @@ -152,7 +165,19 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => { onError: error => setError(error), onSucccess: callback }); - } + }, [schemaID, setError]); + + const cstDelete = useCallback( + async (data: any, callback?: BackendCallback) => { + setError(undefined); + postDeleteConstituenta(schemaID, { + data: data, + showError: true, + setLoading: setProcessing, + onError: error => setError(error), + onSucccess: callback + }); + }, [schemaID, setError]); return ( { isOwned, isEditable, isClaimable, isTracking, toggleTracking, reload, update, download, destroy, claim, - cstUpdate, cstCreate + cstUpdate, cstCreate, cstDelete, }}> { children } diff --git a/rsconcept/frontend/src/context/UsersContext.tsx b/rsconcept/frontend/src/context/UsersContext.tsx index 24a8ccdf..f67f5213 100644 --- a/rsconcept/frontend/src/context/UsersContext.tsx +++ b/rsconcept/frontend/src/context/UsersContext.tsx @@ -5,13 +5,13 @@ import { getActiveUsers } from '../utils/backendAPI' interface IUsersContext { users: IUserInfo[] - reload: () => void + reload: () => Promise getUserLabel: (userID?: number) => string } export const UsersContext = createContext({ users: [], - reload: () => {}, + reload: async () => {}, getUserLabel: () => '' }) diff --git a/rsconcept/frontend/src/hooks/useRSFormDetails.ts b/rsconcept/frontend/src/hooks/useRSFormDetails.ts index bcc44942..ad699cbf 100644 --- a/rsconcept/frontend/src/hooks/useRSFormDetails.ts +++ b/rsconcept/frontend/src/hooks/useRSFormDetails.ts @@ -8,7 +8,8 @@ export function useRSFormDetails({target}: {target?: string}) { const [loading, setLoading] = useState(false); const [error, setError] = useState(undefined); - const fetchData = useCallback(async () => { + const fetchData = useCallback( + async () => { setError(undefined); setSchema(undefined); if (!target) { @@ -20,17 +21,18 @@ export function useRSFormDetails({target}: {target?: string}) { onError: error => setError(error), onSucccess: (response) => { CalculateStats(response.data) + console.log(response.data); setSchema(response.data); } }); }, [target]); async function reload() { - fetchData() + fetchData(); } useEffect(() => { - fetchData() + fetchData(); }, [fetchData]) return { schema, reload, error, setError, loading }; diff --git a/rsconcept/frontend/src/pages/HomePage.tsx b/rsconcept/frontend/src/pages/HomePage.tsx index 32fde596..5f318855 100644 --- a/rsconcept/frontend/src/pages/HomePage.tsx +++ b/rsconcept/frontend/src/pages/HomePage.tsx @@ -1,4 +1,15 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + function HomePage() { + const navigate = useNavigate(); + const {user} = useAuth(); + if (user) { + navigate('/rsforms?filter=personal'); + } else { + navigate('/rsforms?filter=common'); + } + return (

Home page

diff --git a/rsconcept/frontend/src/pages/LoginPage.tsx b/rsconcept/frontend/src/pages/LoginPage.tsx index ce6004b9..4e0c629e 100644 --- a/rsconcept/frontend/src/pages/LoginPage.tsx +++ b/rsconcept/frontend/src/pages/LoginPage.tsx @@ -30,7 +30,8 @@ function LoginPage() { const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); if (!loading) { - login(username, password, () => { navigate('/rsforms?filter=personal'); }); + login(username, password) + .then(() => navigate('/rsforms?filter=personal')); } }; diff --git a/rsconcept/frontend/src/pages/NotFoundPage.tsx b/rsconcept/frontend/src/pages/NotFoundPage.tsx index 1784e7d7..71765938 100644 --- a/rsconcept/frontend/src/pages/NotFoundPage.tsx +++ b/rsconcept/frontend/src/pages/NotFoundPage.tsx @@ -2,7 +2,7 @@ export function NotFoundPage() { return (

Error 404 - Not Found

-

Данная страница не существует

+

Данная страница не существует или запрашиваемый объект отсутствует в базы данных

); } diff --git a/rsconcept/frontend/src/pages/RSFormPage/ConstituentEditor.tsx b/rsconcept/frontend/src/pages/RSFormPage/ConstituentEditor.tsx index d8ee6b2a..3231e792 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/ConstituentEditor.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/ConstituentEditor.tsx @@ -1,19 +1,26 @@ import { useCallback, useEffect, useState } from 'react'; import { useRSForm } from '../../context/RSFormContext'; -import { EditMode } from '../../utils/models'; +import { CstType, EditMode, INewCstData } from '../../utils/models'; import { toast } from 'react-toastify'; import TextArea from '../../components/Common/TextArea'; import ExpressionEditor from './ExpressionEditor'; import SubmitButton from '../../components/Common/SubmitButton'; -import { getCstTypeLabel } from '../../utils/staticUI'; +import { createAliasFor, getCstTypeLabel } from '../../utils/staticUI'; import ConstituentsSideList from './ConstituentsSideList'; -import { SaveIcon } from '../../components/Icons'; +import { DumpBinIcon, SaveIcon, SmallPlusIcon } from '../../components/Icons'; +import CreateCstModal from './CreateCstModal'; +import { AxiosResponse } from 'axios'; +import { useNavigate } from 'react-router-dom'; +import { RSFormTabsList } from './RSFormTabs'; function ConstituentEditor() { + const navigate = useNavigate(); const { - active, schema, setActive, processing, cstUpdate, isEditable, reload + active, schema, setActive, processing, isEditable, reload, + cstDelete, cstUpdate, cstCreate } = useRSForm(); + const [showCstModal, setShowCstModal] = useState(false); const [editMode, setEditMode] = useState(EditMode.TEXT); const [alias, setAlias] = useState(''); @@ -42,7 +49,8 @@ function ConstituentEditor() { } }, [active]); - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = + async (event: React.FormEvent) => { event.preventDefault(); if (!processing) { const data = { @@ -59,14 +67,51 @@ function ConstituentEditor() { 'forms': active?.term?.forms || [], } }; - cstUpdate(data, (response) => { - console.log(response); + cstUpdate(data) + .then(() => { toast.success('Изменения сохранены'); reload(); }); } }; + const handleDelete = useCallback( + async () => { + if (!active || !window.confirm('Вы уверены, что хотите удалить конституенту?')) { + return; + } + const data = { + 'items': [active.entityUID] + } + const index = schema?.items?.indexOf(active) + await cstDelete(data); + if (schema?.items && index && index + 1 < schema?.items?.length) { + setActive(schema?.items[index + 1]); + } + toast.success(`Конституента удалена: ${active.alias}`); + reload(); + }, [active, schema, setActive, cstDelete, reload]); + + const handleAddNew = useCallback( + async (csttype?: CstType) => { + if (!active || !schema) { + return; + } + if (!csttype) { + setShowCstModal(true); + } else { + const data: INewCstData = { + 'csttype': csttype, + 'alias': createAliasFor(csttype, schema!), + 'insert_after': active.entityUID + } + cstCreate(data, (response: AxiosResponse) => { + navigate(`/rsforms/${schema.id}?tab=${RSFormTabsList.CST_EDIT}&active=${response.data['entityUID']}`); + window.location.reload(); + }); + } + }, [active, schema, cstCreate, navigate]); + const handleRename = useCallback(() => { toast.info('Переименование в разработке'); }, []); @@ -78,8 +123,21 @@ function ConstituentEditor() { return (
+ setShowCstModal(!showCstModal)} + onCreate={handleAddNew} + defaultType={active?.cstType as CstType} + />
+
- + +