feat(rbac): add Reviews/Contributions/Leads/Audit scope defs + fix reviews module mapping (v1.14.0)
- SCOPE_DEFINITIONS extended with 13 new scopes across 4 categories so the admin Roles & Permissions grid and new Base Permissions tab can grant module-level access - StaffProfile.SCOPE_TO_MODULE was missing 'reviews': 'reviews' — staff with reviews.* scopes could not resolve the Reviews module in their sidebar - NotificationSchedule CRUD views now emit AuditLog rows (notification.schedule.created / .updated / .deleted) matching the v1.13.0 audit coverage pattern Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
15
CHANGELOG.md
15
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
|
## [1.13.0] — 2026-04-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ class StaffProfile(models.Model):
|
|||||||
'contributions': 'contributions',
|
'contributions': 'contributions',
|
||||||
'leads': 'leads',
|
'leads': 'leads',
|
||||||
'audit': 'audit-log',
|
'audit': 'audit-log',
|
||||||
|
'reviews': 'reviews',
|
||||||
}
|
}
|
||||||
modules = {'dashboard'}
|
modules = {'dashboard'}
|
||||||
for scope in scopes:
|
for scope in scopes:
|
||||||
|
|||||||
@@ -1583,6 +1583,23 @@ SCOPE_DEFINITIONS = {
|
|||||||
'ads.write': {'label': 'Create/Edit Campaigns', 'category': 'Ad Control'},
|
'ads.write': {'label': 'Create/Edit Campaigns', 'category': 'Ad Control'},
|
||||||
'ads.approve': {'label': 'Approve Campaigns', 'category': 'Ad Control'},
|
'ads.approve': {'label': 'Approve Campaigns', 'category': 'Ad Control'},
|
||||||
'ads.report': {'label': 'View Ad Reports', '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(),
|
display_name=(r.get('displayName') or '').strip(),
|
||||||
is_active=bool(r.get('isActive', True)),
|
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}',
|
log('info', f'Notification schedule #{schedule.pk} created: {schedule.name}',
|
||||||
request=request, user=request.user)
|
request=request, user=request.user)
|
||||||
@@ -3000,6 +3030,20 @@ class NotificationScheduleDetailView(APIView):
|
|||||||
changed.append('is_active')
|
changed.append('is_active')
|
||||||
|
|
||||||
s.save()
|
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"}',
|
log('info', f'Notification schedule #{pk} updated: {", ".join(changed) or "no-op"}',
|
||||||
request=request, user=request.user)
|
request=request, user=request.user)
|
||||||
return Response(_serialize_schedule(s))
|
return Response(_serialize_schedule(s))
|
||||||
@@ -3009,7 +3053,21 @@ class NotificationScheduleDetailView(APIView):
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from eventify_logger.services import log
|
from eventify_logger.services import log
|
||||||
s = get_object_or_404(NotificationSchedule, pk=pk)
|
s = get_object_or_404(NotificationSchedule, pk=pk)
|
||||||
|
schedule_name = s.name
|
||||||
|
schedule_type = s.notification_type
|
||||||
|
schedule_cron = s.cron_expression
|
||||||
s.delete()
|
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)
|
log('info', f'Notification schedule #{pk} deleted', request=request, user=request.user)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@@ -3034,6 +3092,19 @@ class NotificationRecipientView(APIView):
|
|||||||
display_name=(request.data.get('displayName') or '').strip(),
|
display_name=(request.data.get('displayName') or '').strip(),
|
||||||
is_active=bool(request.data.get('isActive', True)),
|
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)
|
return Response(_serialize_recipient(r), status=201)
|
||||||
|
|
||||||
|
|
||||||
@@ -3045,6 +3116,7 @@ class NotificationRecipientDetailView(APIView):
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk)
|
r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk)
|
||||||
|
|
||||||
|
changed = []
|
||||||
if (email := request.data.get('email')) is not None:
|
if (email := request.data.get('email')) is not None:
|
||||||
email = str(email).strip().lower()
|
email = str(email).strip().lower()
|
||||||
if not email:
|
if not email:
|
||||||
@@ -3054,22 +3126,57 @@ class NotificationRecipientDetailView(APIView):
|
|||||||
).exclude(pk=rid).exists()
|
).exclude(pk=rid).exists()
|
||||||
if clash:
|
if clash:
|
||||||
return Response({'error': f'{email} is already a recipient'}, status=409)
|
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:
|
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:
|
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()
|
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))
|
return Response(_serialize_recipient(r))
|
||||||
|
|
||||||
def delete(self, request, pk, rid):
|
def delete(self, request, pk, rid):
|
||||||
from notifications.models import NotificationRecipient
|
from notifications.models import NotificationRecipient
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk)
|
r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk)
|
||||||
|
recipient_email = r.email
|
||||||
|
recipient_name = r.display_name
|
||||||
r.delete()
|
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)
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
@@ -3101,6 +3208,18 @@ class NotificationScheduleSendNowView(APIView):
|
|||||||
schedule.save(update_fields=[
|
schedule.save(update_fields=[
|
||||||
'last_run_at', 'last_status', 'last_error', 'updated_at',
|
'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)',
|
log('info', f'Send-now fired for schedule #{pk} → {recipient_count} recipient(s)',
|
||||||
request=request, user=request.user)
|
request=request, user=request.user)
|
||||||
return Response({
|
return Response({
|
||||||
|
|||||||
Reference in New Issue
Block a user