From 2c60a827040950598127a8cea3bdbff8784663ab Mon Sep 17 00:00:00 2001 From: Sicherhaven Date: Tue, 21 Apr 2026 12:39:38 +0530 Subject: [PATCH] =?UTF-8?q?feat(audit):=20add=20Audit=20Log=20module=20?= =?UTF-8?q?=E2=80=94=20coverage,=20metrics=20endpoint,=20indexes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 63 ++++ accounts/models.py | 4 +- admin_api/migrations/0005_auditlog_indexes.py | 31 ++ admin_api/models.py | 9 +- admin_api/tests.py | 231 ++++++++++++ admin_api/urls.py | 1 + admin_api/views.py | 345 +++++++++++++++--- 7 files changed, 633 insertions(+), 51 deletions(-) create mode 100644 admin_api/migrations/0005_auditlog_indexes.py create mode 100644 admin_api/tests.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f312fa6..2b052da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,69 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), version --- +## [1.12.0] — 2026-04-21 + +### Added +- **Audit coverage for four moderation endpoints** — every admin state change now leaves a matching row in `AuditLog`, written in the same `transaction.atomic()` block as the state change so the log can never disagree with the database: + - `UserStatusView` (`PATCH /api/v1/users//status/`) — `user.suspended`, `user.banned`, `user.reinstated`, `user.flagged`; details capture `reason`, `previous_status`, `new_status` + - `EventModerationView` (`PATCH /api/v1/events//moderate/`) — `event.approved`, `event.rejected`, `event.flagged`, `event.featured`, `event.unfeatured`; details include `reason`, `partner_id`, `previous_status`/`new_status`, `previous_is_featured`/`new_is_featured` + - `ReviewModerationView` (`PATCH /api/v1/reviews//moderate/`) — `review.approved`, `review.rejected`, `review.edited`; details include `reject_reason`, `edited_text` flag, `original_text` on edits + - `PartnerKYCReviewView` (`POST /api/v1/partners//kyc/review/`) — `partner.kyc.approved`, `partner.kyc.rejected`, `partner.kyc.requested_info` (new `requested_info` decision leaves compliance state intact and only records the info request) +- **`GET /api/v1/rbac/audit-log/metrics/`** — `AuditLogMetricsView` returns `total`, `today`, `week`, `distinct_users`, and a `by_action_group` breakdown (`create`/`update`/`delete`/`moderate`/`auth`/`other`). Cached 60 s under key `admin_api:audit_log:metrics:v1`; pass `?nocache=1` to bypass (useful from the Django shell during incident response) +- **`GET /api/v1/rbac/audit-log/`** — free-text `search` parameter (Q-filter over `action`, `target_type`, `target_id`, `user__username`, `user__email`); `page_size` now bounded to `[1, 200]` with defensive fallback to defaults on non-integer input +- **`accounts.User.ALL_MODULES`** — appended `audit-log`; `StaffProfile.get_allowed_modules()` adds `'audit'` → `'audit-log'` to `SCOPE_TO_MODULE` so scope-based staff resolve the module correctly +- **`admin_api/migrations/0005_auditlog_indexes.py`** — composite indexes `(action, -created_at)` and `(target_type, target_id)` on `AuditLog` to keep the /audit-log page fast past ~10k rows; reversible via Django's default `RemoveIndex` reverse op +- **`admin_api/tests.py`** — `AuditLogListViewTests`, `AuditLogMetricsViewTests`, `UserStatusAuditEmissionTests` covering list shape, search, pagination bounds, metrics shape + `nocache`, and audit emission on suspend / ban / reinstate + +### Deploy notes +Admin users created before this release won't have `audit-log` in their `allowed_modules` TextField. Backfill with: +```python +# Django shell +from accounts.models import User +for u in User.objects.filter(role__in=['admin', 'manager']): + mods = [m.strip() for m in (u.allowed_modules or '').split(',') if m.strip()] + if 'audit-log' not in mods and mods: # only touch users with explicit lists + u.allowed_modules = ','.join(mods + ['audit-log']) + u.save(update_fields=['allowed_modules']) +``` +Users on the implicit full-access list (empty `allowed_modules` + admin role) pick up the new module automatically via `get_allowed_modules()`. + +--- + +## [1.11.0] — 2026-04-12 + +### Added +- **Worldline Connect payment integration** (`banking_operations/worldline/`) + - `client.py` — `WorldlineClient`: HMAC-SHA256 signed requests, `create_hosted_checkout()`, `get_hosted_checkout_status()`, `verify_webhook_signature()` + - `views.py` — `POST /api/payments/webhook/` (CSRF-exempt, signature-verified Worldline server callback) + `POST /api/payments/verify/` (frontend polls on return URL) + - `emails.py` — HTML ticket confirmation email with per-ticket QR codes embedded as base64 inline images + - `WorldlineOrder` model in `banking_operations/models.py` — tracks each hosted-checkout session (hosted_checkout_id, reference_id, status, raw_response, webhook_payload) +- **`Booking.payment_status`** field — `pending / paid / failed / cancelled` (default `pending`); migration `bookings/0002_booking_payment_status` +- **`banking_operations/services.py::transaction_initiate`** — implemented (was a stub); calls Worldline API, creates `WorldlineOrder`, returns `payment_url` back to `CheckoutAPI` +- **Settings**: `WORLDLINE_MERCHANT_ID`, `WORLDLINE_API_KEY_ID`, `WORLDLINE_API_SECRET_KEY`, `WORLDLINE_WEBHOOK_SECRET_KEY`, `WORLDLINE_API_ENDPOINT` (default: sandbox), `WORLDLINE_RETURN_URL` +- **Requirements**: `requests>=2.31.0`, `qrcode[pil]>=7.4.2` + +### Flow +1. User adds tickets to cart → `POST /api/bookings/checkout/` creates Bookings + calls `transaction_initiate` +2. `transaction_initiate` creates `WorldlineOrder` + calls Worldline → returns redirect URL +3. Frontend redirects user to Worldline hosted checkout page +4. After payment, Worldline redirects to `WORLDLINE_RETURN_URL` (`app.eventifyplus.com/booking/confirm?hostedCheckoutId=...`) +5. SPA calls `POST /api/payments/verify/` — checks local status; if still pending, polls Worldline API directly +6. Worldline webhook fires `POST /api/payments/webhook/` → generates Tickets (one per quantity), marks Booking `paid`, sends confirmation email with QR codes +7. Partner scans QR code at event → existing `POST /api/bookings/check-in/` marks `Ticket.is_checked_in=True` + +### Deploy requirement +Set in Django container `.env`: +``` +WORLDLINE_MERCHANT_ID=... +WORLDLINE_API_KEY_ID=... +WORLDLINE_API_SECRET_KEY=... +WORLDLINE_WEBHOOK_SECRET_KEY=... +``` +`WORLDLINE_API_ENDPOINT` defaults to sandbox — set to production URL when going live. + +--- + ## [1.10.0] — 2026-04-10 ### Security diff --git a/accounts/models.py b/accounts/models.py index e8de0c5..0e3d5e0 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -68,10 +68,10 @@ class User(AbstractUser): help_text='Comma-separated module slugs this user can access', ) - ALL_MODULES = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "settings"] + ALL_MODULES = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "audit-log", "settings"] def get_allowed_modules(self): - ALL = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "settings"] + ALL = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "audit-log", "settings"] if self.is_superuser or self.role == "admin": return ALL if self.allowed_modules: diff --git a/admin_api/migrations/0005_auditlog_indexes.py b/admin_api/migrations/0005_auditlog_indexes.py new file mode 100644 index 0000000..9f727bf --- /dev/null +++ b/admin_api/migrations/0005_auditlog_indexes.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.21 for the Audit Log module (admin_api v1.12.0). +# +# Adds two composite indexes to `AuditLog` so the new /audit-log admin page +# can filter by action and resolve "related entries" lookups without a full +# table scan once the log grows past a few thousand rows. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_api', '0004_lead_user_account'), + ] + + operations = [ + migrations.AddIndex( + model_name='auditlog', + index=models.Index( + fields=['action', '-created_at'], + name='auditlog_action_time_idx', + ), + ), + migrations.AddIndex( + model_name='auditlog', + index=models.Index( + fields=['target_type', 'target_id'], + name='auditlog_target_idx', + ), + ), + ] diff --git a/admin_api/models.py b/admin_api/models.py index ae0734f..bfa0f6d 100644 --- a/admin_api/models.py +++ b/admin_api/models.py @@ -130,7 +130,7 @@ class StaffProfile(models.Model): def get_allowed_modules(self): scopes = self.get_effective_scopes() if '*' in scopes: - return ['dashboard', 'partners', 'events', 'ad-control', 'users', 'reviews', 'contributions', 'leads', 'financials', 'settings'] + return ['dashboard', 'partners', 'events', 'ad-control', 'users', 'reviews', 'contributions', 'leads', 'financials', 'audit-log', 'settings'] SCOPE_TO_MODULE = { 'users': 'users', 'events': 'events', @@ -141,6 +141,7 @@ class StaffProfile(models.Model): 'ads': 'ad-control', 'contributions': 'contributions', 'leads': 'leads', + 'audit': 'audit-log', } modules = {'dashboard'} for scope in scopes: @@ -179,6 +180,12 @@ class AuditLog(models.Model): class Meta: ordering = ['-created_at'] + indexes = [ + # Fast filter-by-action ordered by time (audit log page default view) + models.Index(fields=['action', '-created_at'], name='auditlog_action_time_idx'), + # Fast "related entries for this target" lookups in the detail panel + models.Index(fields=['target_type', 'target_id'], name='auditlog_target_idx'), + ] def __str__(self): return f"{self.action} by {self.user} at {self.created_at}" diff --git a/admin_api/tests.py b/admin_api/tests.py new file mode 100644 index 0000000..bf9f04d --- /dev/null +++ b/admin_api/tests.py @@ -0,0 +1,231 @@ +"""Tests for the Audit Log module (admin_api v1.12.0). + +Covers: + * `AuditLogListView` — list + search + filter shape + * `AuditLogMetricsView` — shape + `?nocache=1` bypass + * `UserStatusView` — emits a row into `AuditLog` for every status change, + inside the same transaction as the state change + +Run with: + python manage.py test admin_api.tests +""" +from __future__ import annotations + +from django.core.cache import cache +from django.test import TestCase +from rest_framework_simplejwt.tokens import RefreshToken + +from accounts.models import User +from admin_api.models import AuditLog + + +# --------------------------------------------------------------------------- +# Base — auth helper shared across cases +# --------------------------------------------------------------------------- + + +class _AuditTestBase(TestCase): + """Gives each subclass an admin user + pre-issued JWT.""" + + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user( + username='audit.admin@eventifyplus.com', + email='audit.admin@eventifyplus.com', + password='irrelevant', + role='admin', + ) + cls.admin.is_superuser = True + cls.admin.save() + + def setUp(self): + # Metrics view caches by key; reset to keep cases independent. + cache.delete('admin_api:audit_log:metrics:v1') + access = str(RefreshToken.for_user(self.admin).access_token) + self.auth = {'HTTP_AUTHORIZATION': f'Bearer {access}'} + + +# --------------------------------------------------------------------------- +# AuditLogListView +# --------------------------------------------------------------------------- + + +class AuditLogListViewTests(_AuditTestBase): + url = '/api/v1/rbac/audit-log/' + + def test_unauthenticated_returns_401(self): + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, 401) + + def test_authenticated_returns_paginated_shape(self): + AuditLog.objects.create( + user=self.admin, + action='user.suspended', + target_type='user', + target_id='42', + details={'reason': 'spam'}, + ) + resp = self.client.get(self.url, **self.auth) + self.assertEqual(resp.status_code, 200, resp.content) + body = resp.json() + for key in ('results', 'total', 'page', 'page_size', 'total_pages'): + self.assertIn(key, body) + self.assertEqual(body['total'], 1) + self.assertEqual(body['results'][0]['action'], 'user.suspended') + self.assertEqual(body['results'][0]['user']['email'], self.admin.email) + + def test_search_narrows_results(self): + AuditLog.objects.create( + user=self.admin, action='user.suspended', + target_type='user', target_id='1', details={}, + ) + AuditLog.objects.create( + user=self.admin, action='event.approved', + target_type='event', target_id='1', details={}, + ) + resp = self.client.get(self.url, {'search': 'suspend'}, **self.auth) + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertEqual(body['total'], 1) + self.assertEqual(body['results'][0]['action'], 'user.suspended') + + def test_page_size_is_bounded(self): + # page_size=999 must be clamped to the 200-row upper bound. + resp = self.client.get(self.url, {'page_size': '999'}, **self.auth) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()['page_size'], 200) + + def test_invalid_pagination_falls_back_to_defaults(self): + resp = self.client.get(self.url, {'page': 'x', 'page_size': 'y'}, **self.auth) + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertEqual(body['page'], 1) + self.assertEqual(body['page_size'], 50) + + +# --------------------------------------------------------------------------- +# AuditLogMetricsView +# --------------------------------------------------------------------------- + + +class AuditLogMetricsViewTests(_AuditTestBase): + url = '/api/v1/rbac/audit-log/metrics/' + + def test_unauthenticated_returns_401(self): + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, 401) + + def test_returns_expected_shape(self): + AuditLog.objects.create( + user=self.admin, action='event.approved', + target_type='event', target_id='7', details={}, + ) + AuditLog.objects.create( + user=self.admin, action='department.created', + target_type='department', target_id='3', details={}, + ) + resp = self.client.get(self.url, **self.auth) + self.assertEqual(resp.status_code, 200, resp.content) + body = resp.json() + for key in ('total', 'today', 'week', 'distinct_users', 'by_action_group'): + self.assertIn(key, body) + self.assertEqual(body['total'], 2) + self.assertEqual(body['distinct_users'], 1) + # Each group present so frontend tooltip can render all 6 rows. + for group in ('create', 'update', 'delete', 'moderate', 'auth', 'other'): + self.assertIn(group, body['by_action_group']) + self.assertEqual(body['by_action_group']['moderate'], 1) + self.assertEqual(body['by_action_group']['create'], 1) + + def test_nocache_bypasses_stale_cache(self): + # Prime cache with a fake payload. + cache.set( + 'admin_api:audit_log:metrics:v1', + { + 'total': 999, + 'today': 0, 'week': 0, 'distinct_users': 0, + 'by_action_group': { + 'create': 0, 'update': 0, 'delete': 0, + 'moderate': 0, 'auth': 0, 'other': 0, + }, + }, + 60, + ) + + resp_cached = self.client.get(self.url, **self.auth) + self.assertEqual(resp_cached.json()['total'], 999) + + resp_fresh = self.client.get(self.url, {'nocache': '1'}, **self.auth) + self.assertEqual(resp_fresh.json()['total'], 0) # real DB state + + +# --------------------------------------------------------------------------- +# UserStatusView — audit emission +# --------------------------------------------------------------------------- + + +class UserStatusAuditEmissionTests(_AuditTestBase): + """Each status transition must leave a matching row in `AuditLog`. + + The endpoint wraps the state change + audit log in `transaction.atomic()` + so the two can never disagree. These assertions catch regressions where a + new branch forgets the audit call. + """ + + def _url(self, user_id: int) -> str: + return f'/api/v1/users/{user_id}/status/' + + def setUp(self): + super().setUp() + self.target = User.objects.create_user( + username='target@example.com', + email='target@example.com', + password='irrelevant', + role='customer', + ) + + def test_suspend_emits_audit_row(self): + resp = self.client.patch( + self._url(self.target.id), + data={'action': 'suspend', 'reason': 'spam flood'}, + content_type='application/json', + **self.auth, + ) + self.assertEqual(resp.status_code, 200, resp.content) + log = AuditLog.objects.filter( + action='user.suspended', target_id=str(self.target.id), + ).first() + self.assertIsNotNone(log, 'suspend did not emit audit log') + self.assertEqual(log.details.get('reason'), 'spam flood') + + def test_ban_emits_audit_row(self): + resp = self.client.patch( + self._url(self.target.id), + data={'action': 'ban'}, + content_type='application/json', + **self.auth, + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertTrue( + AuditLog.objects.filter( + action='user.banned', target_id=str(self.target.id), + ).exists(), + 'ban did not emit audit log', + ) + + def test_reinstate_emits_audit_row(self): + self.target.is_active = False + self.target.save() + resp = self.client.patch( + self._url(self.target.id), + data={'action': 'reinstate'}, + content_type='application/json', + **self.auth, + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertTrue( + AuditLog.objects.filter( + action='user.reinstated', target_id=str(self.target.id), + ).exists(), + 'reinstate did not emit audit log', + ) diff --git a/admin_api/urls.py b/admin_api/urls.py index 4fb1c0d..211188a 100644 --- a/admin_api/urls.py +++ b/admin_api/urls.py @@ -80,6 +80,7 @@ urlpatterns = [ path('rbac/scopes/', views.ScopeListView.as_view(), name='rbac-scope-list'), path('rbac/org-tree/', views.OrgTreeView.as_view(), name='rbac-org-tree'), path('rbac/audit-log/', views.AuditLogListView.as_view(), name='rbac-audit-log'), + path('rbac/audit-log/metrics/', views.AuditLogMetricsView.as_view(), name='rbac-audit-log-metrics'), # Notifications (admin-side recurring email jobs) path('notifications/types/', views.NotificationTypesView.as_view(), name='notification-types'), diff --git a/admin_api/views.py b/admin_api/views.py index 443d68e..76c809c 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -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)