Compare commits

..

9 Commits

Author SHA1 Message Date
d9a2af7168 fix(reviews): expose profile_photo in /api/reviews/list payload
_serialize_review() was not returning the reviewer's profile_picture URL,
so the consumer app had no field to key off and always rendered DiceBear
cartoons for every reviewer.

- Resolves r.reviewer.profile_picture.url when non-empty
- Treats default.png placeholder as no-photo (returns empty string)
- Defensive try/except around FK dereference, same pattern as user.py

Paired with mvnew consumer v1.7.8 which consumes the new field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 00:32:00 +05:30
f75d4f2915 fix: add missing get_object_or_404 import in PartnerImpersonateView 2026-04-21 23:18:37 +05:30
05de552820 feat(partners): add PartnerImpersonateView for admin Login-as-Partner
POST /api/v1/partners/<pk>/impersonate/ mints a short-lived JWT for the
partner's primary partner_manager user. Returns access + refresh tokens
so the partner portal can create a session without requiring a password.
Writes a partner.impersonated audit log row with admin username, partner
name, and impersonated user for traceability.

Closes: admin Login-as-Partner showing "Partner not found" (mock data)
2026-04-21 22:55:08 +05:30
f85188ca6b revert: remove partner role login block from AdminLoginView
Partner accounts must be able to log into admin.eventifyplus.com.
ProtectedRoute empty-module redirect (frontend) handles the access
boundary — no backend login gate needed.
2026-04-21 18:38:10 +05:30
64ff08b2b2 security: block non-admin roles from AdminLoginView
AdminLoginView previously accepted any valid credential regardless of
role. partner_manager / partner / partner_staff / partner_customer /
customer accounts could obtain admin JWTs and land on admin.eventifyplus.com,
where protected pages would render generic "not found" empty states.

Now returns 403 for those roles unless the user is a superuser or has an
attached StaffProfile. Writes an auth.admin_login_failed audit row with
reason=non_admin_role.

Closes gap reported for novakopro@gmail.com on /partners/3.
2026-04-21 18:35:16 +05:30
4a9f754fda feat(rbac): add Reviews/Contributions/Leads/Audit scope defs + fix reviews module mapping (v1.14.0)
- SCOPE_DEFINITIONS extended with 13 new scopes across 4 categories so the
  admin Roles & Permissions grid and new Base Permissions tab can grant
  module-level access
- StaffProfile.SCOPE_TO_MODULE was missing 'reviews': 'reviews' — staff with
  reviews.* scopes could not resolve the Reviews module in their sidebar
- NotificationSchedule CRUD views now emit AuditLog rows
  (notification.schedule.created / .updated / .deleted) matching the
  v1.13.0 audit coverage pattern

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 17:11:01 +05:30
66e41ba647 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>
2026-04-21 13:42:02 +05:30
2c60a82704 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>
2026-04-21 12:39:38 +05:30
9cde886bd4 feat(notifications): add test-send endpoint for single-address preview
POST /api/v1/notifications/schedules/<pk>/test-send/ accepts {"email": "..."},
renders the schedule's email, delivers to that address only with [TEST] prefix.
Does not touch last_run_at or last_status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:53:33 +05:30
8 changed files with 1081 additions and 62 deletions

View File

