59 Commits

Author SHA1 Message Date
16c21c17d2 feat(partner-portal): Sprint 2 — partner-me events CRUD endpoints
Add partner-scoped event endpoints under /api/v1/partners/me/events/:
- GET/POST  /partners/me/events/            → list + create
- GET/PATCH/DELETE /partners/me/events/{pk}/ → detail + update + delete
- POST /partners/me/events/{pk}/duplicate/   → clone as draft

All endpoints enforce partner ownership via _require_owned_event().
Create auto-sets partner FK + source='partner'. Duplicate always
resets status to 'created' (draft).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:20:37 +05:30
761b702e57 feat(partner-portal): Sprint 1 — partner-me settings endpoints
Add 4 self-service endpoints under /api/v1/partners/me/:
- GET/PUT  /partners/me/profile/      → name, email, phone, website, bio
- GET/PUT  /partners/me/notifications/ → 4 boolean notification prefs
- GET/PUT  /partners/me/payout/        → bank account + payout schedule
- POST     /partners/me/change-password/ → current+new password change

Model changes (partner/models.py + migration 0002):
- Partner.bio TextField
- Partner.payout_* fields (holder name, account number, IFSC, bank name, schedule)
- Partner.notif_* boolean fields (new_booking, event_status, payout_update, weekly_report)

Auth: simplejwt Bearer token (same as all admin_api views).
Role guard: _require_partner() enforces partner/partner_manager/partner_staff
and verifies user.partner FK is non-null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:02:34 +05:30
46b391bd51 fix: allow partner portal SSR to reach admin_api (/me/) for impersonation
ALLOWED_HOSTS was missing partner.eventifyplus.com + docker internal
hostnames (eventify-backend, eventify-django). Partner Next.js
server-side authorize() fetch to /api/v1/auth/me/ was rejected with
HTTP 400 DisallowedHost, so admin "Login as Partner" redirected to
/login?error=ImpersonationFailed instead of /dashboard.

Also added `partner` FK to UserSerializer so the /me/ response exposes
the partner id the portal needs to set session.user.partnerId.

Deployed to both eventify-backend and eventify-django containers via
docker cp + HUP.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:30:58 +05:30
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
a8751b5183 feat(notifications): add scheduled email notification system
- NotificationSchedule + NotificationRecipient models with initial migration
- emails.py BUILDERS registry + events_expiring_this_week HTML email builder (IST week bounds)
- send_scheduled_notifications management command (croniter due-check + select_for_update(skip_locked))
- 6 admin API endpoints under /api/v1/notifications/ (types, schedules CRUD, recipients CRUD, send-now)
- date_from/date_to filters on EventListView for dashboard card
- croniter>=2.0.0 added to requirements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:41:46 +05:30
170208d3e5 fix(search): include name field in EventListAPI full-text search
title__icontains only searched the optional title column; most events
are stored in the required name field, so Thrissur Pooram and similar
events were invisible to the q= search filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:03:05 +05:30
ca24a4cb23 fix: restore requests package for google-auth transport
google.auth.transport.requests requires requests — removed it incorrectly
during Clerk cleanup since requests is also a google-auth runtime dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:35:28 +05:30
e0a491e8cb security: fix GoogleLoginView audience check + replace Clerk with direct GIS flow
- verify_oauth2_token now passes GOOGLE_CLIENT_ID as third arg (audience check)
- fail-closed: returns 503 if GOOGLE_CLIENT_ID env var is not set
- add GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '') to settings
- replace ClerkLoginViewTests with GoogleLoginViewTests (4 cases)
- update requirements-docker.txt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:31:18 +05:30
aa2846b884 fix(admin): include eventifyId in _serialize_user for users table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 06:51:14 +05:30
086bbbf546 fix(registration): seed gamification profile with eventify_id on account creation
Added _seed_gamification_profile() helper that inserts a row into
user_gamification_profiles immediately after user.save(), so every new
account has their eventify_id in the Node.js gamification DB from day one.
Non-fatal: failures are logged as warnings without blocking registration.
Called in both RegisterView (mobile) and WebRegisterView (web).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 06:47:58 +05:30
60d98f1ae8 feat: add profile_photo to StatusView response
Expose profile_photo in /user/status/ so the Flutter app can
hydrate the profile picture for existing sessions without requiring
a re-login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:12:27 +05:30
9aa7c01efe feat(favorites): add EventLike model, favorites API, and notifications module
- EventLike model (user × event unique constraint, indexed)
- contributed_by field on Event (EVT ID or email of community contributor)
- Favorites API endpoints: toggle-like, my-likes, my-liked-events
- Notifications app wired into main urls.py at /api/notifications/
- accounts migration 0014_merge_0013 (resolves split 0013 branches)
- requirements.txt updated
2026-04-07 12:56:25 +05:30
d04891c064 feat(leads): link consumer account to lead on submission
- user_account FK on Lead model (SET_NULL, related_name='submitted_leads')
- Migration 0004_lead_user_account
- ScheduleCallView auto-matches consumer account by email on create
- _serialize_lead now returns userAccount: {id, name, email, phone, eventifyId, profilePicture}
2026-04-07 11:52:41 +05:30
9142b8fedb feat(leads): add Lead Manager module with full admin and consumer endpoints
- Lead model in admin_api with status/priority/source/assigned_to fields
- Admin API: metrics, list, detail, update views at /api/v1/leads/
- Consumer API: public ScheduleCallView at /api/leads/schedule-call/
- RBAC: 'leads' module registered in ALL_MODULES and StaffProfile scopes
- Migration 0003_lead with indexes on status, priority, created_at, email
2026-04-07 10:48:04 +05:30
14c474ea87 docs: changelog v1.8.3 — TopEventsAPI fix 2026-04-06 22:21:32 +05:30
8d0e801d86 fix(top-events): remove token gate, add event_status filter and event_type_name
TopEventsAPI had AllowAny permission but still called
validate_token_and_get_user(), blocking unauthenticated carousel fetches.
Also added event_status='published' filter and event_type_name resolution
(model_to_dict only returns the FK integer, not the string name).
2026-04-06 22:16:41 +05:30
a29e8d2892 fix(featured-events): resolve event_type FK to name string in API response
model_to_dict() returns event_type as an integer PK; the DHS frontend
reads ev.event_type_name to show the category badge. Added
event_type_name resolution so the carousel displays e.g. "Festivals".
2026-04-06 21:44:11 +05:30
2fefdd16c9 docs: changelog v1.8.1 — FeaturedEventsAPI token gate fix 2026-04-06 19:45:46 +05:30
8ae97dcdc7 fix(featured-events): remove token gate from FeaturedEventsAPI
FeaturedEventsAPI had AllowAny permission but still called
validate_token_and_get_user(), causing it to return a token-required
error for unauthenticated requests from the desktop hero slider.

