feat(audit): add Audit Log module — coverage, metrics endpoint, indexes
- UserStatusView, EventModerationView, ReviewModerationView, PartnerKYCReviewView: each state change now emits _audit_log() inside the same transaction.atomic() block so the log stays consistent with DB state on partial failure - AuditLogMetricsView: GET /api/v1/rbac/audit-log/metrics/ returns total/today/week/distinct_users/by_action_group; 60 s cache with ?nocache=1 bypass - AuditLogListView: free-text search (Q over action/target/user), page_size bounded to [1, 200] - accounts.User.ALL_MODULES += 'audit-log'; StaffProfile.SCOPE_TO_MODULE['audit'] = 'audit-log' - Migration 0005: composite indexes (action,-created_at) and (target_type,target_id) on AuditLog - admin_api/tests.py: 11 tests covering list shape, search, page bounds, metrics shape+nocache, suspend/ban/reinstate audit emission Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -482,22 +482,67 @@ class PartnerStatusView(APIView):
|
||||
class PartnerKYCReviewView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
_DECISION_SLUG = {
|
||||
'approved': 'partner.kyc.approved',
|
||||
'rejected': 'partner.kyc.rejected',
|
||||
'requested_info': 'partner.kyc.requested_info',
|
||||
}
|
||||
|
||||
def post(self, request, pk):
|
||||
from partner.models import Partner
|
||||
from django.shortcuts import get_object_or_404
|
||||
p = get_object_or_404(Partner, pk=pk)
|
||||
decision = request.data.get('decision')
|
||||
if decision not in ('approved', 'rejected'):
|
||||
return Response({'error': 'decision must be approved or rejected'}, status=400)
|
||||
p.kyc_compliance_status = decision
|
||||
p.is_kyc_compliant = (decision == 'approved')
|
||||
p.kyc_compliance_reason = request.data.get('reason', '')
|
||||
p.save(update_fields=['kyc_compliance_status', 'is_kyc_compliant', 'kyc_compliance_reason'])
|
||||
audit_slug = self._DECISION_SLUG.get(decision)
|
||||
if audit_slug is None:
|
||||
return Response(
|
||||
{'error': 'decision must be approved, rejected, or requested_info'},
|
||||
status=400,
|
||||
)
|
||||
|
||||
reason = request.data.get('reason', '') or ''
|
||||
docs_requested = request.data.get('docs_requested') or []
|
||||
previous_status = p.kyc_compliance_status
|
||||
previous_is_compliant = bool(p.is_kyc_compliant)
|
||||
|
||||
with transaction.atomic():
|
||||
if decision in ('approved', 'rejected'):
|
||||
p.kyc_compliance_status = decision
|
||||
p.is_kyc_compliant = (decision == 'approved')
|
||||
# `requested_info` leaves the compliance state intact — it's a
|
||||
# reviewer signalling they need more documents from the partner.
|
||||
p.kyc_compliance_reason = reason
|
||||
p.save(update_fields=[
|
||||
'kyc_compliance_status', 'is_kyc_compliant', 'kyc_compliance_reason',
|
||||
])
|
||||
_audit_log(
|
||||
request,
|
||||
action=audit_slug,
|
||||
target_type='partner',
|
||||
target_id=p.id,
|
||||
details={
|
||||
'reason': reason,
|
||||
'docs_requested': docs_requested,
|
||||
'previous_status': previous_status,
|
||||
'new_status': p.kyc_compliance_status,
|
||||
'previous_is_compliant': previous_is_compliant,
|
||||
'new_is_compliant': bool(p.is_kyc_compliant),
|
||||
},
|
||||
)
|
||||
|
||||
# Preserve the existing response shape — frontend depends on it.
|
||||
if decision == 'approved':
|
||||
verification_status = 'Verified'
|
||||
elif decision == 'rejected':
|
||||
verification_status = 'Rejected'
|
||||
else:
|
||||
verification_status = 'Info Requested'
|
||||
|
||||
return Response({
|
||||
'id': str(p.id),
|
||||
'kyc_compliance_status': p.kyc_compliance_status,
|
||||
'is_kyc_compliant': p.is_kyc_compliant,
|
||||
'verificationStatus': 'Verified' if decision == 'approved' else 'Rejected',
|
||||
'verificationStatus': verification_status,
|
||||
})
|
||||
|
||||
|
||||
@@ -637,19 +682,50 @@ class UserDetailView(APIView):
|
||||
class UserStatusView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# Map the external `action` slug sent by the admin dashboard to the
|
||||
# audit-log slug we record + the `is_active` value that slug implies.
|
||||
# Keeping the mapping data-driven means a new moderation verb (e.g.
|
||||
# `flag`) only needs one new row, not scattered `if` branches.
|
||||
_ACTION_MAP = {
|
||||
'suspend': ('user.suspended', False),
|
||||
'ban': ('user.banned', False),
|
||||
'reinstate': ('user.reinstated', True),
|
||||
'flag': ('user.flagged', None),
|
||||
}
|
||||
|
||||
def patch(self, request, pk):
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.shortcuts import get_object_or_404
|
||||
User = get_user_model()
|
||||
u = get_object_or_404(User, pk=pk)
|
||||
action = request.data.get('action')
|
||||
if action in ('suspend', 'ban'):
|
||||
u.is_active = False
|
||||
elif action == 'reinstate':
|
||||
u.is_active = True
|
||||
else:
|
||||
return Response({'error': 'action must be suspend, ban, or reinstate'}, status=400)
|
||||
u.save(update_fields=['is_active'])
|
||||
mapping = self._ACTION_MAP.get(action)
|
||||
if mapping is None:
|
||||
return Response(
|
||||
{'error': 'action must be suspend, ban, reinstate, or flag'},
|
||||
status=400,
|
||||
)
|
||||
audit_slug, new_active = mapping
|
||||
reason = request.data.get('reason', '') or ''
|
||||
previous_status = _user_status(u)
|
||||
|
||||
# Audit + state change must succeed or fail together — otherwise the
|
||||
# log says one thing and the database says another.
|
||||
with transaction.atomic():
|
||||
if new_active is not None and u.is_active != new_active:
|
||||
u.is_active = new_active
|
||||
u.save(update_fields=['is_active'])
|
||||
_audit_log(
|
||||
request,
|
||||
action=audit_slug,
|
||||
target_type='user',
|
||||
target_id=u.id,
|
||||
details={
|
||||
'reason': reason,
|
||||
'previous_status': previous_status,
|
||||
'new_status': _user_status(u),
|
||||
},
|
||||
)
|
||||
return Response({'id': str(u.id), 'status': _user_status(u)})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -843,29 +919,60 @@ class EventUpdateView(APIView):
|
||||
class EventModerationView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
_ACTION_SLUG = {
|
||||
'approve': 'event.approved',
|
||||
'reject': 'event.rejected',
|
||||
'flag': 'event.flagged',
|
||||
'feature': 'event.featured',
|
||||
'unfeature': 'event.unfeatured',
|
||||
}
|
||||
|
||||
def patch(self, request, pk):
|
||||
from events.models import Event
|
||||
from django.shortcuts import get_object_or_404
|
||||
e = get_object_or_404(Event, pk=pk)
|
||||
action = request.data.get('action')
|
||||
if action == 'approve':
|
||||
e.event_status = 'published'
|
||||
e.save(update_fields=['event_status'])
|
||||
elif action == 'reject':
|
||||
e.event_status = 'cancelled'
|
||||
e.cancelled_reason = request.data.get('reason', '')
|
||||
e.save(update_fields=['event_status', 'cancelled_reason'])
|
||||
elif action == 'flag':
|
||||
e.event_status = 'flagged'
|
||||
e.save(update_fields=['event_status'])
|
||||
elif action == 'feature':
|
||||
e.is_featured = True
|
||||
e.save(update_fields=['is_featured'])
|
||||
elif action == 'unfeature':
|
||||
e.is_featured = False
|
||||
e.save(update_fields=['is_featured'])
|
||||
else:
|
||||
audit_slug = self._ACTION_SLUG.get(action)
|
||||
if audit_slug is None:
|
||||
return Response({'error': 'Invalid action'}, status=400)
|
||||
|
||||
reason = request.data.get('reason', '') or ''
|
||||
previous_status = e.event_status
|
||||
previous_is_featured = bool(e.is_featured)
|
||||
|
||||
with transaction.atomic():
|
||||
if action == 'approve':
|
||||
e.event_status = 'published'
|
||||
e.save(update_fields=['event_status'])
|
||||
elif action == 'reject':
|
||||
e.event_status = 'cancelled'
|
||||
e.cancelled_reason = reason
|
||||
e.save(update_fields=['event_status', 'cancelled_reason'])
|
||||
elif action == 'flag':
|
||||
e.event_status = 'flagged'
|
||||
e.save(update_fields=['event_status'])
|
||||
elif action == 'feature':
|
||||
e.is_featured = True
|
||||
e.save(update_fields=['is_featured'])
|
||||
elif action == 'unfeature':
|
||||
e.is_featured = False
|
||||
e.save(update_fields=['is_featured'])
|
||||
|
||||
_audit_log(
|
||||
request,
|
||||
action=audit_slug,
|
||||
target_type='event',
|
||||
target_id=e.id,
|
||||
details={
|
||||
'reason': reason,
|
||||
'partner_id': str(e.partner_id) if e.partner_id else None,
|
||||
'previous_status': previous_status,
|
||||
'new_status': e.event_status,
|
||||
'previous_is_featured': previous_is_featured,
|
||||
'new_is_featured': bool(e.is_featured),
|
||||
},
|
||||
)
|
||||
|
||||
e.refresh_from_db()
|
||||
return Response(_serialize_event(e))
|
||||
|
||||
@@ -1093,8 +1200,15 @@ class ReviewModerationView(APIView):
|
||||
from django.shortcuts import get_object_or_404
|
||||
review = get_object_or_404(Review, pk=pk)
|
||||
action = request.data.get('action')
|
||||
|
||||
previous_status = review.status
|
||||
previous_text = review.review_text
|
||||
audit_slug = None
|
||||
details = {}
|
||||
|
||||
if action == 'approve':
|
||||
review.status = 'live'
|
||||
audit_slug = 'review.approved'
|
||||
elif action == 'reject':
|
||||
rr = request.data.get('reject_reason', 'spam')
|
||||
valid_reasons = [c[0] for c in Review.REJECT_CHOICES]
|
||||
@@ -1102,14 +1216,36 @@ class ReviewModerationView(APIView):
|
||||
return Response({'error': 'Invalid reject_reason'}, status=400)
|
||||
review.status = 'rejected'
|
||||
review.reject_reason = rr
|
||||
audit_slug = 'review.rejected'
|
||||
details['reject_reason'] = rr
|
||||
elif action == 'save_and_approve':
|
||||
review.review_text = request.data.get('review_text', review.review_text)
|
||||
review.status = 'live'
|
||||
audit_slug = 'review.edited'
|
||||
details['edited_text'] = True
|
||||
elif action == 'save_live':
|
||||
review.review_text = request.data.get('review_text', review.review_text)
|
||||
audit_slug = 'review.edited'
|
||||
details['edited_text'] = True
|
||||
else:
|
||||
return Response({'error': 'Invalid action'}, status=400)
|
||||
review.save()
|
||||
|
||||
details.update({
|
||||
'previous_status': previous_status,
|
||||
'new_status': review.status,
|
||||
})
|
||||
if previous_text != review.review_text:
|
||||
details['original_text'] = previous_text
|
||||
|
||||
with transaction.atomic():
|
||||
review.save()
|
||||
_audit_log(
|
||||
request,
|
||||
action=audit_slug,
|
||||
target_type='review',
|
||||
target_id=review.id,
|
||||
details=details,
|
||||
)
|
||||
return Response(_serialize_review(review))
|
||||
|
||||
|
||||
@@ -1966,10 +2102,28 @@ class OrgTreeView(APIView):
|
||||
|
||||
|
||||
# 14. AuditLogListView
|
||||
def _serialize_audit_log(log):
|
||||
return {
|
||||
'id': log.id,
|
||||
'user': {
|
||||
'id': log.user.id,
|
||||
'username': log.user.username,
|
||||
'email': log.user.email,
|
||||
} if log.user else None,
|
||||
'action': log.action,
|
||||
'target_type': log.target_type,
|
||||
'target_id': log.target_id,
|
||||
'details': log.details,
|
||||
'ip_address': log.ip_address,
|
||||
'created_at': log.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class AuditLogListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
from django.db.models import Q
|
||||
qs = AuditLog.objects.select_related('user').all()
|
||||
# Filters
|
||||
user_id = request.query_params.get('user')
|
||||
@@ -1988,29 +2142,32 @@ class AuditLogListView(APIView):
|
||||
if date_to:
|
||||
qs = qs.filter(created_at__date__lte=date_to)
|
||||
|
||||
# Free-text search across action, target type/id, and actor identity.
|
||||
# ORed so one box can locate entries regardless of which axis the
|
||||
# admin remembers (slug vs. target vs. user).
|
||||
search = request.query_params.get('search')
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
Q(action__icontains=search)
|
||||
| Q(target_type__icontains=search)
|
||||
| Q(target_id__icontains=search)
|
||||
| Q(user__username__icontains=search)
|
||||
| Q(user__email__icontains=search)
|
||||
)
|
||||
|
||||
# Pagination
|
||||
page = int(request.query_params.get('page', 1))
|
||||
page_size = int(request.query_params.get('page_size', 50))
|
||||
try:
|
||||
page = max(1, int(request.query_params.get('page', 1)))
|
||||
page_size = max(1, min(200, int(request.query_params.get('page_size', 50))))
|
||||
except (ValueError, TypeError):
|
||||
page, page_size = 1, 50
|
||||
total = qs.count()
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
logs = qs[start:end]
|
||||
|
||||
return Response({
|
||||
'results': [{
|
||||
'id': log.id,
|
||||
'user': {
|
||||
'id': log.user.id,
|
||||
'username': log.user.username,
|
||||
'email': log.user.email,
|
||||
} if log.user else None,
|
||||
'action': log.action,
|
||||
'target_type': log.target_type,
|
||||
'target_id': log.target_id,
|
||||
'details': log.details,
|
||||
'ip_address': log.ip_address,
|
||||
'created_at': log.created_at.isoformat(),
|
||||
} for log in logs],
|
||||
'results': [_serialize_audit_log(log) for log in logs],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
@@ -2018,6 +2175,93 @@ class AuditLogListView(APIView):
|
||||
})
|
||||
|
||||
|
||||
# 15. AuditLogMetricsView — aggregated counts for the admin header bar.
|
||||
def _compute_action_groups(qs):
|
||||
"""Bucket action slugs into UI-visible groups without a DB roundtrip per slug.
|
||||
|
||||
Mirrors `actionGroup()` on the frontend so the tooltip numbers match the
|
||||
badge colours.
|
||||
"""
|
||||
groups = {
|
||||
'create': 0,
|
||||
'update': 0,
|
||||
'delete': 0,
|
||||
'moderate': 0,
|
||||
'auth': 0,
|
||||
'other': 0,
|
||||
}
|
||||
for action in qs.values_list('action', flat=True):
|
||||
slug = (action or '').lower()
|
||||
if '.deactivated' in slug or '.deleted' in slug or '.removed' in slug:
|
||||
groups['delete'] += 1
|
||||
elif '.created' in slug or '.invited' in slug or '.added' in slug:
|
||||
groups['create'] += 1
|
||||
elif (
|
||||
'.updated' in slug or '.edited' in slug
|
||||
or '.moved' in slug or '.changed' in slug
|
||||
):
|
||||
groups['update'] += 1
|
||||
elif (
|
||||
slug.startswith('user.')
|
||||
or slug.startswith('event.')
|
||||
or slug.startswith('review.')
|
||||
or slug.startswith('partner.kyc.')
|
||||
):
|
||||
groups['moderate'] += 1
|
||||
elif slug.startswith('auth.') or 'login' in slug or 'logout' in slug:
|
||||
groups['auth'] += 1
|
||||
else:
|
||||
groups['other'] += 1
|
||||
return groups
|
||||
|
||||
|
||||
class AuditLogMetricsView(APIView):
|
||||
"""GET /api/v1/rbac/audit-log/metrics/ — aggregated counts.
|
||||
|
||||
Cached for 60 s because the header bar is fetched on every page load and
|
||||
the numbers are approximate by nature. If you need fresh numbers, bypass
|
||||
cache with `?nocache=1` (useful from the Django shell during incident
|
||||
response).
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
CACHE_KEY = 'admin_api:audit_log:metrics:v1'
|
||||
CACHE_TTL_SECONDS = 60
|
||||
|
||||
def get(self, request):
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
if request.query_params.get('nocache') == '1':
|
||||
payload = self._compute()
|
||||
cache.set(self.CACHE_KEY, payload, self.CACHE_TTL_SECONDS)
|
||||
return Response(payload)
|
||||
|
||||
payload = cache.get(self.CACHE_KEY)
|
||||
if payload is None:
|
||||
payload = self._compute()
|
||||
cache.set(self.CACHE_KEY, payload, self.CACHE_TTL_SECONDS)
|
||||
return Response(payload)
|
||||
|
||||
def _compute(self):
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
now = timezone.now()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = today_start - timedelta(days=6)
|
||||
qs = AuditLog.objects.all()
|
||||
return {
|
||||
'total': qs.count(),
|
||||
'today': qs.filter(created_at__gte=today_start).count(),
|
||||
'week': qs.filter(created_at__gte=week_start).count(),
|
||||
'distinct_users': qs.exclude(user__isnull=True)
|
||||
.values('user_id').distinct().count(),
|
||||
'by_action_group': _compute_action_groups(qs),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Partner Onboarding API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2595,6 +2839,7 @@ class NotificationScheduleListView(APIView):
|
||||
def post(self, request):
|
||||
from notifications.models import NotificationSchedule, NotificationRecipient
|
||||
from django.db import transaction as db_tx
|
||||
from eventify_logger.services import log
|
||||
|
||||
name = (request.data.get('name') or '').strip()
|
||||
ntype = (request.data.get('notificationType') or '').strip()
|
||||
@@ -2649,6 +2894,7 @@ class NotificationScheduleDetailView(APIView):
|
||||
def patch(self, request, pk):
|
||||
from notifications.models import NotificationSchedule
|
||||
from django.shortcuts import get_object_or_404
|
||||
from eventify_logger.services import log
|
||||
s = get_object_or_404(NotificationSchedule, pk=pk)
|
||||
|
||||
changed = []
|
||||
@@ -2684,6 +2930,7 @@ class NotificationScheduleDetailView(APIView):
|
||||
def delete(self, request, pk):
|
||||
from notifications.models import NotificationSchedule
|
||||
from django.shortcuts import get_object_or_404
|
||||
from eventify_logger.services import log
|
||||
s = get_object_or_404(NotificationSchedule, pk=pk)
|
||||
s.delete()
|
||||
log('info', f'Notification schedule #{pk} deleted', request=request, user=request.user)
|
||||
@@ -2758,6 +3005,7 @@ class NotificationScheduleSendNowView(APIView):
|
||||
from notifications.emails import render_and_send
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone as dj_tz
|
||||
from eventify_logger.services import log
|
||||
schedule = get_object_or_404(NotificationSchedule, pk=pk)
|
||||
|
||||
try:
|
||||
@@ -2798,6 +3046,7 @@ class NotificationScheduleTestSendView(APIView):
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMessage
|
||||
from django.shortcuts import get_object_or_404
|
||||
from eventify_logger.services import log
|
||||
|
||||
schedule = get_object_or_404(NotificationSchedule, pk=pk)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user