@@ -5,6 +5,119 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), version
---
## [1.14.1] — 2026-04-22
### Fixed
- **`_serialize_review()` now returns `profile_photo`** (`mobile_api/views/reviews.py`) — `/api/reviews/list` payload was missing the reviewer's photo URL, so the consumer app had no choice but to render DiceBear placeholders for every reviewer regardless of whether they had uploaded a real profile picture
- Resolves `r.reviewer.profile_picture.url` when the field is non-empty and the file name is not `default.png` (the model's placeholder default); returns empty string otherwise so the frontend can fall back cleanly to DiceBear
- Mirrors the existing pattern in `mobile_api/views/user.py` (`LoginView`, `StatusView`, `UpdateProfileView`) — same defensive try/except around FK dereference
- Pure serializer change — no migration, no URL change, no permission change; `gunicorn kill -HUP 1` picks it up
---
## [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
### 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/<id>/status/`) — `user.suspended`, `user.banned`, `user.reinstated`, `user.flagged`; details capture `reason`, `previous_status`, `new_status`
- `EventModerationView` (`PATCH /api/v1/events/<id>/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/<id>/moderate/`) — `review.approved`, `review.rejected`, `review.edited`; details include `reject_reason`, `edited_text` flag, `original_text` on edits
- `PartnerKYCReviewView` (`POST /api/v1/partners/<id>/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

View File

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

View File

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

View File

@@ -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,8 @@ class StaffProfile(models.Model):
'ads': 'ad-control',
'contributions': 'contributions',
'leads': 'leads',
'audit': 'audit-log',
'reviews': 'reviews',
}
modules = {'dashboard'}
for scope in scopes:
@@ -179,6 +181,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}"

325
admin_api/tests.py Normal file
View File

@@ -0,0 +1,325 @@
"""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',
)
# ---------------------------------------------------------------------------
# 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

@@ -18,6 +18,7 @@ urlpatterns = [
path('partners/<int:pk>/', views.PartnerDetailView.as_view(), name='partner-detail'),
path('partners/<int:pk>/status/', views.PartnerStatusView.as_view(), name='partner-status'),
path('partners/<int:pk>/kyc/review/', views.PartnerKYCReviewView.as_view(), name='partner-kyc-review'),
path('partners/<int:pk>/impersonate/', views.PartnerImpersonateView.as_view(), name='partner-impersonate'),
path('partners/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'),
path('partners/<int:partner_id>/staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'),
path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
@@ -80,6 +81,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'),
@@ -88,6 +90,7 @@ urlpatterns = [
path('notifications/schedules/<int:pk>/recipients/', views.NotificationRecipientView.as_view(), name='notification-recipient-create'),
path('notifications/schedules/<int:pk>/recipients/<int:rid>/', views.NotificationRecipientDetailView.as_view(), name='notification-recipient-detail'),
path('notifications/schedules/<int:pk>/send-now/', views.NotificationScheduleSendNowView.as_view(), name='notification-schedule-send-now'),
path('notifications/schedules/<int:pk>/test-send/', views.NotificationScheduleTestSendView.as_view(), name='notification-schedule-test-send'),
# Ad Control
path('ad-control/', include('ad_control.urls')),

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,31 +479,84 @@ 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 '')
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})
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)
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')
p.kyc_compliance_reason = request.data.get('reason', '')
p.save(update_fields=['kyc_compliance_status', 'is_kyc_compliant', 'kyc_compliance_reason'])
# `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 +696,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)
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)})
# ---------------------------------------------------------------------------
@@ -833,7 +923,12 @@ class EventUpdateView(APIView):
updated_fields.append('event_status')
if 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)
@@ -843,17 +938,34 @@ 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')
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 = request.data.get('reason', '')
e.cancelled_reason = reason
e.save(update_fields=['event_status', 'cancelled_reason'])
elif action == 'flag':
e.event_status = 'flagged'
@@ -864,8 +976,22 @@ class EventModerationView(APIView):
elif action == 'unfeature':
e.is_featured = False
e.save(update_fields=['is_featured'])
else:
return Response({'error': 'Invalid action'}, status=400)
_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))
@@ -877,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)
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)
@@ -1000,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'
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))
@@ -1093,8 +1232,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 +1248,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)
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))
@@ -1120,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)
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)
@@ -1198,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):
@@ -1217,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)
@@ -1301,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)
@@ -1337,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})
@@ -1383,12 +1583,29 @@ SCOPE_DEFINITIONS = {
'ads.write': {'label': 'Create/Edit Campaigns', 'category': 'Ad Control'},
'ads.approve': {'label': 'Approve Campaigns', '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(
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),
@@ -1966,10 +2183,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 +2223,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 +2256,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
# ---------------------------------------------------------------------------
@@ -2109,6 +2434,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(
@@ -2238,6 +2568,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)},
@@ -2264,6 +2600,43 @@ class PartnerStaffCreateView(APIView):
)
class PartnerImpersonateView(APIView):
"""
POST /api/v1/partners/<pk>/impersonate/
Admin-only: generate a short-lived JWT for the partner's primary manager user.
Returns access/refresh tokens + user info so the partner portal can create a session.
"""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
from partner.models import Partner as PartnerModel
from django.shortcuts import get_object_or_404
partner = get_object_or_404(PartnerModel, pk=pk)
partner_user = User.objects.filter(partner=partner, role='partner_manager').first()
if not partner_user:
return Response(
{'error': 'No partner_manager user found for this partner.'},
status=status.HTTP_404_NOT_FOUND,
)
refresh = RefreshToken.for_user(partner_user)
_audit_log(request, 'partner.impersonated', 'partner', str(pk), {
'partner_name': partner.name,
'impersonated_user': partner_user.username,
'admin': request.user.username,
})
return Response({
'access': str(refresh.access_token),
'refresh': str(refresh),
'user': {
'id': partner_user.id,
'email': partner_user.email,
'username': partner_user.username,
'role': partner_user.role,
'partnerId': str(pk),
},
})
# ─── Gamification Dashboard (stub) ───────────────────────────────────────────
class GamificationDashboardView(APIView):
permission_classes = [] # public for now; restrict when auth is wired up
@@ -2520,6 +2893,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))
@@ -2595,6 +2970,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()
@@ -2629,6 +3005,19 @@ class NotificationScheduleListView(APIView):
display_name=(r.get('displayName') or '').strip(),
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}',
request=request, user=request.user)
@@ -2649,6 +3038,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 = []
@@ -2677,6 +3067,20 @@ class NotificationScheduleDetailView(APIView):
changed.append('is_active')
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"}',
request=request, user=request.user)
return Response(_serialize_schedule(s))
@@ -2684,8 +3088,23 @@ 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)
schedule_name = s.name
schedule_type = s.notification_type
schedule_cron = s.cron_expression
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)
return Response(status=204)
@@ -2710,6 +3129,19 @@ class NotificationRecipientView(APIView):
display_name=(request.data.get('displayName') or '').strip(),
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)
@@ -2721,6 +3153,7 @@ class NotificationRecipientDetailView(APIView):
from django.shortcuts import get_object_or_404
r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk)
changed = []
if (email := request.data.get('email')) is not None:
email = str(email).strip().lower()
if not email:
@@ -2730,22 +3163,57 @@ class NotificationRecipientDetailView(APIView):
).exclude(pk=rid).exists()
if clash:
return Response({'error': f'{email} is already a recipient'}, status=409)
if r.email != email:
r.email = email
changed.append('email')
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:
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()
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))
def delete(self, request, pk, rid):
from notifications.models import NotificationRecipient
from django.shortcuts import get_object_or_404
r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk)
recipient_email = r.email
recipient_name = r.display_name
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)
@@ -2758,6 +3226,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:
@@ -2776,6 +3245,18 @@ class NotificationScheduleSendNowView(APIView):
schedule.save(update_fields=[
'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)',
request=request, user=request.user)
return Response({
@@ -2783,3 +3264,52 @@ class NotificationScheduleSendNowView(APIView):
'recipientCount': recipient_count,
'schedule': _serialize_schedule(schedule),
})
class NotificationScheduleTestSendView(APIView):
"""Send a preview of this schedule's email to a single address.
Does NOT update last_run_at / last_status — purely for previewing content.
"""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
from notifications.models import NotificationSchedule
from notifications.emails import BUILDERS
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)
email = (request.data.get('email') or '').strip().lower()
if not email:
return Response({'error': 'email is required'}, status=400)
builder = BUILDERS.get(schedule.notification_type)
if builder is None:
return Response(
{'error': f'No builder for type: {schedule.notification_type}'},
status=400,
)
try:
subject, html = builder(schedule)
subject = f'[TEST] {subject}'
msg = EmailMessage(
subject=subject,
body=html,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[email],
)
msg.content_subtype = 'html'
msg.send(fail_silently=False)
except Exception as exc: # noqa: BLE001
log('error', f'Test-send failed for schedule #{pk}: {exc}',
request=request, user=request.user)
return Response({'error': str(exc)}, status=500)
log('info', f'Test email sent for schedule #{pk}{email}',
request=request, user=request.user)
return Response({'ok': True, 'sentTo': email})

View File

@@ -29,11 +29,20 @@ def _serialize_review(r, user_interactions=None):
uname = r.reviewer.username
except Exception:
uname = ''
try:
pic = r.reviewer.profile_picture
if pic and pic.name and 'default.png' not in pic.name:
profile_photo = pic.url
else:
profile_photo = ''
except Exception:
profile_photo = ''
return {
'id': r.id,
'event_id': r.event_id,
'username': uname,
'display_name': display,
'profile_photo': profile_photo,
'rating': r.rating,
'comment': r.review_text,
'status': _STATUS_TO_JSON.get(r.status, r.status),