From 66e41ba6475333a7ca2334bc4cbd8dcdd5a2219b Mon Sep 17 00:00:00 2001 From: Sicherhaven Date: Tue, 21 Apr 2026 13:42:02 +0530 Subject: [PATCH] feat(audit): extend audit coverage to all admin interactions (v1.13.0) - _audit_log helper: optional user= kwarg for login-time calls - AdminLoginView: auth.admin_login / auth.admin_login_failed - PartnerStatusView: partner.status_changed (atomic) - PartnerOnboardView: partner.onboarded - PartnerStaffCreateView: partner.staff.created - EventCreateView/UpdateView/DeleteView: event.created/updated/deleted (atomic) - EventPrimaryImageView: event.primary_image_changed - SettlementReleaseView: settlement.released (atomic) - ReviewDeleteView: review.deleted (atomic) - LeadUpdateView: lead.updated - PaymentGatewaySettingsView: gateway.created/updated/deleted - tests: AuthAuditEmissionTests + EventCrudAuditTests (16 total, all green) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 25 ++++++++++++ admin_api/tests.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++ admin_api/views.py | 93 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 204 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b052da..5fe9272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), version --- +## [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 ### Added diff --git a/admin_api/tests.py b/admin_api/tests.py index bf9f04d..03b9ce9 100644 --- a/admin_api/tests.py +++ b/admin_api/tests.py @@ -229,3 +229,97 @@ class UserStatusAuditEmissionTests(_AuditTestBase): ).exists(), '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', + ) diff --git a/admin_api/views.py b/admin_api/views.py index 76c809c..4192c45 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -26,8 +26,12 @@ class AdminLoginView(APIView): except User.DoesNotExist: pass 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) 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) refresh = RefreshToken.for_user(user) user_data = UserSerializer(user).data @@ -44,6 +48,8 @@ class AdminLoginView(APIView): effective_scopes = [] user_data['allowed_modules'] = allowed_modules 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({ 'access': str(refresh.access_token), 'refresh': str(refresh), @@ -473,9 +479,17 @@ class PartnerStatusView(APIView): valid = ('active', 'suspended', 'inactive', 'archived') if new_status not in valid: return Response({'error': f'status must be one of {valid}'}, status=400) + prev_status = p.status p.status = new_status 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}) @@ -909,8 +923,13 @@ class EventUpdateView(APIView): updated_fields.append('event_status') 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 e = Event.objects.select_related('partner').prefetch_related('eventimages_set').get(pk=pk) return Response(_serialize_event_detail(e)) @@ -984,7 +1003,14 @@ class EventDeleteView(APIView): from events.models import Event from django.shortcuts import get_object_or_404 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) @@ -1107,8 +1133,14 @@ class SettlementReleaseView(APIView): from banking_operations.models import PaymentTransaction from django.shortcuts import get_object_or_404 p = get_object_or_404(PaymentTransaction, pk=pk, payment_type='debit') + prev_status = p.payment_transaction_status 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)) @@ -1256,7 +1288,16 @@ class ReviewDeleteView(APIView): from admin_api.models import Review from django.shortcuts import get_object_or_404 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) @@ -1334,6 +1375,10 @@ class PaymentGatewaySettingsView(APIView): is_active=d.get('is_active', True), 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) def patch(self, request, pk=None): @@ -1353,17 +1398,26 @@ class PaymentGatewaySettingsView(APIView): 'is_active': 'is_active', '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(): if client_field in d: setattr(gw, model_field, d[client_field]) 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)}) def delete(self, request, pk=None): from banking_operations.models import PaymentGateway from django.shortcuts import get_object_or_404 gw = get_object_or_404(PaymentGateway, pk=pk) + gw_name = gw.payment_gateway_name + gw_pk = gw.pk gw.delete() + _audit_log(request, 'gateway.deleted', 'gateway', gw_pk, {'gateway_name': gw_name}) return Response({'status': 'success'}, status=200) @@ -1437,6 +1491,11 @@ class EventCreateView(APIView): pass 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) @@ -1473,9 +1532,14 @@ class EventPrimaryImageView(APIView): return Response({"error": "Image not found for this event"}, status=404) # 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) img.is_primary = True 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}) @@ -1522,9 +1586,9 @@ SCOPE_DEFINITIONS = { } -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( - 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, target_type=target_type, target_id=str(target_id), @@ -2353,6 +2417,11 @@ class PartnerOnboardView(APIView): ) manager_user.set_password(manager_password) 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: return Response( @@ -2482,6 +2551,12 @@ class PartnerStaffCreateView(APIView): ) staff_user.set_password(password) 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: return Response( {"success": False, "error": "Failed to create staff user: " + str(e)}, @@ -2764,6 +2839,8 @@ class LeadUpdateView(APIView): lead.save() 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))