Removed the token check entirely — the endpoint is public by design.
Also tightened the queryset to event_status='published' to match
ConsumerFeaturedEventsView behaviour.
2026-04-06 19:41:25 +05:30
05770d6d21 feat(carousel): wire is_featured flag to consumer featured events API
ConsumerFeaturedEventsView now includes events with is_featured=True
alongside ad placement results. Placement events retain priority;
is_featured events are appended, deduped, and capped at 10 total.
2026-04-06 17:25:19 +05:30
b8a69ceae2 fix(accounts): add merge migration to resolve conflicting eventify_id migrations
0011_user_eventify_id and 0012_user_eventify_id both added eventify_id field
from different base migrations. Created 0013 merge node to unify the graph.
2026-04-06 12:30:49 +05:30
b2a2cbad5f feat(ad_control): new AdSurface + AdPlacement module for placement-based featured/top events
- New ad_control Django app: AdSurface + AdPlacement models with GLOBAL/LOCAL scope
- Admin CRUD API at /api/v1/ad-control/ (JWT-protected): surfaces, placements, picker events
- Placement lifecycle: DRAFT → ACTIVE|SCHEDULED → EXPIRED|DISABLED
- LOCAL scope: Haversine ≤ 50km from event lat/lng (fixed radius, no config needed)
- Consumer APIs: /api/events/featured-events/ and /api/events/top-events/ rewritten
  to use placement-based queries (same URL paths + response shape — no breaking changes)
