60 Commits

Author SHA1 Message Date
611d653938 Sprint 3: partner ticket tier CRUD endpoints
- admin_api/views.py: add _serialize_tier(), PartnerMeEventTiersView
  (GET list + POST create with get_or_create TicketMeta),
  PartnerMeEventTierDetailView (PATCH update + DELETE)
- admin_api/urls.py: wire partners/me/events/{event_pk}/tiers/ and
  .../tiers/{tier_pk}/ with named routes partner-me-event-tiers,
  partner-me-event-tier-detail
- Deployed to eventify-backend + eventify-django containers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:28:57 +05:30
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
94 changed files with 9292 additions and 259 deletions

312
CHANGELOG.md Normal file
View File

@@ -0,0 +1,312 @@
# Changelog
All notable changes to the Eventify Backend are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), versioning follows [Semantic Versioning](https://semver.org/).
---
## [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
### Added
- **Lead Manager** — new `Lead` model in `admin_api` for tracking Schedule-a-Call form submissions and sales inquiries
- Fields: name, email, phone, event_type, message, status (new/contacted/qualified/converted/closed), source (schedule_call/website/manual), priority (low/medium/high), assigned_to (FK User), notes
- Migration `admin_api/0003_lead` with indexes on status, priority, created_at, email
- **Consumer endpoint** `POST /api/leads/schedule-call/` — public (AllowAny, CSRF-exempt) endpoint for the Schedule a Call modal; creates Lead with status=new, source=schedule_call
- **Admin API endpoints** (all IsAuthenticated):
- `GET /api/v1/leads/metrics/` — total, new today, counts per status
- `GET /api/v1/leads/` — paginated list with filters (status, priority, source, search, date_from, date_to)
- `GET /api/v1/leads/<id>/` — single lead detail
- `PATCH /api/v1/leads/<id>/update/` — update status, priority, assigned_to, notes
- **RBAC**: `leads` added to `ALL_MODULES`, `get_allowed_modules()`, and `StaffProfile.SCOPE_TO_MODULE`
---
## [1.8.3] — 2026-04-06
### Fixed
- **`TopEventsAPI` now works without authentication** — `POST /api/events/top-events/` had `AllowAny` permission but still called `validate_token_and_get_user()`, returning `{"status":"error","message":"token and username required"}` for unauthenticated requests
- Removed `validate_token_and_get_user()` call entirely
- Added `event_status='published'` filter (was `is_top_event=True` only)
- Added `event_type_name` field resolution: `e.event_type.event_type if e.event_type else ''``model_to_dict()` only returns the FK integer
---
## [1.8.2] — 2026-04-06
### Fixed
- **`FeaturedEventsAPI` now returns `event_type_name` string** — `model_to_dict()` serialises the `event_type` FK as an integer ID; the hero slider frontend reads `ev.event_type_name` to display the category badge, which was always `null`
- Added `data_dict['event_type_name'] = e.event_type.event_type if e.event_type else ''` after `model_to_dict(e)` to resolve the FK to its human-readable name (e.g. `"Festivals"`)
- No frontend changes required — `fetchHeroSlides()` already falls back to `ev.event_type_name`
---
## [1.8.1] — 2026-04-06
### Fixed
- **`FeaturedEventsAPI` now works without authentication** — `POST /api/events/featured-events/` had `AllowAny` permission but still called `validate_token_and_get_user()`, causing the endpoint to return HTTP 200 + `{"status":"error","message":"token and username required"}` for unauthenticated requests (e.g. the desktop hero slider)
- Removed the `validate_token_and_get_user()` call entirely — the endpoint is public by design and requires no token
- Also tightened the queryset to `event_status='published'` (was `is_featured=True` only) to match `ConsumerFeaturedEventsView` behaviour and avoid returning draft/cancelled events
- Root cause: host Nginx routes `/api/``eventify-backend` container (port 3001), not `eventify-django` (port 8085); the `validate_token_and_get_user` gate in this container was silently blocking all hero slider requests
---
## [1.8.0] — 2026-04-04
### Added
- **`BulkUserPublicInfoView`** (`POST /api/user/bulk-public-info/`)
- Internal endpoint for the Node.js gamification server to resolve user details
- Accepts `{ emails: [...] }` (max 500), returns `{ users: { email: { display_name, district, eventify_id } } }`
- Used for leaderboard data bridge (syncing user names/districts into gamification DB)
- CSRF-exempt, returns only public-safe fields (no passwords, tokens, or sensitive PII)
---
## [1.7.0] — 2026-04-04
### Added
- **Home District with 6-month cooldown**
- `district_changed_at` DateTimeField on User model (migration `0013_user_district_changed_at`) — nullable, no backfill; NULL means "eligible to change immediately"
- `VALID_DISTRICTS` constant (14 Kerala districts) in `accounts/models.py` for server-side validation
- `WebRegisterForm` now accepts optional `district` field; stamps `district_changed_at` on valid selection during signup
- `UpdateProfileView` enforces 183-day (~6 months) cooldown — rejects district changes within the window with a human-readable "Next change: {date}" error
- `district_changed_at` included in all relevant API responses: `LoginView`, `WebRegisterView`, `StatusView`, `UpdateProfileView`
- `StatusView` now also returns `district` field (was previously missing)
---
## [1.6.2] — 2026-04-03
### Security
- **Internal exceptions no longer exposed to API callers** — all 15 `except Exception as e` blocks across `mobile_api/views/user.py` and `mobile_api/views/events.py` now log the real error via `eventify_logger` and return a generic `"An unexpected server error occurred."` to the caller
- Affected views: `RegisterView`, `WebRegisterView`, `LoginView`, `StatusView`, `LogoutView`, `UpdateProfileView`, `EventTypeAPI`, `EventListAPI`, `EventDetailAPI`, `EventImagesListAPI`, `EventsByDateAPI`, `DateSheetAPI`, `PincodeEventsAPI`, `FeaturedEventsAPI`, `TopEventsAPI`
- `StatusView` and `UpdateProfileView` were also missing `log(...)` calls entirely — added
- `from eventify_logger.services import log` import added to `events.py` (was absent)
---
## [1.6.1] — 2026-04-03
### Added
- **`eventify_id` in `StatusView` response** (`/api/user/status/`) — consumer app uses this to refresh the Eventify ID badge (`EVT-XXXXXXXX`) for sessions that pre-date the `eventify_id` login field
- **`accounts` migration `0012_user_eventify_id` deployed to production containers** — backfilled all existing users with unique Eventify IDs; previously the migration existed locally but had not been applied in production
---
## [1.6.0] — 2026-04-02
### Added
- **Unique Eventify ID system** (`EVT-XXXXXXXX` format)
- New `eventify_id` field on `User` model — `CharField(max_length=12, unique=True, editable=False, db_index=True)`
- Charset `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (no ambiguous characters I/O/0/1) giving ~1.78T combinations
- Auto-generated on first `save()` via a 10-attempt retry loop using `secrets.choice()`
- Migration `0012_user_eventify_id`: add nullable → backfill all existing users → make non-null
- `eventify_id` exposed in `accounts/api.py``_partner_user_to_dict()` fields list
- `eventify_id` exposed in `partner/api.py``_user_to_dict()` fields list
- `eventify_id` exposed in `mobile_api/views/user.py``LoginView` response (populates `localStorage.event_user.eventify_id`)
- `eventifyId` exposed in `admin_api/views.py``_serialize_user()` (camelCase for direct TypeScript compatibility)
- Server-side search in `UserListView` now also filters on `eventify_id__icontains`
- Synced migration `0011_user_allowed_modules_alter_user_id` (pulled from server, was missing from local repo)
### Changed
- `accounts/models.py`: merged `allowed_modules` field + `get_allowed_modules()` + `ALL_MODULES` constant from server (previously only existed on server)
---
## [1.5.0] — 2026-03-31
### Added
- `allowed_modules` TextField on `User` model — comma-separated module slug access control
- `get_allowed_modules()` method on `User` — returns list of accessible modules based on role or explicit list
- `ALL_MODULES` class constant listing all platform module slugs
- Migration `0011_user_allowed_modules_alter_user_id`
---
## [1.4.0] — 2026-03-24
### Added
- Partner portal login/logout APIs (`accounts/api.py`) — `PartnerLoginAPI`, `PartnerLogoutAPI`, `PartnerMeAPI`
- `_partner_user_to_dict()` serializer for partner-scoped user data
- Partner CRUD, KYC review, and user management endpoints in `partner/api.py`
---
## [1.3.0] — 2026-03-14
### Changed
- User `id` field changed from `AutoField` to `BigAutoField` (migration `0010_alter_user_id`)
---
## [1.2.0] — 2026-03-10
### Added
- `partner` ForeignKey on `User` model linking users to partners (migration `0009_user_partner`)
- Profile picture upload support (`ImageField`) with `default.png` fallback (migration `00060007`)
---
## [1.1.0] — 2026-02-28
### Added
- Location fields on `User`: `pincode`, `district`, `state`, `country`, `place`, `latitude`, `longitude`
- Custom `UserManager` for programmatic user creation
---
## [1.0.0] — 2026-03-01
### Added
- Initial Django project with custom `User` model extending `AbstractUser`
- Role choices: `admin`, `manager`, `staff`, `customer`, `partner`, `partner_manager`, `partner_staff`, `partner_customer`
- JWT authentication via `djangorestframework-simplejwt`
- Admin API foundation: auth, dashboard metrics, partners, users, events
- Docker + Gunicorn + PostgreSQL 16 production setup

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.10-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements-docker.txt .
RUN pip install --no-cache-dir -r requirements-docker.txt
COPY . .
RUN python manage.py collectstatic --noinput || true
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120", "eventify.wsgi:application"]

275
README.md
View File

@@ -1,30 +1,269 @@
# Eventify - Django
<div align=center>
This repository contains a production-oriented Django project skeleton for the Eventify application.
```
███████╗██╗ ██╗███████╗███╗ ██╗████████╗██╗███████╗██╗ ██╗
██╔════╝██║ ██║██╔════╝████╗ ██║╚══██╔══╝██║██╔════╝╚██╗ ██╔╝
█████╗ ██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║█████╗ ╚████╔╝
██╔══╝ ╚██╗ ██╔╝██╔══╝ ██║╚██╗██║ ██║ ██║██╔══╝ ╚██╔╝
███████╗ ╚████╔╝ ███████╗██║ ╚████║ ██║ ██║██║ ██║
╚══════╝ ╚═══╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═╝
A D M I N B A C K E N D
```
## Features
- Custom `User` model
- EventType (categories), Event, EventImages models
- CRUD for EventType, Event, and Users
- Bootstrap-based templates and navigation
- Settings prepared to use environment variables for production
![Version](https://img.shields.io/badge/version-1.6.0-blue?style=for-the-badge)
![Django](https://img.shields.io/badge/Django-4.2-092E20?style=for-the-badge&logo=django&logoColor=white)
![DRF](https://img.shields.io/badge/DRF-3.15-red?style=for-the-badge)
![Python](https://img.shields.io/badge/Python-3.11-3776AB?style=for-the-badge&logo=python&logoColor=white)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?style=for-the-badge&logo=postgresql&logoColor=white)
![JWT](https://img.shields.io/badge/JWT-Auth-000000?style=for-the-badge&logo=jsonwebtokens&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-Container-2496ED?style=for-the-badge&logo=docker&logoColor=white)
![License](https://img.shields.io/badge/license-Proprietary-lightgrey?style=for-the-badge)
**Production REST API powering the Eventify Admin Command Center**
[Live Admin →](https://admin.eventifyplus.com) · [API Base →](https://admin.eventifyplus.com/api/v1/) · [Gitea →](https://code.bshtech.net/Sicherhaven/eventify_backend)
</div>
---
## ✦ Overview
Eventify Backend is the **Django 4.2 + Django REST Framework** API layer for the Eventify platform. It powers the admin command center at `admin.eventifyplus.com`, providing JWT-authenticated endpoints for partner management, user CRM, event moderation, financial reporting, and platform analytics.
> Built phase-by-phase as a production rebuild — all endpoints are real, all data is live.
---
## ✦ Tech Stack
| Layer | Technology |
|-------|-----------|
| **Framework** | Django 4.2 + Django REST Framework 3.15 |
| **Auth** | `djangorestframework-simplejwt` — JWT (access 1 day / refresh 7 days) |
| **Database** | PostgreSQL 16 (`event_dashboard` DB) |
| **Runtime** | Python 3.11 · Gunicorn · Docker |
| **Reverse Proxy** | Nginx (host-level, ports 80/443) |
| **SSL** | Let's Encrypt (auto-renew via certbot) |
| **Deployment** | AWS EC2 · Docker Compose · `docker cp` deploy |
| **Source Control** | Gitea self-hosted at `code.bshtech.net` |
---
## ✦ Architecture
```
┌─────────────────────────────────────────────────┐
│ admin.eventifyplus.com │
│ (HTTPS / 443) │
└────────────────────┬────────────────────────────┘
│ Nginx Reverse Proxy
┌─────────┴──────────┐
│ │
/api/* → :3001 /* → :8084
│ │
┌───────┴──────┐ ┌────────┴────────┐
│ eventify- │ │ admin-frontend │
│ backend │ │ (React + Vite) │
│ (Django) │ │ [nginx SPA] │
└───────┬──────┘ └─────────────────┘
┌───────┴──────┐
│ eventify- │
│ postgres │
│ (PG 16) │
└──────────────┘
```
---
## ✦ API Reference
All endpoints are under `/api/v1/` and require `Authorization: Bearer <access_token>` except Auth.
### 🔐 Authentication
```
POST /api/v1/admin/auth/login/ → { access, refresh, user }
POST /api/v1/auth/refresh/ → { access }
GET /api/v1/auth/me/ → { user }
GET /api/v1/health/ → { status, db }
```
### 📊 Dashboard
```
GET /api/v1/dashboard/metrics/ → totalRevenue, revenueGrowth, activePartners, liveEvents, ticketSales
GET /api/v1/dashboard/revenue/ → 7-day revenue vs payouts chart data
GET /api/v1/dashboard/activity/ → recent platform activity feed (top 10)
GET /api/v1/dashboard/actions/ → action items panel (KYC queue, flagged events, payouts)
```
### 🤝 Partners
```
GET /api/v1/partners/stats/ → total, active, pendingKyc, highRisk
GET /api/v1/partners/ → paginated list [ status, kyc_status, search ]
GET /api/v1/partners/:id/ → full partner profile + events + KYC docs
PATCH /api/v1/partners/:id/status/ → { status: active|suspended|archived }
POST /api/v1/partners/:id/kyc/review/ → { decision: approved|rejected, reason? }
```
### 👤 Users
```
GET /api/v1/users/metrics/ → total, active, suspended, newThisWeek
GET /api/v1/users/ → paginated list [ status, role, search ]
GET /api/v1/users/:id/ → user profile
PATCH /api/v1/users/:id/status/ → { action: suspend|ban|reinstate }
```
### 🎪 Events
```
GET /api/v1/events/stats/ → total, live, pending, flagged, published
GET /api/v1/events/ → paginated list [ status, partner_id, search ]
GET /api/v1/events/:id/ → event detail
PATCH /api/v1/events/:id/moderate/ → { action: approve|reject|flag|feature|unfeature }
```
---
## ✦ Project Structure
```
eventify-django/
├── admin_api/ ← All admin REST endpoints (Phases 15)
│ ├── views.py ← Auth + Dashboard + Partners + Users + Events views
│ ├── urls.py ← /api/v1/ URL router
│ └── serializers.py ← UserSerializer
├── accounts/ ← Custom User model (extends AbstractUser)
├── events/ ← Event model + legacy CRUD views
├── partner/ ← Partner model + KYC fields
├── bookings/ ← Booking + Ticket models
├── ledger/ ← RazorpayTransaction model
├── banking_operations/ ← PaymentTransaction model
├── eventify/ ← Django settings + root urls.py
├── requirements-docker.txt ← Production dependencies
└── manage.py
```
---
## ✦ Changelog
> Full history in [CHANGELOG.md](./CHANGELOG.md)
| Version | Date | Summary |
|---------|------|---------|
| **1.6.0** | 2026-04-02 | Unique Eventify ID (`EVT-XXXXXXXX`) on User model, exposed across all APIs |
| **1.5.0** | 2026-03-31 | `allowed_modules` field + `get_allowed_modules()` for RBAC |
| **1.4.0** | 2026-03-24 | Partner portal login/logout/me APIs |
| **1.3.0** | 2026-03-14 | User `id``BigAutoField` |
| **1.0.0** | 2026-03-01 | Initial release — Django + JWT + Admin API |
---
## ✦ Build Phases
| Phase | Module | Endpoints | Status |
|-------|--------|-----------|--------|
| **1** | JWT Auth Foundation | login, refresh, me, health | ✅ Live |
| **2** | Dashboard Metrics | metrics, revenue, activity, actions | ✅ Live |
| **3** | Partners API | stats, list, detail, status, KYC review | ✅ Live |
| **4** | Users & RBAC | metrics, list, detail, status | ✅ Live |
| **5** | Events Admin | stats, list, detail, moderate | ✅ Live |
| **6** | Financials & Payouts | transactions, settlements, payouts | ⏳ Planned |
| **7** | Notifications & Settings | notifications, audit log, system config | ⏳ Planned |
---
## ✦ Local Development
## Quick start (development)
1. Create a virtualenv and activate it
```bash
# Clone
git clone https://code.bshtech.net/Sicherhaven/eventify_backend.git
cd eventify_backend
# Virtual environment
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
2. Run migrations and create superuser
```bash
pip install -r requirements-docker.txt
# Environment variables
cp .env.example .env # set DJANGO_SECRET_KEY, DB_* vars
# Database
python manage.py migrate
python manage.py createsuperuser
# Run
python manage.py runserver
```
## Production notes
- Set `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`, and `DJANGO_ALLOWED_HOSTS` environment variables
- Collect static files with `python manage.py collectstatic`
- Serve via uWSGI/gunicorn + nginx or any WSGI server
---
## ✦ Production Deployment
```bash
# Files are deployed via docker cp (no volume mount)
scp admin_api/views.py eventify:/tmp/
ssh eventify docker cp /tmp/views.py eventify-backend:/app/admin_api/views.py
# Reload gunicorn (graceful — no downtime)
ssh eventify docker exec eventify-backend kill -HUP 1
# Verify
ssh eventify curl -s http://localhost:3001/api/v1/health/
```
**Containers:**
| Container | Image | Port | Role |
|-----------|-------|------|------|
| `eventify-backend` | eventify-django | :3001 | Django API |
| `eventify-postgres` | postgres:16-alpine | internal | Database |
| `admin-frontend` | admin-prototype | :8084 | React SPA |
---
## ✦ Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DJANGO_SECRET_KEY` | Django secret key | `django-insecure-...` |
| `DJANGO_DEBUG` | Debug mode | `False` |
| `DJANGO_ALLOWED_HOSTS` | Allowed hostnames | `admin.eventifyplus.com` |
| `DB_NAME` | PostgreSQL database | `event_dashboard` |
| `DB_USER` | PostgreSQL user | `event_user` |
| `DB_PASSWORD` | PostgreSQL password | — |
| `DB_HOST` | PostgreSQL host | `eventify-postgres` |
| `DB_PORT` | PostgreSQL port | `5432` |
---
## ✦ Status Mappings
### Partner KYC
| Backend | Frontend |
|---------|----------|
| `approved` | `Verified` |
| `rejected` | `Rejected` |
| `pending` / `high_risk` / `medium_risk` | `Pending` |
### Event Status
| Backend | Frontend |
|---------|----------|
| `live` | `live` |
| `published` | `published` |
| `pending` / `created` | `draft` |
| `flagged` | `flagged` |
| `cancelled` / `postponed` | `cancelled` |
| `completed` | `completed` |
---
<div align=center>
**Eventify Admin Backend** · Built by [BSH Technologies](https://bshtechnologies.in)
![Made with Django](https://img.shields.io/badge/Made_with-Django-092E20?style=flat-square&logo=django)
![Hosted on AWS](https://img.shields.io/badge/Hosted_on-AWS_EC2-FF9900?style=flat-square&logo=amazonaws)
![Served via Nginx](https://img.shields.io/badge/Served_via-Nginx-009639?style=flat-square&logo=nginx)
</div>

View File

@@ -17,6 +17,7 @@ def _partner_user_to_dict(user, request=None):
user,
fields=[
"id",
"eventify_id",
"username",
"email",
"phone_number",
@@ -38,7 +39,7 @@ def _partner_user_to_dict(user, request=None):
# Add profile picture URL if exists
if getattr(user, "profile_picture", None):
if request:
data["profile_picture"] = request.build_absolute_uri(user.profile_picture.url)
data["profile_picture"] = user.profile_picture.url
else:
data["profile_picture"] = user.profile_picture.url
else:

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.2.21 on 2026-03-31 08:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_alter_user_id'),
]
operations = [
migrations.AddField(
model_name='user',
name='allowed_modules',
field=models.TextField(blank=True, help_text='Comma-separated module slugs this user can access', null=True),
),
migrations.AlterField(
model_name='user',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@@ -0,0 +1,55 @@
# Generated migration for eventify_id field
import secrets
from django.db import migrations, models
EVENTIFY_ID_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
def generate_unique_eventify_id(existing_ids):
for _ in range(100):
candidate = 'EVT-' + ''.join(secrets.choice(EVENTIFY_ID_CHARS) for _ in range(8))
if candidate not in existing_ids:
return candidate
raise RuntimeError("Could not generate a unique Eventify ID after 100 attempts")
def backfill_eventify_ids(apps, schema_editor):
User = apps.get_model('accounts', 'User')
existing_ids = set(User.objects.exclude(eventify_id__isnull=True).values_list('eventify_id', flat=True))
users_to_update = []
for user in User.objects.filter(eventify_id__isnull=True):
new_id = generate_unique_eventify_id(existing_ids)
existing_ids.add(new_id)
user.eventify_id = new_id
users_to_update.append(user)
User.objects.bulk_update(users_to_update, ['eventify_id'])
def reverse_backfill(apps, schema_editor):
pass # No-op: reversing just leaves IDs set, which is fine
class Migration(migrations.Migration):
dependencies = [
('accounts', '0011_user_allowed_modules_alter_user_id'),
]
operations = [
# Step 1: Add the column as nullable
migrations.AddField(
model_name='user',
name='eventify_id',
field=models.CharField(blank=True, db_index=True, editable=False, max_length=12, null=True, unique=True),
),
# Step 2: Backfill all existing users
migrations.RunPython(backfill_eventify_ids, reverse_backfill),
# Step 3: Make the field non-nullable
migrations.AlterField(
model_name='user',
name='eventify_id',
field=models.CharField(blank=False, db_index=True, editable=False, max_length=12, null=False, unique=True),
),
]

View File

@@ -0,0 +1,13 @@
from django.db import migrations
class Migration(migrations.Migration):
"""Merge migration to resolve conflicting eventify_id migrations."""
dependencies = [
('accounts', '0011_user_eventify_id'),
('accounts', '0012_user_eventify_id'),
]
operations = [
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0012_user_eventify_id'),
]
operations = [
migrations.AddField(
model_name='user',
name='district_changed_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,13 @@
from django.db import migrations
class Migration(migrations.Migration):
"""Merge migration to resolve conflicting 0013 migrations."""
dependencies = [
('accounts', '0013_merge_eventify_id'),
('accounts', '0013_user_district_changed_at'),
]
operations = [
]

View File

@@ -1,8 +1,18 @@
import secrets
from django.contrib.auth.models import AbstractUser
from django.db import models
from accounts.manager import UserManager
from partner.models import Partner
EVENTIFY_ID_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' # no I, O, 0, 1
def generate_eventify_id():
return 'EVT-' + ''.join(secrets.choice(EVENTIFY_ID_CHARS) for _ in range(8))
ROLE_CHOICES = (
('admin', 'Admin'),
('manager', 'Manager'),
@@ -14,7 +24,22 @@ ROLE_CHOICES = (
('partner_customer', 'Partner Customer'),
)
VALID_DISTRICTS = [
"Thiruvananthapuram", "Kollam", "Pathanamthitta", "Alappuzha", "Kottayam",
"Idukki", "Ernakulam", "Thrissur", "Palakkad", "Malappuram",
"Kozhikode", "Wayanad", "Kannur", "Kasaragod",
]
class User(AbstractUser):
eventify_id = models.CharField(
max_length=12,
unique=True,
editable=False,
db_index=True,
null=True,
blank=True,
)
phone_number = models.CharField(max_length=15, blank=True, null=True)
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='Staff')
@@ -30,6 +55,7 @@ class User(AbstractUser):
state = models.CharField(max_length=100, blank=True, null=True)
country = models.CharField(max_length=100, blank=True, null=True)
place = models.CharField(max_length=200, blank=True, null=True)
district_changed_at = models.DateTimeField(blank=True, null=True)
# Location fields
latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
@@ -37,7 +63,33 @@ class User(AbstractUser):
profile_picture = models.ImageField(upload_to='profile_pictures/', blank=True, null=True, default='default.png')
allowed_modules = models.TextField(
blank=True, null=True,
help_text='Comma-separated module slugs this user can access',
)
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", "audit-log", "settings"]
if self.is_superuser or self.role == "admin":
return ALL
if self.allowed_modules:
return [m.strip() for m in self.allowed_modules.split(",") if m.strip()]
if self.role == "manager":
return ALL
return []
objects = UserManager()
def save(self, *args, **kwargs):
if not self.eventify_id:
for _ in range(10):
candidate = generate_eventify_id()
if not User.objects.filter(eventify_id=candidate).exists():
self.eventify_id = candidate
break
super().save(*args, **kwargs)
def __str__(self):
return self.username

0
ad_control/__init__.py Normal file
View File

24
ad_control/admin.py Normal file
View File

@@ -0,0 +1,24 @@
from django.contrib import admin
from .models import AdSurface, AdPlacement
@admin.register(AdSurface)
class AdSurfaceAdmin(admin.ModelAdmin):
list_display = ('key', 'name', 'max_slots', 'layout_type', 'sort_behavior', 'is_active', 'active_count')
list_filter = ('is_active', 'layout_type')
search_fields = ('key', 'name')
readonly_fields = ('created_at',)
@admin.register(AdPlacement)
class AdPlacementAdmin(admin.ModelAdmin):
list_display = (
'id', 'event', 'surface', 'status', 'scope', 'priority',
'rank', 'boost_label', 'start_at', 'end_at', 'created_at',
)
list_filter = ('status', 'scope', 'priority', 'surface')
list_editable = ('status', 'scope', 'rank')
search_fields = ('event__name', 'event__title', 'boost_label')
raw_id_fields = ('event', 'created_by', 'updated_by')
readonly_fields = ('created_at', 'updated_at')
ordering = ('surface', 'rank')

7
ad_control/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AdControlConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'ad_control'
verbose_name = 'Ad Control'

View File

View File

@@ -0,0 +1,100 @@
"""
Seed the default AdSurface records and migrate existing boolean flags to placements.
Usage:
python manage.py seed_surfaces # seed surfaces only
python manage.py seed_surfaces --migrate # also migrate is_featured / is_top_event to placements
"""
from django.core.management.base import BaseCommand
from ad_control.models import AdSurface, AdPlacement
from events.models import Event
SURFACES = [
{
'key': 'HOME_FEATURED_CAROUSEL',
'name': 'Featured Carousel',
'description': 'Homepage hero carousel — high-impact banner-style placement.',
'max_slots': 8,
'layout_type': 'carousel',
'sort_behavior': 'rank',
},
{
'key': 'HOME_TOP_EVENTS',
'name': 'Top Events',
'description': 'Homepage "Top Events" grid section below the hero.',
'max_slots': 10,
'layout_type': 'grid',
'sort_behavior': 'rank',
},
]
class Command(BaseCommand):
help = 'Seed default ad surfaces and optionally migrate boolean flags to placements.'
def add_arguments(self, parser):
parser.add_argument(
'--migrate',
action='store_true',
help='Also migrate existing is_featured / is_top_event flags to AdPlacement rows.',
)
def handle(self, *args, **options):
# --- Seed surfaces ---
for s in SURFACES:
obj, created = AdSurface.objects.update_or_create(
key=s['key'],
defaults=s,
)
status = 'CREATED' if created else 'EXISTS'
self.stdout.write(f" [{status}] {obj.key}{obj.name}")
# --- Migrate boolean flags ---
if options['migrate']:
self.stdout.write('\nMigrating boolean flags to placements...')
featured_surface = AdSurface.objects.get(key='HOME_FEATURED_CAROUSEL')
top_surface = AdSurface.objects.get(key='HOME_TOP_EVENTS')
featured_events = Event.objects.filter(is_featured=True)
top_events = Event.objects.filter(is_top_event=True)
created_count = 0
for rank, event in enumerate(featured_events, start=1):
_, created = AdPlacement.objects.get_or_create(
surface=featured_surface,
event=event,
defaults={
'status': 'ACTIVE',
'priority': 'MANUAL',
'scope': 'GLOBAL',
'rank': rank,
'boost_label': 'Featured',
},
)
if created:
created_count += 1
for rank, event in enumerate(top_events, start=1):
_, created = AdPlacement.objects.get_or_create(
surface=top_surface,
event=event,
defaults={
'status': 'ACTIVE',
'priority': 'MANUAL',
'scope': 'GLOBAL',
'rank': rank,
'boost_label': 'Top Event',
},
)
if created:
created_count += 1
self.stdout.write(self.style.SUCCESS(
f' Migrated {created_count} placements '
f'({featured_events.count()} featured, {top_events.count()} top events).'
))
self.stdout.write(self.style.SUCCESS('\nDone.'))

View File

@@ -0,0 +1,104 @@
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),
('events', '0007_add_is_featured_is_top_event'),
]
operations = [
migrations.CreateModel(
name='AdSurface',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(db_index=True, max_length=50, unique=True)),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True, default='')),
('max_slots', models.IntegerField(default=8)),
('layout_type', models.CharField(
choices=[('carousel', 'Carousel'), ('grid', 'Grid'), ('list', 'List')],
default='carousel', max_length=20,
)),
('sort_behavior', models.CharField(
choices=[('rank', 'By Rank'), ('date', 'By Date'), ('popularity', 'By Popularity')],
default='rank', max_length=20,
)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Ad Surface',
'verbose_name_plural': 'Ad Surfaces',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='AdPlacement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(
choices=[
('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('SCHEDULED', 'Scheduled'),
('EXPIRED', 'Expired'), ('DISABLED', 'Disabled'),
],
db_index=True, default='DRAFT', max_length=20,
)),
('priority', models.CharField(
choices=[('SPONSORED', 'Sponsored'), ('MANUAL', 'Manual'), ('ALGO', 'Algorithm')],
default='MANUAL', max_length=20,
)),
('scope', models.CharField(
choices=[
('GLOBAL', 'Global \u2014 shown to all users'),
('LOCAL', 'Local \u2014 shown to nearby users (50 km radius)'),
],
db_index=True, default='GLOBAL', max_length=10,
)),
('rank', models.IntegerField(default=0, help_text='Lower rank = higher position')),
('start_at', models.DateTimeField(blank=True, help_text='When this placement becomes active', null=True)),
('end_at', models.DateTimeField(blank=True, help_text='When this placement expires', null=True)),
('boost_label', models.CharField(
blank=True, default='', help_text='Display label e.g. "Featured", "Top Pick", "Sponsored"',
max_length=50,
)),
('notes', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='created_placements', to=settings.AUTH_USER_MODEL,
)),
('event', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='ad_placements',
to='events.event',
)),
('surface', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='placements',
to='ad_control.adsurface',
)),
('updated_by', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='updated_placements', to=settings.AUTH_USER_MODEL,
)),
],
options={
'verbose_name': 'Ad Placement',
'verbose_name_plural': 'Ad Placements',
'ordering': ['rank', '-created_at'],
},
),
migrations.AddConstraint(
model_name='adplacement',
constraint=models.UniqueConstraint(
condition=models.Q(('status__in', ['DRAFT', 'ACTIVE', 'SCHEDULED'])),
fields=('surface', 'event'),
name='unique_active_placement_per_surface',
),
),
]

View File

107
ad_control/models.py Normal file
View File

@@ -0,0 +1,107 @@
from django.db import models
from django.conf import settings
from events.models import Event
class AdSurface(models.Model):
"""
A display surface where ad placements can appear.
e.g. HOME_FEATURED_CAROUSEL, HOME_TOP_EVENTS, CATEGORY_FEATURED
"""
LAYOUT_CHOICES = [
('carousel', 'Carousel'),
('grid', 'Grid'),
('list', 'List'),
]
SORT_CHOICES = [
('rank', 'By Rank'),
('date', 'By Date'),
('popularity', 'By Popularity'),
]
key = models.CharField(max_length=50, unique=True, db_index=True)
name = models.CharField(max_length=100)
description = models.TextField(blank=True, default='')
max_slots = models.IntegerField(default=8)
layout_type = models.CharField(max_length=20, choices=LAYOUT_CHOICES, default='carousel')
sort_behavior = models.CharField(max_length=20, choices=SORT_CHOICES, default='rank')
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
verbose_name = 'Ad Surface'
verbose_name_plural = 'Ad Surfaces'
def __str__(self):
return f"{self.name} ({self.key})"
@property
def active_count(self):
return self.placements.filter(status__in=['ACTIVE', 'SCHEDULED']).count()
class AdPlacement(models.Model):
"""
A single placement of an event on a surface.
Supports scheduling, ranking, scope-based targeting, and lifecycle status.
"""
STATUS_CHOICES = [
('DRAFT', 'Draft'),
('ACTIVE', 'Active'),
('SCHEDULED', 'Scheduled'),
('EXPIRED', 'Expired'),
('DISABLED', 'Disabled'),
]
PRIORITY_CHOICES = [
('SPONSORED', 'Sponsored'),
('MANUAL', 'Manual'),
('ALGO', 'Algorithm'),
]
SCOPE_CHOICES = [
('GLOBAL', 'Global — shown to all users'),
('LOCAL', 'Local — shown to nearby users (50 km radius)'),
]
surface = models.ForeignKey(
AdSurface, on_delete=models.CASCADE, related_name='placements',
)
event = models.ForeignKey(
Event, on_delete=models.CASCADE, related_name='ad_placements',
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='DRAFT', db_index=True)
priority = models.CharField(max_length=20, choices=PRIORITY_CHOICES, default='MANUAL')
scope = models.CharField(max_length=10, choices=SCOPE_CHOICES, default='GLOBAL', db_index=True)
rank = models.IntegerField(default=0, help_text='Lower rank = higher position')
start_at = models.DateTimeField(null=True, blank=True, help_text='When this placement becomes active')
end_at = models.DateTimeField(null=True, blank=True, help_text='When this placement expires')
boost_label = models.CharField(
max_length=50, blank=True, default='',
help_text='Display label e.g. "Featured", "Top Pick", "Sponsored"',
)
notes = models.TextField(blank=True, default='')
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name='created_placements',
)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name='updated_placements',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['rank', '-created_at']
verbose_name = 'Ad Placement'
verbose_name_plural = 'Ad Placements'
constraints = [
models.UniqueConstraint(
fields=['surface', 'event'],
condition=models.Q(status__in=['DRAFT', 'ACTIVE', 'SCHEDULED']),
name='unique_active_placement_per_surface',
),
]
def __str__(self):
return f"{self.event.name} on {self.surface.key} [{self.status}]"

18
ad_control/urls.py Normal file
View File

@@ -0,0 +1,18 @@
from django.urls import path
from . import views
# Admin CRUD endpoints — mounted at /api/v1/ad-control/
urlpatterns = [
# Surfaces
path('surfaces/', views.SurfaceListView.as_view(), name='ad-surfaces'),
# Placements CRUD
path('placements/', views.PlacementListCreateView.as_view(), name='ad-placements'),
path('placements/<int:pk>/', views.PlacementDetailView.as_view(), name='ad-placement-detail'),
path('placements/<int:pk>/publish/', views.PlacementPublishView.as_view(), name='ad-placement-publish'),
path('placements/<int:pk>/unpublish/', views.PlacementUnpublishView.as_view(), name='ad-placement-unpublish'),
path('placements/reorder/', views.PlacementReorderView.as_view(), name='ad-placements-reorder'),
# Events picker
path('events/', views.PickerEventsView.as_view(), name='ad-picker-events'),
]

566
ad_control/views.py Normal file
View File

@@ -0,0 +1,566 @@
"""
Ad Control — Admin CRUD API views.
All endpoints require JWT authentication (IsAuthenticated).
Mounted at /api/v1/ad-control/ via admin_api/urls.py.
"""
import json
import math
from datetime import datetime
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from django.db import models as db_models
from django.db.models import Q, Count, Max
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from django.http import JsonResponse
from .models import AdSurface, AdPlacement
from events.models import Event, EventImages
# ---------------------------------------------------------------------------
# Serialisation helpers
# ---------------------------------------------------------------------------
def _serialize_surface(s):
"""Serialize an AdSurface to camelCase dict matching admin panel types."""
active = s.placements.filter(status__in=['ACTIVE', 'SCHEDULED']).count()
return {
'id': str(s.id),
'key': s.key,
'name': s.name,
'description': s.description,
'maxSlots': s.max_slots,
'layoutType': s.layout_type,
'sortBehavior': s.sort_behavior,
'isActive': s.is_active,
'activeCount': active,
'createdAt': s.created_at.isoformat() if s.created_at else None,
}
def _serialize_picker_event(e):
"""Serialize an Event for the picker modal (lightweight)."""
try:
thumb = EventImages.objects.get(event=e.id, is_primary=True)
cover = thumb.event_image.url
except EventImages.DoesNotExist:
cover = None
return {
'id': str(e.id),
'title': e.title or e.name,
'city': e.district,
'state': e.state,
'country': 'IN',
'date': str(e.start_date) if e.start_date else '',
'endDate': str(e.end_date) if e.end_date else '',
'organizer': e.partner.name if e.partner else 'Eventify',
'organizerLogo': '',
'category': e.event_type.event_type if e.event_type else '',
'coverImage': cover,
'approvalStatus': 'APPROVED' if e.event_status == 'published' else (
'REJECTED' if e.event_status == 'cancelled' else 'PENDING'
),
'ticketsSold': 0,
'capacity': 0,
}
def _serialize_placement(p, include_event=True):
"""Serialize an AdPlacement to camelCase dict matching admin panel types."""
result = {
'id': str(p.id),
'surfaceId': str(p.surface_id),
'itemType': 'EVENT',
'eventId': str(p.event_id),
'status': p.status,
'priority': p.priority,
'scope': p.scope,
'rank': p.rank,
'startAt': p.start_at.isoformat() if p.start_at else None,
'endAt': p.end_at.isoformat() if p.end_at else None,
'targeting': {
'cityIds': [],
'categoryIds': [],
'countryCodes': ['IN'],
},
'boostLabel': p.boost_label or None,
'notes': p.notes or None,
'createdBy': str(p.created_by_id) if p.created_by_id else 'system',
'updatedBy': str(p.updated_by_id) if p.updated_by_id else 'system',
'createdAt': p.created_at.isoformat(),
'updatedAt': p.updated_at.isoformat(),
}
if include_event:
result['event'] = _serialize_picker_event(p.event)
return result
# ---------------------------------------------------------------------------
# Admin API — Surfaces
# ---------------------------------------------------------------------------
class SurfaceListView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
surfaces = AdSurface.objects.filter(is_active=True)
return JsonResponse({
'success': True,
'data': [_serialize_surface(s) for s in surfaces],
})
# ---------------------------------------------------------------------------
# Admin API — Placements CRUD
# ---------------------------------------------------------------------------
class PlacementListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
"""List placements, optionally filtered by surface_id and status."""
qs = AdPlacement.objects.select_related('event', 'event__event_type', 'event__partner', 'surface')
surface_id = request.GET.get('surface_id')
status = request.GET.get('status')
if surface_id:
qs = qs.filter(surface_id=surface_id)
if status and status != 'ALL':
qs = qs.filter(status=status)
# Auto-expire: mark past-endAt placements as EXPIRED
now = timezone.now()
expired = qs.filter(
status__in=['ACTIVE', 'SCHEDULED'],
end_at__isnull=False,
end_at__lt=now,
)
if expired.exists():
expired.update(status='EXPIRED', updated_at=now)
# Re-fetch after expiry update
qs = AdPlacement.objects.select_related(
'event', 'event__event_type', 'event__partner', 'surface',
)
if surface_id:
qs = qs.filter(surface_id=surface_id)
if status and status != 'ALL':
qs = qs.filter(status=status)
qs = qs.order_by('rank', '-created_at')
return JsonResponse({
'success': True,
'data': [_serialize_placement(p) for p in qs],
})
def post(self, request):
"""Create a new placement."""
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'message': 'Invalid JSON'}, status=400)
surface_id = data.get('surfaceId')
event_id = data.get('eventId')
scope = data.get('scope', 'GLOBAL')
priority = data.get('priority', 'MANUAL')
start_at = data.get('startAt')
end_at = data.get('endAt')
boost_label = data.get('boostLabel', '')
notes = data.get('notes', '')
if not surface_id or not event_id:
return JsonResponse({'success': False, 'message': 'surfaceId and eventId are required'}, status=400)
try:
surface = AdSurface.objects.get(id=surface_id)
except AdSurface.DoesNotExist:
return JsonResponse({'success': False, 'message': 'Surface not found'}, status=404)
try:
event = Event.objects.get(id=event_id)
except Event.DoesNotExist:
return JsonResponse({'success': False, 'message': 'Event not found'}, status=404)
# Check max slots
active_count = surface.placements.filter(status__in=['ACTIVE', 'SCHEDULED']).count()
if active_count >= surface.max_slots:
return JsonResponse({
'success': False,
'message': f'Surface "{surface.name}" is full ({surface.max_slots} max slots)',
}, status=400)
# Check duplicate
if AdPlacement.objects.filter(
surface=surface, event=event, status__in=['DRAFT', 'ACTIVE', 'SCHEDULED'],
).exists():
return JsonResponse({
'success': False,
'message': 'This event is already placed on this surface',
}, status=400)
# Calculate next rank
max_rank = surface.placements.aggregate(max_rank=Max('rank'))['max_rank'] or 0
placement = AdPlacement.objects.create(
surface=surface,
event=event,
status='DRAFT',
priority=priority,
scope=scope,
rank=max_rank + 1,
start_at=parse_datetime(start_at) if start_at else None,
end_at=parse_datetime(end_at) if end_at else None,
boost_label=boost_label,
notes=notes,
created_by=request.user,
updated_by=request.user,
)
return JsonResponse({
'success': True,
'message': 'Placement created as draft',
'data': _serialize_placement(placement),
}, status=201)
class PlacementDetailView(APIView):
permission_classes = [IsAuthenticated]
def patch(self, request, pk):
"""Update a placement's config."""
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'message': 'Invalid JSON'}, status=400)
try:
placement = AdPlacement.objects.select_related('event', 'surface').get(id=pk)
except AdPlacement.DoesNotExist:
return JsonResponse({'success': False, 'message': 'Placement not found'}, status=404)
if 'startAt' in data:
placement.start_at = parse_datetime(data['startAt']) if data['startAt'] else None
if 'endAt' in data:
placement.end_at = parse_datetime(data['endAt']) if data['endAt'] else None
if 'scope' in data:
placement.scope = data['scope']
if 'priority' in data:
placement.priority = data['priority']
if 'boostLabel' in data:
placement.boost_label = data['boostLabel'] or ''
if 'notes' in data:
placement.notes = data['notes'] or ''
placement.updated_by = request.user
placement.save()
return JsonResponse({'success': True, 'message': 'Placement updated'})
def delete(self, request, pk):
"""Delete a placement."""
try:
placement = AdPlacement.objects.get(id=pk)
except AdPlacement.DoesNotExist:
return JsonResponse({'success': False, 'message': 'Placement not found'}, status=404)
placement.delete()
return JsonResponse({'success': True, 'message': 'Placement deleted'})
class PlacementPublishView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, pk):
"""Publish a placement (DRAFT → ACTIVE or SCHEDULED)."""
try:
placement = AdPlacement.objects.get(id=pk)
except AdPlacement.DoesNotExist:
return JsonResponse({'success': False, 'message': 'Placement not found'}, status=404)
now = timezone.now()
if placement.start_at and placement.start_at > now:
placement.status = 'SCHEDULED'
else:
placement.status = 'ACTIVE'
placement.updated_by = request.user
placement.save()
return JsonResponse({
'success': True,
'message': f'Placement {"scheduled" if placement.status == "SCHEDULED" else "published"}',
})
class PlacementUnpublishView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, pk):
"""Unpublish a placement (→ DISABLED)."""
try:
placement = AdPlacement.objects.get(id=pk)
except AdPlacement.DoesNotExist:
return JsonResponse({'success': False, 'message': 'Placement not found'}, status=404)
placement.status = 'DISABLED'
placement.updated_by = request.user
placement.save()
return JsonResponse({'success': True, 'message': 'Placement unpublished'})
class PlacementReorderView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
"""Bulk-update ranks for a surface's placements."""
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'message': 'Invalid JSON'}, status=400)
surface_id = data.get('surfaceId')
ordered_ids = data.get('orderedIds', [])
if not surface_id or not ordered_ids:
return JsonResponse({'success': False, 'message': 'surfaceId and orderedIds required'}, status=400)
now = timezone.now()
for index, pid in enumerate(ordered_ids):
AdPlacement.objects.filter(id=pid, surface_id=surface_id).update(
rank=index + 1, updated_at=now,
)
return JsonResponse({
'success': True,
'message': f'Reordered {len(ordered_ids)} placements',
})
# ---------------------------------------------------------------------------
# Admin API — Events picker
# ---------------------------------------------------------------------------
class PickerEventsView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
"""List events for the event picker modal (search, paginated)."""
search = request.GET.get('search', '').strip()
page = int(request.GET.get('page', 1))
page_size = int(request.GET.get('page_size', 20))
qs = Event.objects.select_related('event_type', 'partner').filter(
event_status__in=['published', 'live'],
).order_by('-start_date')
if search:
qs = qs.filter(
Q(name__icontains=search) |
Q(title__icontains=search) |
Q(district__icontains=search) |
Q(place__icontains=search)
)
total = qs.count()
start = (page - 1) * page_size
events = qs[start:start + page_size]
return JsonResponse({
'success': True,
'data': [_serialize_picker_event(e) for e in events],
'total': total,
'page': page,
'totalPages': math.ceil(total / page_size) if total > 0 else 1,
})
# ---------------------------------------------------------------------------
# Consumer API — Featured & Top Events (replaces boolean-based queries)
# ---------------------------------------------------------------------------
def _haversine_km(lat1, lng1, lat2, lng2):
"""Great-circle distance in km between two lat/lng points."""
R = 6371
d_lat = math.radians(float(lat2) - float(lat1))
d_lng = math.radians(float(lng2) - float(lng1))
a = (
math.sin(d_lat / 2) ** 2
+ math.cos(math.radians(float(lat1)))
* math.cos(math.radians(float(lat2)))
* math.sin(d_lng / 2) ** 2
)
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def _get_placement_events(surface_key, user_lat=None, user_lng=None):
"""
Core placement resolution logic.
1. Fetch ACTIVE placements on the given surface
2. Filter by schedule window (start_at / end_at)
3. GLOBAL placements → always included
4. LOCAL placements → included only if user is within 50 km of event
5. Sort by priority (SPONSORED > MANUAL > ALGO) then rank
6. Limit to surface.max_slots
"""
LOCAL_RADIUS_KM = 50
try:
surface = AdSurface.objects.get(key=surface_key, is_active=True)
except AdSurface.DoesNotExist:
return []
now = timezone.now()
qs = AdPlacement.objects.select_related(
'event', 'event__event_type', 'event__partner',
).filter(
surface=surface,
status='ACTIVE',
).filter(
Q(start_at__isnull=True) | Q(start_at__lte=now),
).filter(
Q(end_at__isnull=True) | Q(end_at__gt=now),
).order_by('rank')
result = []
priority_order = {'SPONSORED': 0, 'MANUAL': 1, 'ALGO': 2}
for p in qs:
if p.scope == 'GLOBAL':
result.append(p)
elif p.scope == 'LOCAL':
# Only include if user sent location AND is within 50 km
if user_lat is not None and user_lng is not None:
dist = _haversine_km(user_lat, user_lng, p.event.latitude, p.event.longitude)
if dist <= LOCAL_RADIUS_KM:
result.append(p)
# Sort: priority first, then rank
result.sort(key=lambda p: (priority_order.get(p.priority, 9), p.rank))
# Limit to max_slots
return result[:surface.max_slots]
def _serialize_event_for_consumer(e):
"""Serialize an Event for the consumer mobile/web API (matches existing format)."""
try:
thumb = EventImages.objects.get(event=e.id, is_primary=True)
thumb_url = thumb.event_image.url
except EventImages.DoesNotExist:
thumb_url = ''
return {
'id': e.id,
'name': e.name,
'title': e.title or e.name,
'description': (e.description or '')[:200],
'start_date': str(e.start_date) if e.start_date else '',
'end_date': str(e.end_date) if e.end_date else '',
'start_time': str(e.start_time) if e.start_time else '',
'end_time': str(e.end_time) if e.end_time else '',
'pincode': e.pincode,
'place': e.place,
'district': e.district,
'state': e.state,
'is_bookable': e.is_bookable,
'event_type': e.event_type_id,
'event_status': e.event_status,
'venue_name': e.venue_name,
'latitude': float(e.latitude),
'longitude': float(e.longitude),
'location_name': e.place,
'thumb_img': thumb_url,
'is_eventify_event': e.is_eventify_event,
'source': e.source,
}
class ConsumerFeaturedEventsView(APIView):
"""
Public API — returns featured events from the HOME_FEATURED_CAROUSEL surface.
POST /api/events/featured-events/
Optional body: { "latitude": float, "longitude": float }
"""
authentication_classes = []
permission_classes = []
def post(self, request):
try:
try:
data = json.loads(request.body) if request.body else {}
except json.JSONDecodeError:
data = {}
user_lat = data.get('latitude')
user_lng = data.get('longitude')
placements = _get_placement_events(
'HOME_FEATURED_CAROUSEL',
user_lat=user_lat,
user_lng=user_lng,
)
# IDs already covered by ad placements (used for dedup)
placement_ids = {p.event_id for p in placements}
# Start with placement events (they take priority)
events = [_serialize_event_for_consumer(p.event) for p in placements]
# Append is_featured events that aren't already in the placement set
featured_qs = (
Event.objects
.filter(is_featured=True, event_status='published')
.exclude(id__in=placement_ids)
.order_by('-start_date', '-created_date')
)
for evt in featured_qs:
if len(events) >= 10:
break
events.append(_serialize_event_for_consumer(evt))
# Cap at 10 total
events = events[:10]
return JsonResponse({'status': 'success', 'events': events})
except Exception as e:
return JsonResponse({'status': 'error', 'message': str(e)}, status=500)
class ConsumerTopEventsView(APIView):
"""
Public API — returns top events from the HOME_TOP_EVENTS surface.
POST /api/events/top-events/
Optional body: { "latitude": float, "longitude": float }
"""
authentication_classes = []
permission_classes = []
def post(self, request):
try:
try:
data = json.loads(request.body) if request.body else {}
except json.JSONDecodeError:
data = {}
user_lat = data.get('latitude')
user_lng = data.get('longitude')
placements = _get_placement_events(
'HOME_TOP_EVENTS',
user_lat=user_lat,
user_lng=user_lng,
)
events = [_serialize_event_for_consumer(p.event) for p in placements]
return JsonResponse({'status': 'success', 'events': events})
except Exception as e:
return JsonResponse({'status': 'error', 'message': str(e)}, status=500)

0
admin_api/__init__.py Normal file
View File

4
admin_api/apps.py Normal file
View File

@@ -0,0 +1,4 @@
from django.apps import AppConfig
class AdminApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'admin_api'

View File

@@ -0,0 +1,36 @@
# Generated by Django 4.2.21 on 2026-03-25 02:17
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('events', '0010_merge_20260324_1443'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Review',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
('review_text', models.TextField()),
('submission_date', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(choices=[('pending', 'Pending'), ('live', 'Live'), ('rejected', 'Rejected')], default='pending', max_length=10)),
('reject_reason', models.CharField(blank=True, choices=[('spam', 'Spam'), ('inappropriate', 'Inappropriate'), ('fake', 'Fake')], max_length=15, null=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admin_reviews', to='events.event')),
('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admin_reviews', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-submission_date'],
'indexes': [models.Index(fields=['status'], name='admin_api_r_status_2f6c07_idx'), models.Index(fields=['submission_date'], name='admin_api_r_submiss_02d0c1_idx')],
},
),
]

View File

@@ -0,0 +1,92 @@
# Generated by Django 4.2.21 on 2026-03-26 13:42
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_api', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CustomRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(unique=True)),
('description', models.TextField(blank=True, default='')),
('scopes', models.JSONField(default=list)),
('is_system', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Department',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(unique=True)),
('description', models.TextField(blank=True, default='')),
('base_scopes', models.JSONField(default=list)),
('color', models.CharField(default='#3B82F6', max_length=7)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Squad',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('extra_scopes', models.JSONField(default=list)),
('created_at', models.DateTimeField(auto_now_add=True)),
('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='squads', to='admin_api.department')),
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_squads', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='StaffProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('staff_role', models.CharField(choices=[('SUPER_ADMIN', 'Super Admin'), ('MANAGER', 'Manager'), ('MEMBER', 'Member')], default='MEMBER', max_length=20)),
('status', models.CharField(choices=[('active', 'Active'), ('invited', 'Invited'), ('deactivated', 'Deactivated')], default='active', max_length=20)),
('joined_at', models.DateTimeField(auto_now_add=True)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_members', to='admin_api.department')),
('squad', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='admin_api.squad')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='staff_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['user__first_name'],
},
),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(max_length=100)),
('target_type', models.CharField(max_length=50)),
('target_id', models.CharField(max_length=50)),
('details', models.JSONField(default=dict)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 4.2.21 on 2026-04-07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_api', '0002_rbac_models'),
]
operations = [
migrations.CreateModel(
name='Lead',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(max_length=20)),
('event_type', models.CharField(choices=[('private', 'Private Event'), ('ticketed', 'Ticketed Event'), ('corporate', 'Corporate Event'), ('wedding', 'Wedding'), ('other', 'Other')], default='private', max_length=20)),
('message', models.TextField(blank=True, default='')),
('status', models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('qualified', 'Qualified'), ('converted', 'Converted'), ('closed', 'Closed')], default='new', max_length=20)),
('source', models.CharField(choices=[('schedule_call', 'Schedule a Call'), ('website', 'Website'), ('manual', 'Manual')], default='schedule_call', max_length=20)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')], default='medium', max_length=10)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_leads', to=settings.AUTH_USER_MODEL)),
('notes', 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='lead',
index=models.Index(fields=['status'], name='admin_api_lead_status_idx'),
),
migrations.AddIndex(
model_name='lead',
index=models.Index(fields=['priority'], name='admin_api_lead_priority_idx'),
),
migrations.AddIndex(
model_name='lead',
index=models.Index(fields=['created_at'], name='admin_api_lead_created_idx'),
),
migrations.AddIndex(
model_name='lead',
index=models.Index(fields=['email'], name='admin_api_lead_email_idx'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.21 on 2026-04-07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_api', '0003_lead'),
]
operations = [
migrations.AddField(
model_name='lead',
name='user_account',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='submitted_leads',
to=settings.AUTH_USER_MODEL,
help_text='Consumer platform account that submitted this lead (auto-matched by email)',
),
),
]

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

254
admin_api/models.py Normal file
View File

@@ -0,0 +1,254 @@
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
class Review(models.Model):
STATUS_PENDING = 'pending'
STATUS_LIVE = 'live'
STATUS_REJECTED = 'rejected'
STATUS_CHOICES = [
(STATUS_PENDING, 'Pending'),
(STATUS_LIVE, 'Live'),
(STATUS_REJECTED, 'Rejected'),
]
REJECT_CHOICES = [
('spam', 'Spam'),
('inappropriate', 'Inappropriate'),
('fake', 'Fake'),
]
reviewer = models.ForeignKey(
'accounts.User', on_delete=models.CASCADE, related_name='admin_reviews'
)
event = models.ForeignKey(
'events.Event', on_delete=models.CASCADE, related_name='admin_reviews'
)
rating = models.IntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
review_text = models.TextField()
submission_date = models.DateTimeField(auto_now_add=True)
status = models.CharField(
max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDING
)
reject_reason = models.CharField(
max_length=15, choices=REJECT_CHOICES, null=True, blank=True
)
display_name = models.CharField(max_length=100, blank=True, default='')
is_verified = models.BooleanField(default=False)
helpful_count = models.IntegerField(default=0)
flag_count = models.IntegerField(default=0)
class Meta:
ordering = ['-submission_date']
indexes = [
models.Index(fields=['status']),
models.Index(fields=['submission_date']),
]
def __str__(self):
return f'Review #{self.pk} by {self.reviewer_id}{self.status}'
class ReviewInteraction(models.Model):
INTERACTION_CHOICES = [('HELPFUL', 'Helpful'), ('FLAG', 'Flag')]
review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name='interactions')
username = models.CharField(max_length=255)
interaction_type = models.CharField(max_length=20, choices=INTERACTION_CHOICES)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('review', 'username', 'interaction_type')
def __str__(self):
return f'{self.username} {self.interaction_type} on Review #{self.review_id}'
# ---------------------------------------------------------------------------
# RBAC Models
# ---------------------------------------------------------------------------
from accounts.models import User
class Department(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True, default='')
base_scopes = models.JSONField(default=list)
color = models.CharField(max_length=7, default='#3B82F6')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class Squad(models.Model):
name = models.CharField(max_length=100)
department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name='squads')
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_squads')
extra_scopes = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
def __str__(self):
return f"{self.department.name} > {self.name}"
class StaffProfile(models.Model):
ROLE_CHOICES = [('SUPER_ADMIN', 'Super Admin'), ('MANAGER', 'Manager'), ('MEMBER', 'Member')]
STATUS_CHOICES = [('active', 'Active'), ('invited', 'Invited'), ('deactivated', 'Deactivated')]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='staff_profile')
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, related_name='staff_members')
squad = models.ForeignKey(Squad, on_delete=models.SET_NULL, null=True, blank=True, related_name='members')
staff_role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='MEMBER')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['user__first_name']
def get_effective_scopes(self):
if self.staff_role == 'SUPER_ADMIN' or self.user.is_superuser:
return ['*']
scopes = set()
if self.department:
scopes.update(self.department.base_scopes or [])
if self.squad:
scopes.update(self.squad.extra_scopes or [])
if self.staff_role == 'MANAGER':
scopes.add('settings.staff')
return list(scopes)
def get_allowed_modules(self):
scopes = self.get_effective_scopes()
if '*' in scopes:
return ['dashboard', 'partners', 'events', 'ad-control', 'users', 'reviews', 'contributions', 'leads', 'financials', 'audit-log', 'settings']
SCOPE_TO_MODULE = {
'users': 'users',
'events': 'events',
'finance': 'financials',
'partners': 'partners',
'tickets': 'dashboard',
'settings': 'settings',
'ads': 'ad-control',
'contributions': 'contributions',
'leads': 'leads',
'audit': 'audit-log',
'reviews': 'reviews',
}
modules = {'dashboard'}
for scope in scopes:
prefix = scope.split('.')[0]
if prefix in SCOPE_TO_MODULE:
modules.add(SCOPE_TO_MODULE[prefix])
return list(modules)
def __str__(self):
return f"{self.user.username} ({self.staff_role})"
class CustomRole(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True, default='')
scopes = models.JSONField(default=list)
is_system = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class AuditLog(models.Model):
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='audit_logs')
action = models.CharField(max_length=100)
target_type = models.CharField(max_length=50)
target_id = models.CharField(max_length=50)
details = models.JSONField(default=dict)
ip_address = models.GenericIPAddressField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
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}"
# ---------------------------------------------------------------------------
# Lead Manager
# ---------------------------------------------------------------------------
class Lead(models.Model):
EVENT_TYPE_CHOICES = [
('private', 'Private Event'),
('ticketed', 'Ticketed Event'),
('corporate', 'Corporate Event'),
('wedding', 'Wedding'),
('other', 'Other'),
]
STATUS_CHOICES = [
('new', 'New'),
('contacted', 'Contacted'),
('qualified', 'Qualified'),
('converted', 'Converted'),
('closed', 'Closed'),
]
SOURCE_CHOICES = [
('schedule_call', 'Schedule a Call'),
('website', 'Website'),
('manual', 'Manual'),
]
PRIORITY_CHOICES = [
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
]
name = models.CharField(max_length=200)
email = models.EmailField()
phone = models.CharField(max_length=20)
event_type = models.CharField(max_length=20, choices=EVENT_TYPE_CHOICES, default='private')
message = models.TextField(blank=True, default='')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='new')
source = models.CharField(max_length=20, choices=SOURCE_CHOICES, default='schedule_call')
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='medium')
assigned_to = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_leads'
)
user_account = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True, related_name='submitted_leads',
help_text='Consumer platform account that submitted this lead (auto-matched by email)'
)
notes = 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=['status']),
models.Index(fields=['priority']),
models.Index(fields=['created_at']),
models.Index(fields=['email']),
]
def __str__(self):
return f'Lead #{self.pk}{self.name} ({self.status})'

19
admin_api/serializers.py Normal file
View File

@@ -0,0 +1,19 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField()
role = serializers.SerializerMethodField()
partner = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
model = User
fields = ['id', 'email', 'username', 'name', 'role', 'partner']
def get_name(self, obj):
return f"{obj.first_name} {obj.last_name}".strip() or obj.username
def get_role(self, obj):
if obj.is_superuser:
return 'superadmin'
if obj.is_staff:
return 'admin'
return getattr(obj, 'role', 'user')

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

109
admin_api/urls.py Normal file
View File

@@ -0,0 +1,109 @@
from django.urls import path, include
from rest_framework_simplejwt.views import TokenRefreshView
from . import views
urlpatterns = [
path('admin/auth/login/', views.AdminLoginView.as_view(), name='admin_login'),
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('auth/me/', views.MeView.as_view(), name='auth_me'),
path('health/', views.HealthView.as_view(), name='health'),
# Phase 2: Dashboard endpoints
path('dashboard/metrics/', views.DashboardMetricsView.as_view(), name='dashboard-metrics'),
path('dashboard/revenue/', views.DashboardRevenueView.as_view(), name='dashboard-revenue'),
path('dashboard/activity/', views.DashboardActivityView.as_view(), name='dashboard-activity'),
path('dashboard/actions/', views.DashboardActionsView.as_view(), name='dashboard-actions'),
# Phase 3: Partner endpoints
path('partners/stats/', views.PartnerStatsView.as_view(), name='partner-stats'),
path('partners/', views.PartnerListView.as_view(), name='partner-list'),
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'),
# 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'),
# Partner-Me: ticket tiers (Sprint 3)
path('partners/me/events/<int:event_pk>/tiers/', views.PartnerMeEventTiersView.as_view(), name='partner-me-event-tiers'),
path('partners/me/events/<int:event_pk>/tiers/<int:tier_pk>/', views.PartnerMeEventTierDetailView.as_view(), name='partner-me-event-tier-detail'),
path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
path('users/', views.UserListView.as_view(), name='user-list'),
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),
path('users/<int:pk>/status/', views.UserStatusView.as_view(), name='user-status'),
# Phase 5: Events endpoints
path('events/stats/', views.EventStatsView.as_view(), name='event-stats'),
path('events/', views.EventListView.as_view(), name='event-list'),
path('events/<int:pk>/', views.EventDetailView.as_view(), name='event-detail'),
path('events/<int:pk>/update/', views.EventUpdateView.as_view(), name='event-update'),
path('events/<int:pk>/moderate/', views.EventModerationView.as_view(), name='event-moderate'),
path('events/<int:pk>/delete/', views.EventDeleteView.as_view(), name='event-delete'),
path('events/create/', views.EventCreateView.as_view(), name='event-create'),
path('events/types/', views.EventTypesView.as_view(), name='event-types'),
path('events/<int:pk>/primary-image/', views.EventPrimaryImageView.as_view(), name='event-primary-image'),
path('financials/metrics/', views.FinancialMetricsView.as_view(), name='financial-metrics'),
path('financials/transactions/', views.TransactionListView.as_view(), name='transaction-list'),
path('financials/settlements/', views.SettlementListView.as_view(), name='settlement-list'),
path('financials/settlements/<int:pk>/release/', views.SettlementReleaseView.as_view(), name='settlement-release'),
path('reviews/metrics/', views.ReviewMetricsView.as_view(), name='review-metrics'),
path('reviews/', views.ReviewListView.as_view(), name='review-list'),
path('reviews/<int:pk>/moderate/', views.ReviewModerationView.as_view(), name='review-moderate'),
path('reviews/<int:pk>/', views.ReviewDeleteView.as_view(), name='review-delete'),
# Lead Manager
path('leads/metrics/', views.LeadMetricsView.as_view(), name='lead-metrics'),
path('leads/', views.LeadListView.as_view(), name='lead-list'),
path('leads/<int:pk>/', views.LeadDetailView.as_view(), name='lead-detail'),
path('leads/<int:pk>/update/', views.LeadUpdateView.as_view(), name='lead-update'),
path('gamification/submit-event/', views.GamificationSubmitEventView.as_view(), name='gamification-submit-event'),
path('gamification/submit-event', views.GamificationSubmitEventView.as_view()),
path('shop/items/', views.ShopItemsView.as_view(), name='shop-items'),
path('shop/items', views.ShopItemsView.as_view()),
path('shop/redeem/', views.ShopRedeemView.as_view(), name='shop-redeem'),
path('shop/redeem', views.ShopRedeemView.as_view()),
path('gamification/dashboard/', views.GamificationDashboardView.as_view(), name='gamification-dashboard'),
path('gamification/dashboard', views.GamificationDashboardView.as_view()),
# Payment gateway settings
path('settings/payment-gateway/active/', views.ActivePaymentGatewayView.as_view(), name='active-payment-gateway'),
path('settings/payment-gateways/', views.PaymentGatewaySettingsView.as_view(), name='payment-gateways'),
path('settings/payment-gateways/<int:pk>/', views.PaymentGatewaySettingsView.as_view(), name='payment-gateway-detail'),
# RBAC
path('rbac/departments/', views.DepartmentListCreateView.as_view(), name='rbac-department-list'),
path('rbac/departments/<int:pk>/', views.DepartmentDetailView.as_view(), name='rbac-department-detail'),
path('rbac/squads/', views.SquadListCreateView.as_view(), name='rbac-squad-list'),
path('rbac/squads/<int:pk>/', views.SquadDetailView.as_view(), name='rbac-squad-detail'),
path('rbac/staff/', views.StaffListView.as_view(), name='rbac-staff-list'),
path('rbac/staff/invite/', views.StaffInviteView.as_view(), name='rbac-staff-invite'),
path('rbac/staff/<int:pk>/', views.StaffUpdateView.as_view(), name='rbac-staff-update'),
path('rbac/staff/<int:pk>/deactivate/', views.StaffDeactivateView.as_view(), name='rbac-staff-deactivate'),
path('rbac/staff/<int:pk>/move/', views.StaffMoveView.as_view(), name='rbac-staff-move'),
path('rbac/roles/', views.RoleListCreateView.as_view(), name='rbac-role-list'),
path('rbac/roles/<int:pk>/', views.RoleDetailView.as_view(), name='rbac-role-detail'),
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'),
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
path('ad-control/', include('ad_control.urls')),
]

3899
admin_api/views.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@ def _payment_gateway_to_dict(gateway, request=None):
# Add logo URL if exists
if gateway.payment_gateway_logo:
if request:
data["payment_gateway_logo"] = request.build_absolute_uri(gateway.payment_gateway_logo.url)
data["payment_gateway_logo"] = gateway.payment_gateway_logo.url
else:
data["payment_gateway_logo"] = gateway.payment_gateway_logo.url
else:

View File

@@ -44,7 +44,7 @@ class PaymentGatewayCredentials(models.Model):
class PaymentTransaction(models.Model):
payment_transaction_id = models.CharField(max_length=250)
payment_type = models.CharField(max_length=250, choices=[
payment_type = models.CharField(max_length=250, db_index=True, choices=[
('credit', 'Credit'),
('debit', 'Debit'),
('transfer', 'Transfer'),
@@ -58,14 +58,14 @@ class PaymentTransaction(models.Model):
payment_gateway = models.ForeignKey(PaymentGateway, on_delete=models.CASCADE)
payment_transaction_amount = models.DecimalField(max_digits=10, decimal_places=2)
payment_transaction_currency = models.CharField(max_length=10)
payment_transaction_status = models.CharField(max_length=250, choices=[
payment_transaction_status = models.CharField(max_length=250, db_index=True, choices=[
('pending', 'Pending'),
('completed', 'Completed'),
('failed', 'Failed'),
('refunded', 'Refunded'),
('cancelled', 'Cancelled'),
])
payment_transaction_date = models.DateField(auto_now_add=True)
payment_transaction_date = models.DateField(auto_now_add=True, db_index=True)
payment_transaction_time = models.TimeField(auto_now_add=True)
payment_transaction_notes = models.TextField(blank=True, null=True)
payment_transaction_raw_data = models.JSONField(blank=True, null=True)

View File

@@ -61,7 +61,7 @@ class Booking(models.Model):
ticket_type = models.ForeignKey(TicketType, on_delete=models.CASCADE)
quantity = models.IntegerField()
price = models.DecimalField(max_digits=10, decimal_places=2)
created_date = models.DateField(auto_now_add=True)
created_date = models.DateField(auto_now_add=True, db_index=True)
updated_date = models.DateField(auto_now=True)
transaction_id = models.CharField(max_length=250, blank=True, null=True)

25
create_temp_user.py Normal file
View File

@@ -0,0 +1,25 @@
import os
import django
import sys
sys.path.append('/var/www/myproject/eventify_prod')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eventify.settings')
django.setup()
from django.contrib.auth import get_user_model
User = get_user_model()
username = 'support_agent'
password = 'AgentPass123!'
email = 'agent@example.com'
if User.objects.filter(username=username).exists():
print(f"User {username} already exists. Resetting password.")
u = User.objects.get(username=username)
u.set_password(password)
u.save()
else:
print(f"Creating user {username}.")
User.objects.create_superuser(username, email, password)
print("Done.")

View File

@@ -3,16 +3,26 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'change-me-in-production')
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
# DEBUG = os.environ.get('DJANGO_DEBUG', 'False') == 'True'
#
# ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split(',')
DEBUG = True
DEBUG = False
ALLOWED_HOSTS = [
'*'
'db.eventifyplus.com',
'uat.eventifyplus.com',
'em.eventifyplus.com',
'backend.eventifyplus.com',
'admin.eventifyplus.com',
'app.eventifyplus.com',
'partner.eventifyplus.com',
'eventify-backend',
'eventify-django',
'localhost',
'127.0.0.1',
]
INSTALLED_APPS = [
@@ -33,7 +43,13 @@ INSTALLED_APPS = [
'bookings',
'banking_operations',
'rest_framework',
'rest_framework.authtoken'
'rest_framework.authtoken',
'rest_framework_simplejwt',
'admin_api',
'django_summernote',
'ledger',
'notifications',
'ad_control',
]
INSTALLED_APPS += [
@@ -54,10 +70,21 @@ MIDDLEWARE = [
]
CORS_ALLOWED_ORIGINS = [
"https://app.eventifyplus.com",
"https://admin.eventifyplus.com",
"https://uat.eventifyplus.com",
"http://localhost:5178",
"http://localhost:5179",
"http://localhost:5173",
"http://localhost:3001",
"http://localhost:3000",
"http://localhost:8080",
"https://prototype.eventifyplus.com",
"https://eventifyplus.com",
"https://mv.eventifyplus.com"
"https://mv.eventifyplus.com",
"https://db.eventifyplus.com",
"https://test.eventifyplus.com",
"https://em.eventifyplus.com"
]
ROOT_URLCONF = 'eventify.urls'
@@ -82,8 +109,12 @@ WSGI_APPLICATION = 'eventify.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'),
'NAME': os.environ.get('DB_NAME', str(BASE_DIR / 'db.sqlite3')),
'USER': os.environ.get('DB_USER', ''),
'PASSWORD': os.environ.get('DB_PASS', ''),
'HOST': os.environ.get('DB_HOST', ''),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
@@ -92,7 +123,6 @@ DATABASES = {
# 'ENGINE': 'django.db.backends.postgresql',
# 'NAME': 'eventify_uat_db', # your DB name
# 'USER': 'eventify_uat', # your DB user
# 'PASSWORD': 'eventifyplus@!@#$', # your DB password
# 'HOST': '0.0.0.0', # or IP/domain
# 'PORT': '5440', # default PostgreSQL port
# }
@@ -118,11 +148,60 @@ STATICFILES_DIRS = [BASE_DIR / 'static']
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
X_FRAME_OPTIONS = 'SAMEORIGIN'
AUTH_USER_MODEL = 'accounts.User'
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'dashboard'
LOGOUT_REDIRECT_URL = 'login'
# EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# DEFAULT_FROM_EMAIL = 'no-reply@example.com'
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'mail.bshtech.net'
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = 'no-reply@eventifyplus.com'
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
DEFAULT_FROM_EMAIL = 'Eventify <no-reply@eventifyplus.com>'
SUMMERNOTE_THEME = 'bs5'
# Reverse proxy / CSRF fix
CSRF_TRUSTED_ORIGINS = [
'https://app.eventifyplus.com',
'https://admin.eventifyplus.com',
'https://db.eventifyplus.com',
'https://uat.eventifyplus.com',
'https://test.eventifyplus.com',
'https://eventifyplus.com',
]
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True
# --- JWT Auth (Phase 1) ---
from datetime import timedelta
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), # Reduced from 1 day for security
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'AUTH_HEADER_TYPES': ('Bearer',),
'USER_ID_FIELD': '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

@@ -35,7 +35,11 @@ urlpatterns = [
path('partner/', include('partner.urls')),
path('banking/', include('banking_operations.urls')),
path('api/', include('mobile_api.urls')),
path('api/v1/', include('admin_api.urls')),
path('api/notifications/', include('notifications.urls')),
# path('web-api/', include('web_api.urls')),
path('summernote/', include('django_summernote.urls')),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -3,9 +3,9 @@ from .models import Event, EventImages
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'start_date', 'end_date', 'event_type', 'event_status', 'is_featured', 'is_top_event')
list_filter = ('event_status', 'event_type', 'is_featured', 'is_top_event')
list_editable = ('is_featured', 'is_top_event')
list_display = ('id', 'name', 'start_date', 'end_date', 'event_type', 'event_status', 'source', 'is_featured', 'is_top_event')
list_filter = ('event_status', 'event_type', 'source', 'is_featured', 'is_top_event')
list_editable = ('is_featured', 'is_top_event', 'source')
search_fields = ('name', 'place', 'district')
@admin.register(EventImages)

View File

@@ -45,7 +45,7 @@ def _event_to_dict(event, request=None):
}
if event.event_type.event_type_icon:
if request:
data["event_type"]["event_type_icon"] = request.build_absolute_uri(event.event_type.event_type_icon.url)
data["event_type"]["event_type_icon"] = event.event_type.event_type_icon.url
else:
data["event_type"]["event_type_icon"] = event.event_type.event_type_icon.url
else:

View File

@@ -36,9 +36,13 @@ class EventForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set source to 'official' only and hide the field
self.fields['source'].initial = 'official'
self.fields['source'].widget = forms.HiddenInput()
# Show source as visible radio buttons with Bootstrap styling
self.fields['source'].widget = forms.RadioSelect(
choices=self.fields['source'].choices,
attrs={'class': 'form-check-input'}
)
if not self.instance.pk:
self.fields['source'].initial = 'eventify'
# Check if all_year_event is True (from instance or initial data)
all_year_event = False
@@ -60,8 +64,7 @@ class EventForm(forms.ModelForm):
cleaned_data = super().clean()
all_year_event = cleaned_data.get('all_year_event', False)
# Force source to be 'official' only
cleaned_data['source'] = 'official'
# Source is now user-selectable (eventify/community/partner)
# If all_year_event is True, clear date/time fields
if all_year_event:

View File

@@ -0,0 +1,14 @@
# Generated by Django 4.2.21 on 2026-03-24 14:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('events', '0007_add_is_featured_is_top_event'),
('events', '0009_alter_event_id_alter_eventimages_id'),
]
operations = [
]

View File

@@ -0,0 +1,73 @@
"""
Add contributed_by field to Event and backfill from overloaded source field.
The admin dashboard stores community contributor identifiers (EVT-XXXXXXXX or email)
in the source field. This migration:
1. Adds a dedicated contributed_by CharField
2. Copies user identifiers from source → contributed_by
3. Normalizes source back to its intended choices ('eventify', 'community', 'partner')
"""
from django.db import migrations, models
def backfill_contributed_by(apps, schema_editor):
"""Move user identifiers from source to contributed_by."""
Event = apps.get_model('events', 'Event')
STANDARD_SOURCES = {'eventify', 'community', 'partner', 'eventify_team', 'official', ''}
for event in Event.objects.all().iterator():
source_val = (event.source or '').strip()
changed = False
# User identifier: contains @ (email) or starts with EVT- (eventifyId)
if source_val and source_val not in STANDARD_SOURCES and not source_val.startswith('partner:'):
event.contributed_by = source_val
event.source = 'community'
changed = True
# Normalize eventify_team → eventify
elif source_val == 'eventify_team':
event.source = 'eventify'
changed = True
# Normalize official → eventify
elif source_val == 'official':
event.source = 'eventify'
changed = True
if changed:
event.save(update_fields=['source', 'contributed_by'])
def reverse_backfill(apps, schema_editor):
"""Reverse: move contributed_by back to source."""
Event = apps.get_model('events', 'Event')
for event in Event.objects.exclude(contributed_by__isnull=True).exclude(contributed_by='').iterator():
event.source = event.contributed_by
event.contributed_by = None
event.save(update_fields=['source', 'contributed_by'])
class Migration(migrations.Migration):
dependencies = [
('events', '0010_merge_20260324_1443'),
]
operations = [
# Step 1: Add the field
migrations.AddField(
model_name='event',
name='contributed_by',
field=models.CharField(
blank=True,
help_text='Eventify ID (EVT-XXXXXXXX) or email of the community contributor',
max_length=100,
null=True,
),
),
# Step 2: Backfill data
migrations.RunPython(backfill_contributed_by, reverse_backfill),
]

View File

@@ -0,0 +1,38 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('events', '0011_event_contributed_by'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EventLike',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('event', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='likes',
to='events.event',
)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='event_likes',
to=settings.AUTH_USER_MODEL,
)),
],
options={
'unique_together': {('user', 'event')},
},
),
migrations.AddIndex(
model_name='eventlike',
index=models.Index(fields=['user', '-created_at'], name='events_even_user_id_created_idx'),
),
]

View File

@@ -0,0 +1,48 @@
# Generated by Django 4.2.21 on 2025-11-26 22:07
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('master_data', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Event',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateField(auto_now_add=True)),
('name', models.CharField(max_length=200)),
('description', models.TextField()),
('start_date', models.DateField()),
('end_date', models.DateField()),
('latitude', models.DecimalField(decimal_places=6, max_digits=9)),
('longitude', models.DecimalField(decimal_places=6, max_digits=9)),
('pincode', models.CharField(max_length=10)),
('district', models.CharField(max_length=100)),
('state', models.CharField(max_length=100)),
('place', models.CharField(max_length=200)),
('is_bookable', models.BooleanField(default=False)),
('is_eventify_event', models.BooleanField(default=True)),
('outside_event_url', models.URLField(default='NA')),
('event_status', models.CharField(choices=[('created', 'Created'), ('cancelled', 'Cancelled'), ('pending', 'Pending'), ('completed', 'Completed'), ('postponed', 'Postponed')], default='pending', max_length=250)),
('cancelled_reason', models.TextField(default='NA')),
('event_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='master_data.eventtype')),
],
),
migrations.CreateModel(
name='EventImages',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_primary', models.BooleanField(default=False)),
('event_image', models.ImageField(upload_to='event_images')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')),
],
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.21 on 2025-11-28 20:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='event',
name='important_information',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='event',
name='title',
field=models.CharField(blank=True, max_length=250),
),
migrations.AddField(
model_name='event',
name='venue_name',
field=models.CharField(blank=True, max_length=250),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.2.21 on 2025-11-28 21:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0002_event_important_information_event_title_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='end_time',
field=models.TimeField(blank=True, null=True),
),
migrations.AddField(
model_name='event',
name='start_time',
field=models.TimeField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.0 on 2025-12-19 22:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0003_event_end_time_event_start_time'),
]
operations = [
migrations.AddField(
model_name='event',
name='all_year_event',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='event',
name='end_date',
field=models.DateField(blank=True, null=True),
),
migrations.AlterField(
model_name='event',
name='start_date',
field=models.DateField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0 on 2025-12-19 22:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0004_event_all_year_event_alter_event_end_date_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='source',
field=models.CharField(blank=True, choices=[('eventify', 'Eventify'), ('community', 'Community')], max_length=250),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0 on 2025-12-19 22:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0005_event_source'),
]
operations = [
migrations.AlterField(
model_name='event',
name='source',
field=models.CharField(blank=True, choices=[('official', 'Official'), ('community', 'Community')], max_length=250),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0006_alter_event_source'),
]
operations = [
migrations.AddField(
model_name='event',
name='is_featured',
field=models.BooleanField(default=False, help_text='Show this event in the featured section'),
),
migrations.AddField(
model_name='event',
name='is_top_event',
field=models.BooleanField(default=False, help_text='Show this event in the Top Events section'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 4.2.27 on 2026-03-13 16:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0006_alter_event_source'),
]
operations = [
migrations.AddField(
model_name='event',
name='gst_percentage_1',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='event',
name='gst_percentage_2',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='event',
name='include_gst',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='event',
name='event_status',
field=models.CharField(choices=[('created', 'Created'), ('cancelled', 'Cancelled'), ('pending', 'Pending'), ('completed', 'Completed'), ('postponed', 'Postponed'), ('published', 'Published'), ('live', 'Live'), ('flagged', 'Flagged')], default='pending', max_length=250),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 4.2.27 on 2026-03-14 15:57
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('partner', '0001_initial'),
('events', '0007_event_gst_percentage_1_event_gst_percentage_2_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='is_partner_event',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='event',
name='partner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='partner.partner'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-14 19:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0008_event_is_partner_event_event_partner'),
]
operations = [
migrations.AlterField(
model_name='event',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='eventimages',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@@ -0,0 +1,14 @@
# Generated by Django 4.2.21 on 2026-03-24 14:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('events', '0007_add_is_featured_is_top_event'),
('events', '0009_alter_event_id_alter_eventimages_id'),
]
operations = [
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 4.2.21 on 2026-03-30 10:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0010_merge_20260324_1443'),
]
operations = [
migrations.AlterField(
model_name='event',
name='created_date',
field=models.DateField(auto_now_add=True, db_index=True),
),
migrations.AlterField(
model_name='event',
name='event_status',
field=models.CharField(choices=[('created', 'Created'), ('cancelled', 'Cancelled'), ('pending', 'Pending'), ('completed', 'Completed'), ('postponed', 'Postponed'), ('published', 'Published'), ('live', 'Live'), ('flagged', 'Flagged')], db_index=True, default='pending', max_length=250),
),
migrations.AlterField(
model_name='event',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='event',
name='source',
field=models.CharField(choices=[('eventify', 'Added by Eventify'), ('community', 'Community Contribution'), ('partner', 'Partner Event')], default='eventify', max_length=50),
),
migrations.AlterField(
model_name='event',
name='start_date',
field=models.DateField(blank=True, db_index=True, null=True),
),
migrations.AlterField(
model_name='eventimages',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

View File

@@ -5,10 +5,10 @@ from partner.models import Partner
class Event(models.Model):
created_date = models.DateField(auto_now_add=True)
created_date = models.DateField(auto_now_add=True, db_index=True)
name = models.CharField(max_length=200)
description = models.TextField()
start_date = models.DateField(blank=True, null=True)
start_date = models.DateField(blank=True, null=True, db_index=True)
end_date = models.DateField(blank=True, null=True)
start_time = models.TimeField(blank=True, null=True)
end_time = models.TimeField(blank=True, null=True)
@@ -42,21 +42,27 @@ class Event(models.Model):
('published', 'Published'),
('live', 'Live'),
('flagged', 'Flagged'),
], default='pending')
], default='pending', db_index=True)
cancelled_reason = models.TextField(default='NA')
title = models.CharField(max_length=250, blank=True)
important_information = models.TextField(blank=True)
venue_name = models.CharField(max_length=250, blank=True)
source = models.CharField(max_length=250, blank=True, choices=[
('official', 'Official'),
('community', 'Community'),
source = models.CharField(max_length=50, default='eventify', choices=[
('eventify', 'Added by Eventify'),
('community', 'Community Contribution'),
('partner', 'Partner Event'),
])
is_featured = models.BooleanField(default=False, help_text='Show this event in the featured section')
is_top_event = models.BooleanField(default=False, help_text='Show this event in the Top Events section')
contributed_by = models.CharField(
max_length=100, blank=True, null=True,
help_text='Eventify ID (EVT-XXXXXXXX) or email of the community contributor',
)
def __str__(self):
return f"{self.name} ({self.start_date})"
@@ -70,3 +76,26 @@ class EventImages(models.Model):
return f"{self.event_image}"
class EventLike(models.Model):
user = models.ForeignKey(
'accounts.User',
on_delete=models.CASCADE,
related_name='event_likes'
)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name='likes'
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'event')
indexes = [
models.Index(fields=['user', '-created_at']),
]
def __str__(self):
return f"{self.user.email} likes {self.event.name}"

View File

@@ -1,5 +1,6 @@
from django.views import generic
from django.urls import reverse_lazy
from django.db.models import Q
from .models import Event
from .models import EventImages
from .forms import EventForm
@@ -18,6 +19,17 @@ class EventListView(LoginRequiredMixin, generic.ListView):
template_name = 'events/event_list.html'
paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset()
query = self.request.GET.get('q')
if query:
queryset = queryset.filter(
Q(name__icontains=query) |
Q(district__icontains=query) |
Q(state__icontains=query)
)
return queryset
class EventCreateView(LoginRequiredMixin, generic.CreateView):
model = Event
@@ -91,5 +103,3 @@ def delete_event_image(request, pk, img_id):
image.delete()
messages.success(request, "Image deleted!")
return redirect("events:event_images", pk=pk)

View File

@@ -36,6 +36,7 @@ class RazorpayTransaction(models.Model):
status = models.CharField(
max_length=50,
help_text="created/authorized/captured/failed/refunded",
db_index=True,
)
method = models.CharField(
max_length=50,
@@ -59,7 +60,7 @@ class RazorpayTransaction(models.Model):
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
captured_at = models.DateTimeField(blank=True, null=True)
captured_at = models.DateTimeField(blank=True, null=True, db_index=True)
def __str__(self):
return f"{self.razorpay_payment_id or self.razorpay_order_id} - {self.status}"

View File

@@ -31,6 +31,9 @@ class RegisterForm(forms.ModelForm):
# Set username equal to email to avoid separate username errors
user.username = self.cleaned_data['email']
user.set_password(self.cleaned_data['password'])
# Mark as a customer / end-user
user.is_customer = True
user.role = 'customer'
if commit:
user.save()
return user
@@ -42,7 +45,7 @@ class WebRegisterForm(forms.ModelForm):
class Meta:
model = User
fields = ['first_name', 'last_name', 'email', 'phone_number', 'password', 'confirm_password']
fields = ['first_name', 'last_name', 'email', 'phone_number', 'password', 'confirm_password', 'district']
def clean_email(self):
email = self.cleaned_data.get('email')
@@ -70,9 +73,15 @@ class WebRegisterForm(forms.ModelForm):
# Set username equal to email to avoid separate username errors
user.username = self.cleaned_data['email']
user.set_password(self.cleaned_data['password'])
print('*' * 100)
print(user.username)
print('*' * 100)
# Mark as a customer / end-user
user.is_customer = True
user.role = 'customer'
from django.utils import timezone
from accounts.models import VALID_DISTRICTS
if user.district and user.district in VALID_DISTRICTS:
user.district_changed_at = timezone.now()
elif user.district:
user.district = None # reject invalid district silently
if commit:
user.save()
return user

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

@@ -1,5 +1,9 @@
from django.urls import path
from .views import *
from mobile_api.views.user import ScheduleCallView
from mobile_api.views.reviews import ReviewSubmitView, MobileReviewListView, ReviewHelpfulView, ReviewFlagView
from mobile_api.views.favorites import ToggleLikeView, MyLikedIdsView, MyLikedEventsView
from ad_control.views import ConsumerFeaturedEventsView, ConsumerTopEventsView
# Customer URLS
@@ -9,6 +13,9 @@ urlpatterns = [
path('user/status/', StatusView.as_view(), name='user_status'),
path('user/logout/', LogoutView.as_view(), name='user_logout'),
path('user/update-profile/', UpdateProfileView.as_view(), name='update_profile'),
path('user/bulk-public-info/', BulkUserPublicInfoView.as_view(), name='bulk_public_info'),
path('user/google-login/', GoogleLoginView.as_view(), name='google_login'),
path('leads/schedule-call/', ScheduleCallView.as_view(), name='schedule_call'),
]
# Event URLS
@@ -21,6 +28,22 @@ urlpatterns += [
path('events/events-by-category/', EventsByCategoryAPI.as_view(), name='api_events_by_category'),
path('events/events-by-month-year/', EventsByMonthYearAPI.as_view(), name='events_by_month_year'),
path('events/events-by-date/', EventsByDateAPI.as_view(), name='events_by_date'),
path('events/featured-events/', FeaturedEventsAPI.as_view(), name='featured_events'),
path('events/top-events/', TopEventsAPI.as_view(), name='top_events'),
path('events/featured-events/', ConsumerFeaturedEventsView.as_view(), name='featured_events'),
path('events/top-events/', ConsumerTopEventsView.as_view(), name='top_events'),
path('events/contributor-profile/', ContributorProfileAPI.as_view(), name='contributor_profile'),
]
# Review URLs
urlpatterns += [
path('reviews/submit', ReviewSubmitView.as_view()),
path('reviews/list', MobileReviewListView.as_view()),
path('reviews/helpful', ReviewHelpfulView.as_view()),
path('reviews/flag', ReviewFlagView.as_view()),
]
# Favorites URLs
urlpatterns += [
path('events/like/', ToggleLikeView.as_view()),
path('events/my-likes/', MyLikedIdsView.as_view()),
path('events/my-liked-events/', MyLikedEventsView.as_view()),
]

View File

@@ -80,13 +80,13 @@ def validate_token_and_get_user(request, error_status_code=None):
status=status
))
# Verify username matches token user
# if user.username != username:
# status = 401 if error_status_code else None
# return (None, None, None, JsonResponse(
# {"status": "error", "message": "token does not match user"},
# status=status
# ))
# Verify token belongs to this user
if token.user_id != user.id:
status = 401 if error_status_code else None
return (None, None, None, JsonResponse(
{"status": "error", "message": "token does not match user"},
status=status
))
# Success - return user, token, data, and None for error_response
return (user, token, data, None)

View File

@@ -1,2 +1,3 @@
from .user import *
from .events import *
from .reviews import *

View File

@@ -1,7 +1,8 @@
import json
from django.http import JsonResponse
from rest_framework.views import APIView
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAuthenticated, AllowAny
from events.models import Event, EventImages
from master_data.models import EventType
from django.forms.models import model_to_dict
@@ -10,98 +11,288 @@ from django.views.decorators.csrf import csrf_exempt
from django.db.models import Q
from datetime import datetime, timedelta
import calendar
import math
from mobile_api.utils import validate_token_and_get_user
from accounts.models import User
from eventify_logger.services import log
def _resolve_contributor(identifier):
"""Resolve an eventifyId or email to a contributor dict. Returns None on miss."""
if not identifier:
return None
try:
user = User.objects.filter(
Q(eventify_id=identifier) | Q(email=identifier)
).first()
if not user:
return None
# Count events this user contributed
events_count = Event.objects.filter(
Q(contributed_by=user.eventify_id) | Q(contributed_by=user.email)
).filter(
event_status__in=['published', 'live', 'completed']
).count()
full_name = user.get_full_name() or user.username or ''
avatar = ''
if user.profile_picture and hasattr(user.profile_picture, 'url'):
try:
avatar = user.profile_picture.url
except Exception:
avatar = ''
return {
'name': full_name,
'email': user.email,
'eventify_id': user.eventify_id or '',
'avatar': avatar,
'member_since': user.date_joined.strftime('%b %Y') if user.date_joined else '',
'events_contributed': events_count,
'location': ', '.join(filter(None, [user.place or '', user.district or '', user.state or ''])),
}
except Exception:
return None
def _serialize_event_for_contributor(event):
"""Lightweight event serializer for contributor profile listings."""
primary_img = ''
try:
img = EventImages.objects.filter(event=event, is_primary=True).first()
if not img:
img = EventImages.objects.filter(event=event).first()
if img and img.event_image:
primary_img = img.event_image.url
except Exception:
pass
return {
'id': event.id,
'name': event.name or event.title or '',
'title': event.title or event.name or '',
'start_date': event.start_date.isoformat() if event.start_date else '',
'end_date': event.end_date.isoformat() if event.end_date else '',
'start_time': str(event.start_time or ''),
'end_time': str(event.end_time or ''),
'image': primary_img,
'venue_name': event.venue_name or '',
'place': event.place or '',
'district': event.district or '',
'state': event.state or '',
'pincode': event.pincode or '',
'latitude': str(event.latitude) if event.latitude else '',
'longitude': str(event.longitude) if event.longitude else '',
'event_type': event.event_type_id,
'event_status': event.event_status or '',
'source': event.source or '',
}
def _haversine_km(lat1, lon1, lat2, lon2):
"""Great-circle distance between two points in km."""
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2 +
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
math.sin(dlon / 2) ** 2)
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
@method_decorator(csrf_exempt, name='dispatch')
class EventTypeListAPIView(APIView):
permission_classes = [AllowAny]
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
# Fetch event types manually without serializer
event_types_queryset = EventType.objects.all()
event_types = []
for event_type in event_types_queryset:
event_type_data = {
"id": event_type.id,
"event_type": event_type.event_type,
"event_type_icon": request.build_absolute_uri(event_type.event_type_icon.url) if event_type.event_type_icon else None
"event_type_icon": event_type.event_type_icon.url if event_type.event_type_icon else None
}
event_types.append(event_type_data)
print(event_types)
return JsonResponse({
"status": "success",
"event_types": event_types
})
return JsonResponse({"status": "success", "event_types": event_types})
except Exception as e:
return JsonResponse(
{"status": "error", "message": str(e)},
)
log("error", "EventTypeAPI exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
class EventListAPI(APIView):
permission_classes = [AllowAny]
@staticmethod
def _serialize_event(e, thumb_map):
"""Slim serialization for list views — only fields the Flutter app uses."""
img = thumb_map.get(e.id)
lat = e.latitude
lng = e.longitude
desc = e.description or ''
return {
'id': e.id,
'name': e.name or '',
'title': e.title or '',
'description': desc[:200] if len(desc) > 200 else desc,
'start_date': str(e.start_date) if e.start_date else '',
'end_date': str(e.end_date) if e.end_date else '',
'start_time': str(e.start_time) if e.start_time else '',
'end_time': str(e.end_time) if e.end_time else '',
'pincode': e.pincode or '',
'place': e.place or '',
'is_bookable': bool(e.is_bookable),
'event_type': e.event_type_id,
'event_status': e.event_status or '',
'venue_name': getattr(e, 'venue_name', '') or '',
'latitude': float(lat) if lat is not None else None,
'longitude': float(lng) if lng is not None else None,
'location_name': getattr(e, 'location_name', '') or '',
'thumb_img': img.event_image.url if img and img.event_image else '',
'is_eventify_event': bool(e.is_eventify_event),
'source': e.source or 'eventify',
}
def post(self, request):
try:
print('*' * 100)
print(request.body)
print('*' * 100)
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
try:
data = json.loads(request.body) if request.body else {}
except Exception:
data = {}
pincode = data.get("pincode")
print('*' * 100)
print(pincode)
print('*' * 100)
# pincode is optional - if not provided or 'all', return all events
pincode = data.get("pincode", "all")
page = int(data.get("page", 1))
page_size = int(data.get("page_size", 50))
per_type = int(data.get("per_type", 0))
q = data.get("q", "").strip()
events = Event.objects.all().order_by('-created_date')
event_list = []
# New optional geo params
user_lat = data.get("latitude")
user_lng = data.get("longitude")
try:
radius_km = float(data.get("radius_km", 10))
except (ValueError, TypeError):
radius_km = 10
for e in events:
data_dict = model_to_dict(e)
# Build base queryset
MIN_EVENTS_THRESHOLD = 6
qs = Event.objects.all()
used_radius = None
# Priority 1: Haversine radius filtering (if lat/lng provided)
if user_lat is not None and user_lng is not None:
try:
thumb_img = EventImages.objects.get(event=e.id, is_primary=True)
data_dict['thumb_img'] = request.build_absolute_uri(thumb_img.event_image.url)
except EventImages.DoesNotExist:
data_dict['thumb_img'] = ''
user_lat = float(user_lat)
user_lng = float(user_lng)
event_list.append(data_dict)
# Bounding box pre-filter (1 degree lat ≈ 111km)
lat_delta = radius_km / 111.0
lng_delta = radius_km / (111.0 * max(math.cos(math.radians(user_lat)), 0.01))
print('*' * 100)
print(event_list)
print('*' * 100)
candidates = qs.filter(
latitude__gte=user_lat - lat_delta,
latitude__lte=user_lat + lat_delta,
longitude__gte=user_lng - lng_delta,
longitude__lte=user_lng + lng_delta,
latitude__isnull=False,
longitude__isnull=False,
)
# Exact Haversine filter in Python
nearby_ids = []
for e in candidates:
if e.latitude is not None and e.longitude is not None:
dist = _haversine_km(user_lat, user_lng, float(e.latitude), float(e.longitude))
if dist <= radius_km:
nearby_ids.append(e.id)
# Progressive radius expansion if too few results
if len(nearby_ids) < MIN_EVENTS_THRESHOLD:
for expanded_r in [r for r in [25, 50, 100] if r > radius_km]:
lat_delta_ex = expanded_r / 111.0
lng_delta_ex = expanded_r / (111.0 * max(math.cos(math.radians(user_lat)), 0.01))
candidates_ex = qs.filter(
latitude__gte=user_lat - lat_delta_ex,
latitude__lte=user_lat + lat_delta_ex,
longitude__gte=user_lng - lng_delta_ex,
longitude__lte=user_lng + lng_delta_ex,
latitude__isnull=False,
longitude__isnull=False,
)
nearby_ids = []
for e in candidates_ex:
if e.latitude is not None and e.longitude is not None:
dist = _haversine_km(user_lat, user_lng, float(e.latitude), float(e.longitude))
if dist <= expanded_r:
nearby_ids.append(e.id)
if len(nearby_ids) >= MIN_EVENTS_THRESHOLD:
radius_km = expanded_r
break
if nearby_ids:
qs = qs.filter(id__in=nearby_ids)
used_radius = radius_km
except (ValueError, TypeError):
pass # Invalid lat/lng — fall back to pincode
# Priority 2: Pincode filtering (backward compatible fallback)
if used_radius is None and pincode and pincode != 'all':
pincode_qs = qs.filter(pincode=pincode)
if pincode_qs.count() >= MIN_EVENTS_THRESHOLD:
qs = pincode_qs
# Priority 3: Full-text search on title / name / description
if q:
qs = qs.filter(Q(title__icontains=q) | Q(name__icontains=q) | Q(description__icontains=q))
if per_type > 0 and page == 1:
type_ids = list(qs.values_list('event_type_id', flat=True).distinct())
events_page = []
for tid in sorted(type_ids):
chunk = list(qs.filter(event_type_id=tid).order_by('-created_date')[:per_type])
events_page.extend(chunk)
total_count = qs.count()
end = len(events_page)
else:
total_count = qs.count()
qs = qs.order_by('-created_date')
start = (page - 1) * page_size
end = start + page_size
events_page = list(qs[start:end])
page_ids = [e.id for e in events_page]
primary_images = EventImages.objects.filter(event_id__in=page_ids, is_primary=True)
thumb_map = {img.event_id: img for img in primary_images}
event_list = [self._serialize_event(e, thumb_map) for e in events_page]
return JsonResponse({
"status": "success",
"events": event_list
"events": event_list,
"total_count": total_count,
"page": page,
"page_size": page_size,
"has_next": end < total_count,
"radius_km": used_radius,
})
except Exception as e:
return JsonResponse(
{"status": "error", "message": str(e)},
)
log("error", "EventListAPI exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
class EventDetailAPI(APIView):
permission_classes = [AllowAny]
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
try:
data = json.loads(request.body) if request.body else {}
except Exception:
data = {}
event_id = data.get("event_id")
events = Event.objects.get(id=event_id)
event_images = EventImages.objects.filter(event=event_id)
event_data = model_to_dict(events)
@@ -110,21 +301,26 @@ class EventDetailAPI(APIView):
for ei in event_images:
event_img = {}
event_img['is_primary'] = ei.is_primary
event_img['image'] = request.build_absolute_uri(ei.event_image.url)
event_img['image'] = ei.event_image.url
event_images_list.append(event_img)
event_data["images"] = event_images_list
print(event_data)
# Resolve contributor from contributed_by field
contributed_by = getattr(events, 'contributed_by', None)
if contributed_by:
contributor = _resolve_contributor(contributed_by)
if contributor:
event_data["contributor"] = contributor
return JsonResponse(event_data)
except Exception as e:
return JsonResponse(
{"status": "error", "message": str(e)},
)
log("error", "EventDetailAPI exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
class EventImagesListAPI(APIView):
authentication_classes = []
permission_classes = [AllowAny]
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
@@ -138,7 +334,7 @@ class EventImagesListAPI(APIView):
res_data["status"] = "success"
event_images_list = []
for ei in event_images:
event_images_list.append(request.build_absolute_uri(ei.event_image.url))
event_images_list.append(ei.event_image.url)
res_data["images"] = event_images_list
@@ -147,13 +343,16 @@ class EventImagesListAPI(APIView):
return JsonResponse(res_data)
except Exception as e:
log("error", "EventImagesListAPI exception", request=request, logger_data={"error": str(e)})
return JsonResponse(
{"status": "error", "message": str(e)},
{"status": "error", "message": "An unexpected server error occurred."},
)
@method_decorator(csrf_exempt, name='dispatch')
class EventsByCategoryAPI(APIView):
authentication_classes = []
permission_classes = [AllowAny]
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
@@ -172,9 +371,7 @@ class EventsByCategoryAPI(APIView):
for event in events_dict:
try:
event['event_image'] = request.build_absolute_uri(
EventImages.objects.get(event=event['id'], is_primary=True).event_image.url
)
event['event_image'] = EventImages.objects.get(event=event['id'], is_primary=True).event_image.url
except EventImages.DoesNotExist:
event['event_image'] = ''
# event['start_date'] = convert_date_to_dd_mm_yyyy(event['start_date'])
@@ -186,13 +383,16 @@ class EventsByCategoryAPI(APIView):
})
except Exception as e:
log("error", "EventsByDateAPI exception", request=request, logger_data={"error": str(e)})
return JsonResponse(
{"status": "error", "message": str(e)},
{"status": "error", "message": "An unexpected server error occurred."},
)
@method_decorator(csrf_exempt, name='dispatch')
class EventsByMonthYearAPI(APIView):
authentication_classes = []
permission_classes = [AllowAny]
"""
API to get events by month and year.
Returns dates that have events, total count, and date-wise breakdown.
@@ -306,15 +506,18 @@ class EventsByMonthYearAPI(APIView):
"total_number_of_events": total_events,
"date_events": date_events
})
except Exception as e:
log("error", "DateSheetAPI exception", request=request, logger_data={"error": str(e)})
return JsonResponse(
{"status": "error", "message": str(e)},
{"status": "error", "message": "An unexpected server error occurred."},
)
@method_decorator(csrf_exempt, name='dispatch')
class EventsByDateAPI(APIView):
authentication_classes = []
permission_classes = [AllowAny]
"""
API to get events occurring on a specific date.
Returns complete event information with primary images.
@@ -352,70 +555,123 @@ class EventsByDateAPI(APIView):
data_dict = model_to_dict(e)
try:
thumb_img = EventImages.objects.get(event=e.id, is_primary=True)
data_dict['thumb_img'] = request.build_absolute_uri(thumb_img.event_image.url)
data_dict['thumb_img'] = thumb_img.event_image.url
except EventImages.DoesNotExist:
data_dict['thumb_img'] = ''
event_list.append(data_dict)
return JsonResponse({
"status": "success",
"events": event_list
})
except Exception as e:
log("error", "PincodeEventsAPI exception", request=request, logger_data={"error": str(e)})
return JsonResponse(
{"status": "error", "message": str(e)},
{"status": "error", "message": "An unexpected server error occurred."},
)
@method_decorator(csrf_exempt, name='dispatch')
class FeaturedEventsAPI(APIView):
authentication_classes = []
permission_classes = [AllowAny]
"""Returns events where is_featured=True — used for the homepage hero carousel."""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
events = Event.objects.filter(is_featured=True).order_by('-created_date')
events = Event.objects.filter(is_featured=True, event_status='published').order_by('-created_date')
event_list = []
for e in events:
data_dict = model_to_dict(e)
data_dict['event_type_name'] = e.event_type.event_type if e.event_type else ''
try:
thumb = EventImages.objects.get(event=e.id, is_primary=True)
data_dict['thumb_img'] = request.build_absolute_uri(thumb.event_image.url)
data_dict['thumb_img'] = thumb.event_image.url
except EventImages.DoesNotExist:
data_dict['thumb_img'] = ''
event_list.append(data_dict)
return JsonResponse({"status": "success", "events": event_list})
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)})
log("error", "FeaturedEventsAPI exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
@method_decorator(csrf_exempt, name='dispatch')
class TopEventsAPI(APIView):
authentication_classes = []
permission_classes = [AllowAny]
"""Returns events where is_top_event=True — used for the Top Events section."""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
events = Event.objects.filter(is_top_event=True).order_by('-created_date')
events = Event.objects.filter(is_top_event=True, event_status='published').order_by('-created_date')
event_list = []
for e in events:
data_dict = model_to_dict(e)
data_dict['event_type_name'] = e.event_type.event_type if e.event_type else ''
try:
thumb = EventImages.objects.get(event=e.id, is_primary=True)
data_dict['thumb_img'] = request.build_absolute_uri(thumb.event_image.url)
data_dict['thumb_img'] = thumb.event_image.url
except EventImages.DoesNotExist:
data_dict['thumb_img'] = ''
event_list.append(data_dict)
return JsonResponse({"status": "success", "events": event_list})
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)})
log("error", "TopEventsAPI exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
@method_decorator(csrf_exempt, name='dispatch')
class ContributorProfileAPI(APIView):
"""
Public API to fetch a contributor's profile and their events.
POST /api/events/contributor-profile/
Body: { "contributor_id": "EVT-XXXXXXXX" } (or email)
"""
authentication_classes = []
permission_classes = [AllowAny]
def post(self, request):
try:
try:
data = json.loads(request.body) if request.body else {}
except Exception:
data = {}
contributor_id = data.get("contributor_id", "").strip()
if not contributor_id:
return JsonResponse(
{"status": "error", "message": "contributor_id is required"},
status=400,
)
# Resolve user
contributor = _resolve_contributor(contributor_id)
if not contributor:
return JsonResponse(
{"status": "error", "message": "Contributor not found"},
status=404,
)
# Fetch this contributor's events
user_identifiers = [v for v in [contributor['eventify_id'], contributor['email']] if v]
events_qs = Event.objects.filter(
contributed_by__in=user_identifiers,
event_status__in=['published', 'live', 'completed'],
).order_by('-start_date', '-created_date')
events_list = [_serialize_event_for_contributor(e) for e in events_qs]
return JsonResponse({
"status": "success",
"contributor": contributor,
"events": events_list,
})
except Exception as e:
log("error", "ContributorProfileAPI exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})

View File

@@ -0,0 +1,146 @@
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
from django.core.paginator import Paginator
from events.models import Event, EventLike, EventImages
from mobile_api.utils import validate_token_and_get_user
from eventify_logger.services import log
def _serialize_liked_event(event):
"""Serialize an Event for the liked-events list."""
primary_img = EventImages.objects.filter(
event=event, is_primary=True
).first()
if not primary_img:
primary_img = EventImages.objects.filter(event=event).first()
return {
'id': event.id,
'title': event.title or event.name,
'image': primary_img.event_image.url if primary_img else '',
'date': str(event.start_date) if event.start_date else None,
'location': event.place or '',
'venue': event.venue_name or '',
'event_type': event.event_type.event_type if event.event_type else '',
'event_status': event.event_status,
}
@method_decorator(csrf_exempt, name='dispatch')
class ToggleLikeView(View):
"""POST /api/events/like/ — toggle like on/off for an event."""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
event_id = data.get('event_id')
if not event_id:
return JsonResponse(
{'status': 'error', 'message': 'event_id is required'},
status=400
)
try:
event = Event.objects.get(pk=event_id)
except Event.DoesNotExist:
return JsonResponse(
{'status': 'error', 'message': 'Event not found'},
status=404
)
like, created = EventLike.objects.get_or_create(user=user, event=event)
if not created:
like.delete()
return JsonResponse({'status': 'success', 'liked': False})
return JsonResponse({'status': 'success', 'liked': True})
except Exception as e:
log("error", "ToggleLikeView exception", request=request,
logger_data={"error": str(e)})
return JsonResponse(
{'status': 'error', 'message': 'An unexpected server error occurred.'},
status=500
)
@method_decorator(csrf_exempt, name='dispatch')
class MyLikedIdsView(View):
"""POST /api/events/my-likes/ — return all liked event IDs for the user."""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
liked_ids = list(
EventLike.objects.filter(user=user)
.values_list('event_id', flat=True)
)
return JsonResponse({'status': 'success', 'liked_event_ids': liked_ids})
except Exception as e:
log("error", "MyLikedIdsView exception", request=request,
logger_data={"error": str(e)})
return JsonResponse(
{'status': 'error', 'message': 'An unexpected server error occurred.'},
status=500
)
@method_decorator(csrf_exempt, name='dispatch')
class MyLikedEventsView(View):
"""POST /api/events/my-liked-events/ — paginated liked events with full data."""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
page = int(data.get('page', 1))
page_size = min(int(data.get('page_size', 20)), 50)
# Event IDs liked by this user, newest first
liked_event_ids = list(
EventLike.objects.filter(user=user)
.order_by('-created_at')
.values_list('event_id', flat=True)
)
# Preserve ordering from liked_event_ids
from django.db.models import Case, When, IntegerField
ordering = Case(
*[When(pk=pk, then=pos) for pos, pk in enumerate(liked_event_ids)],
output_field=IntegerField()
)
events_qs = Event.objects.filter(id__in=liked_event_ids).order_by(ordering)
paginator = Paginator(events_qs, page_size)
page_obj = paginator.get_page(page)
events_data = [_serialize_liked_event(e) for e in page_obj]
return JsonResponse({
'status': 'success',
'events': events_data,
'total': paginator.count,
'page': page,
'page_size': page_size,
'has_next': page_obj.has_next(),
})
except Exception as e:
log("error", "MyLikedEventsView exception", request=request,
logger_data={"error": str(e)})
return JsonResponse(
{'status': 'error', 'message': 'An unexpected server error occurred.'},
status=500
)

283
mobile_api/views/reviews.py Normal file
View File

@@ -0,0 +1,283 @@
"""
Customer-facing review API views.
Writes to admin_api.Review so admin panel sees reviews immediately.
"""
import json
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from django.db.models import Avg, Count, Q
from admin_api.models import Review, ReviewInteraction
from events.models import Event
from mobile_api.utils import validate_token_and_get_user
_STATUS_TO_JSON = {'live': 'PUBLISHED', 'pending': 'PENDING', 'rejected': 'FLAGGED'}
_JSON_TO_STATUS = {'PUBLISHED': 'live', 'PENDING': 'pending', 'FLAGGED': 'rejected'}
def _serialize_review(r, user_interactions=None):
"""Serialize a Review to match the customer app's expected shape."""
interactions = user_interactions or {}
try:
display = r.display_name or r.reviewer.get_full_name() or r.reviewer.username
except Exception:
display = r.display_name or ''
try:
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),
'is_verified': r.is_verified,
'helpful_count': r.helpful_count,
'flag_count': r.flag_count,
'has_helpful': interactions.get('HELPFUL', False),
'has_flagged': interactions.get('FLAG', False),
'created_at': r.submission_date.isoformat() if r.submission_date else '',
}
def _rating_distribution(event_id):
"""Return {1:count, 2:count, ..., 5:count} for live reviews."""
dist = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
qs = Review.objects.filter(event_id=event_id, status='live').values('rating').annotate(c=Count('id'))
for row in qs:
dist[row['rating']] = row['c']
return dist
def _aggregates(event_id):
"""Return (average_rating, review_count) for live reviews."""
agg = Review.objects.filter(event_id=event_id, status='live').aggregate(
avg=Avg('rating'), cnt=Count('id')
)
return round(float(agg['avg'] or 0), 1), agg['cnt'] or 0
@method_decorator(csrf_exempt, name='dispatch')
class ReviewSubmitView(APIView):
"""POST /api/reviews/submit -- Submit or update a review."""
authentication_classes = []
permission_classes = [AllowAny]
def post(self, request):
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
event_id = data.get('event_id')
rating = data.get('rating')
comment = data.get('comment', '') or ''
is_verified = bool(data.get('is_verified', False))
if not event_id or not rating:
return JsonResponse({'status': 'error', 'message': 'event_id and rating are required'}, status=400)
try:
rating = int(rating)
if rating < 1 or rating > 5:
raise ValueError
except (ValueError, TypeError):
return JsonResponse({'status': 'error', 'message': 'Rating must be 1-5'}, status=400)
try:
event = Event.objects.get(pk=event_id)
except Event.DoesNotExist:
return JsonResponse({'status': 'error', 'message': 'Event not found'}, status=404)
# Upsert: one review per user per event
review, created = Review.objects.update_or_create(
reviewer=user,
event=event,
defaults={
'rating': rating,
'review_text': comment,
'is_verified': is_verified,
'status': 'live', # auto-approve for customer submissions
'display_name': user.get_full_name() or user.username,
}
)
avg_rating, review_count = _aggregates(event_id)
return JsonResponse({
'status': 'success',
'review': _serialize_review(review),
'average_rating': avg_rating,
'review_count': review_count,
})
@method_decorator(csrf_exempt, name='dispatch')
class MobileReviewListView(APIView):
"""POST /api/reviews/list -- Get published reviews for an event."""
authentication_classes = []
permission_classes = [AllowAny]
def post(self, request):
try:
body = json.loads(request.body) if request.body else {}
except json.JSONDecodeError:
body = {}
event_id = body.get('event_id')
username = body.get('username', '')
page = int(body.get('page', 1))
page_size = min(int(body.get('page_size', 10)), 50)
if not event_id:
return JsonResponse({'status': 'error', 'message': 'event_id is required'}, status=400)
qs = Review.objects.filter(event_id=event_id, status='live').select_related('reviewer')
qs = qs.order_by('-is_verified', '-helpful_count', '-submission_date')
total = qs.count()
reviews = list(qs[(page - 1) * page_size: page * page_size])
# Bulk lookup interactions for current user
user_interactions = {}
if username and reviews:
review_ids = [r.id for r in reviews]
interactions = ReviewInteraction.objects.filter(
username=username, review_id__in=review_ids
)
for inter in interactions:
if inter.review_id not in user_interactions:
user_interactions[inter.review_id] = {}
user_interactions[inter.review_id][inter.interaction_type] = True
formatted = [
_serialize_review(r, user_interactions.get(r.id, {}))
for r in reviews
]
avg_rating, review_count = _aggregates(event_id)
distribution = _rating_distribution(event_id)
# Get user's own review (any status)
user_review = None
if username:
try:
from accounts.models import User
u = User.objects.get(username=username)
ur = Review.objects.filter(event_id=event_id, reviewer=u).first()
if ur:
user_review = _serialize_review(ur)
except Exception:
pass
return JsonResponse({
'status': 'success',
'reviews': formatted,
'total': total,
'page': page,
'page_size': page_size,
'average_rating': avg_rating,
'review_count': review_count,
'distribution': distribution,
'user_review': user_review,
})
@method_decorator(csrf_exempt, name='dispatch')
class ReviewHelpfulView(APIView):
"""POST /api/reviews/helpful -- Mark a review as helpful."""
authentication_classes = []
permission_classes = [AllowAny]
def post(self, request):
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
review_id = data.get('review_id')
if not review_id:
return JsonResponse({'status': 'error', 'message': 'review_id is required'}, status=400)
try:
review = Review.objects.get(pk=review_id)
except Review.DoesNotExist:
return JsonResponse({'status': 'error', 'message': 'Review not found'}, status=404)
if review.reviewer == user:
return JsonResponse({'status': 'error', 'message': 'Cannot mark your own review as helpful'}, status=400)
_, created = ReviewInteraction.objects.get_or_create(
review=review,
username=user.username,
interaction_type='HELPFUL'
)
if created:
new_count = ReviewInteraction.objects.filter(review=review, interaction_type='HELPFUL').count()
review.helpful_count = new_count
review.save(update_fields=['helpful_count'])
return JsonResponse({
'status': 'success',
'helpful_count': review.helpful_count,
})
@method_decorator(csrf_exempt, name='dispatch')
class ReviewFlagView(APIView):
"""POST /api/reviews/flag -- Flag/report a review."""
authentication_classes = []
permission_classes = [AllowAny]
def post(self, request):
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
review_id = data.get('review_id')
if not review_id:
return JsonResponse({'status': 'error', 'message': 'review_id is required'}, status=400)
try:
review = Review.objects.get(pk=review_id)
except Review.DoesNotExist:
return JsonResponse({'status': 'error', 'message': 'Review not found'}, status=404)
if review.reviewer == user:
return JsonResponse({'status': 'error', 'message': 'Cannot flag your own review'}, status=400)
_, created = ReviewInteraction.objects.get_or_create(
review=review,
username=user.username,
interaction_type='FLAG'
)
new_count = ReviewInteraction.objects.filter(review=review, interaction_type='FLAG').count()
new_status = review.status
if new_count >= 3 and review.status == 'live':
new_status = 'rejected'
review.status = 'rejected'
review.reject_reason = 'inappropriate'
review.flag_count = new_count
review.save(update_fields=['flag_count', 'status', 'reject_reason'])
return JsonResponse({
'status': 'success',
'flag_count': new_count,
'review_status': _STATUS_TO_JSON.get(new_status, new_status),
})

View File

@@ -1,19 +1,41 @@
# accounts/views.py
import json
import secrets
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from rest_framework.views import APIView
from rest_framework.authtoken.models import Token
from mobile_api.forms import RegisterForm, LoginForm, WebRegisterForm
from rest_framework.authentication import TokenAuthentication
from django.contrib.auth import logout
from django.db import connection
from mobile_api.utils import validate_token_and_get_user
from utils.errors_json_convertor import simplify_form_errors
from accounts.models import User
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')
class RegisterView(View):
def post(self, request):
@@ -22,6 +44,7 @@ class RegisterView(View):
form = RegisterForm(data)
if form.is_valid():
user = form.save()
_seed_gamification_profile(user)
token, _ = Token.objects.get_or_create(user=user)
log("info", "API user registration", request=request, user=user)
return JsonResponse({'message': 'User registered successfully', 'token': token.key}, status=201)
@@ -29,7 +52,7 @@ class RegisterView(View):
return JsonResponse({'errors': form.errors}, status=400)
except Exception as e:
log("error", "API registration exception", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({'error': 'An unexpected server error occurred. Please try again.'}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
@@ -49,6 +72,7 @@ class WebRegisterView(View):
if form.is_valid():
print('2')
user = form.save()
_seed_gamification_profile(user)
token, _ = Token.objects.get_or_create(user=user)
print('3')
log("info", "Web user registration", request=request, user=user)
@@ -58,13 +82,18 @@ class WebRegisterView(View):
'username': user.username,
'email': user.email,
'phone_number': user.phone_number,
'district': user.district or '',
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
'first_name': user.first_name,
'last_name': user.last_name,
'eventify_id': user.eventify_id or '',
}
return JsonResponse(response, status=201)
log("warning", "Web registration failed", request=request, logger_data=dict(errors=form.errors))
return JsonResponse({'errors': form.errors}, status=400)
except Exception as e:
log("error", "Web registration exception", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({'error': 'An unexpected server error occurred. Please try again.'}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
@@ -82,8 +111,9 @@ class LoginView(View):
print('3')
log("info", "API login", request=request, user=user)
response = {
'message': 'Login successful',
'message': 'Login successful',
'token': token.key,
'eventify_id': user.eventify_id,
'username': user.username,
'email': user.email,
'phone_number': user.phone_number,
@@ -92,12 +122,13 @@ class LoginView(View):
'role': user.role,
'pincode': user.pincode,
'district': user.district,
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
'state': user.state,
'country': user.country,
'place': user.place,
'latitude': user.latitude,
'longitude': user.longitude,
'profile_photo': request.build_absolute_uri(user.profile_picture.url) if user.profile_picture else ''
'profile_photo': user.profile_picture.url if user.profile_picture else ''
}
print('4')
print(response)
@@ -107,7 +138,7 @@ class LoginView(View):
return JsonResponse(simplify_form_errors(form), status=401)
except Exception as e:
log("error", "API login exception", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({'error': 'An unexpected server error occurred. Please try again.'}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
@@ -121,11 +152,16 @@ class StatusView(View):
return JsonResponse({
"status": "logged_in",
"username": user.username,
"email": user.email
"email": user.email,
"eventify_id": user.eventify_id or '',
"district": user.district or '',
"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:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
log("error", "API status exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
@@ -150,7 +186,7 @@ class LogoutView(View):
except Exception as e:
log("error", "API logout exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": str(e)}, status=500)
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
@@ -242,15 +278,33 @@ class UpdateProfileView(View):
user.pincode = None
updated_fields.append('pincode')
# Update district
# Update district (with 6-month cooldown)
if 'district' in json_data:
district = json_data.get('district', '').strip()
if district:
user.district = district
updated_fields.append('district')
elif district == '':
user.district = None
updated_fields.append('district')
from django.utils import timezone
from datetime import timedelta
from accounts.models import VALID_DISTRICTS
COOLDOWN = timedelta(days=183) # ~6 months
new_district = json_data.get('district', '').strip()
if new_district and new_district not in VALID_DISTRICTS:
errors['district'] = 'Invalid district.'
elif new_district and new_district != (user.district or ''):
if user.district_changed_at and timezone.now() < user.district_changed_at + COOLDOWN:
next_date = (user.district_changed_at + COOLDOWN).strftime('%d %b %Y')
errors['district'] = f'District can only be changed once every 6 months. Next change: {next_date}.'
else:
user.district = new_district
user.district_changed_at = timezone.now()
updated_fields.append('district')
elif new_district == '' and user.district:
if user.district_changed_at and timezone.now() < user.district_changed_at + COOLDOWN:
next_date = (user.district_changed_at + COOLDOWN).strftime('%d %b %Y')
errors['district'] = f'District can only be changed once every 6 months. Next change: {next_date}.'
else:
user.district = None
user.district_changed_at = timezone.now()
updated_fields.append('district')
# Update state
if 'state' in json_data:
@@ -315,6 +369,7 @@ class UpdateProfileView(View):
'phone_number': user.phone_number,
'pincode': user.pincode,
'district': user.district,
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
'state': user.state,
'country': user.country,
'place': user.place,
@@ -328,7 +383,174 @@ class UpdateProfileView(View):
}, status=400)
except Exception as e:
log("error", "API update profile exception", request=request, logger_data={"error": str(e)})
return JsonResponse({
'success': False,
'error': str(e)
'error': 'An unexpected server error occurred. Please try again.'
}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
class BulkUserPublicInfoView(APIView):
"""Internal endpoint for Node.js gamification server to resolve user details.
Accepts POST with { emails: [...] } (max 500).
Returns { users: { email: { district, display_name, eventify_id } } }
"""
authentication_classes = []
permission_classes = []
def post(self, request):
try:
json_data = json.loads(request.body)
emails = json_data.get('emails', [])
if not emails or not isinstance(emails, list) or len(emails) > 500:
return JsonResponse({'error': 'Provide 1-500 emails'}, status=400)
users_qs = User.objects.filter(email__in=emails).values_list(
'email', 'first_name', 'last_name', 'district', 'eventify_id'
)
result = {}
for email, first, last, district, eid in users_qs:
name = f"{first} {last}".strip() or email.split('@')[0]
result[email] = {
'display_name': name,
'district': district or '',
'eventify_id': eid or '',
}
return JsonResponse({'users': result})
except Exception as e:
log("error", "BulkUserPublicInfoView error", logger_data={"error": str(e)})
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
class GoogleLoginView(View):
"""Verify a Google ID token, find or create the user, return the same response shape as LoginView."""
def post(self, request):
try:
from google.oauth2 import id_token as google_id_token
from google.auth.transport import requests as google_requests
from django.conf import settings
data = json.loads(request.body)
token = data.get('id_token')
if not token:
return JsonResponse({'error': 'id_token is required'}, status=400)
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')
if not email:
return JsonResponse({'error': 'Email not found in Google token'}, status=400)
user, created = User.objects.get_or_create(
email=email,
defaults={
'username': email,
'first_name': idinfo.get('given_name', ''),
'last_name': idinfo.get('family_name', ''),
'role': 'customer',
},
)
if created:
user.set_password(secrets.token_urlsafe(32))
user.save()
log("info", "Google OAuth new user created", request=request, user=user)
auth_token, _ = Token.objects.get_or_create(user=user)
log("info", "Google OAuth login", request=request, user=user)
return JsonResponse({
'message': 'Login successful',
'token': auth_token.key,
'eventify_id': user.eventify_id or '',
'username': user.username,
'email': user.email,
'phone_number': user.phone_number or '',
'first_name': user.first_name,
'last_name': user.last_name,
'role': user.role,
'pincode': user.pincode or '',
'district': user.district or '',
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
'state': user.state or '',
'country': user.country or '',
'place': user.place or '',
'latitude': user.latitude or '',
'longitude': user.longitude or '',
'profile_photo': user.profile_picture.url if user.profile_picture else '',
}, status=200)
except ValueError as e:
log("warning", "Google OAuth invalid token", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': 'Invalid Google token'}, status=401)
except Exception as e:
log("error", "Google OAuth exception", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
class ScheduleCallView(View):
"""Public endpoint for the 'Schedule a Call' form on the consumer app."""
def post(self, request):
from admin_api.models import Lead
try:
data = json.loads(request.body)
name = (data.get('name') or '').strip()
email = (data.get('email') or '').strip()
phone = (data.get('phone') or '').strip()
event_type = (data.get('eventType') or '').strip()
message = (data.get('message') or '').strip()
errors = {}
if not name:
errors['name'] = ['This field is required.']
if not email:
errors['email'] = ['This field is required.']
if not phone:
errors['phone'] = ['This field is required.']
valid_event_types = [c[0] for c in Lead.EVENT_TYPE_CHOICES]
if not event_type or event_type not in valid_event_types:
errors['eventType'] = [f'Must be one of: {", ".join(valid_event_types)}']
if errors:
return JsonResponse({'errors': errors}, status=400)
# Auto-link to a consumer account if one exists with this email
from django.contrib.auth import get_user_model
_User = get_user_model()
try:
consumer_account = _User.objects.get(email=email)
except _User.DoesNotExist:
consumer_account = None
lead = Lead.objects.create(
name=name,
email=email,
phone=phone,
event_type=event_type,
message=message,
status='new',
source='schedule_call',
priority='medium',
user_account=consumer_account,
)
log("info", f"New schedule-call lead #{lead.pk} from {email}", request=request)
return JsonResponse({
'status': 'success',
'message': 'Your request has been submitted. Our team will get back to you soon.',
'lead_id': lead.pk,
}, status=201)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON body.'}, status=400)
except Exception as e:
log("error", "Schedule call exception", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)

View File

10
notifications/admin.py Normal file
View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from .models import Notification
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = ('title', 'user', 'notification_type', 'is_read', 'created_at')
list_filter = ('notification_type', 'is_read', 'created_at')
search_fields = ('title', 'message', 'user__email')
readonly_fields = ('created_at',)

6
notifications/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'notifications'

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

102
notifications/models.py Normal file
View File

@@ -0,0 +1,102 @@
"""
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 accounts.models import User
class Notification(models.Model):
NOTIFICATION_TYPES = [
('event', 'Event'),
('promo', 'Promotion'),
('system', 'System'),
('booking', 'Booking'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications')
title = models.CharField(max_length=255)
message = models.TextField()
notification_type = models.CharField(max_length=20, choices=NOTIFICATION_TYPES, default='system')
is_read = models.BooleanField(default=False)
action_url = models.URLField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
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})'

8
notifications/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from .views import NotificationListView, NotificationMarkReadView, NotificationCountView
urlpatterns = [
path('list/', NotificationListView.as_view(), name='notification_list'),
path('mark-read/', NotificationMarkReadView.as_view(), name='notification_mark_read'),
path('count/', NotificationCountView.as_view(), name='notification_count'),
]

85
notifications/views.py Normal file
View File

@@ -0,0 +1,85 @@
import json
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from mobile_api.utils import validate_token_and_get_user
from eventify_logger.services import log
from .models import Notification
@method_decorator(csrf_exempt, name='dispatch')
class NotificationListView(View):
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
if error_response:
return error_response
page = int(data.get('page', 1))
page_size = int(data.get('page_size', 20))
offset = (page - 1) * page_size
notifications = Notification.objects.filter(user=user)[offset:offset + page_size]
total = Notification.objects.filter(user=user).count()
items = [{
'id': n.id,
'title': n.title,
'message': n.message,
'notification_type': n.notification_type,
'is_read': n.is_read,
'action_url': n.action_url or '',
'created_at': n.created_at.isoformat(),
} for n in notifications]
return JsonResponse({
'status': 'success',
'notifications': items,
'total': total,
'page': page,
'page_size': page_size,
})
except Exception as e:
log("error", "NotificationListView error", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
class NotificationMarkReadView(View):
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
if error_response:
return error_response
mark_all = data.get('mark_all', False)
notification_id = data.get('notification_id')
if mark_all:
Notification.objects.filter(user=user, is_read=False).update(is_read=True)
return JsonResponse({'status': 'success', 'message': 'All notifications marked as read'})
if notification_id:
Notification.objects.filter(id=notification_id, user=user).update(is_read=True)
return JsonResponse({'status': 'success', 'message': 'Notification marked as read'})
return JsonResponse({'error': 'Provide notification_id or mark_all=true'}, status=400)
except Exception as e:
log("error", "NotificationMarkReadView error", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
class NotificationCountView(View):
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
if error_response:
return error_response
count = Notification.objects.filter(user=user, is_read=False).count()
return JsonResponse({'status': 'success', 'unread_count': count})
except Exception as e:
log("error", "NotificationCountView error", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)

View File

@@ -58,7 +58,7 @@ def _partner_to_dict(partner, request=None):
# Add document file URL if exists
if partner.kyc_compliance_document_file:
if request:
data["kyc_compliance_document_file"] = request.build_absolute_uri(partner.kyc_compliance_document_file.url)
data["kyc_compliance_document_file"] = partner.kyc_compliance_document_file.url
else:
data["kyc_compliance_document_file"] = partner.kyc_compliance_document_file.url
else:
@@ -168,7 +168,7 @@ def _build_kyc_documents(partner, request):
name = f"{type_label} - {partner.name}"
if partner.kyc_compliance_document_file:
if request:
url = request.build_absolute_uri(partner.kyc_compliance_document_file.url)
url = partner.kyc_compliance_document_file.url
else:
url = partner.kyc_compliance_document_file.url
else:
@@ -835,6 +835,7 @@ def _user_to_dict(user, request=None):
user,
fields=[
"id",
"eventify_id",
"username",
"email",
"phone_number",
@@ -854,7 +855,7 @@ def _user_to_dict(user, request=None):
# Add profile picture URL if exists
if user.profile_picture:
if request:
data["profile_picture"] = request.build_absolute_uri(user.profile_picture.url)
data["profile_picture"] = user.profile_picture.url
else:
data["profile_picture"] = user.profile_picture.url
else:

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

@@ -45,7 +45,7 @@ class Partner(models.Model):
primary_contact_person_email = models.EmailField()
primary_contact_person_phone = models.CharField(max_length=15)
status = models.CharField(max_length=250, choices=STATUS_CHOICES, default='active')
status = models.CharField(max_length=250, choices=STATUS_CHOICES, default='active', db_index=True)
address = models.TextField(blank=True, null=True)
city = models.CharField(max_length=250, blank=True, null=True)
@@ -58,12 +58,35 @@ class Partner(models.Model):
longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
is_kyc_compliant = models.BooleanField(default=False)
kyc_compliance_status = models.CharField(max_length=250, choices=KYC_COMPLIANCE_STATUS_CHOICES, default='pending')
kyc_compliance_status = models.CharField(max_length=250, choices=KYC_COMPLIANCE_STATUS_CHOICES, default='pending', db_index=True)
kyc_compliance_reason = models.TextField(blank=True, null=True)
kyc_compliance_document_type = models.CharField(max_length=250, choices=KYC_DOCUMENT_TYPE_CHOICES, blank=True, null=True)
kyc_compliance_document_other_type = models.CharField(max_length=250, 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)
# 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):
return self.name

13
requirements-docker.txt Normal file
View File

@@ -0,0 +1,13 @@
Django==4.2.21
Pillow==10.1.0
django-summernote
djangorestframework==3.14.0
django-cors-headers==4.3.0
gunicorn==21.2.0
django-extensions==3.2.3
psycopg2-binary==2.9.9
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

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

View File

@@ -1,75 +1,81 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>Eventify</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- jQuery required for Summernote -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'accounts:dashboard' %}">Eventify</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navmenu">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navmenu">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'accounts:dashboard' %}">Eventify</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navmenu">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navmenu">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<!-- Accessible by Admin, Manager, Staff -->
<li class="nav-item">
<a class="nav-link" href="{% url 'accounts:dashboard' %}">Dashboard</a>
</li>
<!-- Accessible by Admin, Manager, Staff -->
<li class="nav-item">
<a class="nav-link" href="{% url 'accounts:dashboard' %}">Dashboard</a>
</li>
{% if user.role == "admin" or user.role == "manager" %}
{% if user.role == "admin" or user.role == "manager" %}
<!-- Admin + Manager -->
<li class="nav-item">
<a class="nav-link" href="{% url 'master_data:event_type_list' %}">Categories</a>
</li>
{% endif %}
{% endif %}
{% if user.role in "admin manager staff" %}
{% if user.role in "admin manager staff" %}
<!-- Admin + Manager + Staff -->
<li class="nav-item">
<a class="nav-link" href="{% url 'events:event_list' %}">Events</a>
</li>
{% endif %}
{% endif %}
{% if user.role == "admin" %}
{% if user.role == "admin" %}
<!-- Admin only -->
<li class="nav-item">
<a class="nav-link" href="{% url 'accounts:user_list' %}">Users</a>
</li>
{% endif %}
{% endif %}
</ul>
<ul class="navbar-nav">
{% if user.is_authenticated %}
</ul>
<ul class="navbar-nav">
{% if user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="#">
{% if user.first_name and user.last_name %}
{% if user.first_name and user.last_name %}
{{ user.first_name }} {{ user.last_name }}
{% elif user.username %}
{% elif user.username %}
{{ user.username }}
{% else %}
{% else %}
{{ user.email }}
{% endif %}
{% endif %}
</a></li>
<li class="nav-item"><a class="nav-link text-danger" href="{% url 'accounts:logout' %}">Logout</a></li>
{% else %}
<li class="nav-item"><a class="nav-link text-danger" href="{% url 'accounts:logout' %}">Logout</a>
</li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{% url 'accounts:login' %}">Login</a></li>
{% endif %}
</ul>
{% endif %}
</ul>
</div>
</div>
</div>
</nav>
<div class="container mt-4">
{% if messages %}
</nav>
<div class="container mt-4">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% endif %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
</html>

View File

@@ -16,13 +16,35 @@
*{box-sizing:border-box}
body{margin:0;font-family:Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;background:var(--muted);color:#111}
.auth-wrapper{display:flex;min-height:100vh}
/* LEFT PANEL — video */
.auth-left{
position:relative;
width:40%;
min-width:320px;
background:linear-gradient(180deg,var(--blue1),var(--blue2));
color:#fff;padding:48px;display:flex;flex-direction:column;justify-content:center;gap:10px;
overflow:hidden;
color:#fff;
display:flex;flex-direction:column;justify-content:flex-end;
}
.brand{font-weight:700;font-size:28px}
.auth-left video{
position:absolute;inset:0;
width:100%;height:100%;
object-fit:cover;
z-index:0;
}
/* dark gradient overlay for text legibility */
.auth-left::after{
content:'';
position:absolute;inset:0;
background:linear-gradient(180deg,rgba(10,20,60,0.35) 0%,rgba(10,20,60,0.72) 100%);
z-index:1;
}
.auth-left-content{
position:relative;z-index:2;
padding:48px;
display:flex;flex-direction:column;gap:10px;
}
.brand{font-weight:700;font-size:28px;letter-spacing:-0.5px}
.auth-left h1{font-size:36px;margin:0}
.auth-left p{opacity:.92;margin:0;font-size:16px}
@@ -52,10 +74,12 @@
.message.warning{background:#fff7e6;color:#7a4b00}
.errorlist{color:#b00020;margin:6px 0 0 0;font-size:13px}
/* responsive */
/* responsive — mobile: hide video, show compact gradient header */
@media (max-width:900px){
.auth-wrapper{flex-direction:column}
.auth-left{width:100%;min-height:180px;padding:28px;text-align:center}
.auth-left{width:100%;min-height:180px;justify-content:flex-end;}
.auth-left video{display:block;}
.auth-left-content{padding:28px;text-align:center}
.auth-right{padding:20px}
.auth-card{border-radius:14px;padding:28px}
}
@@ -64,8 +88,14 @@
<body>
<div class="auth-wrapper">
<div class="auth-left">
<div class="brand">Eventify</div>
<p>{% block left_subtext %}Your events at your fingertips.{% endblock %}</p>
<video autoplay muted loop playsinline preload="auto" poster="https://images.pexels.com/videos/36761729/kerala-kerala-tourism-36761729.jpeg?auto=compress&cs=tinysrgb&w=750">
<source src="https://videos.pexels.com/video-files/36761729/15579487_1920_1080_30fps.mp4" type="video/mp4">
<source src="https://videos.pexels.com/video-files/36761729/15579486_1280_720_30fps.mp4" type="video/mp4">
</video>
<div class="auth-left-content">
<div class="brand">Eventify</div>
<p>{% block left_subtext %}Your events at your fingertips.{% endblock %}</p>
</div>
</div>
<div class="auth-right">

View File

@@ -1,49 +1,63 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mt-4">
<h3>{% if object %}Edit{% else %}Add{% endif %} Event</h3>
<div class="container mt-4">
<h3>{% if object %}Edit{% else %}Add{% endif %} Event</h3>
<form method="post" novalidate>
{% csrf_token %}
<form method="post" novalidate>
{% csrf_token %}
{{ form.media }}
{% for field in form %}
<div class="mb-3">
{{ field.label_tag }}
{{ field }}
{% for error in field.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
{% for field in form %}
<div class="mb-3">
{{ field.label_tag }}
{% if field.name == 'source' %}
<div class="mt-2">
{% for radio in field %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">{{ radio.choice_label }}</label>
</div>
{% endfor %}
</div>
{% else %}
{{ field }}
{% endif %}
{% for error in field.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
{% endfor %}
<div class="mb-3 mt-4">
<button class="btn btn-primary">Save</button>
<a class="btn btn-secondary" href="{% url 'events:event_list' %}">Cancel</a>
</form>
</div>
</div>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const allYearEventCheckbox = document.getElementById('id_all_year_event');
const startDateField = document.getElementById('id_start_date');
const endDateField = document.getElementById('id_end_date');
const startTimeField = document.getElementById('id_start_time');
const endTimeField = document.getElementById('id_end_time');
<script>
document.addEventListener('DOMContentLoaded', function () {
const allYearEventCheckbox = document.getElementById('id_all_year_event');
const startDateField = document.getElementById('id_start_date');
const endDateField = document.getElementById('id_end_date');
const startTimeField = document.getElementById('id_start_time');
const endTimeField = document.getElementById('id_end_time');
function toggleDateTimeFields() {
const isDisabled = allYearEventCheckbox.checked;
startDateField.disabled = isDisabled;
endDateField.disabled = isDisabled;
startTimeField.disabled = isDisabled;
endTimeField.disabled = isDisabled;
}
function toggleDateTimeFields() {
const isDisabled = allYearEventCheckbox.checked;
startDateField.disabled = isDisabled;
endDateField.disabled = isDisabled;
startTimeField.disabled = isDisabled;
endTimeField.disabled = isDisabled;
}
// Set initial state
toggleDateTimeFields();
// Set initial state
toggleDateTimeFields();
// Listen for checkbox changes
if (allYearEventCheckbox) {
allYearEventCheckbox.addEventListener('change', toggleDateTimeFields);
}
});
</script>
{% endblock %}
// Listen for checkbox changes
if (allYearEventCheckbox) {
allYearEventCheckbox.addEventListener('change', toggleDateTimeFields);
}
});
</script>
{% endblock %}

View File

@@ -1,9 +1,20 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between mb-3">
<h3>Events</h3>
<a class="btn btn-success" href="{% url 'events:event_add' %}">Add Event</a>
<div class="row mb-3">
<div class="col-md-6">
<h3>Events</h3>
</div>
<div class="col-md-4">
<form method="get" action="." class="d-flex">
<input class="form-control me-2" type="search" name="q" placeholder="Search events..." aria-label="Search" value="{{ request.GET.q }}">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
<div class="col-md-2 text-end">
<a class="btn btn-success" href="{% url 'events:event_add' %}">Add Event</a>
</div>
</div>
<table class="table table-hover">
<thead>
<tr>
@@ -44,4 +55,35 @@
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
{% if is_paginated %}
<nav aria-label="Page navigation">
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}">&laquo; First</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}">Previous</a>
</li>
{% endif %}
<li class="page-item disabled">
<span class="page-link">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}">Next</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}">Last &raquo;</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

29
update_events.py Normal file
View File

@@ -0,0 +1,29 @@
import os
import django
import sys
import datetime
# Add the project directory to sys.path
sys.path.append('/var/www/myproject/eventify_prod')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eventify.settings')
django.setup()
from events.models import Event
start = datetime.date(2026, 1, 1)
end = datetime.date(2026, 12, 31)
print(f"Checking for events from {start} to {end}...")
events = Event.objects.filter(start_date=start, end_date=end)
count = events.count()
print(f"Found {count} events matching the criteria.")
if count > 0:
# Update matched events
updated_count = events.update(all_year_event=True)
print(f"Successfully updated {updated_count} events to be 'All Year'.")
else:
print("No events found to update.")

19
user.py Normal file
View File

@@ -0,0 +1,19 @@
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eventify.settings")
django.setup()
from django.contrib.auth import get_user_model
User = get_user_model()
def make_all_users_admin():
users = User.objects.all()
for user in users:
user.role = "admin" # assuming role field exists
user.save()
print(f"Updated: {user.username} -> Admin")
if __name__ == "__main__":
make_all_users_admin()
print("All users updated to admin role!")