diff --git a/backend/composer/api/permissions.py b/backend/composer/api/permissions.py index b74891a1..0087660b 100644 --- a/backend/composer/api/permissions.py +++ b/backend/composer/api/permissions.py @@ -4,11 +4,12 @@ from composer.models import ConnectivityStatement, Sentence - # Permission Checks: Only staff users can update a Connectivity Statement when it is in state exported class IsStaffUserIfExportedStateInConnectivityStatement(permissions.BasePermission): def has_object_permission(self, request, view, obj): - if (request.method not in permissions.SAFE_METHODS) and (obj.state == CSState.EXPORTED): + if (request.method not in permissions.SAFE_METHODS) and ( + obj.state == CSState.EXPORTED + ): return request.user.is_staff return True @@ -26,7 +27,7 @@ def has_permission(self, request, view): return True # If creating a new instance, ensure related entity ownership - if request.method == 'POST' and view.action == 'create': + if request.method == "POST" and view.action == "create": return check_related_entity_ownership(request) # For unsafe methods (PATCH, PUT, DELETE), allow only authenticated users @@ -40,16 +41,31 @@ def has_object_permission(self, request, view, obj): return True # Allow 'assign_owner' action to any authenticated user - if view.action == 'assign_owner': + if view.action == "assign_owner": return request.user.is_authenticated # Write and delete permissions (PATCH, PUT, DELETE) are only allowed to the owner return obj.owner == request.user + class IsOwnerOfConnectivityStatementOrReadOnly(permissions.BasePermission): """ Custom permission to allow only the owner of the related ConnectivityStatement to modify. """ + + def has_permission(self, request, view): + # Allow safe methods (GET, HEAD, OPTIONS) for all users + if request.method in permissions.SAFE_METHODS: + return True + + # If creating a new instance, ensure related entity ownership + if request.method == "POST" and view.action == "create": + return check_related_entity_ownership(request) + + # For unsafe methods (PATCH, PUT, DELETE), allow only authenticated users + # Object-level permissions (e.g., ownership) are handled by has_object_permission + return request.user.is_authenticated + def has_object_permission(self, request, view, obj): # Read permissions are allowed to any request if request.method in permissions.SAFE_METHODS: @@ -57,7 +73,6 @@ def has_object_permission(self, request, view, obj): # Write permissions are only allowed to the owner of the related ConnectivityStatement return obj.connectivity_statement.owner == request.user - def check_related_entity_ownership(request): @@ -65,8 +80,8 @@ def check_related_entity_ownership(request): Helper method to check ownership of sentence or connectivity statement. Raises PermissionDenied if the user is not the owner. """ - sentence_id = request.data.get('sentence_id') - connectivity_statement_id = request.data.get('connectivity_statement_id') + sentence_id = request.data.get("sentence_id") + connectivity_statement_id = request.data.get("connectivity_statement_id") # Check ownership for sentence_id if sentence_id: @@ -80,10 +95,12 @@ def check_related_entity_ownership(request): # Check ownership for connectivity_statement_id if connectivity_statement_id: try: - connectivity_statement = ConnectivityStatement.objects.get(id=connectivity_statement_id) + connectivity_statement = ConnectivityStatement.objects.get( + id=connectivity_statement_id + ) except ConnectivityStatement.DoesNotExist: raise PermissionDenied() if connectivity_statement.owner != request.user: raise PermissionDenied() - - return True \ No newline at end of file + + return True diff --git a/backend/composer/api/serializers.py b/backend/composer/api/serializers.py index b5d7aefe..c372844e 100644 --- a/backend/composer/api/serializers.py +++ b/backend/composer/api/serializers.py @@ -517,7 +517,7 @@ class Meta: class StatementAlertSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) - connectivity_statement = serializers.PrimaryKeyRelatedField( + connectivity_statement_id = serializers.PrimaryKeyRelatedField( queryset=ConnectivityStatement.objects.all(), required=True ) alert_type = serializers.PrimaryKeyRelatedField( @@ -533,7 +533,7 @@ class Meta: "saved_by", "created_at", "updated_at", - "connectivity_statement", + "connectivity_statement_id", ) read_only_fields = ("created_at", "updated_at", "saved_by") validators = [] @@ -542,24 +542,24 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # If 'connectivity_statement' is provided in context, make it not required - if 'connectivity_statement' in self.context: - self.fields['connectivity_statement'].required = False + if 'connectivity_statement_id' in self.context: + self.fields['connectivity_statement_id'].required = False # If updating an instance, set 'alert_type' and 'connectivity_statement' as read-only if self.instance: self.fields['alert_type'].read_only = True - self.fields['connectivity_statement'].read_only = True + self.fields['connectivity_statement_id'].read_only = True def validate(self, data): # Get 'connectivity_statement' from context or instance - connectivity_statement = self.context.get('connectivity_statement') or data.get('connectivity_statement') + connectivity_statement = self.context.get('connectivity_statement_id') or data.get('connectivity_statement_id') if not connectivity_statement and self.instance: connectivity_statement = self.instance.connectivity_statement if not connectivity_statement: raise serializers.ValidationError({ - 'connectivity_statement': 'This field is required.' + 'connectivity_statement_id': 'This field is required.' }) - data['connectivity_statement'] = connectivity_statement + data['connectivity_statement_id'] = connectivity_statement.id # Get 'alert_type' from data or instance alert_type = data.get('alert_type') or getattr(self.instance, 'alert_type', None) @@ -850,13 +850,13 @@ def _update_statement_alerts(self, instance, alerts_data): alert_instance = existing_alerts[alert_id] # Remove 'alert_type' and 'connectivity_statement' from alert_data alert_data.pop('alert_type', None) - alert_data.pop('connectivity_statement', None) + alert_data.pop('connectivity_statement_id', None) serializer = StatementAlertSerializer( alert_instance, data=alert_data, context={ "request": self.context.get("request"), - "connectivity_statement": instance, # Pass the parent instance + "connectivity_statement_id": instance.id, # Pass the parent instance }, ) serializer.is_valid(raise_exception=True) @@ -867,7 +867,7 @@ def _update_statement_alerts(self, instance, alerts_data): data=alert_data, context={ "request": self.context.get("request"), - "connectivity_statement": instance, # Pass the parent instance + "connectivity_statement_id": instance.id, # Pass the parent instance }, ) serializer.is_valid(raise_exception=True) diff --git a/backend/composer/api/views.py b/backend/composer/api/views.py index 6fa67923..b383ebe8 100644 --- a/backend/composer/api/views.py +++ b/backend/composer/api/views.py @@ -338,6 +338,7 @@ class AlertTypeViewSet(viewsets.ReadOnlyModelViewSet): queryset = AlertType.objects.all() serializer_class = AlertTypeSerializer + class ConnectivityStatementViewSet( ProvenanceMixin, SpecieMixin, @@ -456,13 +457,18 @@ class SentenceViewSet( def get_queryset(self): if "ordering" not in self.request.query_params: - return super().get_queryset().annotate( - is_current_user=Case( - When(owner=self.request.user, then=Value(1)), - default=Value(0), - output_field=IntegerField(), + return ( + super() + .get_queryset() + .annotate( + is_current_user=Case( + When(owner=self.request.user, then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ) ) - ).order_by("-is_current_user", "-modified_date") + .order_by("-is_current_user", "-modified_date") + ) return super().get_queryset() @@ -531,21 +537,16 @@ class DestinationViewSet(viewsets.ModelViewSet): permission_classes = [IsOwnerOfConnectivityStatementOrReadOnly] filterset_class = DestinationFilter + class StatementAlertViewSet(viewsets.ModelViewSet): """ StatementAlert """ + queryset = StatementAlert.objects.all() serializer_class = StatementAlertSerializer permission_classes = [IsOwnerOfConnectivityStatementOrReadOnly] - def create(self, request, *args, **kwargs): - try: - return super().create(request, *args, **kwargs) - except Exception as e: - raise - - @extend_schema( responses=OpenApiTypes.OBJECT, ) @@ -560,7 +561,7 @@ def jsonschemas(request): ProvenanceSerializer, SpecieSerializer, NoteSerializer, - StatementAlertSerializer + StatementAlertSerializer, ] schema = {} diff --git a/frontend/src/apiclient/backend/api.ts b/frontend/src/apiclient/backend/api.ts index 3cc429f6..156e4de0 100644 --- a/frontend/src/apiclient/backend/api.ts +++ b/frontend/src/apiclient/backend/api.ts @@ -2094,7 +2094,7 @@ export interface PatchedStatementAlert { * @type {number} * @memberof PatchedStatementAlert */ - 'connectivity_statement'?: number; + 'connectivity_statement_id'?: number; } /** * Via @@ -2610,7 +2610,7 @@ export interface StatementAlert { * @type {number} * @memberof StatementAlert */ - 'connectivity_statement': number; + 'connectivity_statement_id': number; } /** * Note Tag diff --git a/frontend/src/components/DistillationTab/StatementAlertsAccordion.tsx b/frontend/src/components/DistillationTab/StatementAlertsAccordion.tsx index 397ebf6c..f42865a0 100644 --- a/frontend/src/components/DistillationTab/StatementAlertsAccordion.tsx +++ b/frontend/src/components/DistillationTab/StatementAlertsAccordion.tsx @@ -11,7 +11,7 @@ import StatementForm from "../Forms/StatementForm"; import connectivityStatementService from "../../services/StatementService"; import Stack from "@mui/material/Stack"; import IconButton from "@mui/material/IconButton"; -import { DeleteOutlined} from "@mui/icons-material"; +import { DeleteOutlined } from "@mui/icons-material"; import statementService from "../../services/StatementService"; import Select from "@mui/material/Select"; import AlertMenuItem from "./AlertMenuItem"; @@ -59,46 +59,61 @@ const StatementAlertsAccordion = (props: any) => { const addAlert = (typeId: number) => { if (!activeTypes.includes(typeId)) { - const newAlert = { connectivity_statement: parseInt(statement.id), alert_type: typeId, text: "" } + const newAlert = { connectivity_statement_id: parseInt(statement.id), alert_type: typeId, text: "" }; + let isCancelled = false; - connectivityStatementService.createAlert(newAlert).then((res: any) => { - currentAlertRef.current = res; - - const updatedAlerts = [ - ...(statement.statement_alerts || []), - res, - ]; - const updatedStatement = { ...statement, statement_alerts: updatedAlerts }; - setActiveTypes([...activeTypes, typeId]); - setStatement(updatedStatement); - const newIndex = updatedAlerts.length - 1; - setOpenFormIndex(newIndex); - setTimeout(() => { - const textArea = document.querySelectorAll(`#root_statement_alerts_0_text`); - if (textArea) { - (textArea[newIndex] as HTMLTextAreaElement).focus(); - } - }, 500); + connectivityStatementService.createAlert(newAlert, () => { + isCancelled = true; }) + .then((res: any) => { + if (isCancelled) return; + + currentAlertRef.current = res; + console.log(res) + const updatedAlerts = [ + ...(statement.statement_alerts || []), + res, + ]; + const updatedStatement = { ...statement, statement_alerts: updatedAlerts }; + setActiveTypes([...activeTypes, typeId]); + setStatement(updatedStatement); + const newIndex = updatedAlerts.length - 1; + setOpenFormIndex(newIndex); + setTimeout(() => { + const textArea = document.querySelectorAll(`#root_statement_alerts_0_text`); + if (textArea) { + (textArea[newIndex] as HTMLTextAreaElement).focus(); + } + }, 500); + }) } }; - - const confirmDelete = async () => { if (alertToDelete === null) return; - + + let isCancelled = false; + try { - await statementService.destroyAlert(alertToDelete).then(() => { - refreshStatement(); - }) + await statementService.destroyAlert(alertToDelete, parseInt(statement.id),() => { + isCancelled = true; + }).then(() => { + if (isCancelled) return; + + refreshStatement(); + }); } catch (error) { - alert(`Error deleting alert: ${error}`); + if (!isCancelled) { + alert(`Error deleting alert: ${error}`); + } + } finally { + if (!isCancelled) { + setOpenFormIndex(null); + setOpenDialog(false); + setAlertToDelete(null); + } } - - setOpenFormIndex(null); - setOpenDialog(false); - setAlertToDelete(null); }; + const handleDelete = async (index: number) => { setAlertToDelete(index); setOpenDialog(true); @@ -122,10 +137,16 @@ const StatementAlertsAccordion = (props: any) => { const onInputBlur = async (value: string) => { const alert = currentAlertRef.current; + if (!alert) return; + const updatedAlert = { ...alert, text: value }; - await connectivityStatementService.updateAlert(alert.id, updatedAlert).then(() => refreshStatement()) - } - + await connectivityStatementService.updateAlert(alert.id, updatedAlert, () => { + return + }).then(() => { + refreshStatement(); + }); + }; + return ( { flexDirection: "column", gap: '0.75rem' }}> - + } {statement.statement_alerts?.map((alert: any, index: number) => ( { }} > toggleFormVisibility(index)} elevation={0} @@ -261,7 +283,10 @@ const StatementAlertsAccordion = (props: any) => { expandIcon={} aria-controls="panel1bh-content" className="panel1bh-header" - sx={{ p: 0, display: "flex", flexDirection: "row-reverse", m: 0 }} + sx={{ p: 0, display: "flex", flexDirection: "row-reverse", m: 0, '&.Mui-disabled':{ + opacity: '1 !important', + } + }} > {alerts.find((type: any) => type.id === alert.alert_type)?.name || "Unknown"} @@ -305,13 +330,13 @@ const StatementAlertsAccordion = (props: any) => { onInputBlur={onInputBlur} /> handleDelete(alert.id)} - disabled={alert?.text?.trim() !== ''} + disabled={alert?.text?.trim() !== '' || isDisabled} > - {openFormIndex !== index && ( + {(openFormIndex !== index) && ( { handleDelete(alert.id)} - disabled={alert?.text?.trim() !== ''} + disabled={alert?.text?.trim() !== '' || isDisabled} > diff --git a/frontend/src/components/Forms/StatementForm.tsx b/frontend/src/components/Forms/StatementForm.tsx index 10c5dd73..debd2940 100644 --- a/frontend/src/components/Forms/StatementForm.tsx +++ b/frontend/src/components/Forms/StatementForm.tsx @@ -53,7 +53,7 @@ const StatementForm = (props: any) => { copiedSchema.title = ""; copiedSchema.properties.destinations.title = ""; copiedSchema.properties.statement_alerts.items.properties.alert_type.type = "number"; - copiedSchema.properties.statement_alerts.items.properties.connectivity_statement.type = "number"; + copiedSchema.properties.statement_alerts.items.properties.connectivity_statement_id.type = "number"; copiedSchema.properties.forward_connection.type = ["string", "null"]; copiedUISchema["ui:order"] = ["destination_type", "*"]; @@ -84,7 +84,7 @@ const StatementForm = (props: any) => { onBlur: (value: string) => onInputBlur(value), }, }, - connectivity_statement: { + connectivity_statement_id: { "ui:widget": "hidden", } }, diff --git a/frontend/src/components/SentenceStatementWithDois.tsx b/frontend/src/components/SentenceStatementWithDois.tsx index 819ee3a7..96e79f70 100644 --- a/frontend/src/components/SentenceStatementWithDois.tsx +++ b/frontend/src/components/SentenceStatementWithDois.tsx @@ -8,7 +8,7 @@ import StatementWithProvenances from "./StatementWithProvenances"; import { useSectionStyle } from "../styles/styles"; import { useTheme } from "@mui/system"; -const SentenceStatementWithDois = ({ statement } : any) => { +const SentenceStatementWithDois = ({ statement, refreshStatement } : any) => { const theme = useTheme() const sectionStyle = useSectionStyle(theme) @@ -37,7 +37,7 @@ const SentenceStatementWithDois = ({ statement } : any) => { spacing={{ xs: 1, sm: 2 }} alignItems='center' > - + openStatement(row)} diff --git a/frontend/src/components/StatementWithProvenances.tsx b/frontend/src/components/StatementWithProvenances.tsx index 2fb8390d..0b0a534f 100644 --- a/frontend/src/components/StatementWithProvenances.tsx +++ b/frontend/src/components/StatementWithProvenances.tsx @@ -20,7 +20,7 @@ const StatementWithProvenances = ({ statement, background = "#fff", refreshState { return onSave(fetchedData, userId) - .then(() => resolve(ChangeRequestStatus.SAVED)) + .then((res) => resolve(res)) .catch((error) => reject(error)); }) .catch((error) => { @@ -36,7 +36,7 @@ export const checkOwnership = ( } } else { onSave(fetchedData, userId) - .then(() => resolve(ChangeRequestStatus.SAVED)) + .then((res) => resolve(res)) .catch((error) => reject(error)); } }) diff --git a/frontend/src/services/StatementService.ts b/frontend/src/services/StatementService.ts index a88cd159..89dd36a4 100644 --- a/frontend/src/services/StatementService.ts +++ b/frontend/src/services/StatementService.ts @@ -77,7 +77,7 @@ class ConnectivityStatementService extends AbstractService { return checkOwnership( id, // Retry the partial update after ownership is reassigned, including new owner ID - async (userId: number) => { + async () => { const updatedPatchedStatement = { ...patchedConnectivityStatementUpdate, }; @@ -235,15 +235,57 @@ class ConnectivityStatementService extends AbstractService { return composerApi.composerAlertList(undefined).then((res: any) => res.data); } - async createAlert(statementAlert: any) { - return composerApi.composerStatementAlertCreate(statementAlert).then((res: any) => res.data); + async createAlert(statementAlert: any, onCancel: () => void) { + try { + return await composerApi.composerStatementAlertCreate(statementAlert).then((res: any) => res.data); + } catch (err) { + return await checkOwnership( + statementAlert.connectivity_statement_id, + async () => { + return await composerApi.composerStatementAlertCreate(statementAlert).then((res: any) => res.data); + }, + () => { + onCancel() + }, + (owner) => + `This statement is currently assigned to ${owner.first_name}. You are in read-only mode. Would you like to assign this statement to yourself and gain edit access?` + ); + } } - async destroyAlert(id: number) { - return composerApi.composerStatementAlertDestroy(id).then((response: any) => response.data); + async destroyAlert(id: number, connectivity_statement_id: number, onCancel: () => void) { + try { + return await composerApi.composerStatementAlertDestroy(id).then((response: any) => response.data); + } catch (err) { + return await checkOwnership( + connectivity_statement_id, + async () => { + return await composerApi.composerStatementAlertDestroy(id).then((response: any) => response.data); + }, + () => { + onCancel() + }, + (owner) => + `This statement is currently assigned to ${owner.first_name}. You are in read-only mode. Would you like to assign this statement to yourself and gain edit access?` + ); + } } - async updateAlert(id: number, statementAlert: any) { - return composerApi.composerStatementAlertUpdate(id, statementAlert).then((response: any) => response.data); + async updateAlert(id: number, statementAlert: any, onCancel: () => void) { + try { + return await composerApi.composerStatementAlertUpdate(id, statementAlert).then((response: any) => response.data); + } catch (err) { + return await checkOwnership( + statementAlert.connectivity_statement_id, + async () => { + return await composerApi.composerStatementAlertUpdate(id, statementAlert).then((response: any) => response.data); + }, + () => { + onCancel() + }, + (owner) => + `This statement is currently assigned to ${owner.first_name}. You are in read-only mode. Would you like to assign this statement to yourself and gain edit access?` + ); + } } async assignOwner(id: number, patchedConnectivityStatement?: PatchedConnectivityStatement): Promise { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 7c9d0314..c9792280 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -3481,7 +3481,7 @@ components: type: string format: date-time readOnly: true - connectivity_statement: + connectivity_statement_id: type: integer PatchedVia: type: object @@ -3810,11 +3810,11 @@ components: type: string format: date-time readOnly: true - connectivity_statement: + connectivity_statement_id: type: integer required: - alert_type - - connectivity_statement + - connectivity_statement_id - created_at - saved_by - updated_at