- Seed command: seed_surfaces --migrate converts existing is_featured/is_top_event booleans
- mount: admin_api/urls.py → ad-control/, mobile_api/urls.py → replaced consumer views
- settings.py: added ad_control to INSTALLED_APPS
2026-04-06 12:10:06 +05:30
635a1224cd fix: add localhost:8080 to CORS_ALLOWED_ORIGINS for Flutter web preview 2026-04-04 18:56:47 +05:30
3a3f6d4179 feat: HOME-007 — server-side event title/description search (q param) 2026-04-04 17:33:56 +05:30
c9afbcf3cc feat(accounts): home district with 6-month cooldown
- accounts/models.py: add district_changed_at DateTimeField + VALID_DISTRICTS constant (14 Kerala districts)
- migration 0013_user_district_changed_at: nullable DateTimeField, no backfill
- WebRegisterForm: accept optional district during signup, stamp district_changed_at
- UpdateProfileView: enforce 183-day cooldown with human-readable error
- LoginView/WebRegisterView/StatusView: include district_changed_at in responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 10:42:44 +05:30
ac2b2ba242 fix(users): add include_all param to UserListView for contributor search
Superusers (admins) were excluded by the is_superuser=False filter,
making them unsearchable in the contributor picker. Pass include_all=1
to bypass this filter when searching for event contributors.
2026-04-03 17:41:45 +05:30
a208ddf1f7 fix(users): add eventify_id__icontains to UserListView search filter
EVT-XXXXXXXX searches were returning no results because the Q filter
only covered first_name, last_name, email, username, phone_number.
2026-04-03 17:37:09 +05:30
4a24e9cdca feat(events): add EventDeleteView for permanent event deletion
- Add EventDeleteView with DELETE /api/v1/events/<pk>/delete/
- Register delete URL in admin_api/urls.py
2026-04-03 17:21:26 +05:30
bae9ac9e23 docs: add v1.6.1 and v1.6.2 CHANGELOG entries
Documents StatusView eventify_id addition and the security fix
that stops internal Python exceptions from reaching API callers.
2026-04-03 09:27:15 +05:30
a5bdde278d security: never expose internal exceptions to API callers
All except blocks in user.py and events.py now log the real
error server-side (via eventify_logger) and return a generic
"An unexpected server error occurred." message to the client.
Python tracebacks, model field names, and ORM errors are no
longer visible in API responses.
2026-04-03 09:23:26 +05:30
fc5aa555e5 feat(api): return eventify_id in StatusView response
Adds `eventify_id` to the `/api/user/status/` endpoint so that
`initProfileTickets` can fetch the EVT-XXXXXXXX badge for users
whose localStorage session pre-dates the eventify_id login field.
2026-04-03 09:14:37 +05:30
9d61967350 feat: add Haversine radius-based location filtering to EventListAPI
- Add _haversine_km() great-circle distance function (pure Python, no PostGIS)
- EventListAPI now accepts optional latitude, longitude, radius_km params
- Bounding-box SQL pre-filter narrows candidates, Haversine filters precisely
- Progressive radius expansion: 10km → 25km → 50km → 100km if <6 results
- Backward compatible: falls back to pincode filtering when no coords provided
- Response includes radius_km field showing effective search radius used
- Guard radius_km float conversion against malformed input
- Use `is not None` checks for lat/lng (handles 0.0 edge case)
- Expansion list filters to only try radii larger than requested
2026-04-03 08:56:00 +05:30
99f376506d docs: add CHANGELOG.md and update README version to 1.6.0
- CHANGELOG.md: full history from 1.0.0 → 1.6.0 (Keep a Changelog format)
- README.md: bump version badge 1.5.0 → 1.6.0, add changelog summary table
2026-04-02 11:03:18 +05:30
384797551f feat: add Eventify ID (EVT-XXXXXXXX) to User model and all APIs
- Add eventify_id CharField (unique, indexed, editable=False) to User
- Auto-generate on save() with charset excluding I/O/0/1 for clarity
- Migration 0012: add field nullable, backfill all existing users, make non-null
- Sync migration 0011 (allowed_modules) pulled from server
- Expose eventify_id in accounts/api.py, partner/api.py serializers
- Expose eventify_id in mobile_api login response (populates localStorage)
2026-04-02 10:26:08 +05:30
255519473b feat: add RBAC migrations, user modules, admin API updates, and utility scripts 2026-04-02 04:06:02 +00:00
1b6185c758 security: fix SMTP credential exposure and auth bypass
- C-1: Move EMAIL_HOST_PASSWORD to os.environ (was hardcoded plaintext)
- C-2: Enable token-user cross-validation in validate_token_and_get_user()
  (compares token.user_id with user.id to prevent impersonation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:29:42 +00:00
43123d0ff1 feat: add source field with 3 options, fix EventListAPI fallback, add is_eventify_event to API response
- Event.source field updated: eventify, community, partner (radio select in form)
- EventListAPI: fallback to all events when pincode returns < 6
- EventListAPI: include is_eventify_event and source in serializer
- Admin API: add source to list serializer
- Django admin: source in list_display, list_filter, list_editable
- Event form template: proper radio button rendering for source field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:23:03 +00:00
388057b641 feat: add user search/filter, banned metric, mobile review API, event detail improvements
- admin_api/views.py: Add banned count to UserMetrics, fix server-side search/filter in UserListView
- admin_api/models.py: Add ReviewInteraction model, display_name/is_verified/helpful_count/flag_count to Review
- mobile_api/views/reviews.py: Customer-facing review submit/list/helpful/flag endpoints
- mobile_api/urls.py: Wire review API routes
- mobile_api/views/events.py: Event detail and listing improvements
- Security hardening across API modules
2026-03-26 09:50:03 +00:00
5a2752a2de fix: security audit remediation — Django settings + payment gateway API
- ALLOWED_HOSTS: wildcard replaced with explicit domain list (#15)
- CORS_ALLOWED_ORIGINS: added app.eventifyplus.com (#16)
- CSRF_TRUSTED_ORIGINS: added app.eventifyplus.com (#18)
- JWT ACCESS_TOKEN_LIFETIME: 1 day reduced to 30 minutes (#19)
- ROTATE_REFRESH_TOKENS enabled
- SECRET_KEY: removed unsafe fallback, crash on missing env var
- Added ActivePaymentGatewayView for dynamic gateway config (#1, #5, #20)
- Added PaymentGatewaySettingsView CRUD for admin panel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:25:11 +00:00
b12f4952b3 fix: update admin_api migration dependency to existing events migration
0001_initial was referencing events.0011_dashboard_indexes which no
longer exists as a file on disk (the DB has it applied but the file
was removed). Updated dependency to 0010_merge_20260324_1443 which
is the latest events migration file present, resolving the
NodeNotFoundError on management commands.
2026-03-25 11:49:15 +05:30
ea378f19b1 refactor: migrate users to PostgreSQL, remove SQLite secondary DB
Users have been migrated from eventify-django SQLite to eventify-backend
PostgreSQL. The temporary users_db workaround is no longer needed:

- settings.py: removed users_db SQLite secondary database config
- views.py: removed _user_db()/_user_qs() helpers; user views now query
  the default PostgreSQL directly with plain User.objects.filter()
- docker-compose.yml: SQLite read-only volume mount removed

All 27 users (25 non-superuser customers) now live in PostgreSQL.
2026-03-25 11:43:12 +05:30
54aa7ce06e fix: read real users from eventify-django SQLite via secondary database
The admin_api was querying eventify-backend's empty PostgreSQL. Real users
live in eventify-django's SQLite (db.sqlite3 on host). Fix:

- settings.py: auto-adds 'users_db' database config when users_db.sqlite3
  is mounted into the container (read-only volume in docker-compose)
- views.py: _user_db() helper selects the correct database alias;
  _user_qs() defers 'partner' field (absent from older SQLite schema)
- UserMetricsView, UserListView, UserDetailView, UserStatusView all use
  _user_qs() so they query the 25 real registered customers
2026-03-25 11:38:03 +05:30
a3d1bbad30 fix: scope users API to end-users and tag new registrations as customers
- UserListView and UserMetricsView now filter is_superuser=False so only
  end-user accounts appear in the admin Users page (not admin/staff)
- _serialize_user now returns avatarUrl from profile_picture field so the
  grid view renders profile images instead of broken img tags
- RegisterForm and WebRegisterForm now set is_customer=True and
  role='customer' on save so future registrants are correctly classified
2026-03-25 11:10:29 +05:30
54315408eb Phase 7: Reviews Moderation — Review model + migration + 4 admin endpoints (metrics, list, moderate, delete) 2026-03-25 02:46:50 +00:00
3103eff949 Phase 6: Financials & Payouts — 4 new financial endpoints (metrics, transactions, settlements, release) 2026-03-24 19:05:33 +00:00
bc0a9ad5c8 docs: beautify README with ASCII banner, badges, API reference, and architecture diagram 2026-03-24 18:47:15 +00:00
d921dde598 Phase 5: Events Admin — 4 new event endpoints (stats, list, detail, moderate) 2026-03-24 18:42:15 +00:00
54d31dd3b1 Phase 4: Users & RBAC — 4 new user endpoints (list, metrics, detail, status) 2026-03-24 18:26:55 +00:00
Ubuntu
cbe06e9c8f feat: Phase 3 - Partners API (5 endpoints + 2 helpers)
- GET /api/v1/partners/stats/ - total, active, pendingKyc, highRisk counts
- GET /api/v1/partners/ - paginated list with status/kyc/type/search filters
- GET /api/v1/partners/:id/ - full detail with events, kycDocuments, dealTerms, ledger
- PATCH /api/v1/partners/:id/status/ - suspend/activate partner
- POST /api/v1/partners/:id/kyc/review/ - approve/reject KYC with reason

Helpers: _serialize_partner(), _partner_kyc_docs()
Status/KYC/type mapping: backend snake_case to frontend capitalised values
Risk score derived from kyc_compliance_status (high_risk=80, approved=5, etc.)
All views IsAuthenticated, models imported inside methods
2026-03-24 18:11:33 +00:00
Ubuntu
b60d03142c feat: Phase 1+2 - JWT auth, dashboard metrics API, DB indexes
Phase 1 - JWT Auth Foundation:
- Replace token auth with djangorestframework-simplejwt
- POST /api/v1/admin/auth/login/ - returns access + refresh JWT
- POST /api/v1/auth/refresh/ - JWT refresh
- GET /api/v1/auth/me/ - current admin profile
- GET /api/v1/health/ - DB health check
- Add ledger app to INSTALLED_APPS

Phase 2 - Dashboard Metrics API:
- GET /api/v1/dashboard/metrics/ - revenue, partners, events, tickets
- GET /api/v1/dashboard/revenue/ - 7-day revenue vs payouts chart data
- GET /api/v1/dashboard/activity/ - last 10 platform events feed
- GET /api/v1/dashboard/actions/ - KYC queue, flagged events, pending payouts

DB Indexes (dashboard query optimisation):
- RazorpayTransaction: status, captured_at
- Partner: status, kyc_compliance_status
- Event: event_status, start_date, created_date
- Booking: created_date
- PaymentTransaction: payment_type, payment_transaction_status, payment_transaction_date

Infra:
- Add Dockerfile for eventify-backend container
- Add simplejwt to requirements.txt
- All 4 dashboard views use IsAuthenticated permission class
2026-03-24 17:46:41 +00:00
Ubuntu
37001f8e70 feat: add JWT auth foundation - /api/v1/ with admin login, refresh, me, health endpoints
- Add djangorestframework-simplejwt==5.3.1 to requirements-docker.txt
- Configure REST_FRAMEWORK with JWTAuthentication and SIMPLE_JWT settings
- Create admin_api Django app with AdminLoginView, MeView, HealthView
- Wire /api/v1/ routes without touching existing /api/ mobile endpoints
- Resolve pre-existing events migration conflict (0010_merge)
- Superuser admin created for initial authentication
2026-03-24 14:46:03 +00:00
24 changed files with 2542 additions and 65 deletions

View File

@@ -5,6 +5,154 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), version
--- ---
## [1.14.2] — 2026-04-22
### Fixed
- **Admin "Login as Partner" impersonation now completes into `/dashboard`** instead of bouncing back to `/login?error=ImpersonationFailed`. Two linked issues:
- **`ALLOWED_HOSTS`** (`eventify/settings.py`) — partner portal's server-side `authorize()` (Next.js) calls `${BACKEND_API_URL}/api/v1/auth/me/` with `BACKEND_API_URL=http://eventify-backend:8000`, so the HTTP `Host` header was `eventify-backend` — not in the Django allowlist. `SecurityMiddleware` rejected with HTTP 400 DisallowedHost, `authorize()` returned null, `signIn()` failed, and the `/impersonate` page redirected to the login error. Added `partner.eventifyplus.com`, `eventify-backend`, and `eventify-django` to `ALLOWED_HOSTS`. Same Host issue was silently breaking regular partner password login too — fixed as a side effect.
- **`UserSerializer` missing `partner` field** (`admin_api/serializers.py`) — `MeView` returned `/api/v1/auth/me/` payload with no `partner` key, so the partner portal's `auth.ts` set `partnerId: ""` on the NextAuth session. Downstream dashboard queries that filter by `partnerId` would then return empty/403. Added `partner = PrimaryKeyRelatedField(read_only=True)` to the serializer's `fields` list. Payload now includes `"partner": <id>`.
- Deploy: `docker cp` both files into **both** `eventify-backend` and `eventify-django` containers + `kill -HUP 1` on each (per shared admin_api rule).
---
## [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
- **`GoogleLoginView` audience-check fix** (`POST /api/user/google-login/`) — **CRITICAL security patch**
- `verify_oauth2_token(token, google_requests.Request())` was called **without** the third `audience` argument, meaning any valid Google-signed ID token from *any* OAuth client was accepted — token spoofing from external apps was trivially possible
- Fixed to `verify_oauth2_token(token, google_requests.Request(), settings.GOOGLE_CLIENT_ID)` — only tokens whose `aud` claim matches our registered Client ID are now accepted
- Added fail-closed guard: if `settings.GOOGLE_CLIENT_ID` is empty the view returns HTTP 503 instead of silently accepting all tokens
### Changed
- **Removed Clerk scaffolding** — the `@clerk/react` broker approach added in a prior iteration has been replaced with direct Google Identity Services (GIS) ID-token flow on the frontend. Simpler architecture: one trust boundary instead of three.
- Removed `ClerkLoginView`, `_clerk_jwks_client`, `_get_clerk_jwks_client()` from `mobile_api/views/user.py`
- Removed `path('user/clerk-login/', ...)` from `mobile_api/urls.py`
- Removed `CLERK_JWKS_URL` / `CLERK_ISSUER` / `CLERK_SECRET_KEY` from `eventify/settings.py`; replaced with `GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')`
- Removed `PyJWT[crypto]>=2.8.0` and `requests>=2.31.0` from `requirements.txt` + `requirements-docker.txt` (no longer needed; `google-auth>=2.0.0` handles verification)
### Added
- **Settings**: `GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')` in `eventify/settings.py`
- **Tests**: `mobile_api/tests.py::GoogleLoginViewTests` — 4 cases: valid token creates user (audience arg verified), missing `id_token` → 400, `ValueError` (wrong sig / wrong aud) → 401, existing user reuses DRF token
### Context
- The consumer SPA (`app.eventifyplus.com`) now loads the Google Identity Services script dynamically and POSTs a Google ID token to the existing `/api/user/google-login/` endpoint. Django is the sole session authority. `localStorage.event_token` / `event_user` are unchanged.
- Deploy requirement: set `GOOGLE_CLIENT_ID` in the Django container `.env` **before** deploying — without it the view returns 503 (fail-closed by design).
---
## [1.9.0] — 2026-04-07 ## [1.9.0] — 2026-04-07
### Added ### Added

View File

@@ -68,10 +68,10 @@ class User(AbstractUser):
help_text='Comma-separated module slugs this user can access', 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): 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": if self.is_superuser or self.role == "admin":
return ALL return ALL
if self.allowed_modules: 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): def get_allowed_modules(self):
scopes = self.get_effective_scopes() scopes = self.get_effective_scopes()
if '*' in 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 = { SCOPE_TO_MODULE = {
'users': 'users', 'users': 'users',
'events': 'events', 'events': 'events',
@@ -141,6 +141,8 @@ class StaffProfile(models.Model):
'ads': 'ad-control', 'ads': 'ad-control',
'contributions': 'contributions', 'contributions': 'contributions',
'leads': 'leads', 'leads': 'leads',
'audit': 'audit-log',
'reviews': 'reviews',
} }
modules = {'dashboard'} modules = {'dashboard'}
for scope in scopes: for scope in scopes:
@@ -179,6 +181,12 @@ class AuditLog(models.Model):
class Meta: class Meta:
ordering = ['-created_at'] 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): def __str__(self):
return f"{self.action} by {self.user} at {self.created_at}" return f"{self.action} by {self.user} at {self.created_at}"

View File

@@ -5,9 +5,10 @@ User = get_user_model()
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField() name = serializers.SerializerMethodField()
role = serializers.SerializerMethodField() role = serializers.SerializerMethodField()
partner = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta: class Meta:
model = User model = User
fields = ['id', 'email', 'username', 'name', 'role'] fields = ['id', 'email', 'username', 'name', 'role', 'partner']
def get_name(self, obj): def get_name(self, obj):
return f"{obj.first_name} {obj.last_name}".strip() or obj.username return f"{obj.first_name} {obj.last_name}".strip() or obj.username
def get_role(self, obj): def get_role(self, obj):

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,8 +18,18 @@ urlpatterns = [
path('partners/<int:pk>/', views.PartnerDetailView.as_view(), name='partner-detail'), 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>/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>/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/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'),
path('partners/<int:partner_id>/staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'), path('partners/<int:partner_id>/staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'),
# Partner-Me: partner portal self-service (Sprint 1)
path('partners/me/profile/', views.PartnerMeProfileView.as_view(), name='partner-me-profile'),
path('partners/me/notifications/', views.PartnerMeNotificationsView.as_view(), name='partner-me-notifications'),
path('partners/me/payout/', views.PartnerMePayoutView.as_view(), name='partner-me-payout'),
path('partners/me/change-password/', views.PartnerMeChangePasswordView.as_view(), name='partner-me-change-password'),
# Partner-Me: events (Sprint 2)
path('partners/me/events/', views.PartnerMeEventsView.as_view(), name='partner-me-events'),
path('partners/me/events/<int:pk>/', views.PartnerMeEventDetailView.as_view(), name='partner-me-event-detail'),
path('partners/me/events/<int:pk>/duplicate/', views.PartnerMeEventDuplicateView.as_view(), name='partner-me-event-duplicate'),
path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'), path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
path('users/', views.UserListView.as_view(), name='user-list'), path('users/', views.UserListView.as_view(), name='user-list'),
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'), path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),
@@ -80,6 +90,16 @@ urlpatterns = [
path('rbac/scopes/', views.ScopeListView.as_view(), name='rbac-scope-list'), 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/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/', 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'),
path('notifications/schedules/', views.NotificationScheduleListView.as_view(), name='notification-schedule-list'),
path('notifications/schedules/<int:pk>/', views.NotificationScheduleDetailView.as_view(), name='notification-schedule-detail'),
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 # Ad Control
path('ad-control/', include('ad_control.urls')), path('ad-control/', include('ad_control.urls')),

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,9 @@ ALLOWED_HOSTS = [
'backend.eventifyplus.com', 'backend.eventifyplus.com',
'admin.eventifyplus.com', 'admin.eventifyplus.com',
'app.eventifyplus.com', 'app.eventifyplus.com',
'partner.eventifyplus.com',
'eventify-backend',
'eventify-django',
'localhost', 'localhost',
'127.0.0.1', '127.0.0.1',
] ]
@@ -196,3 +199,9 @@ SIMPLE_JWT = {
'USER_ID_FIELD': 'id', 'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id', 'USER_ID_CLAIM': 'user_id',
} }
# --- Google OAuth (Sign in with Google via GIS ID-token flow) -----------
# The Client ID is public (safe in VITE_* env vars and the SPA bundle).
# There is NO client secret — we use the ID-token flow, not auth-code flow.
# Set the SAME value in the Django container .env and in SPA .env.local.
GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')

View File

@@ -1,3 +1,89 @@
from django.test import TestCase """Unit tests for GoogleLoginView.
# Create your tests here. Run with:
python manage.py test mobile_api.tests
"""
import json
from unittest.mock import patch, MagicMock
from django.test import TestCase, override_settings
from rest_framework.authtoken.models import Token
from accounts.models import User
@override_settings(GOOGLE_CLIENT_ID='test-client-id.apps.googleusercontent.com')
class GoogleLoginViewTests(TestCase):
url = '/api/user/google-login/'
def _valid_idinfo(self, email='new.user@example.com'):
return {
'email': email,
'given_name': 'New',
'family_name': 'User',
'aud': 'test-client-id.apps.googleusercontent.com',
}
@patch('google.oauth2.id_token.verify_oauth2_token')
def test_valid_token_creates_user(self, mock_verify):
mock_verify.return_value = self._valid_idinfo('fresh@example.com')
resp = self.client.post(
self.url,
data=json.dumps({'id_token': 'fake.google.jwt'}),
content_type='application/json',
)
self.assertEqual(resp.status_code, 200, resp.content)
body = resp.json()
self.assertEqual(body['email'], 'fresh@example.com')
self.assertEqual(body['role'], 'customer')
self.assertTrue(body['token'])
user = User.objects.get(email='fresh@example.com')
self.assertTrue(Token.objects.filter(user=user).exists())
# Confirm audience was passed to verify_oauth2_token
_, call_kwargs = mock_verify.call_args[0], mock_verify.call_args
self.assertEqual(mock_verify.call_args[0][2], 'test-client-id.apps.googleusercontent.com')
def test_missing_id_token_returns_400(self):
resp = self.client.post(
self.url,
data=json.dumps({}),
content_type='application/json',
)
self.assertEqual(resp.status_code, 400)
self.assertEqual(resp.json()['error'], 'id_token is required')
@patch('google.oauth2.id_token.verify_oauth2_token')
def test_invalid_token_returns_401(self, mock_verify):
mock_verify.side_effect = ValueError('Token audience mismatch')
resp = self.client.post(
self.url,
data=json.dumps({'id_token': 'tampered.or.wrong-aud.jwt'}),
content_type='application/json',
)
self.assertEqual(resp.status_code, 401)
self.assertEqual(resp.json()['error'], 'Invalid Google token')
@patch('google.oauth2.id_token.verify_oauth2_token')
def test_existing_user_reuses_token(self, mock_verify):
existing = User.objects.create_user(
username='returning@example.com',
email='returning@example.com',
password='irrelevant',
role='customer',
)
existing_auth_token = Token.objects.create(user=existing)
mock_verify.return_value = self._valid_idinfo('returning@example.com')
resp = self.client.post(
self.url,
data=json.dumps({'id_token': 'returning.user.jwt'}),
content_type='application/json',
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['token'], existing_auth_token.key)
# No duplicate user created
self.assertEqual(User.objects.filter(email='returning@example.com').count(), 1)

View File

@@ -244,9 +244,9 @@ class EventListAPI(APIView):
if pincode_qs.count() >= MIN_EVENTS_THRESHOLD: if pincode_qs.count() >= MIN_EVENTS_THRESHOLD:
qs = pincode_qs qs = pincode_qs
# Priority 3: Full-text search on title / description # Priority 3: Full-text search on title / name / description
if q: if q:
qs = qs.filter(Q(title__icontains=q) | Q(description__icontains=q)) qs = qs.filter(Q(title__icontains=q) | Q(name__icontains=q) | Q(description__icontains=q))
if per_type > 0 and page == 1: if per_type > 0 and page == 1:
type_ids = list(qs.values_list('event_type_id', flat=True).distinct()) type_ids = list(qs.values_list('event_type_id', flat=True).distinct())

View File

@@ -29,11 +29,20 @@ def _serialize_review(r, user_interactions=None):
uname = r.reviewer.username uname = r.reviewer.username
except Exception: except Exception:
uname = '' 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 { return {
'id': r.id, 'id': r.id,
'event_id': r.event_id, 'event_id': r.event_id,
'username': uname, 'username': uname,
'display_name': display, 'display_name': display,
'profile_photo': profile_photo,
'rating': r.rating, 'rating': r.rating,
'comment': r.review_text, 'comment': r.review_text,
'status': _STATUS_TO_JSON.get(r.status, r.status), 'status': _STATUS_TO_JSON.get(r.status, r.status),

View File

@@ -10,12 +10,32 @@ from rest_framework.authtoken.models import Token
from mobile_api.forms import RegisterForm, LoginForm, WebRegisterForm from mobile_api.forms import RegisterForm, LoginForm, WebRegisterForm
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
from django.contrib.auth import logout from django.contrib.auth import logout
from django.db import connection
from mobile_api.utils import validate_token_and_get_user from mobile_api.utils import validate_token_and_get_user
from utils.errors_json_convertor import simplify_form_errors from utils.errors_json_convertor import simplify_form_errors
from accounts.models import User from accounts.models import User
from eventify_logger.services import log from eventify_logger.services import log
def _seed_gamification_profile(user):
"""Insert a gamification profile row for a newly registered user.
Non-fatal: if the insert fails for any reason, registration still succeeds."""
try:
with connection.cursor() as cursor:
cursor.execute("""
INSERT INTO user_gamification_profiles (user_id, eventify_id)
VALUES (%s, %s)
ON CONFLICT (user_id) DO UPDATE
SET eventify_id = COALESCE(
user_gamification_profiles.eventify_id,
EXCLUDED.eventify_id
)
""", [user.email, user.eventify_id])
except Exception as e:
log("warning", "Failed to seed gamification profile on registration",
logger_data={"user": user.email, "error": str(e)})
@method_decorator(csrf_exempt, name='dispatch') @method_decorator(csrf_exempt, name='dispatch')
class RegisterView(View): class RegisterView(View):
def post(self, request): def post(self, request):
@@ -24,6 +44,7 @@ class RegisterView(View):
form = RegisterForm(data) form = RegisterForm(data)
if form.is_valid(): if form.is_valid():
user = form.save() user = form.save()
_seed_gamification_profile(user)
token, _ = Token.objects.get_or_create(user=user) token, _ = Token.objects.get_or_create(user=user)
log("info", "API user registration", request=request, user=user) log("info", "API user registration", request=request, user=user)
return JsonResponse({'message': 'User registered successfully', 'token': token.key}, status=201) return JsonResponse({'message': 'User registered successfully', 'token': token.key}, status=201)
@@ -51,6 +72,7 @@ class WebRegisterView(View):
if form.is_valid(): if form.is_valid():
print('2') print('2')
user = form.save() user = form.save()
_seed_gamification_profile(user)
token, _ = Token.objects.get_or_create(user=user) token, _ = Token.objects.get_or_create(user=user)
print('3') print('3')
log("info", "Web user registration", request=request, user=user) log("info", "Web user registration", request=request, user=user)
@@ -134,6 +156,7 @@ class StatusView(View):
"eventify_id": user.eventify_id or '', "eventify_id": user.eventify_id or '',
"district": user.district or '', "district": user.district or '',
"district_changed_at": user.district_changed_at.isoformat() if user.district_changed_at else None, "district_changed_at": user.district_changed_at.isoformat() if user.district_changed_at else None,
"profile_photo": user.profile_picture.url if user.profile_picture else '',
}) })
except Exception as e: except Exception as e:
@@ -408,12 +431,22 @@ class GoogleLoginView(View):
from google.oauth2 import id_token as google_id_token from google.oauth2 import id_token as google_id_token
from google.auth.transport import requests as google_requests from google.auth.transport import requests as google_requests
from django.conf import settings
data = json.loads(request.body) data = json.loads(request.body)
token = data.get('id_token') token = data.get('id_token')
if not token: if not token:
return JsonResponse({'error': 'id_token is required'}, status=400) return JsonResponse({'error': 'id_token is required'}, status=400)
idinfo = google_id_token.verify_oauth2_token(token, google_requests.Request()) if not settings.GOOGLE_CLIENT_ID:
log("error", "GOOGLE_CLIENT_ID not configured", request=request)
return JsonResponse({'error': 'Google login temporarily unavailable'}, status=503)
idinfo = google_id_token.verify_oauth2_token(
token,
google_requests.Request(),
settings.GOOGLE_CLIENT_ID,
)
email = idinfo.get('email') email = idinfo.get('email')
if not email: if not email:
return JsonResponse({'error': 'Email not found in Google token'}, status=400) return JsonResponse({'error': 'Email not found in Google token'}, status=400)

