diff --git a/rsconcept/backend/apps/rsform/serializers.py b/rsconcept/backend/apps/rsform/serializers.py index 9c3a62c7..ac5dc180 100644 --- a/rsconcept/backend/apps/rsform/serializers.py +++ b/rsconcept/backend/apps/rsform/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import RSForm +from .models import Constituenta, RSForm class FileSerializer(serializers.Serializer): @@ -16,3 +16,14 @@ class RSFormSerializer(serializers.ModelSerializer): model = RSForm fields = '__all__' read_only_fields = ('owner', 'id') + + +class ConstituentaSerializer(serializers.ModelSerializer): + class Meta: + model = Constituenta + fields = '__all__' + read_only_fields = ('id', 'order', 'alias', 'csttype') + + def update(self, instance: Constituenta, validated_data): + instance.schema.save() + return super().update(instance, validated_data) diff --git a/rsconcept/backend/apps/rsform/tests/t_views.py b/rsconcept/backend/apps/rsform/tests/t_views.py index 60cbe328..4279ee01 100644 --- a/rsconcept/backend/apps/rsform/tests/t_views.py +++ b/rsconcept/backend/apps/rsform/tests/t_views.py @@ -7,7 +7,7 @@ from rest_framework.test import APITestCase, APIRequestFactory, APIClient from rest_framework.exceptions import ErrorDetail from apps.users.models import User -from apps.rsform.models import Syntax, RSForm, CstType +from apps.rsform.models import Syntax, RSForm, Constituenta, CstType from apps.rsform.views import ( convert_to_ascii, convert_to_math, @@ -15,6 +15,53 @@ from apps.rsform.views import ( ) +class TestConstituentaAPI(APITestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = User.objects.create(username='UserTest') + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.rsform_owned: RSForm = RSForm.objects.create(title='Test', alias='T1', owner=self.user) + self.rsform_unowned: RSForm = RSForm.objects.create(title='Test2', alias='T2') + self.cst1 = Constituenta.objects.create( + alias='X1', schema=self.rsform_owned, order=1, convention='Test') + self.cst2 = Constituenta.objects.create( + alias='X2', schema=self.rsform_unowned, order=1, convention='Test1') + + def test_retrieve(self): + response = self.client.get(f'/api/constituents/{self.cst1.id}/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['alias'], self.cst1.alias) + self.assertEqual(response.data['convention'], self.cst1.convention) + + def test_partial_update(self): + data = json.dumps({'convention': 'tt'}) + response = self.client.patch(f'/api/constituents/{self.cst2.id}/', data, content_type='application/json') + self.assertEqual(response.status_code, 403) + + self.client.logout() + response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') + self.assertEqual(response.status_code, 403) + + self.client.force_authenticate(user=self.user) + response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.cst1.refresh_from_db() + self.assertEqual(response.data['convention'], 'tt') + self.assertEqual(self.cst1.convention, 'tt') + + response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') + self.assertEqual(response.status_code, 200) + + def test_readonly_cst_fields(self): + data = json.dumps({'alias': 'X33', 'order': 10}) + response = self.client.patch(f'/api/constituents/{self.cst1.id}/', data, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['alias'], 'X1') + self.assertEqual(response.data['alias'], self.cst1.alias) + self.assertEqual(response.data['order'], self.cst1.order) + + class TestRSFormViewset(APITestCase): def setUp(self): self.factory = APIRequestFactory() diff --git a/rsconcept/backend/apps/rsform/urls.py b/rsconcept/backend/apps/rsform/urls.py index 6408d3bd..adffd2b6 100644 --- a/rsconcept/backend/apps/rsform/urls.py +++ b/rsconcept/backend/apps/rsform/urls.py @@ -7,6 +7,7 @@ rsform_router = routers.SimpleRouter() rsform_router.register(r'rsforms', views.RSFormViewSet) urlpatterns = [ + path('constituents//', views.ConstituentAPIView.as_view()), path('rsforms/import-trs/', views.TrsImportView.as_view()), path('rsforms/create-detailed/', views.create_rsform), path('func/parse-expression/', views.parse_expression), diff --git a/rsconcept/backend/apps/rsform/utils.py b/rsconcept/backend/apps/rsform/utils.py index c98dda2c..94653f2d 100644 --- a/rsconcept/backend/apps/rsform/utils.py +++ b/rsconcept/backend/apps/rsform/utils.py @@ -11,6 +11,12 @@ class ObjectOwnerOrAdmin(BasePermission): return request.user == obj.owner or request.user.is_staff +class SchemaOwnerOrAdmin(BasePermission): + ''' Permission for object ownership restriction ''' + def has_object_permission(self, request, view, obj): + return request.user == obj.schema.owner or request.user.is_staff + + def read_trs(file) -> dict: ''' Read JSON from TRS file ''' # TODO: deal with different versions diff --git a/rsconcept/backend/apps/rsform/views.py b/rsconcept/backend/apps/rsform/views.py index 6a411ac9..5319a5cb 100644 --- a/rsconcept/backend/apps/rsform/views.py +++ b/rsconcept/backend/apps/rsform/views.py @@ -1,11 +1,10 @@ import json from django.http import HttpResponse from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import views, viewsets, filters, generics, permissions from rest_framework.decorators import action -from rest_framework import views, viewsets, filters from rest_framework.response import Response from rest_framework.decorators import api_view -from rest_framework import permissions import pyconcept from . import models @@ -13,6 +12,19 @@ from . import serializers from . import utils +class ConstituentAPIView(generics.RetrieveUpdateAPIView): + queryset = models.Constituenta.objects.all() + serializer_class = serializers.ConstituentaSerializer + + def get_permissions(self): + result = super().get_permissions() + if self.request.method.lower() == 'get': + result.append(permissions.AllowAny()) + else: + result.append(utils.SchemaOwnerOrAdmin()) + return result + + class RSFormViewSet(viewsets.ModelViewSet): queryset = models.RSForm.objects.all() serializer_class = serializers.RSFormSerializer diff --git a/rsconcept/frontend/package-lock.json b/rsconcept/frontend/package-lock.json index ecb207e2..4d5709a1 100644 --- a/rsconcept/frontend/package-lock.json +++ b/rsconcept/frontend/package-lock.json @@ -16,6 +16,7 @@ "@types/react": "^18.2.7", "@types/react-dom": "^18.2.4", "axios": "^1.4.0", + "js-file-download": "^0.4.12", "react": "^18.2.0", "react-data-table-component": "^7.5.3", "react-dom": "^18.2.0", @@ -11872,6 +11873,11 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-file-download": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", + "integrity": "sha512-rML+NkoD08p5Dllpjo0ffy4jRHeY6Zsapvr/W86N7E0yuzAO6qa5X9+xog6zQNlH102J7IXljNY2FtS6Lj3ucg==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/rsconcept/frontend/package.json b/rsconcept/frontend/package.json index 573f48c9..12b0ca5b 100644 --- a/rsconcept/frontend/package.json +++ b/rsconcept/frontend/package.json @@ -11,6 +11,7 @@ "@types/react": "^18.2.7", "@types/react-dom": "^18.2.4", "axios": "^1.4.0", + "js-file-download": "^0.4.12", "react": "^18.2.0", "react-data-table-component": "^7.5.3", "react-dom": "^18.2.0", diff --git a/rsconcept/frontend/src/backendAPI.ts b/rsconcept/frontend/src/backendAPI.ts index 5336adf6..a78d3695 100644 --- a/rsconcept/frontend/src/backendAPI.ts +++ b/rsconcept/frontend/src/backendAPI.ts @@ -109,6 +109,15 @@ export async function patchRSForm(target: string, request?: IFrontRequest) { }); } +export async function patchConstituenta(target: string, request?: IFrontRequest) { + AxiosPatch({ + title: `Constituenta id=${target}`, + endpoint: `${config.url.BASE}constituents/${target}/`, + request: request + }); +} + + export async function deleteRSForm(target: string, request?: IFrontRequest) { AxiosDelete({ title: `RSForm id=${target}`, diff --git a/rsconcept/frontend/src/components/Common/Card.tsx b/rsconcept/frontend/src/components/Common/Card.tsx index af6dcc9b..85b96201 100644 --- a/rsconcept/frontend/src/components/Common/Card.tsx +++ b/rsconcept/frontend/src/components/Common/Card.tsx @@ -4,9 +4,9 @@ interface CardProps { children: React.ReactNode } -function Card({title, widthClass='w-fit', children}: CardProps) { +function Card({title, widthClass='min-w-fit', children}: CardProps) { return ( -
+
{ title &&

{title}

} {children}
diff --git a/rsconcept/frontend/src/components/Common/Divider.tsx b/rsconcept/frontend/src/components/Common/Divider.tsx new file mode 100644 index 00000000..6f4e07eb --- /dev/null +++ b/rsconcept/frontend/src/components/Common/Divider.tsx @@ -0,0 +1,10 @@ +function Divider() { + return ( +
+ ); +} + +export default Divider; \ No newline at end of file diff --git a/rsconcept/frontend/src/components/Common/LabeledText.tsx b/rsconcept/frontend/src/components/Common/LabeledText.tsx new file mode 100644 index 00000000..9ab30a98 --- /dev/null +++ b/rsconcept/frontend/src/components/Common/LabeledText.tsx @@ -0,0 +1,27 @@ +interface LabeledTextProps { + id?: string + label: string + text: any + tooltip?: string +} + +function LabeledText({id, label, text, tooltip}: LabeledTextProps) { + return ( +
+ + + {text} + +
+ ); +} + +export default LabeledText; \ No newline at end of file diff --git a/rsconcept/frontend/src/context/RSFormContext.tsx b/rsconcept/frontend/src/context/RSFormContext.tsx index 4c6229d9..f33dfdf8 100644 --- a/rsconcept/frontend/src/context/RSFormContext.tsx +++ b/rsconcept/frontend/src/context/RSFormContext.tsx @@ -3,7 +3,7 @@ import { IConstituenta, IRSForm } from '../models'; import { useRSFormDetails } from '../hooks/useRSFormDetails'; import { ErrorInfo } from '../components/BackendError'; import { useAuth } from './AuthContext'; -import { BackendCallback, deleteRSForm, getTRSFile, patchRSForm, postClaimRSForm } from '../backendAPI'; +import { BackendCallback, deleteRSForm, getTRSFile, patchConstituenta, patchRSForm, postClaimRSForm } from '../backendAPI'; interface IRSFormContext { schema?: IRSForm @@ -20,6 +20,8 @@ interface IRSFormContext { destroy: (callback: BackendCallback) => void claim: (callback: BackendCallback) => void download: (callback: BackendCallback) => void + + cstUpdate: (data: any, callback: BackendCallback) => void } export const RSFormContext = createContext({ @@ -37,6 +39,8 @@ export const RSFormContext = createContext({ destroy: () => {}, claim: () => {}, download: () => {}, + + cstUpdate: () => {}, }) interface RSFormStateProps { @@ -94,11 +98,23 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => { }); } + async function cstUpdate(data: any, callback?: BackendCallback) { + setError(undefined); + patchConstituenta(String(active!.entityUID), { + data: data, + showError: true, + setLoading: setProcessing, + onError: error => setError(error), + onSucccess: callback + }); + } + return ( { children } diff --git a/rsconcept/frontend/src/hooks/useRSFormDetails.ts b/rsconcept/frontend/src/hooks/useRSFormDetails.ts index 7a7502f9..29a22e0f 100644 --- a/rsconcept/frontend/src/hooks/useRSFormDetails.ts +++ b/rsconcept/frontend/src/hooks/useRSFormDetails.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { IRSForm } from '../models' +import { CalculateStats, IRSForm } from '../models' import { ErrorInfo } from '../components/BackendError'; import { getRSFormDetails } from '../backendAPI'; @@ -18,7 +18,10 @@ export function useRSFormDetails({target}: {target?: string}) { showError: true, setLoading: setLoading, onError: error => setError(error), - onSucccess: response => setSchema(response.data) + onSucccess: (response) => { + CalculateStats(response.data) + setSchema(response.data); + } }); }, [target]); diff --git a/rsconcept/frontend/src/models.ts b/rsconcept/frontend/src/models.ts index d1028609..48db12c6 100644 --- a/rsconcept/frontend/src/models.ts +++ b/rsconcept/frontend/src/models.ts @@ -74,6 +74,7 @@ export interface IConstituenta { term?: { raw: string resolved?: string + forms?: string[] } definition?: { formal: string @@ -83,13 +84,32 @@ export interface IConstituenta { } } parse?: { - status: string + status: ParsingStatus valueClass: ValueClass typification: string syntaxTree: string } } +// RSForm stats +export interface IRSFormStats { + count_all: number + count_errors: number + count_property: number + count_incalc: number + + count_termin: number + + count_base: number + count_constant: number + count_structured: number + count_axiom: number + count_term: number + count_function: number + count_predicate: number + count_theorem: number +} + // RSForm data export interface IRSForm { id: number @@ -101,9 +121,10 @@ export interface IRSForm { time_update: string owner?: number items?: IConstituenta[] + stats?: IRSFormStats } -// RSForm data +// RSForm user input export interface IRSFormCreateData { title: string alias: string @@ -152,4 +173,93 @@ export function GetCstTypeLabel(type: CstType) { case CstType.PREDICATE: return 'Предикат-функция'; case CstType.THEOREM: return 'Теорема'; } +} + +export function CalculateStats(schema: IRSForm) { + if (!schema.items) { + schema.stats = { + count_all: 0, + count_errors: 0, + count_property: 0, + count_incalc: 0, + + count_termin: 0, + + count_base: 0, + count_constant: 0, + count_structured: 0, + count_axiom: 0, + count_term: 0, + count_function: 0, + count_predicate: 0, + count_theorem: 0, + } + return; + } + schema.stats = { + count_all: schema.items?.length || 0, + count_errors: schema.items?.reduce( + (sum, cst) => sum + + (cst.parse?.status === ParsingStatus.INCORRECT ? 1 : 0) || 0, + 0 + ), + count_property: schema.items?.reduce( + (sum, cst) => sum + + (cst.parse?.valueClass === ValueClass.PROPERTY ? 1 : 0) || 0, + 0 + ), + count_incalc: schema.items?.reduce( + (sum, cst) => sum + + ((cst.parse?.status === ParsingStatus.VERIFIED && + cst.parse?.valueClass === ValueClass.INVALID) ? 1 : 0) || 0, + 0 + ), + + count_termin: schema.items?.reduce( + (sum, cst) => (sum + + (cst.term?.raw ? 1 : 0) || 0), + 0 + ), + + count_base: schema.items?.reduce( + (sum, cst) => sum + + (cst.cstType === CstType.BASE ? 1 : 0), + 0 + ), + count_constant: schema.items?.reduce( + (sum, cst) => sum + + (cst.cstType === CstType.CONSTANT ? 1 : 0), + 0 + ), + count_structured: schema.items?.reduce( + (sum, cst) => sum + + (cst.cstType === CstType.STRUCTURED ? 1 : 0), + 0 + ), + count_axiom: schema.items?.reduce( + (sum, cst) => sum + + (cst.cstType === CstType.AXIOM ? 1 : 0), + 0 + ), + count_term: schema.items?.reduce( + (sum, cst) => sum + + (cst.cstType === CstType.TERM ? 1 : 0), + 0 + ), + count_function: schema.items?.reduce( + (sum, cst) => sum + + (cst.cstType === CstType.FUNCTION ? 1 : 0), + 0 + ), + count_predicate: schema.items?.reduce( + (sum, cst) => sum + + (cst.cstType === CstType.PREDICATE ? 1 : 0), + 0 + ), + count_theorem: schema.items?.reduce( + (sum, cst) => sum + + (cst.cstType === CstType.THEOREM ? 1 : 0), + 0 + ), + } } \ No newline at end of file diff --git a/rsconcept/frontend/src/pages/RSFormPage/ConstituentEditor.tsx b/rsconcept/frontend/src/pages/RSFormPage/ConstituentEditor.tsx index ad3c77cd..47f7e8d0 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/ConstituentEditor.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/ConstituentEditor.tsx @@ -8,7 +8,9 @@ import ExpressionEditor from './ExpressionEditor'; import SubmitButton from '../../components/Common/SubmitButton'; function ConstituentEditor() { - const { active, schema, setActive, isEditable } = useRSForm(); + const { + active, schema, setActive, processing, cstUpdate, isEditable, reload + } = useRSForm(); const [alias, setAlias] = useState(''); const [type, setType] = useState(''); @@ -27,6 +29,7 @@ function ConstituentEditor() { if (active) { setAlias(active.alias); setType(GetCstTypeLabel(active.cstType)); + setConvention(active.convention || ''); setTerm(active.term?.raw || ''); setTextDefinition(active.definition?.text?.raw || ''); setExpression(active.definition?.formal || ''); @@ -35,18 +38,27 @@ function ConstituentEditor() { const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); - // if (!processing) { - // const data = { - // 'title': title, - // 'alias': alias, - // 'comment': comment, - // 'is_common': common, - // }; - // upload(data, () => { - // toast.success('Изменения сохранены'); - // reload(); - // }); - // } + if (!processing) { + const data = { + 'alias': alias, + 'convention': convention, + 'definition_formal': expression, + 'definition_text': { + 'raw': textDefinition, + 'resolved': '', + }, + 'term': { + 'raw': term, + 'resolved': '', + 'forms': active?.term?.forms || [], + } + }; + cstUpdate(data, (response) => { + console.log(response); + toast.success('Изменения сохранены'); + reload(); + }); + } }; const handleRename = useCallback(() => { diff --git a/rsconcept/frontend/src/pages/RSFormPage/RSFormCard.tsx b/rsconcept/frontend/src/pages/RSFormPage/RSFormCard.tsx index 7bbf784a..b28b3c8d 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/RSFormCard.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/RSFormCard.tsx @@ -10,6 +10,7 @@ import { CrownIcon, DownloadIcon, DumpBinIcon, ShareIcon } from '../../component import { useUsers } from '../../context/UsersContext'; import { useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; +import fileDownload from 'js-file-download'; import { AxiosResponse } from 'axios'; function RSFormCard() { @@ -23,10 +24,6 @@ function RSFormCard() { const [comment, setComment] = useState(''); const [common, setCommon] = useState(false); - const fileRef = useRef(null); - const [fileURL, setFileUrl] = useState(); - const [fileName, setFileName] = useState(); - useEffect(() => { setTitle(schema!.title) setAlias(schema!.alias) @@ -69,15 +66,13 @@ function RSFormCard() { const handleDownload = useCallback(() => { download((response: AxiosResponse) => { try { - setFileName((schema?.alias || 'Schema') + '.trs') - setFileUrl(URL.createObjectURL(new Blob([response.data]))); - fileRef.current?.click(); - if (fileURL) URL.revokeObjectURL(fileURL); + const fileName = (schema?.alias || 'Schema') + '.trs'; + fileDownload(response.data, fileName); } catch (error: any) { toast.error(error.message); } }); - }, [download, schema?.alias, fileURL]); + }, [download, schema?.alias]); const handleShare = useCallback(() => { const url = window.location.href + '&share'; @@ -86,7 +81,7 @@ function RSFormCard() { }, []); return ( -
+ - -