diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe9272..9b758fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), version --- +## [1.14.0] — 2026-04-21 + +### Added +- **Module-level RBAC scopes for Reviews, Contributions, Leads, Audit Log** — `SCOPE_DEFINITIONS` in `admin_api/views.py` extended with 13 new entries so the admin dashboard's Roles & Permissions grid and the new Base Permissions tab can grant/revoke access at module granularity: + - Reviews: `reviews.read`, `reviews.moderate`, `reviews.delete` + - Contributions: `contributions.read`, `contributions.approve`, `contributions.reject`, `contributions.award` + - Leads: `leads.read`, `leads.write`, `leads.assign`, `leads.convert` + - Audit Log: `audit.read`, `audit.export` +- **`NotificationSchedule` audit emissions** in `admin_api/views.py` — `NotificationScheduleListView.post` and `NotificationScheduleDetailView.patch` / `.delete` now write `notification.schedule.created` / `.updated` / `.deleted` `AuditLog` rows. Update emits only when at least one field actually changed. Delete captures `name`/`notification_type`/`cron_expression` before the row is deleted so the audit trail survives the deletion + +### Fixed +- **`StaffProfile.get_allowed_modules()`** in `admin_api/models.py` — `SCOPE_TO_MODULE` was missing the `'reviews': 'reviews'` entry, so staff granted `reviews.*` scopes could not see the Reviews module in their sidebar. Added + +--- + ## [1.13.0] — 2026-04-21 ### Added diff --git a/admin_api/models.py b/admin_api/models.py index bfa0f6d..be6a004 100644 --- a/admin_api/models.py +++ b/admin_api/models.py @@ -142,6 +142,7 @@ class StaffProfile(models.Model): 'contributions': 'contributions', 'leads': 'leads', 'audit': 'audit-log', + 'reviews': 'reviews', } modules = {'dashboard'} for scope in scopes: diff --git a/admin_api/views.py b/admin_api/views.py index 4192c45..055d860 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -1583,6 +1583,23 @@ SCOPE_DEFINITIONS = { 'ads.write': {'label': 'Create/Edit Campaigns', 'category': 'Ad Control'}, 'ads.approve': {'label': 'Approve Campaigns', 'category': 'Ad Control'}, 'ads.report': {'label': 'View Ad Reports', 'category': 'Ad Control'}, + # Reviews Module + 'reviews.read': {'label': 'View Reviews', 'category': 'Reviews'}, + 'reviews.moderate': {'label': 'Moderate Reviews', 'category': 'Reviews'}, + 'reviews.delete': {'label': 'Delete Reviews', 'category': 'Reviews'}, + # Contributions Module + 'contributions.read': {'label': 'View Contributions', 'category': 'Contributions'}, + 'contributions.approve': {'label': 'Approve Contributions', 'category': 'Contributions'}, + 'contributions.reject': {'label': 'Reject Contributions', 'category': 'Contributions'}, + 'contributions.award': {'label': 'Award EP Points', 'category': 'Contributions'}, + # Leads Module + 'leads.read': {'label': 'View Leads', 'category': 'Leads'}, + 'leads.write': {'label': 'Edit Lead Details', 'category': 'Leads'}, + 'leads.assign': {'label': 'Assign Leads', 'category': 'Leads'}, + 'leads.convert': {'label': 'Convert Leads', 'category': 'Leads'}, + # Audit Log Module + 'audit.read': {'label': 'View Audit Log', 'category': 'Audit Log'}, + 'audit.export': {'label': 'Export Audit Log CSV', 'category': 'Audit Log'}, } @@ -2951,6 +2968,19 @@ class NotificationScheduleListView(APIView): display_name=(r.get('displayName') or '').strip(), is_active=bool(r.get('isActive', True)), ) + _audit_log( + request, + 'notification.schedule.created', + 'NotificationSchedule', + schedule.pk, + details={ + 'name': schedule.name, + 'notification_type': schedule.notification_type, + 'cron_expression': schedule.cron_expression, + 'is_active': schedule.is_active, + 'recipient_count': len(seen_emails), + }, + ) log('info', f'Notification schedule #{schedule.pk} created: {schedule.name}', request=request, user=request.user) @@ -3000,6 +3030,20 @@ class NotificationScheduleDetailView(APIView): changed.append('is_active') s.save() + if changed: + _audit_log( + request, + 'notification.schedule.updated', + 'NotificationSchedule', + s.pk, + details={ + 'name': s.name, + 'changed_fields': changed, + 'cron_expression': s.cron_expression, + 'notification_type': s.notification_type, + 'is_active': s.is_active, + }, + ) log('info', f'Notification schedule #{pk} updated: {", ".join(changed) or "no-op"}', request=request, user=request.user) return Response(_serialize_schedule(s)) @@ -3009,7 +3053,21 @@ class NotificationScheduleDetailView(APIView): from django.shortcuts import get_object_or_404 from eventify_logger.services import log s = get_object_or_404(NotificationSchedule, pk=pk) + schedule_name = s.name + schedule_type = s.notification_type + schedule_cron = s.cron_expression s.delete() + _audit_log( + request, + 'notification.schedule.deleted', + 'NotificationSchedule', + pk, + details={ + 'name': schedule_name, + 'notification_type': schedule_type, + 'cron_expression': schedule_cron, + }, + ) log('info', f'Notification schedule #{pk} deleted', request=request, user=request.user) return Response(status=204) @@ -3034,6 +3092,19 @@ class NotificationRecipientView(APIView): display_name=(request.data.get('displayName') or '').strip(), is_active=bool(request.data.get('isActive', True)), ) + _audit_log( + request, + 'notification.recipient.added', + 'NotificationRecipient', + r.pk, + details={ + 'schedule_id': schedule.pk, + 'schedule_name': schedule.name, + 'email': r.email, + 'display_name': r.display_name, + 'is_active': r.is_active, + }, + ) return Response(_serialize_recipient(r), status=201) @@ -3045,6 +3116,7 @@ class NotificationRecipientDetailView(APIView): from django.shortcuts import get_object_or_404 r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk) + changed = [] if (email := request.data.get('email')) is not None: email = str(email).strip().lower() if not email: @@ -3054,22 +3126,57 @@ class NotificationRecipientDetailView(APIView): ).exclude(pk=rid).exists() if clash: return Response({'error': f'{email} is already a recipient'}, status=409) - r.email = email + if r.email != email: + r.email = email + changed.append('email') if (display_name := request.data.get('displayName')) is not None: - r.display_name = str(display_name).strip() + new_name = str(display_name).strip() + if r.display_name != new_name: + r.display_name = new_name + changed.append('display_name') if (is_active := request.data.get('isActive')) is not None: - r.is_active = bool(is_active) + new_active = bool(is_active) + if r.is_active != new_active: + r.is_active = new_active + changed.append('is_active') r.save() + if changed: + _audit_log( + request, + 'notification.recipient.updated', + 'NotificationRecipient', + r.pk, + details={ + 'schedule_id': pk, + 'email': r.email, + 'display_name': r.display_name, + 'is_active': r.is_active, + 'changed_fields': changed, + }, + ) return Response(_serialize_recipient(r)) def delete(self, request, pk, rid): from notifications.models import NotificationRecipient from django.shortcuts import get_object_or_404 r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk) + recipient_email = r.email + recipient_name = r.display_name r.delete() + _audit_log( + request, + 'notification.recipient.removed', + 'NotificationRecipient', + rid, + details={ + 'schedule_id': pk, + 'email': recipient_email, + 'display_name': recipient_name, + }, + ) return Response(status=204) @@ -3101,6 +3208,18 @@ class NotificationScheduleSendNowView(APIView): schedule.save(update_fields=[ 'last_run_at', 'last_status', 'last_error', 'updated_at', ]) + _audit_log( + request, + 'notification.schedule.dispatched', + 'NotificationSchedule', + schedule.pk, + details={ + 'schedule_name': schedule.name, + 'notification_type': schedule.notification_type, + 'recipient_count': recipient_count, + 'triggered_at': schedule.last_run_at.isoformat(), + }, + ) log('info', f'Send-now fired for schedule #{pk} → {recipient_count} recipient(s)', request=request, user=request.user) return Response({