181
notifications/emails.py Normal file
View File

@@ -0,0 +1,181 @@
"""HTML email builders for scheduled admin notifications.
Each builder is registered in ``BUILDERS`` keyed by ``NotificationSchedule.notification_type``
and returns ``(subject, html_body)``. Add new types by appending to the registry
and extending ``NotificationSchedule.TYPE_CHOICES``.
Week bounds for ``events_expiring_this_week`` are computed in Asia/Kolkata so the
"this week" semantics match the operations team's wall-clock week regardless of
``settings.TIME_ZONE`` (currently UTC).
"""
from datetime import date, datetime, timedelta
from html import escape
try:
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover — fallback for py<3.9
from backports.zoneinfo import ZoneInfo # type: ignore
from django.conf import settings
from django.core.mail import EmailMessage
from django.db.models import Q
from eventify_logger.services import log
IST = ZoneInfo('Asia/Kolkata')
def _today_in_ist() -> date:
return datetime.now(IST).date()
def _upcoming_week_bounds(today: date) -> tuple[date, date]:
"""Return (next Monday, next Sunday) inclusive.
If today is Monday the result is *this week* (today..Sunday).
If today is any other weekday the result is *next week* (next Monday..next Sunday).
Mon=0 per Python ``weekday()``.
"""
days_until_monday = (7 - today.weekday()) % 7
monday = today + timedelta(days=days_until_monday)
sunday = monday + timedelta(days=6)
return monday, sunday
def _build_events_expiring_this_week(schedule) -> tuple[str, str]:
from events.models import Event
today = _today_in_ist()
monday, sunday = _upcoming_week_bounds(today)
qs = (
Event.objects
.select_related('partner', 'event_type')
.filter(event_status='published')
.filter(
Q(end_date__isnull=False, end_date__gte=monday, end_date__lte=sunday)
| Q(end_date__isnull=True, start_date__gte=monday, start_date__lte=sunday)
)
.order_by('end_date', 'start_date', 'name')
)
events = list(qs)
rows_html = ''
for e in events:
end = e.end_date or e.start_date
title = e.title or e.name or '(untitled)'
partner_name = ''
if e.partner_id:
try:
partner_name = e.partner.name or ''
except Exception:
partner_name = ''
category = ''
if e.event_type_id and e.event_type:
category = getattr(e.event_type, 'event_type', '') or ''
rows_html += (
'<tr>'
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">{escape(title)}</td>'
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">{escape(partner_name or "")}</td>'
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">{escape(category or "")}</td>'
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">'
f'{end.strftime("%a %d %b %Y") if end else ""}</td>'
'</tr>'
)
if not events:
rows_html = (
'<tr><td colspan="4" style="padding:24px;text-align:center;color:#888;">'
'No published events are expiring next week.'
'</td></tr>'
)
subject = (
f'[Eventify] {len(events)} event(s) expiring '
f'{monday.strftime("%d %b")}{sunday.strftime("%d %b")}'
)
html = f"""<!doctype html>
<html><body style="margin:0;padding:0;background:#f5f5f5;font-family:Arial,Helvetica,sans-serif;color:#1a1a1a;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f5f5;">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:10px;overflow:hidden;box-shadow:0 2px 6px rgba(15,69,207,0.08);">
<tr><td style="background:#0F45CF;color:#ffffff;padding:24px 28px;">
<h2 style="margin:0;font-size:20px;">Events expiring next week</h2>
<p style="margin:6px 0 0;color:#d2dcff;font-size:14px;">
{monday.strftime("%A %d %b %Y")} &rarr; {sunday.strftime("%A %d %b %Y")}
&middot; {len(events)} event(s)
</p>
</td></tr>
<tr><td style="padding:20px 24px;">
<p style="margin:0 0 12px;font-size:14px;color:#444;">
Scheduled notification: <strong>{escape(schedule.name)}</strong>
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:14px;">
<thead>
<tr style="background:#f0f4ff;color:#0F45CF;">
<th align="left" style="padding:10px 12px;">Title</th>
<th align="left" style="padding:10px 12px;">Partner</th>
<th align="left" style="padding:10px 12px;">Category</th>
<th align="left" style="padding:10px 12px;">End date</th>
</tr>
</thead>
<tbody>{rows_html}</tbody>
</table>
</td></tr>
<tr><td style="padding:16px 24px 24px;color:#888;font-size:12px;">
Sent automatically by Eventify Command Center.
To change recipients or the schedule, open
<a href="https://admin.eventifyplus.com/settings" style="color:#0F45CF;">admin.eventifyplus.com &rsaquo; Settings &rsaquo; Notifications</a>.
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
return subject, html
BUILDERS: dict = {
'events_expiring_this_week': _build_events_expiring_this_week,
}
def render_and_send(schedule) -> int:
"""Render the email for ``schedule`` and deliver it to active recipients.
Returns the number of recipients the message was sent to. Raises on SMTP
failure so the management command can mark the schedule as errored.
"""
builder = BUILDERS.get(schedule.notification_type)
if builder is None:
raise ValueError(f'No builder for notification type: {schedule.notification_type}')
subject, html = builder(schedule)
recipients = list(
schedule.recipients.filter(is_active=True).values_list('email', flat=True)
)
if not recipients:
log('warning', 'notification schedule has no active recipients', logger_data={
'schedule_id': schedule.id,
'schedule_name': schedule.name,
})
return 0
msg = EmailMessage(
subject=subject,
body=html,
from_email=settings.DEFAULT_FROM_EMAIL,
to=recipients,
)
msg.content_subtype = 'html'
msg.send(fail_silently=False)
log('info', 'notification email sent', logger_data={
'schedule_id': schedule.id,
'schedule_name': schedule.name,
'type': schedule.notification_type,
'recipient_count': len(recipients),
})
return len(recipients)

View File

View File

@@ -0,0 +1,153 @@
"""Dispatch due ``NotificationSchedule`` jobs.
Host cron invokes this every ~15 minutes via ``docker exec``. The command
walks all active schedules, evaluates their cron expression against
``last_run_at`` using ``croniter``, and fires any that are due. A row-level
``select_for_update(skip_locked=True)`` prevents duplicate sends if two cron
ticks race or the container is restarted mid-run.
Evaluation timezone is **Asia/Kolkata** to match
``notifications/emails.py::_upcoming_week_bounds`` — the same wall-clock week
used in the outgoing email body.
Flags:
--schedule-id <id> Fire exactly one schedule, ignoring cron check.
--dry-run Resolve due schedules + render emails, send nothing.
"""
from datetime import datetime, timedelta
try:
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover — py<3.9
from backports.zoneinfo import ZoneInfo # type: ignore
from croniter import croniter
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from eventify_logger.services import log
from notifications.emails import BUILDERS, render_and_send
from notifications.models import NotificationSchedule
IST = ZoneInfo('Asia/Kolkata')
def _is_due(schedule: NotificationSchedule, now_ist: datetime) -> bool:
"""Return True if ``schedule`` should fire at ``now_ist``.
``croniter`` is seeded with ``last_run_at`` (or one year ago for a fresh
schedule) and asked for the next fire time. If that time has already
passed relative to ``now_ist`` the schedule is due.
"""
if not croniter.is_valid(schedule.cron_expression):
return False
if schedule.last_run_at is not None:
seed = schedule.last_run_at.astimezone(IST)
else:
seed = now_ist - timedelta(days=365)
itr = croniter(schedule.cron_expression, seed)
next_fire = itr.get_next(datetime)
return next_fire <= now_ist
class Command(BaseCommand):
help = 'Dispatch due NotificationSchedule email jobs.'
def add_arguments(self, parser):
parser.add_argument(
'--schedule-id', type=int, default=None,
help='Force-run a single schedule by ID, ignoring cron check.',
)
parser.add_argument(
'--dry-run', action='store_true',
help='Render and log but do not send or persist last_run_at.',
)
def handle(self, *args, **opts):
schedule_id = opts.get('schedule_id')
dry_run = opts.get('dry_run', False)
now_ist = datetime.now(IST)
qs = NotificationSchedule.objects.filter(is_active=True)
if schedule_id is not None:
qs = qs.filter(id=schedule_id)
candidate_ids = list(qs.values_list('id', flat=True))
if not candidate_ids:
self.stdout.write('No active schedules to evaluate.')
return
fired = 0
skipped = 0
errored = 0
for sid in candidate_ids:
with transaction.atomic():
locked_qs = (
NotificationSchedule.objects
.select_for_update(skip_locked=True)
.filter(id=sid, is_active=True)
)
schedule = locked_qs.first()
if schedule is None:
skipped += 1
continue
forced = schedule_id is not None
if not forced and not _is_due(schedule, now_ist):
skipped += 1
continue
if schedule.notification_type not in BUILDERS:
schedule.last_status = NotificationSchedule.STATUS_ERROR
schedule.last_error = (
f'No builder registered for {schedule.notification_type!r}'
)
schedule.save(update_fields=['last_status', 'last_error', 'updated_at'])
errored += 1
continue
if dry_run:
self.stdout.write(
f'[dry-run] would fire schedule {schedule.id} '
f'({schedule.name}) type={schedule.notification_type}'
)
fired += 1
continue
try:
recipient_count = render_and_send(schedule)
except Exception as exc: # noqa: BLE001 — wide catch, store msg
log('error', 'notification dispatch failed', logger_data={
'schedule_id': schedule.id,
'schedule_name': schedule.name,
'error': str(exc),
})
schedule.last_status = NotificationSchedule.STATUS_ERROR
schedule.last_error = str(exc)[:2000]
schedule.save(update_fields=['last_status', 'last_error', 'updated_at'])
errored += 1
continue
schedule.last_run_at = timezone.now()
schedule.last_status = NotificationSchedule.STATUS_SUCCESS
schedule.last_error = ''
schedule.save(update_fields=[
'last_run_at', 'last_status', 'last_error', 'updated_at',
])
fired += 1
self.stdout.write(
f'Fired schedule {schedule.id} ({schedule.name}) '
f'{recipient_count} recipient(s)'
)
summary = f'Done. fired={fired} skipped={skipped} errored={errored}'
self.stdout.write(summary)
log('info', 'send_scheduled_notifications complete', logger_data={
'fired': fired, 'skipped': skipped, 'errored': errored,
'dry_run': dry_run, 'forced_id': schedule_id,
})

View File

@@ -0,0 +1,93 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('message', models.TextField()),
('notification_type', models.CharField(
choices=[
('event', 'Event'),
('promo', 'Promotion'),
('system', 'System'),
('booking', 'Booking'),
],
default='system', max_length=20,
)),
('is_read', models.BooleanField(default=False)),
('action_url', models.URLField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='notifications',
to=settings.AUTH_USER_MODEL,
)),
],
options={'ordering': ['-created_at']},
),
migrations.CreateModel(
name='NotificationSchedule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('notification_type', models.CharField(
choices=[('events_expiring_this_week', 'Events Expiring This Week')],
db_index=True, max_length=64,
)),
('cron_expression', models.CharField(
default='0 0 * * 1',
help_text=(
'Standard 5-field cron (minute hour dom month dow). '
'Evaluated in Asia/Kolkata.'
),
max_length=100,
)),
('is_active', models.BooleanField(db_index=True, default=True)),
('last_run_at', models.DateTimeField(blank=True, null=True)),
('last_status', models.CharField(blank=True, default='', max_length=20)),
('last_error', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={'ordering': ['-created_at']},
),
migrations.AddIndex(
model_name='notificationschedule',
index=models.Index(
fields=['is_active', 'notification_type'],
name='notificatio_is_acti_26dfb5_idx',
),
),
migrations.CreateModel(
name='NotificationRecipient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254)),
('display_name', models.CharField(blank=True, default='', max_length=200)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('schedule', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='recipients',
to='notifications.notificationschedule',
)),
],
options={
'ordering': ['display_name', 'email'],
'unique_together': {('schedule', 'email')},
},
),
]

View File

View File

@@ -1,4 +1,16 @@
"""
Two distinct concerns live in this app:
1. ``Notification`` — consumer-facing in-app inbox entries surfaced on the mobile
SPA (/api/notifications/list/). One row per user per alert.
2. ``NotificationSchedule`` + ``NotificationRecipient`` — admin-side recurring
email jobs configured from the Command Center Settings tab and dispatched by
the ``send_scheduled_notifications`` management command (host cron).
Not user-facing; strictly operational.
"""
from django.db import models from django.db import models
from accounts.models import User from accounts.models import User
@@ -23,3 +35,68 @@ class Notification(models.Model):
def __str__(self): def __str__(self):
return f"{self.notification_type}: {self.title}{self.user.email}" return f"{self.notification_type}: {self.title}{self.user.email}"
class NotificationSchedule(models.Model):
"""One configurable recurring email job.
New types are added by registering a builder in ``notifications/emails.py``
and adding the slug to ``TYPE_CHOICES`` below. Cron expression is evaluated
in ``Asia/Kolkata`` by the dispatcher (matches operations team timezone).
"""
TYPE_EVENTS_EXPIRING_THIS_WEEK = 'events_expiring_this_week'
TYPE_CHOICES = [
(TYPE_EVENTS_EXPIRING_THIS_WEEK, 'Events Expiring This Week'),
]
STATUS_SUCCESS = 'success'
STATUS_ERROR = 'error'
name = models.CharField(max_length=200)
notification_type = models.CharField(
max_length=64, choices=TYPE_CHOICES, db_index=True,
)
cron_expression = models.CharField(
max_length=100, default='0 0 * * 1',
help_text='Standard 5-field cron (minute hour dom month dow). '
'Evaluated in Asia/Kolkata.',
)
is_active = models.BooleanField(default=True, db_index=True)
last_run_at = models.DateTimeField(null=True, blank=True)
last_status = models.CharField(max_length=20, blank=True, default='')
last_error = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
indexes = [models.Index(fields=['is_active', 'notification_type'])]
def __str__(self):
return f'{self.name} ({self.notification_type})'
class NotificationRecipient(models.Model):
"""Free-form recipient — not tied to a User row so external stakeholders
(vendors, partners, sponsors) can receive notifications without needing
platform accounts."""
schedule = models.ForeignKey(
NotificationSchedule,
on_delete=models.CASCADE,
related_name='recipients',
)
email = models.EmailField()
display_name = models.CharField(max_length=200, blank=True, default='')
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [('schedule', 'email')]
ordering = ['display_name', 'email']
def __str__(self):
label = self.display_name or self.email
return f'{label} ({self.schedule.name})'

View File

@@ -0,0 +1,70 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('partner', '0001_initial'),
]
operations = [
# Profile extras
migrations.AddField(
model_name='partner',
name='bio',
field=models.TextField(blank=True, null=True),
),
# Payout settings
migrations.AddField(
model_name='partner',
name='payout_account_holder_name',
field=models.CharField(blank=True, max_length=250, null=True),
),
migrations.AddField(
model_name='partner',
name='payout_account_number',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AddField(
model_name='partner',
name='payout_ifsc_code',
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AddField(
model_name='partner',
name='payout_bank_name',
field=models.CharField(blank=True, max_length=250, null=True),
),
migrations.AddField(
model_name='partner',
name='payout_schedule',
field=models.CharField(
choices=[('weekly', 'Weekly'), ('biweekly', 'Bi-weekly'), ('monthly', 'Monthly')],
default='monthly',
max_length=20,
),
),
# Notification preferences
migrations.AddField(
model_name='partner',
name='notif_new_booking',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='partner',
name='notif_event_status',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='partner',
name='notif_payout_update',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='partner',
name='notif_weekly_report',
field=models.BooleanField(default=False),
),
]

View File

@@ -65,5 +65,28 @@ class Partner(models.Model):
kyc_compliance_document_file = models.FileField(upload_to='kyc_documents/', blank=True, null=True) kyc_compliance_document_file = models.FileField(upload_to='kyc_documents/', blank=True, null=True)
kyc_compliance_document_number = models.CharField(max_length=250, blank=True, null=True) kyc_compliance_document_number = models.CharField(max_length=250, blank=True, null=True)
# Profile extras
bio = models.TextField(blank=True, null=True)
# Payout settings
PAYOUT_SCHEDULE_CHOICES = (
('weekly', 'Weekly'),
('biweekly', 'Bi-weekly'),
('monthly', 'Monthly'),
)
payout_account_holder_name = models.CharField(max_length=250, blank=True, null=True)
payout_account_number = models.CharField(max_length=50, blank=True, null=True)
payout_ifsc_code = models.CharField(max_length=20, blank=True, null=True)
payout_bank_name = models.CharField(max_length=250, blank=True, null=True)
payout_schedule = models.CharField(
max_length=20, choices=PAYOUT_SCHEDULE_CHOICES, default='monthly'
)
# Notification preferences
notif_new_booking = models.BooleanField(default=True)
notif_event_status = models.BooleanField(default=True)
notif_payout_update = models.BooleanField(default=True)
notif_weekly_report = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@@ -7,3 +7,7 @@ gunicorn==21.2.0
django-extensions==3.2.3 django-extensions==3.2.3
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
djangorestframework-simplejwt==5.3.1 djangorestframework-simplejwt==5.3.1
google-auth>=2.0.0
requests>=2.28.0
qrcode[pil]>=7.4.2
croniter>=2.0.0

View File

@@ -2,3 +2,6 @@ Django>=4.2
Pillow Pillow
django-summernote django-summernote
google-auth>=2.0.0 google-auth>=2.0.0
requests>=2.31.0
qrcode[pil]>=7.4.2
croniter>=2.0.0