Compare commits
2 Commits
2c60a82704
...
4a9f754fda
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a9f754fda | |||
| 66e41ba647 |
40
CHANGELOG.md
40
CHANGELOG.md
@@ -5,6 +5,46 @@ 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
|
||||||
|
- **Full admin interaction audit coverage** — `_audit_log()` calls added to 12 views; every meaningful admin state change now writes an `AuditLog` row:
|
||||||
|
|
||||||
|
| View | Action slug(s) | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `AdminLoginView` | `auth.admin_login`, `auth.admin_login_failed` | Uses new `user=` kwarg (anonymous at login time) |
|
||||||
|
| `PartnerStatusView` | `partner.status_changed` | Wrapped in `transaction.atomic()` |
|
||||||
|
| `PartnerOnboardView` | `partner.onboarded` | Inside existing `transaction.atomic()` block |
|
||||||
|
| `PartnerStaffCreateView` | `partner.staff.created` | Logged after `staff_user.save()` |
|
||||||
|
| `EventCreateView` | `event.created` | title, partner_id, source in details |
|
||||||
|
| `EventUpdateView` | `event.updated` | changed_fields list in details, wrapped in `transaction.atomic()` |
|
||||||
|
| `EventDeleteView` | `event.deleted` | title + partner_id captured BEFORE delete, wrapped in `transaction.atomic()` |
|
||||||
|
| `SettlementReleaseView` | `settlement.released` | prev/new status in details, `transaction.atomic()` |
|
||||||
|
| `ReviewDeleteView` | `review.deleted` | reviewer_user_id + event_id + rating captured BEFORE delete |
|
||||||
|
| `PaymentGatewaySettingsView` | `gateway.created`, `gateway.updated`, `gateway.deleted` | changed_fields on update |
|
||||||
|
| `EventPrimaryImageView` | `event.primary_image_changed` | prev + new primary image id in details |
|
||||||
|
| `LeadUpdateView` | `lead.updated` | changed_fields list; only emits if any field was changed |
|
||||||
|
|
||||||
|
- **`_audit_log` helper** — optional `user=None` kwarg so `AdminLoginView` can supply the authenticated user explicitly (request.user is still anonymous at that point in the login flow). All 20+ existing callers are unaffected (no kwarg = falls through to `request.user`).
|
||||||
|
- **`admin_api/tests.py`** — `AuthAuditEmissionTests` (login success + failed login) and `EventCrudAuditTests` (create/update/delete) bring total test count to 16, all green
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.12.0] — 2026-04-21
|
## [1.12.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:
|
||||||
|
|||||||
@@ -229,3 +229,97 @@ class UserStatusAuditEmissionTests(_AuditTestBase):
|
|||||||
).exists(),
|
).exists(),
|
||||||
'reinstate did not emit audit log',
|
'reinstate did not emit audit log',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AdminLoginView — audit emission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AuthAuditEmissionTests(_AuditTestBase):
|
||||||
|
"""Successful and failed logins must leave matching rows in AuditLog."""
|
||||||
|
|
||||||
|
url = '/api/v1/admin/auth/login/'
|
||||||
|
|
||||||
|
def test_successful_login_emits_audit_row(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data={'username': self.admin.username, 'password': 'irrelevant'},
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
log = AuditLog.objects.filter(
|
||||||
|
action='auth.admin_login', target_id=str(self.admin.id),
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(log, 'successful login did not emit audit log')
|
||||||
|
self.assertEqual(log.details.get('username'), self.admin.username)
|
||||||
|
|
||||||
|
def test_failed_login_emits_audit_row(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data={'username': self.admin.username, 'password': 'wrong-password'},
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 401, resp.content)
|
||||||
|
self.assertTrue(
|
||||||
|
AuditLog.objects.filter(action='auth.admin_login_failed').exists(),
|
||||||
|
'failed login did not emit audit log',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EventCreateView / EventUpdateView / EventDeleteView — audit emission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class EventCrudAuditTests(_AuditTestBase):
|
||||||
|
"""Event CRUD operations must emit matching audit rows."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
from events.models import EventType
|
||||||
|
self.event_type = EventType.objects.create(event_type='Test Category')
|
||||||
|
|
||||||
|
def _create_event_id(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
'/api/v1/events/create/',
|
||||||
|
data={'title': 'Test Event', 'eventType': self.event_type.id},
|
||||||
|
content_type='application/json',
|
||||||
|
**self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 201, resp.content)
|
||||||
|
return resp.json()['id']
|
||||||
|
|
||||||
|
def test_create_event_emits_audit_row(self):
|
||||||
|
event_id = self._create_event_id()
|
||||||
|
self.assertTrue(
|
||||||
|
AuditLog.objects.filter(action='event.created', target_id=str(event_id)).exists(),
|
||||||
|
'event create did not emit audit log',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_event_emits_audit_row(self):
|
||||||
|
event_id = self._create_event_id()
|
||||||
|
AuditLog.objects.all().delete()
|
||||||
|
resp = self.client.patch(
|
||||||
|
f'/api/v1/events/{event_id}/update/',
|
||||||
|
data={'title': 'Updated Title'},
|
||||||
|
content_type='application/json',
|
||||||
|
**self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
log = AuditLog.objects.filter(action='event.updated', target_id=str(event_id)).first()
|
||||||
|
self.assertIsNotNone(log, 'event update did not emit audit log')
|
||||||
|
self.assertIn('title', log.details.get('changed_fields', []))
|
||||||
|
|
||||||
|
def test_delete_event_emits_audit_row(self):
|
||||||
|
event_id = self._create_event_id()
|
||||||
|
AuditLog.objects.all().delete()
|
||||||
|
resp = self.client.delete(
|
||||||
|
f'/api/v1/events/{event_id}/delete/',
|
||||||
|
**self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 204)
|
||||||
|
self.assertTrue(
|
||||||
|
AuditLog.objects.filter(action='event.deleted', target_id=str(event_id)).exists(),
|
||||||
|
'event delete did not emit audit log',
|
||||||
|
)
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ class AdminLoginView(APIView):
|
|||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
if not user:
|
if not user:
|
||||||
|
_audit_log(request, 'auth.admin_login_failed', 'auth', 'failed',
|
||||||
|
{'identifier': identifier, 'reason': 'invalid_credentials'}, user=None)
|
||||||
return Response({'error': 'Invalid credentials'}, status=status.HTTP_401_UNAUTHORIZED)
|
return Response({'error': 'Invalid credentials'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
|
_audit_log(request, 'auth.admin_login_failed', 'auth', str(user.id),
|
||||||
|
{'identifier': identifier, 'reason': 'account_disabled'}, user=user)
|
||||||
return Response({'error': 'Account is disabled'}, status=status.HTTP_403_FORBIDDEN)
|
return Response({'error': 'Account is disabled'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
refresh = RefreshToken.for_user(user)
|
refresh = RefreshToken.for_user(user)
|
||||||
user_data = UserSerializer(user).data
|
user_data = UserSerializer(user).data
|
||||||
@@ -44,6 +48,8 @@ class AdminLoginView(APIView):
|
|||||||
effective_scopes = []
|
effective_scopes = []
|
||||||
user_data['allowed_modules'] = allowed_modules
|
user_data['allowed_modules'] = allowed_modules
|
||||||
user_data['effective_scopes'] = effective_scopes
|
user_data['effective_scopes'] = effective_scopes
|
||||||
|
_audit_log(request, 'auth.admin_login', 'auth', str(user.id),
|
||||||
|
{'username': user.username, 'role': user.role}, user=user)
|
||||||
return Response({
|
return Response({
|
||||||
'access': str(refresh.access_token),
|
'access': str(refresh.access_token),
|
||||||
'refresh': str(refresh),
|
'refresh': str(refresh),
|
||||||
@@ -473,9 +479,17 @@ class PartnerStatusView(APIView):
|
|||||||
valid = ('active', 'suspended', 'inactive', 'archived')
|
valid = ('active', 'suspended', 'inactive', 'archived')
|
||||||
if new_status not in valid:
|
if new_status not in valid:
|
||||||
return Response({'error': f'status must be one of {valid}'}, status=400)
|
return Response({'error': f'status must be one of {valid}'}, status=400)
|
||||||
|
prev_status = p.status
|
||||||
p.status = new_status
|
p.status = new_status
|
||||||
p.kyc_compliance_reason = request.data.get('reason', p.kyc_compliance_reason or '')
|
p.kyc_compliance_reason = request.data.get('reason', p.kyc_compliance_reason or '')
|
||||||
p.save(update_fields=['status', 'kyc_compliance_reason'])
|
with transaction.atomic():
|
||||||
|
p.save(update_fields=['status', 'kyc_compliance_reason'])
|
||||||
|
_audit_log(request, 'partner.status_changed', 'partner', p.id, {
|
||||||
|
'previous_status': prev_status,
|
||||||
|
'new_status': p.status,
|
||||||
|
'reason': request.data.get('reason', '') or '',
|
||||||
|
'partner_name': p.name,
|
||||||
|
})
|
||||||
return Response({'id': str(p.id), 'status': p.status})
|
return Response({'id': str(p.id), 'status': p.status})
|
||||||
|
|
||||||
|
|
||||||
@@ -909,7 +923,12 @@ class EventUpdateView(APIView):
|
|||||||
updated_fields.append('event_status')
|
updated_fields.append('event_status')
|
||||||
|
|
||||||
if updated_fields:
|
if updated_fields:
|
||||||
e.save(update_fields=updated_fields)
|
with transaction.atomic():
|
||||||
|
e.save(update_fields=updated_fields)
|
||||||
|
_audit_log(request, 'event.updated', 'event', e.id, {
|
||||||
|
'changed_fields': updated_fields,
|
||||||
|
'partner_id': str(e.partner_id) if e.partner_id else None,
|
||||||
|
})
|
||||||
|
|
||||||
# Re-fetch with relations for response
|
# Re-fetch with relations for response
|
||||||
e = Event.objects.select_related('partner').prefetch_related('eventimages_set').get(pk=pk)
|
e = Event.objects.select_related('partner').prefetch_related('eventimages_set').get(pk=pk)
|
||||||
@@ -984,7 +1003,14 @@ class EventDeleteView(APIView):
|
|||||||
from events.models import Event
|
from events.models import Event
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
e = get_object_or_404(Event, pk=pk)
|
e = get_object_or_404(Event, pk=pk)
|
||||||
e.delete()
|
event_title = e.title or getattr(e, 'name', '') or ''
|
||||||
|
partner_id = str(e.partner_id) if e.partner_id else None
|
||||||
|
with transaction.atomic():
|
||||||
|
e.delete()
|
||||||
|
_audit_log(request, 'event.deleted', 'event', pk, {
|
||||||
|
'title': event_title,
|
||||||
|
'partner_id': partner_id,
|
||||||
|
})
|
||||||
return Response({'status': 'deleted'}, status=204)
|
return Response({'status': 'deleted'}, status=204)
|
||||||
|
|
||||||
|
|
||||||
@@ -1107,8 +1133,14 @@ class SettlementReleaseView(APIView):
|
|||||||
from banking_operations.models import PaymentTransaction
|
from banking_operations.models import PaymentTransaction
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
p = get_object_or_404(PaymentTransaction, pk=pk, payment_type='debit')
|
p = get_object_or_404(PaymentTransaction, pk=pk, payment_type='debit')
|
||||||
|
prev_status = p.payment_transaction_status
|
||||||
p.payment_transaction_status = 'completed'
|
p.payment_transaction_status = 'completed'
|
||||||
p.save(update_fields=['payment_transaction_status'])
|
with transaction.atomic():
|
||||||
|
p.save(update_fields=['payment_transaction_status'])
|
||||||
|
_audit_log(request, 'settlement.released', 'settlement', p.id, {
|
||||||
|
'previous_status': prev_status,
|
||||||
|
'new_status': 'completed',
|
||||||
|
})
|
||||||
return Response(_serialize_settlement(p))
|
return Response(_serialize_settlement(p))
|
||||||
|
|
||||||
|
|
||||||
@@ -1256,7 +1288,16 @@ class ReviewDeleteView(APIView):
|
|||||||
from admin_api.models import Review
|
from admin_api.models import Review
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
review = get_object_or_404(Review, pk=pk)
|
review = get_object_or_404(Review, pk=pk)
|
||||||
review.delete()
|
reviewer_id = str(review.reviewer_id) if review.reviewer_id else None
|
||||||
|
event_id = str(review.event_id) if review.event_id else None
|
||||||
|
rating = review.rating
|
||||||
|
with transaction.atomic():
|
||||||
|
review.delete()
|
||||||
|
_audit_log(request, 'review.deleted', 'review', pk, {
|
||||||
|
'reviewer_user_id': reviewer_id,
|
||||||
|
'event_id': event_id,
|
||||||
|
'rating': rating,
|
||||||
|
})
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
@@ -1334,6 +1375,10 @@ class PaymentGatewaySettingsView(APIView):
|
|||||||
is_active=d.get('is_active', True),
|
is_active=d.get('is_active', True),
|
||||||
gateway_priority=int(d.get('gateway_priority', 0)),
|
gateway_priority=int(d.get('gateway_priority', 0)),
|
||||||
)
|
)
|
||||||
|
_audit_log(request, 'gateway.created', 'gateway', gw.pk, {
|
||||||
|
'gateway_name': gw.payment_gateway_name,
|
||||||
|
'is_active': gw.is_active,
|
||||||
|
})
|
||||||
return Response({'status': 'success', 'payment_gateway': _serialize_gateway(gw, include_secret=True)}, status=201)
|
return Response({'status': 'success', 'payment_gateway': _serialize_gateway(gw, include_secret=True)}, status=201)
|
||||||
|
|
||||||
def patch(self, request, pk=None):
|
def patch(self, request, pk=None):
|
||||||
@@ -1353,17 +1398,26 @@ class PaymentGatewaySettingsView(APIView):
|
|||||||
'is_active': 'is_active',
|
'is_active': 'is_active',
|
||||||
'gateway_priority': 'gateway_priority',
|
'gateway_priority': 'gateway_priority',
|
||||||
}
|
}
|
||||||
|
changed_fields = [cf for cf in field_map if cf in d]
|
||||||
for client_field, model_field in field_map.items():
|
for client_field, model_field in field_map.items():
|
||||||
if client_field in d:
|
if client_field in d:
|
||||||
setattr(gw, model_field, d[client_field])
|
setattr(gw, model_field, d[client_field])
|
||||||
gw.save()
|
gw.save()
|
||||||
|
_audit_log(request, 'gateway.updated', 'gateway', gw.pk, {
|
||||||
|
'gateway_name': gw.payment_gateway_name,
|
||||||
|
'changed_fields': changed_fields,
|
||||||
|
'is_active': gw.is_active,
|
||||||
|
})
|
||||||
return Response({'status': 'success', 'payment_gateway': _serialize_gateway(gw, include_secret=True)})
|
return Response({'status': 'success', 'payment_gateway': _serialize_gateway(gw, include_secret=True)})
|
||||||
|
|
||||||
def delete(self, request, pk=None):
|
def delete(self, request, pk=None):
|
||||||
from banking_operations.models import PaymentGateway
|
from banking_operations.models import PaymentGateway
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
gw = get_object_or_404(PaymentGateway, pk=pk)
|
gw = get_object_or_404(PaymentGateway, pk=pk)
|
||||||
|
gw_name = gw.payment_gateway_name
|
||||||
|
gw_pk = gw.pk
|
||||||
gw.delete()
|
gw.delete()
|
||||||
|
_audit_log(request, 'gateway.deleted', 'gateway', gw_pk, {'gateway_name': gw_name})
|
||||||
return Response({'status': 'success'}, status=200)
|
return Response({'status': 'success'}, status=200)
|
||||||
|
|
||||||
|
|
||||||
@@ -1437,6 +1491,11 @@ class EventCreateView(APIView):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
event.save()
|
event.save()
|
||||||
|
_audit_log(request, 'event.created', 'event', event.id, {
|
||||||
|
'title': event.title,
|
||||||
|
'partner_id': str(event.partner_id) if event.partner_id else None,
|
||||||
|
'source': event.source,
|
||||||
|
})
|
||||||
|
|
||||||
return Response(_serialize_event_detail(event), status=201)
|
return Response(_serialize_event_detail(event), status=201)
|
||||||
|
|
||||||
@@ -1473,9 +1532,14 @@ class EventPrimaryImageView(APIView):
|
|||||||
return Response({"error": "Image not found for this event"}, status=404)
|
return Response({"error": "Image not found for this event"}, status=404)
|
||||||
|
|
||||||
# Clear all primary flags for this event, then set the selected one
|
# Clear all primary flags for this event, then set the selected one
|
||||||
|
prev_primary = EventImages.objects.filter(event=event, is_primary=True).values_list('pk', flat=True).first()
|
||||||
EventImages.objects.filter(event=event).update(is_primary=False)
|
EventImages.objects.filter(event=event).update(is_primary=False)
|
||||||
img.is_primary = True
|
img.is_primary = True
|
||||||
img.save()
|
img.save()
|
||||||
|
_audit_log(request, 'event.primary_image_changed', 'event', pk, {
|
||||||
|
'new_primary_image_id': str(image_id),
|
||||||
|
'previous_primary_image_id': str(prev_primary) if prev_primary else None,
|
||||||
|
})
|
||||||
|
|
||||||
return Response({"success": True, "primaryImageId": image_id})
|
return Response({"success": True, "primaryImageId": image_id})
|
||||||
|
|
||||||
@@ -1519,12 +1583,29 @@ 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'},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _audit_log(request, action, target_type, target_id, details=None):
|
def _audit_log(request, action, target_type, target_id, details=None, user=None):
|
||||||
AuditLog.objects.create(
|
AuditLog.objects.create(
|
||||||
user=request.user if request.user.is_authenticated else None,
|
user=user if user is not None else (request.user if request.user.is_authenticated else None),
|
||||||
action=action,
|
action=action,
|
||||||
target_type=target_type,
|
target_type=target_type,
|
||||||
target_id=str(target_id),
|
target_id=str(target_id),
|
||||||
@@ -2353,6 +2434,11 @@ class PartnerOnboardView(APIView):
|
|||||||
)
|
)
|
||||||
manager_user.set_password(manager_password)
|
manager_user.set_password(manager_password)
|
||||||
manager_user.save()
|
manager_user.save()
|
||||||
|
_audit_log(request, 'partner.onboarded', 'partner', partner.id, {
|
||||||
|
'partner_name': partner.name,
|
||||||
|
'manager_user_id': manager_user.id,
|
||||||
|
'manager_email': manager_user.email,
|
||||||
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -2482,6 +2568,12 @@ class PartnerStaffCreateView(APIView):
|
|||||||
)
|
)
|
||||||
staff_user.set_password(password)
|
staff_user.set_password(password)
|
||||||
staff_user.save()
|
staff_user.save()
|
||||||
|
_audit_log(request, 'partner.staff.created', 'partner', partner.id, {
|
||||||
|
'new_user_id': staff_user.id,
|
||||||
|
'username': staff_user.username,
|
||||||
|
'email': staff_user.email,
|
||||||
|
'role': staff_user.role,
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response(
|
return Response(
|
||||||
{"success": False, "error": "Failed to create staff user: " + str(e)},
|
{"success": False, "error": "Failed to create staff user: " + str(e)},
|
||||||
@@ -2764,6 +2856,8 @@ class LeadUpdateView(APIView):
|
|||||||
|
|
||||||
lead.save()
|
lead.save()
|
||||||
log("info", f"Lead #{pk} updated: {', '.join(changed)}", request=request, user=request.user)
|
log("info", f"Lead #{pk} updated: {', '.join(changed)}", request=request, user=request.user)
|
||||||
|
if changed:
|
||||||
|
_audit_log(request, 'lead.updated', 'lead', pk, {'changed_fields': changed})
|
||||||
return Response(_serialize_lead(lead))
|
return Response(_serialize_lead(lead))
|
||||||
|
|
||||||
|
|
||||||
@@ -2874,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)
|
||||||
@@ -2923,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))
|
||||||
@@ -2932,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)
|
||||||
|
|
||||||
@@ -2957,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -2968,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:
|
||||||
@@ -2977,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -3024,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