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 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 13:42:02 +05:30
parent 2c60a82704
commit 66e41ba647
3 changed files with 204 additions and 8 deletions

View File

@@ -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

View File

@@ -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',
)

View File

@@ -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,7 +923,12 @@ 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)
@@ -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))