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