72 Commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:30:58 +05:30
d9a2af7168 fix(reviews): expose profile_photo in /api/reviews/list payload
_serialize_review() was not returning the reviewer's profile_picture URL,
so the consumer app had no field to key off and always rendered DiceBear
cartoons for every reviewer.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 17:11:01 +05:30
66e41ba647 feat(audit): extend audit coverage to all admin interactions (v1.13.0)
- _audit_log helper: optional user= kwarg for login-time calls
- AdminLoginView: auth.admin_login / auth.admin_login_failed
- PartnerStatusView: partner.status_changed (atomic)
- PartnerOnboardView: partner.onboarded
- PartnerStaffCreateView: partner.staff.created
- EventCreateView/UpdateView/DeleteView: event.created/updated/deleted (atomic)
- EventPrimaryImageView: event.primary_image_changed
- SettlementReleaseView: settlement.released (atomic)
- ReviewDeleteView: review.deleted (atomic)
- LeadUpdateView: lead.updated
- PaymentGatewaySettingsView: gateway.created/updated/deleted
- tests: AuthAuditEmissionTests + EventCrudAuditTests (16 total, all green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:42:02 +05:30
2c60a82704 feat(audit): add Audit Log module — coverage, metrics endpoint, indexes
- UserStatusView, EventModerationView, ReviewModerationView,
  PartnerKYCReviewView: each state change now emits _audit_log()
  inside the same transaction.atomic() block so the log stays
  consistent with DB state on partial failure
- AuditLogMetricsView: GET /api/v1/rbac/audit-log/metrics/ returns
  total/today/week/distinct_users/by_action_group; 60 s cache with
  ?nocache=1 bypass
- AuditLogListView: free-text search (Q over action/target/user),
  page_size bounded to [1, 200]
- accounts.User.ALL_MODULES += 'audit-log';
  StaffProfile.SCOPE_TO_MODULE['audit'] = 'audit-log'
- Migration 0005: composite indexes (action,-created_at) and
  (target_type,target_id) on AuditLog
- admin_api/tests.py: 11 tests covering list shape, search,
  page bounds, metrics shape+nocache, suspend/ban/reinstate
  audit emission

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 12:39:38 +05:30
9cde886bd4 feat(notifications): add test-send endpoint for single-address preview
POST /api/v1/notifications/schedules/<pk>/test-send/ accepts {"email": "..."},
renders the schedule's email, delivers to that address only with [TEST] prefix.
Does not touch last_run_at or last_status.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Infra:
- Add Dockerfile for eventify-backend container
- Add simplejwt to requirements.txt
- All 4 dashboard views use IsAuthenticated permission class
2026-03-24 17:46:41 +00:00
Ubuntu
37001f8e70 feat: add JWT auth foundation - /api/v1/ with admin login, refresh, me, health endpoints
- Add djangorestframework-simplejwt==5.3.1 to requirements-docker.txt
- Configure REST_FRAMEWORK with JWTAuthentication and SIMPLE_JWT settings
- Create admin_api Django app with AdminLoginView, MeView, HealthView
- Wire /api/v1/ routes without touching existing /api/ mobile endpoints
- Resolve pre-existing events migration conflict (0010_merge)
- Superuser admin created for initial authentication
2026-03-24 14:46:03 +00:00
Ubuntu
7bda3fd905 fix: correct dict key syntax in FeaturedEventsAPI and TopEventsAPI responses 2026-03-24 14:09:43 +00:00
Ubuntu
aaaab190da feat: add is_featured/is_top_event fields and API endpoints
- Event model: added is_featured, is_top_event BooleanFields
- Migration 0007 applied to DB
- EventForm: checkboxes for both new fields
- EventAdmin: list_display, list_editable, list_filter for both flags
- FeaturedEventsAPI: POST /api/events/featured-events/ -> is_featured=True events
- TopEventsAPI: POST /api/events/top-events/ -> is_top_event=True events
2026-03-24 14:09:43 +00:00
b54439a4c2 The changes for the new 2026-03-24 19:21:25 +05:30
Vivek P Prakash
c04395afc9 The new updates of partners and user
Made-with: Cursor
2026-03-15 00:29:17 +05:30
Vivek P Prakash
88b3aafb0b Refactor eventify urlpatterns to include new bookings endpoint 2026-01-28 16:52:06 +05:30
Vivek P Prakash
7fee636fca Add bookings URL path to eventify urlpatterns 2026-01-28 16:51:43 +05:30
Vivek
c43ea6b0c7 Update in the cor headers 2025-12-25 02:42:10 +05:30
Vivek
8488df7c14 Changes in the event model to take the event source 2025-12-20 04:05:07 +05:30
Vivek
2cd2c763f6 Update in the profile 2025-12-20 03:55:40 +05:30
Vivek
1d821bf981 Update in the event list 2025-12-20 03:53:31 +05:30
Vivek
1f9269467c Updates for the eventify model to enable teh all year event 2025-12-20 03:46:04 +05:30
Vivek
d1e618e06b Fixes for the icon in the eventtypes 2025-12-20 02:28:15 +05:30
Vivek
2d43d4b1e3 Update in the pincode-events 2025-12-20 01:42:25 +05:30
163 changed files with 15122 additions and 248 deletions

BIN
.DS_Store vendored

Binary file not shown.

Submodule .claude/worktrees/strange-ellis added at 88b3aafb0b

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ db.sqlite3
/media/
/staticfiles/
.env
venv/

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>

947
accounts/api.py Normal file
View File

@@ -0,0 +1,947 @@
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.forms.models import model_to_dict
from django.contrib.auth import authenticate, logout
from rest_framework.views import APIView
from rest_framework.authtoken.models import Token
import json
from .models import User
from mobile_api.utils import validate_token_and_get_user
def _partner_user_to_dict(user, request=None):
"""Serialize partner-related User for JSON (same structure as _user_to_dict)."""
data = model_to_dict(
user,
fields=[
"id",
"eventify_id",
"username",
"email",
"phone_number",
"role",
"is_staff",
"is_customer",
"is_user",
"pincode",
"district",
"state",
"country",
"place",
"latitude",
"longitude",
"first_name",
"last_name",
],
)
# Add profile picture URL if exists
if getattr(user, "profile_picture", None):
if request:
data["profile_picture"] = user.profile_picture.url
else:
data["profile_picture"] = user.profile_picture.url
else:
data["profile_picture"] = None
return data
def _user_to_dict(user, request=None):
"""Serialize any User for JSON (admin/staff or partner)."""
return _partner_user_to_dict(user, request)
@method_decorator(csrf_exempt, name="dispatch")
class PartnerLoginAPI(APIView):
"""
Partner Login API.
Body: username (or email), password (required).
Returns: token, user data.
"""
def post(self, request):
try:
# Parse JSON or form data
is_multipart = request.content_type and "multipart/form-data" in request.content_type
if is_multipart:
data = request.POST.dict()
else:
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse(
{"status": "error", "message": "Invalid JSON"},
status=400,
)
username = data.get("username") or data.get("email")
password = data.get("password")
if not username or not password:
return JsonResponse(
{"status": "error", "message": "username and password are required."},
status=400,
)
# Authenticate user
user = authenticate(request, username=username, password=password)
if not user:
return JsonResponse(
{"status": "error", "message": "Invalid username or password."},
status=401,
)
# Check if user has partner role
partner_roles = ["partner", "partner_manager", "partner_staff"]
if user.role not in partner_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to access partner portal."},
status=403,
)
# Get or create token
token, _ = Token.objects.get_or_create(user=user)
return JsonResponse(
{
"status": "success",
"message": "Login successful",
"token": token.key,
"user": _partner_user_to_dict(user, request),
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PartnerLogoutAPI(APIView):
"""
Partner Logout API.
Body: token, username (required).
Returns: success message.
"""
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
# Check if user has partner role
partner_roles = ["partner", "partner_manager", "partner_staff"]
if user.role not in partner_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to access partner portal."},
status=403,
)
# Delete token
token.delete()
return JsonResponse(
{
"status": "success",
"message": "Logged out successfully.",
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PartnerDashboardAPI(APIView):
"""
Partner Dashboard API.
Body: token, username (required).
Returns: dashboard statistics.
"""
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
# Check if user has partner role
partner_roles = ["partner", "partner_manager", "partner_staff"]
if user.role not in partner_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to access this page."},
status=403,
)
# Get statistics for partner users (including partner_customer)
all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"]
partner_users = User.objects.filter(role__in=all_partner_roles)
total_partner_users = partner_users.count()
return JsonResponse(
{
"status": "success",
"dashboard": {
"total_partner_users": total_partner_users,
},
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PartnerListUsersAPI(APIView):
"""
Partner List Users API.
Body: token, username (required);
role (optional filter: partner, partner_manager, partner_staff, partner_customer).
Returns: list of partner users.
"""
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
# Check if user has partner role
partner_roles = ["partner", "partner_manager", "partner_staff"]
if user.role not in partner_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to access this page."},
status=403,
)
# Filter users by partner-related roles
all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"]
qs = User.objects.filter(role__in=all_partner_roles).order_by("-id")
# Optional role filter
role_filter = data.get("role")
if role_filter:
if role_filter not in all_partner_roles:
return JsonResponse(
{
"status": "error",
"message": f"Invalid role filter. Must be one of: {', '.join(all_partner_roles)}",
},
status=400,
)
qs = qs.filter(role=role_filter)
users = [_partner_user_to_dict(u, request) for u in qs]
return JsonResponse(
{
"status": "success",
"users": users,
"total_count": len(users),
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PartnerCreateUserAPI(APIView):
"""
Partner Create User API.
Body: token, username, username (for new user), email, password, role (required);
full_name, phone_number, pincode, district, state, country, place, latitude, longitude (optional).
Returns: created user data.
"""
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
# Check if user has partner role
partner_roles = ["partner", "partner_manager", "partner_staff"]
if user.role not in partner_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to access this page."},
status=403,
)
# Extract user data
new_username = data.get("username")
email = data.get("email")
password = data.get("password")
role = data.get("role")
full_name = data.get("full_name", "").strip()
if not all([new_username, email, password, role]):
return JsonResponse(
{
"status": "error",
"message": "username, email, password, and role are required.",
},
status=400,
)
# Validate role - must be one of the partner-related roles
valid_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"]
if role not in valid_partner_roles:
return JsonResponse(
{
"status": "error",
"message": f"Invalid role. Must be one of: {', '.join(valid_partner_roles)}",
},
status=400,
)
# Check if username already exists
if User.objects.filter(username=new_username).exists():
return JsonResponse(
{"status": "error", "message": "Username already exists."},
status=400,
)
# Check if email already exists
if User.objects.filter(email=email).exists():
return JsonResponse(
{"status": "error", "message": "Email already exists."},
status=400,
)
# Create user
new_user = User.objects.create_user(
username=new_username,
email=email,
password=password,
role=role,
phone_number=data.get("phone_number"),
pincode=data.get("pincode"),
district=data.get("district"),
state=data.get("state"),
country=data.get("country"),
place=data.get("place"),
)
# Handle full_name - split into first_name and last_name
if full_name:
parts = full_name.split(None, 1)
new_user.first_name = parts[0]
if len(parts) > 1:
new_user.last_name = parts[1]
# Set location coordinates if provided
if data.get("latitude") is not None:
try:
latitude = float(data["latitude"])
if latitude < -90 or latitude > 90:
return JsonResponse(
{"status": "error", "message": "latitude must be between -90 and 90."},
status=400,
)
new_user.latitude = latitude
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "latitude must be numeric."},
status=400,
)
if data.get("longitude") is not None:
try:
longitude = float(data["longitude"])
if longitude < -180 or longitude > 180:
return JsonResponse(
{"status": "error", "message": "longitude must be between -180 and 180."},
status=400,
)
new_user.longitude = longitude
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "longitude must be numeric."},
status=400,
)
# Handle profile picture upload if provided
if "profile_picture" in request.FILES:
new_user.profile_picture = request.FILES["profile_picture"]
new_user.save()
return JsonResponse(
{
"status": "success",
"message": f"User created successfully with role: {role}.",
"user": _partner_user_to_dict(new_user, request),
},
status=201,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PartnerUpdateUserAPI(APIView):
"""
Partner Update User API.
Body: token, username, user_id (required);
email, phone_number, role, full_name, pincode, district, state,
country, place, latitude, longitude, password, profile_picture (optional).
Returns: updated user data.
"""
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
# Check if user has partner role
partner_roles = ["partner", "partner_manager", "partner_staff"]
if user.role not in partner_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to access this page."},
status=403,
)
user_id = data.get("user_id")
if not user_id:
return JsonResponse(
{"status": "error", "message": "user_id is required."},
status=400,
)
try:
target_user = User.objects.get(id=user_id)
except User.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "User not found."},
status=404,
)
# Validate that the user has a partner-related role
all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"]
if target_user.role not in all_partner_roles:
return JsonResponse(
{
"status": "error",
"message": "User is not a partner-related user. Only users with partner roles can be updated.",
},
status=400,
)
# Update fields if provided
if data.get("email") is not None:
new_email = data["email"]
# Check if email already exists for another user
if User.objects.filter(email=new_email).exclude(id=user_id).exists():
return JsonResponse(
{"status": "error", "message": "Email already exists for another user."},
status=400,
)
target_user.email = new_email
if data.get("phone_number") is not None:
target_user.phone_number = data["phone_number"] or None
if data.get("role") is not None:
new_role = data["role"]
if new_role not in all_partner_roles:
return JsonResponse(
{
"status": "error",
"message": f"Invalid role. Must be one of: {', '.join(all_partner_roles)}",
},
status=400,
)
target_user.role = new_role
# Handle full_name
if data.get("full_name"):
full_name = data["full_name"].strip()
if full_name:
parts = full_name.split(None, 1)
target_user.first_name = parts[0]
if len(parts) > 1:
target_user.last_name = parts[1]
else:
target_user.last_name = ""
if "pincode" in data:
target_user.pincode = data["pincode"] or None
if "district" in data:
target_user.district = data["district"] or None
if "state" in data:
target_user.state = data["state"] or None
if "country" in data:
target_user.country = data["country"] or None
if "place" in data:
target_user.place = data["place"] or None
if data.get("latitude") is not None:
try:
latitude = float(data["latitude"])
if latitude < -90 or latitude > 90:
return JsonResponse(
{"status": "error", "message": "latitude must be between -90 and 90."},
status=400,
)
target_user.latitude = latitude
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "latitude must be numeric."},
status=400,
)
if data.get("longitude") is not None:
try:
longitude = float(data["longitude"])
if longitude < -180 or longitude > 180:
return JsonResponse(
{"status": "error", "message": "longitude must be between -180 and 180."},
status=400,
)
target_user.longitude = longitude
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "longitude must be numeric."},
status=400,
)
# Handle profile picture upload if provided
if "profile_picture" in request.FILES:
target_user.profile_picture = request.FILES["profile_picture"]
# Handle password update if provided
if data.get("password"):
target_user.set_password(data["password"])
target_user.save()
return JsonResponse(
{
"status": "success",
"message": "Partner user updated successfully.",
"user": _partner_user_to_dict(target_user, request),
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PartnerDeleteUserAPI(APIView):
"""
Partner Delete User API.
Body: token, username, user_id (required).
Returns: success message.
"""
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
# Check if user has partner role
partner_roles = ["partner", "partner_manager", "partner_staff"]
if user.role not in partner_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to access this page."},
status=403,
)
user_id = data.get("user_id")
if not user_id:
return JsonResponse(
{"status": "error", "message": "user_id is required."},
status=400,
)
try:
target_user = User.objects.get(id=user_id)
except User.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "User not found."},
status=404,
)
# Validate that the user has a partner-related role
all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"]
if target_user.role not in all_partner_roles:
return JsonResponse(
{
"status": "error",
"message": "User is not a partner-related user. Only users with partner roles can be deleted.",
},
status=400,
)
# Prevent deleting yourself
if target_user.id == user.id:
return JsonResponse(
{"status": "error", "message": "You cannot delete your own account."},
status=400,
)
username = target_user.username
target_user.delete()
return JsonResponse(
{
"status": "success",
"message": f"Partner user '{username}' deleted successfully.",
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class LoginAPI(APIView):
"""
Admin/Staff Login API (accounts).
Body: username (or email), password (required).
Returns: token and user details for admin/manager/staff roles.
"""
def post(self, request):
try:
# Parse JSON or form data
is_multipart = request.content_type and "multipart/form-data" in request.content_type
if is_multipart:
data = request.POST.dict()
else:
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse(
{"status": "error", "message": "Invalid JSON"},
status=400,
)
username = data.get("username") or data.get("email")
password = data.get("password")
if not username or not password:
return JsonResponse(
{"status": "error", "message": "username and password are required."},
status=400,
)
user = authenticate(request, username=username, password=password)
if not user:
return JsonResponse(
{"status": "error", "message": "Invalid username or password."},
status=401,
)
# Only allow admin/manager/staff to use this login
allowed_roles = ["admin", "manager", "staff"]
if user.role not in allowed_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to access the admin portal."},
status=403,
)
token, _ = Token.objects.get_or_create(user=user)
return JsonResponse(
{
"status": "success",
"message": "Login successful",
"token": token.key,
"user": _user_to_dict(user, request),
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class LogoutAPI(APIView):
"""
Logout API for token-based sessions.
Body: token, username (required).
"""
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
logout(request)
token.delete()
return JsonResponse(
{
"status": "success",
"message": "Logout successful.",
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class UserListAPI(APIView):
"""
List users (admin / manager / staff only).
Body: token, username (required); optional role filter.
"""
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
# Only allow admin/manager/staff to list users
allowed_roles = ["admin", "manager", "staff"]
if user.role not in allowed_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to list users."},
status=403,
)
qs = User.objects.all().order_by("-id")
role_filter = data.get("role")
if role_filter:
qs = qs.filter(role=role_filter)
users = [_user_to_dict(u, request) for u in qs]
return JsonResponse(
{
"status": "success",
"users": users,
"total_count": len(users),
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class UserCreateAPI(APIView):
"""
Create a user (admin / manager / staff only).
Body: token, username, new_username, email, password, role ('admin'|'manager'|'staff'), phone_number (optional).
"""
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
allowed_roles = ["admin", "manager", "staff"]
if user.role not in allowed_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to create users."},
status=403,
)
new_username = data.get("username") or data.get("new_username")
email = data.get("email")
password = data.get("password")
role = data.get("role")
if not all([new_username, email, password, role]):
return JsonResponse(
{
"status": "error",
"message": "username, email, password, and role are required.",
},
status=400,
)
valid_roles = ["admin", "manager", "staff"]
if role not in valid_roles:
return JsonResponse(
{
"status": "error",
"message": f"Invalid role. Must be one of: {', '.join(valid_roles)}",
},
status=400,
)
if User.objects.filter(username=new_username).exists():
return JsonResponse(
{"status": "error", "message": "Username already exists."},
status=400,
)
if User.objects.filter(email=email).exists():
return JsonResponse(
{"status": "error", "message": "Email already exists."},
status=400,
)
new_user = User.objects.create_user(
username=new_username,
email=email,
password=password,
role=role,
phone_number=data.get("phone_number"),
)
return JsonResponse(
{
"status": "success",
"message": f"User created successfully with role: {role}.",
"user": _user_to_dict(new_user, request),
},
status=201,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class UserUpdateAPI(APIView):
"""
Update a user (admin / manager / staff only).
Body: token, username, user_id (required); email, phone_number, role (optional).
"""
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
allowed_roles = ["admin", "manager", "staff"]
if user.role not in allowed_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to update users."},
status=403,
)
user_id = data.get("user_id")
if not user_id:
return JsonResponse(
{"status": "error", "message": "user_id is required."},
status=400,
)
try:
target_user = User.objects.get(id=user_id)
except User.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "User not found."},
status=404,
)
if data.get("email") is not None:
new_email = data["email"]
if User.objects.filter(email=new_email).exclude(id=user_id).exists():
return JsonResponse(
{"status": "error", "message": "Email already exists for another user."},
status=400,
)
target_user.email = new_email
if data.get("phone_number") is not None:
target_user.phone_number = data["phone_number"] or None
if data.get("role") is not None:
new_role = data["role"]
valid_roles = ["admin", "manager", "staff"]
if new_role not in valid_roles:
return JsonResponse(
{
"status": "error",
"message": f"Invalid role. Must be one of: {', '.join(valid_roles)}",
},
status=400,
)
target_user.role = new_role
target_user.save()
return JsonResponse(
{
"status": "success",
"message": "User updated successfully.",
"user": _user_to_dict(target_user, request),
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class UserDeleteAPI(APIView):
"""
Delete a user (admin / manager / staff only).
Body: token, username, user_id (required).
"""
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
allowed_roles = ["admin", "manager", "staff"]
if user.role not in allowed_roles:
return JsonResponse(
{"status": "error", "message": "You are not authorized to delete users."},
status=403,
)
user_id = data.get("user_id")
if not user_id:
return JsonResponse(
{"status": "error", "message": "user_id is required."},
status=400,
)
try:
target_user = User.objects.get(id=user_id)
except User.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "User not found."},
status=404,
)
if target_user.id == user.id:
return JsonResponse(
{"status": "error", "message": "You cannot delete your own account."},
status=400,
)
username = target_user.username
target_user.delete()
return JsonResponse(
{
"status": "success",
"message": f"User '{username}' deleted successfully.",
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)

View File

@@ -88,3 +88,110 @@ class LoginForm(AuthenticationForm):
"placeholder": "Enter password"
})
)
class PartnerUserForm(forms.ModelForm):
full_name = forms.CharField(
max_length=150,
required=True,
label="Full Name"
)
password = forms.CharField(
widget=forms.PasswordInput,
label="Password",
required=True,
help_text="Required for new users. Leave blank if you don't want to change the password when editing."
)
confirm_password = forms.CharField(
widget=forms.PasswordInput,
label="Confirm Password",
required=True
)
phone_number = forms.CharField(
max_length=15,
required=False,
label="Phone Number"
)
ROLE_CHOICES = [
('partner', 'Partner'),
('partner_manager', 'Partner Manager'),
('partner_staff', 'Partner Staff'),
('partner_customer', 'Partner Customer'),
]
role = forms.ChoiceField(
choices=ROLE_CHOICES,
required=True,
label="Role"
)
class Meta:
model = User
fields = ["username", "email", "phone_number", "role"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs.update({"class": "form-control"})
# Make password fields optional for updates, required for new users
if self.instance and self.instance.pk:
self.fields['password'].required = False
self.fields['confirm_password'].required = False
# Pre-populate full_name from first_name and last_name
if self.instance.first_name or self.instance.last_name:
self.fields['full_name'].initial = f"{self.instance.first_name} {self.instance.last_name}".strip()
else:
# For new users, password is required
self.fields['password'].required = True
self.fields['confirm_password'].required = True
def clean(self):
cleaned_data = super().clean()
password = cleaned_data.get("password")
confirm_password = cleaned_data.get("confirm_password")
# For new users, password is required
if not self.instance or not self.instance.pk:
if not password:
self.add_error("password", "Password is required for new users.")
if not confirm_password:
self.add_error("confirm_password", "Please confirm your password.")
# Validate password match if password is provided
if password or confirm_password:
if password != confirm_password:
self.add_error("confirm_password", "Passwords do not match!")
return cleaned_data
def save(self, commit=True):
user = super().save(commit=False)
# Set password - required for new users, optional for updates
password = self.cleaned_data.get('password')
if password:
user.set_password(password)
elif not user.pk:
# New user must have a password
raise ValueError("Password is required for new users.")
# Save phone_number and role to the User model
user.phone_number = self.cleaned_data.get("phone_number")
user.role = self.cleaned_data.get("role")
# Handle full_name - split into first_name and last_name
full_name = self.cleaned_data.get("full_name", "").strip()
if full_name:
parts = full_name.split(None, 1)
user.first_name = parts[0]
if len(parts) > 1:
user.last_name = parts[1]
else:
user.last_name = ""
if commit:
user.save()
return user

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0 on 2025-12-19 22:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_user_profile_picture'),
]
operations = [
migrations.AlterField(
model_name='user',
name='profile_picture',
field=models.ImageField(blank=True, default='default.png', null=True, upload_to='profile_pictures/'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.27 on 2026-03-13 16:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0007_alter_user_profile_picture'),
]
operations = [
migrations.AlterField(
model_name='user',
name='role',
field=models.CharField(choices=[('admin', 'Admin'), ('manager', 'Manager'), ('staff', 'Staff'), ('customer', 'Customer'), ('partner', 'Partner'), ('partner_manager', 'Partner Manager'), ('partner_staff', 'Partner Staff'), ('partner_customer', 'Partner Customer')], default='Staff', max_length=20),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.27 on 2026-03-14 07:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('partner', '0001_initial'),
('accounts', '0008_alter_user_role'),
]
operations = [
migrations.AddField(
model_name='user',
name='partner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='partner.partner'),
),
]

View File

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

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,19 +1,50 @@
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'),
('staff', 'Staff'),
('customer', 'Customer'),
('partner', 'Partner'),
('partner_manager', 'Partner Manager'),
('partner_staff', 'Partner Staff'),
('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')
partner = models.ForeignKey(Partner, on_delete=models.CASCADE, blank=True, null=True)
is_staff = models.BooleanField(default=False)
is_customer = models.BooleanField(default=False)
is_user = models.BooleanField(default=False)
@@ -24,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)
@@ -31,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

View File

@@ -1,14 +1,37 @@
from django.urls import path
from . import views
from . import views, api
app_name = 'accounts'
app_name = "accounts"
urlpatterns = [
path('login/', views.login_view, name='login'),
path('logout/', views.logout_view, name='logout'),
path('dashboard/', views.dashboard, name='dashboard'),
path('users/', views.UserListView.as_view(), name='user_list'),
path('users/add/', views.UserCreateView.as_view(), name='user_add'),
path('users/<int:pk>/edit/', views.UserUpdateView.as_view(), name='user_edit'),
path('users/<int:pk>/delete/', views.UserDeleteView.as_view(), name='user_delete'),
path("login/", views.login_view, name="login"),
path("logout/", views.logout_view, name="logout"),
path("dashboard/", views.dashboard, name="dashboard"),
path("users/", views.UserListView.as_view(), name="user_list"),
path("users/add/", views.UserCreateView.as_view(), name="user_add"),
path("users/<int:pk>/edit/", views.UserUpdateView.as_view(), name="user_edit"),
path("users/<int:pk>/delete/", views.UserDeleteView.as_view(), name="user_delete"),
]
# Core account APIs (admin/staff)
urlpatterns += [
path("api/login/", api.LoginAPI.as_view(), name="api_login"),
path("api/logout/", api.LogoutAPI.as_view(), name="api_logout"),
path("api/users/list/", api.UserListAPI.as_view(), name="api_user_list"),
path("api/users/create/", api.UserCreateAPI.as_view(), name="api_user_create"),
path("api/users/update/", api.UserUpdateAPI.as_view(), name="api_user_update"),
path("api/users/delete/", api.UserDeleteAPI.as_view(), name="api_user_delete"),
]
# Partner APIs
urlpatterns += [
path("api/partner/login/", api.PartnerLoginAPI.as_view(), name="partner_api_login"),
path("api/partner/logout/", api.PartnerLogoutAPI.as_view(), name="partner_api_logout"),
path("api/partner/dashboard/", api.PartnerDashboardAPI.as_view(), name="partner_api_dashboard"),
path("api/partner/users/list/", api.PartnerListUsersAPI.as_view(), name="partner_api_user_list"),
path("api/partner/users/create/", api.PartnerCreateUserAPI.as_view(), name="partner_api_user_create"),
path("api/partner/users/update/", api.PartnerUpdateUserAPI.as_view(), name="partner_api_user_update"),
path("api/partner/users/delete/", api.PartnerDeleteUserAPI.as_view(), name="partner_api_user_delete"),
]

View File

@@ -1,16 +1,16 @@
from django.shortcuts import render
from django.shortcuts import render, redirect
from django.views import generic
from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.contrib import messages
from django.contrib.auth import authenticate, login, logout
from .models import User
from .forms import LoginForm
from .forms import UserForm
from .forms import LoginForm, UserForm, PartnerUserForm
from events.models import Event
from master_data.models import EventType
from django.contrib.auth import authenticate, login, logout
from django.shortcuts import redirect
from django.contrib import messages
from eventify_logger.services import log
def dashboard(request):
@@ -62,16 +62,150 @@ def login_view(request):
user = form.get_user()
login(request, user)
if user.role == 'admin' or user.role == 'manager' or user.role == 'staff':
log("info", "Admin/Manager/Staff login", request=request, user=user)
return redirect("accounts:dashboard")
else:
log("warning", "Login attempt - user not authorized", request=request, user=user)
messages.error(request, "You are not authorized to access this page.")
else:
log("warning", "Invalid login attempt", request=request)
messages.error(request, "Invalid username or password")
return render(request, "accounts/login.html", {"form": form})
def logout_view(request):
if request.user.is_authenticated:
log("info", "User logout", request=request, user=request.user)
logout(request)
messages.success(request, "You have been logged out successfully.")
return redirect("accounts:login")
return redirect("accounts:login")
# Partner Views Mixin
class PartnerRequiredMixin(LoginRequiredMixin):
"""Mixin to ensure user has partner role (partner, partner_manager, partner_staff)"""
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
partner_roles = ['partner', 'partner_manager', 'partner_staff']
if request.user.role not in partner_roles:
raise PermissionDenied("You are not authorized to access this page.")
return super().dispatch(request, *args, **kwargs)
# Partner Login/Logout/Dashboard
def partner_login_view(request):
if request.user.is_authenticated:
partner_roles = ['partner', 'partner_manager', 'partner_staff']
if request.user.role in partner_roles:
return redirect("accounts:partner_dashboard")
else:
messages.error(request, "You are not authorized to access partner portal.")
return redirect("accounts:login")
form = LoginForm(request, data=request.POST or None)
if request.method == "POST":
if form.is_valid():
user = form.get_user()
partner_roles = ['partner', 'partner_manager', 'partner_staff']
if user.role in partner_roles:
log("info", "Partner portal login", request=request, user=user)
login(request, user)
return redirect("accounts:partner_dashboard")
else:
log("warning", "Partner login - user not authorized", request=request, user=user)
messages.error(request, "You are not authorized to access partner portal.")
else:
log("warning", "Partner portal - invalid login attempt", request=request)
messages.error(request, "Invalid username or password")
return render(request, "partner/login.html", {"form": form})
def partner_logout_view(request):
if request.user.is_authenticated:
log("info", "Partner portal logout", request=request, user=request.user)
logout(request)
messages.success(request, "You have been logged out successfully.")
return redirect("accounts:partner_login")
def partner_dashboard(request):
"""Partner dashboard view"""
partner_roles = ['partner', 'partner_manager', 'partner_staff']
if not request.user.is_authenticated or request.user.role not in partner_roles:
messages.error(request, "You are not authorized to access this page.")
return redirect("accounts:partner_login")
# Get statistics for partner users (including partner_customer)
all_partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer']
partner_users = User.objects.filter(role__in=all_partner_roles)
total_partner_users = partner_users.count()
# You can add more partner-specific statistics here
# For example, events created by partner, bookings, etc.
return render(request, 'partner/dashboard.html', {
'total_partner_users': total_partner_users,
})
# Partner User Management Views
class PartnerUserListView(PartnerRequiredMixin, generic.ListView):
model = User
template_name = 'partner/user_list.html'
context_object_name = 'users'
paginate_by = 20
def get_queryset(self):
"""Filter users to show only partner-related roles"""
partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer']
return User.objects.filter(role__in=partner_roles).order_by('-id')
class PartnerUserCreateView(PartnerRequiredMixin, generic.CreateView):
model = User
form_class = PartnerUserForm
template_name = 'partner/user_form.html'
success_url = reverse_lazy('accounts:partner_user_list')
def form_valid(self, form):
messages.success(self.request, "Partner user created successfully.")
return super().form_valid(form)
class PartnerUserUpdateView(PartnerRequiredMixin, generic.UpdateView):
model = User
form_class = PartnerUserForm
template_name = 'partner/user_form.html'
success_url = reverse_lazy('accounts:partner_user_list')
def get_queryset(self):
"""Only allow editing users with partner-related roles"""
partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer']
return User.objects.filter(role__in=partner_roles)
def form_valid(self, form):
messages.success(self.request, "Partner user updated successfully.")
return super().form_valid(form)
class PartnerUserDeleteView(PartnerRequiredMixin, generic.DeleteView):
model = User
template_name = 'partner/user_confirm_delete.html'
success_url = reverse_lazy('accounts:partner_user_list')
def get_queryset(self):
"""Only allow deleting users with partner-related roles"""
partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer']
return User.objects.filter(role__in=partner_roles)
def delete(self, request, *args, **kwargs):
# Prevent users from deleting themselves
if self.get_object().id == request.user.id:
messages.error(request, "You cannot delete your own account.")
return redirect(self.success_url)
messages.success(request, "Partner user deleted successfully.")
return super().delete(request, *args, **kwargs)

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

106
admin_api/urls.py Normal file
View File

@@ -0,0 +1,106 @@
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'),
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')),
]

3721
admin_api/views.py Normal file

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

432
banking_operations/api.py Normal file
View File

@@ -0,0 +1,432 @@
import uuid
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.forms.models import model_to_dict
from rest_framework.views import APIView
from banking_operations.models import PaymentGateway, PaymentGatewayCredentials
from mobile_api.utils import validate_token_and_get_user
def _payment_gateway_to_dict(gateway, request=None):
"""Serialize PaymentGateway for JSON."""
data = model_to_dict(
gateway,
fields=[
"id",
"payment_gateway_id",
"payment_gateway_name",
"payment_gateway_description",
"payment_gateway_url",
"payment_gateway_api_key",
"payment_gateway_api_secret",
"payment_gateway_api_url",
"payment_gateway_api_version",
"payment_gateway_api_method",
"is_active",
"created_date",
"updated_date",
"gateway_priority",
],
)
# Add logo URL if exists
if gateway.payment_gateway_logo:
if request:
data["payment_gateway_logo"] = gateway.payment_gateway_logo.url
else:
data["payment_gateway_logo"] = gateway.payment_gateway_logo.url
else:
data["payment_gateway_logo"] = None
return data
@method_decorator(csrf_exempt, name="dispatch")
class PaymentGatewayCreateAPI(APIView):
"""
Create a new PaymentGateway.
Body: token, username, payment_gateway_name, payment_gateway_description,
payment_gateway_api_key, payment_gateway_api_secret, payment_gateway_api_version,
payment_gateway_api_method (required);
payment_gateway_logo (file upload), payment_gateway_url, payment_gateway_api_url,
is_active, gateway_priority (optional).
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
payment_gateway_name = data.get("payment_gateway_name")
payment_gateway_description = data.get("payment_gateway_description")
payment_gateway_api_key = data.get("payment_gateway_api_key")
payment_gateway_api_secret = data.get("payment_gateway_api_secret")
payment_gateway_api_version = data.get("payment_gateway_api_version")
payment_gateway_api_method = data.get("payment_gateway_api_method")
if not all([
payment_gateway_name,
payment_gateway_description,
payment_gateway_api_key,
payment_gateway_api_secret,
payment_gateway_api_version,
payment_gateway_api_method,
]):
return JsonResponse(
{
"status": "error",
"message": "payment_gateway_name, payment_gateway_description, payment_gateway_api_key, "
"payment_gateway_api_secret, payment_gateway_api_version, and "
"payment_gateway_api_method are required.",
},
status=400,
)
# Generate payment_gateway_id if not provided
payment_gateway_id = data.get("payment_gateway_id")
if not payment_gateway_id:
payment_gateway_id = str(uuid.uuid4().hex[:10]).upper()
gateway = PaymentGateway.objects.create(
payment_gateway_id=payment_gateway_id,
payment_gateway_name=payment_gateway_name,
payment_gateway_description=payment_gateway_description,
payment_gateway_url=data.get("payment_gateway_url"),
payment_gateway_api_key=payment_gateway_api_key,
payment_gateway_api_secret=payment_gateway_api_secret,
payment_gateway_api_url=data.get("payment_gateway_api_url"),
payment_gateway_api_version=payment_gateway_api_version,
payment_gateway_api_method=payment_gateway_api_method,
is_active=data.get("is_active", True),
gateway_priority=data.get("gateway_priority", 0),
)
# Handle logo upload if provided
if "payment_gateway_logo" in request.FILES:
gateway.payment_gateway_logo = request.FILES["payment_gateway_logo"]
gateway.save()
return JsonResponse(
{"status": "success", "payment_gateway": _payment_gateway_to_dict(gateway, request)},
status=201,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PaymentGatewayListAPI(APIView):
"""
List PaymentGateways, optionally filtered by is_active.
Body: token, username, is_active (optional).
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
qs = PaymentGateway.objects.all().order_by("-gateway_priority", "-created_date")
is_active = data.get("is_active")
if is_active is not None:
qs = qs.filter(is_active=bool(is_active))
gateways = [_payment_gateway_to_dict(g, request) for g in qs]
return JsonResponse({"status": "success", "payment_gateways": gateways}, status=200)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PaymentGatewayUpdateAPI(APIView):
"""
Update an existing PaymentGateway.
Body: token, username, payment_gateway_id (required);
payment_gateway_name, payment_gateway_description, payment_gateway_url,
payment_gateway_api_key, payment_gateway_api_secret, payment_gateway_api_url,
payment_gateway_api_version, payment_gateway_api_method, is_active,
gateway_priority (optional);
payment_gateway_logo (file upload, optional).
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
payment_gateway_id = data.get("payment_gateway_id")
if not payment_gateway_id:
return JsonResponse(
{"status": "error", "message": "payment_gateway_id is required."},
status=400,
)
try:
gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id)
except PaymentGateway.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "PaymentGateway not found."},
status=404,
)
# Update fields if provided
if data.get("payment_gateway_name") is not None:
gateway.payment_gateway_name = data["payment_gateway_name"]
if data.get("payment_gateway_description") is not None:
gateway.payment_gateway_description = data["payment_gateway_description"]
if "payment_gateway_url" in data:
gateway.payment_gateway_url = data["payment_gateway_url"] or None
if data.get("payment_gateway_api_key") is not None:
gateway.payment_gateway_api_key = data["payment_gateway_api_key"]
if data.get("payment_gateway_api_secret") is not None:
gateway.payment_gateway_api_secret = data["payment_gateway_api_secret"]
if "payment_gateway_api_url" in data:
gateway.payment_gateway_api_url = data["payment_gateway_api_url"] or None
if data.get("payment_gateway_api_version") is not None:
gateway.payment_gateway_api_version = data["payment_gateway_api_version"]
if data.get("payment_gateway_api_method") is not None:
gateway.payment_gateway_api_method = data["payment_gateway_api_method"]
if data.get("is_active") is not None:
gateway.is_active = bool(data["is_active"])
if data.get("gateway_priority") is not None:
try:
gateway.gateway_priority = int(data["gateway_priority"])
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "gateway_priority must be an integer."},
status=400,
)
# Handle logo upload if provided
if "payment_gateway_logo" in request.FILES:
gateway.payment_gateway_logo = request.FILES["payment_gateway_logo"]
gateway.save()
return JsonResponse(
{"status": "success", "payment_gateway": _payment_gateway_to_dict(gateway, request)},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PaymentGatewayDeleteAPI(APIView):
"""
Delete an existing PaymentGateway.
Body: token, username, payment_gateway_id.
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
payment_gateway_id = data.get("payment_gateway_id")
if not payment_gateway_id:
return JsonResponse(
{"status": "error", "message": "payment_gateway_id is required."},
status=400,
)
try:
gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id)
except PaymentGateway.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "PaymentGateway not found."},
status=404,
)
gateway_name = gateway.payment_gateway_name
gateway.delete()
return JsonResponse(
{"status": "success", "message": f"PaymentGateway '{gateway_name}' deleted successfully."},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
def _payment_gateway_credentials_to_dict(credentials, request=None):
"""Serialize PaymentGatewayCredentials for JSON."""
data = model_to_dict(
credentials,
fields=[
"id",
"payment_gateway_credentials_value",
"created_date",
"updated_date",
],
)
data["payment_gateway_id"] = credentials.payment_gateway_id
data["payment_gateway_name"] = credentials.payment_gateway.payment_gateway_name
data["payment_gateway_payment_gateway_id"] = credentials.payment_gateway.payment_gateway_id
return data
@method_decorator(csrf_exempt, name="dispatch")
class PaymentGatewayCredentialsCreateAPI(APIView):
"""
Create a new PaymentGatewayCredentials.
Body: token, username, payment_gateway_id (or payment_gateway_payment_gateway_id),
payment_gateway_credentials_value (required).
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
payment_gateway_id = data.get("payment_gateway_id") or data.get("payment_gateway_payment_gateway_id")
payment_gateway_credentials_value = data.get("payment_gateway_credentials_value")
if not payment_gateway_id or not payment_gateway_credentials_value:
return JsonResponse(
{
"status": "error",
"message": "payment_gateway_id (or payment_gateway_payment_gateway_id) and payment_gateway_credentials_value are required.",
},
status=400,
)
try:
gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id)
except PaymentGateway.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "PaymentGateway not found."},
status=404,
)
credentials = PaymentGatewayCredentials.objects.create(
payment_gateway=gateway,
payment_gateway_credentials_value=payment_gateway_credentials_value,
)
return JsonResponse(
{"status": "success", "credentials": _payment_gateway_credentials_to_dict(credentials, request)},
status=201,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PaymentGatewayCredentialsListAPI(APIView):
"""
List PaymentGatewayCredentials, optionally filtered by payment_gateway_id.
Body: token, username, payment_gateway_id (optional).
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
qs = PaymentGatewayCredentials.objects.select_related("payment_gateway").all().order_by("-created_date")
payment_gateway_id = data.get("payment_gateway_id") or data.get("payment_gateway_payment_gateway_id")
if payment_gateway_id:
try:
gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id)
qs = qs.filter(payment_gateway=gateway)
except PaymentGateway.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "PaymentGateway not found."},
status=404,
)
credentials_list = [_payment_gateway_credentials_to_dict(c, request) for c in qs]
return JsonResponse({"status": "success", "credentials": credentials_list}, status=200)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PaymentGatewayCredentialsUpdateAPI(APIView):
"""
Update an existing PaymentGatewayCredentials.
Body: token, username, credentials_id (required);
payment_gateway_credentials_value (optional).
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
credentials_id = data.get("credentials_id")
if not credentials_id:
return JsonResponse(
{"status": "error", "message": "credentials_id is required."},
status=400,
)
try:
credentials = PaymentGatewayCredentials.objects.select_related("payment_gateway").get(id=credentials_id)
except PaymentGatewayCredentials.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "PaymentGatewayCredentials not found."},
status=404,
)
# Update credentials value if provided
if data.get("payment_gateway_credentials_value") is not None:
credentials.payment_gateway_credentials_value = data["payment_gateway_credentials_value"]
credentials.save()
return JsonResponse(
{"status": "success", "credentials": _payment_gateway_credentials_to_dict(credentials, request)},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class PaymentGatewayCredentialsDeleteAPI(APIView):
"""
Delete an existing PaymentGatewayCredentials.
Body: token, username, credentials_id.
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
credentials_id = data.get("credentials_id")
if not credentials_id:
return JsonResponse(
{"status": "error", "message": "credentials_id is required."},
status=400,
)
try:
credentials = PaymentGatewayCredentials.objects.select_related("payment_gateway").get(id=credentials_id)
except PaymentGatewayCredentials.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "PaymentGatewayCredentials not found."},
status=404,
)
gateway_name = credentials.payment_gateway.payment_gateway_name
credentials.delete()
return JsonResponse(
{
"status": "success",
"message": f"PaymentGatewayCredentials for '{gateway_name}' deleted successfully.",
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)

View File

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

View File

@@ -0,0 +1,69 @@
# Generated by Django 4.2.27 on 2026-03-13 16:55
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='PaymentGateway',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('payment_gateway_id', models.CharField(max_length=250)),
('payment_gateway_name', models.CharField(max_length=250)),
('payment_gateway_description', models.TextField()),
('payment_gateway_logo', models.ImageField(blank=True, null=True, upload_to='payment_gateways/')),
('payment_gateway_url', models.URLField(blank=True, null=True)),
('payment_gateway_api_key', models.CharField(max_length=250)),
('payment_gateway_api_secret', models.CharField(max_length=250)),
('payment_gateway_api_url', models.URLField(blank=True, null=True)),
('payment_gateway_api_version', models.CharField(max_length=250)),
('payment_gateway_api_method', models.CharField(max_length=250)),
('is_active', models.BooleanField(default=True)),
('created_date', models.DateField(auto_now_add=True)),
('updated_date', models.DateField(auto_now=True)),
('gateway_priority', models.IntegerField(default=0)),
],
),
migrations.CreateModel(
name='PaymentTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('payment_transaction_id', models.CharField(max_length=250)),
('payment_type', models.CharField(choices=[('credit', 'Credit'), ('debit', 'Debit'), ('transfer', 'Transfer'), ('other', 'Other')], max_length=250)),
('payment_sub_type', models.CharField(choices=[('online', 'Online'), ('offline', 'Offline'), ('other', 'Other')], max_length=250)),
('payment_transaction_amount', models.DecimalField(decimal_places=2, max_digits=10)),
('payment_transaction_currency', models.CharField(max_length=10)),
('payment_transaction_status', models.CharField(choices=[('pending', 'Pending'), ('completed', 'Completed'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('cancelled', 'Cancelled')], max_length=250)),
('payment_transaction_date', models.DateField(auto_now_add=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)),
('payment_transaction_response', models.JSONField(blank=True, null=True)),
('payment_transaction_error', models.JSONField(blank=True, null=True)),
('last_updated_date', models.DateField(blank=True, null=True)),
('last_updated_time', models.TimeField(blank=True, null=True)),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('payment_gateway', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='banking_operations.paymentgateway')),
],
),
migrations.CreateModel(
name='PaymentGatewayCredentials',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('payment_gateway_credentials_value', models.TextField()),
('created_date', models.DateField(auto_now_add=True)),
('updated_date', models.DateField(auto_now=True)),
('payment_gateway', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='banking_operations.paymentgateway')),
],
),
]

View File

@@ -0,0 +1,85 @@
from django.db import models
from django.contrib.auth import get_user_model
import uuid
User = get_user_model()
# Create your models here.
class PaymentGateway(models.Model):
payment_gateway_id = models.CharField(max_length=250)
payment_gateway_name = models.CharField(max_length=250)
payment_gateway_description = models.TextField()
payment_gateway_logo = models.ImageField(upload_to='payment_gateways/', blank=True, null=True)
payment_gateway_url = models.URLField(blank=True, null=True)
payment_gateway_api_key = models.CharField(max_length=250)
payment_gateway_api_secret = models.CharField(max_length=250)
payment_gateway_api_url = models.URLField(blank=True, null=True)
payment_gateway_api_version = models.CharField(max_length=250)
payment_gateway_api_method = models.CharField(max_length=250)
is_active = models.BooleanField(default=True)
created_date = models.DateField(auto_now_add=True)
updated_date = models.DateField(auto_now=True)
gateway_priority = models.IntegerField(default=0)
def __str__(self):
return self.payment_gateway_name
def __save__(self):
if not self.payment_gateway_id:
self.payment_gateway_id = str(uuid.uuid4().hex[:10]).upper()
super().save(*args, **kwargs)
class PaymentGatewayCredentials(models.Model):
payment_gateway = models.ForeignKey(PaymentGateway, on_delete=models.CASCADE)
payment_gateway_credentials_value = models.TextField()
created_date = models.DateField(auto_now_add=True)
updated_date = models.DateField(auto_now=True)
def __str__(self):
return self.payment_gateway.payment_gateway_name + " - " + self.payment_gateway_credentials_value
class PaymentTransaction(models.Model):
payment_transaction_id = models.CharField(max_length=250)
payment_type = models.CharField(max_length=250, db_index=True, choices=[
('credit', 'Credit'),
('debit', 'Debit'),
('transfer', 'Transfer'),
('other', 'Other'),
])
payment_sub_type = models.CharField(max_length=250, choices=[
('online', 'Online'),
('offline', 'Offline'),
('other', 'Other'),
])
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, db_index=True, choices=[
('pending', 'Pending'),
('completed', 'Completed'),
('failed', 'Failed'),
('refunded', 'Refunded'),
('cancelled', 'Cancelled'),
])
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)
payment_transaction_response = models.JSONField(blank=True, null=True)
payment_transaction_error = models.JSONField(blank=True, null=True)
last_updated_date = models.DateField(blank=True, null=True)
last_updated_time = models.TimeField(blank=True, null=True)
last_updated_by = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
def __str__(self):
return self.payment_gateway.payment_gateway_name + " - " + self.payment_transaction_id
def __save__(self):
if not self.payment_transaction_id:
self.payment_transaction_id = str(self.payment_gateway.payment_gateway_name[:3].upper()) + str(uuid.uuid4().hex[:10]).upper()
super().save(*args, **kwargs)

View File

@@ -0,0 +1,170 @@
"""
Banking/payment services. transaction_initiate is called by checkout (and others)
to start a payment flow. Replace the stub with real gateway integration (e.g. Razorpay).
"""
from decimal import Decimal
from django.contrib.auth import get_user_model
from banking_operations.models import PaymentTransaction, PaymentGateway
User = get_user_model()
def transaction_initiate(
request,
user,
amount,
currency="INR",
reference_type="checkout",
reference_id=None,
bookings=None,
extra_data=None,
):
"""
Initiate a payment transaction (e.g. create Razorpay order and return payment URL).
Args:
request: Django request (for building URLs, gateway config, etc.).
user: User instance (customer).
amount: Total amount in payment currency (Decimal or float).
currency: Currency code, e.g. "INR".
reference_type: Application reference type, e.g. "checkout".
reference_id: Application reference id (e.g. booking_ids or order id).
bookings: Optional list of Booking instances or IDs linked to this transaction.
extra_data: Optional dict for gateway-specific data.
Returns:
dict: On success: {"success": True, "transaction_id": "...", "payment_url": "...", "message": "..."}
On failure: {"success": False, "message": "..."}
"""
# Stub: replace with real gateway call when banking_operations payment flow is implemented.
return {
"success": True,
"message": "Transaction initiation stub",
"transaction_id": None,
"payment_url": None,
}
def create_payment_transaction(
payment_type,
payment_sub_type,
payment_gateway,
transaction_amount,
currency="INR",
notes=None,
raw_data=None,
user=None,
):
"""
Create a PaymentTransaction with pending status.
Args:
payment_type: Payment type - 'credit', 'debit', 'transfer', or 'other'
payment_sub_type: Payment sub type - 'online', 'offline', or 'other'
payment_gateway: PaymentGateway instance or payment_gateway_id (str)
transaction_amount: Transaction amount (Decimal, float, or string)
currency: Currency code, e.g. "INR" (default: "INR")
notes: Optional transaction notes (str)
raw_data: Optional raw data dict to store in payment_transaction_raw_data (dict)
user: Optional User instance for last_updated_by
Returns:
tuple: (success: bool, transaction: PaymentTransaction or None, error_message: str or None)
"""
try:
# Validate payment_type
valid_payment_types = ['credit', 'debit', 'transfer', 'other']
if payment_type not in valid_payment_types:
return False, None, f"Invalid payment_type. Must be one of: {', '.join(valid_payment_types)}"
# Validate payment_sub_type
valid_payment_sub_types = ['online', 'offline', 'other']
if payment_sub_type not in valid_payment_sub_types:
return False, None, f"Invalid payment_sub_type. Must be one of: {', '.join(valid_payment_sub_types)}"
# Get PaymentGateway instance
if isinstance(payment_gateway, PaymentGateway):
gateway = payment_gateway
elif isinstance(payment_gateway, str):
try:
gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway)
except PaymentGateway.DoesNotExist:
return False, None, f"PaymentGateway with id '{payment_gateway}' not found."
else:
return False, None, "payment_gateway must be a PaymentGateway instance or payment_gateway_id string."
# Validate transaction_amount
try:
amount = Decimal(str(transaction_amount))
if amount <= 0:
return False, None, "transaction_amount must be greater than zero."
except (ValueError, TypeError):
return False, None, "transaction_amount must be a valid number."
# Validate currency
if not currency or len(currency) > 10:
return False, None, "currency must be a valid currency code (max 10 characters)."
# Create PaymentTransaction
transaction = PaymentTransaction.objects.create(
payment_type=payment_type,
payment_sub_type=payment_sub_type,
payment_gateway=gateway,
payment_transaction_amount=amount,
payment_transaction_currency=currency,
payment_transaction_status='pending', # Initial state as requested
payment_transaction_notes=notes,
payment_transaction_raw_data=raw_data,
last_updated_by=user if isinstance(user, User) else None,
)
return True, transaction.payment_transaction_id, None
except Exception as e:
return False, None, str(e)
def update_payment_transaction(
payment_transaction_id,
payment_transaction_status,
payment_transaction_notes=None,
payment_transaction_raw_data=None,
payment_transaction_response=None,
payment_transaction_error=None,
user=None,
):
"""
Update a PaymentTransaction with the given status and notes.
Args:
payment_transaction_id: PaymentTransaction id (str)
payment_transaction_status: PaymentTransaction status - 'pending', 'completed', 'failed', 'refunded', 'cancelled'
payment_transaction_notes: Optional transaction notes (str)
payment_transaction_raw_data: Optional raw data dict to store in payment_transaction_raw_data (dict)
payment_transaction_response: Optional response dict to store in payment_transaction_response (dict)
payment_transaction_error: Optional error dict to store in payment_transaction_error (dict)
user: Optional User instance for last_updated_by
"""
try:
# Get PaymentTransaction instance
if isinstance(payment_transaction_id, PaymentTransaction):
transaction = payment_transaction_id
elif isinstance(payment_transaction_id, str):
try:
transaction = PaymentTransaction.objects.get(payment_transaction_id=payment_transaction_id)
except PaymentTransaction.DoesNotExist:
return False, None, f"PaymentTransaction with id '{payment_transaction_id}' not found."
else:
return False, None, "payment_transaction_id must be a PaymentTransaction instance or payment_transaction_id string."
# Update PaymentTransaction
transaction.payment_transaction_status = payment_transaction_status
transaction.payment_transaction_notes = payment_transaction_notes
transaction.payment_transaction_raw_data = payment_transaction_raw_data
transaction.payment_transaction_response = payment_transaction_response
transaction.payment_transaction_error = payment_transaction_error
transaction.last_updated_by = user if isinstance(user, User) else None
transaction.save()
return True, transaction.payment_transaction_id, None
except Exception as e:
return False, None, str(e)

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,24 @@
from django.urls import path
from banking_operations.api import (
PaymentGatewayCreateAPI,
PaymentGatewayListAPI,
PaymentGatewayUpdateAPI,
PaymentGatewayDeleteAPI,
PaymentGatewayCredentialsCreateAPI,
PaymentGatewayCredentialsListAPI,
PaymentGatewayCredentialsUpdateAPI,
PaymentGatewayCredentialsDeleteAPI,
)
urlpatterns = [
path("payment-gateway/create/", PaymentGatewayCreateAPI.as_view(), name="payment_gateway_create"),
path("payment-gateway/list/", PaymentGatewayListAPI.as_view(), name="payment_gateway_list"),
path("payment-gateway/update/", PaymentGatewayUpdateAPI.as_view(), name="payment_gateway_update"),
path("payment-gateway/delete/", PaymentGatewayDeleteAPI.as_view(), name="payment_gateway_delete"),
path("payment-gateway-credentials/create/", PaymentGatewayCredentialsCreateAPI.as_view(), name="payment_gateway_credentials_create"),
path("payment-gateway-credentials/list/", PaymentGatewayCredentialsListAPI.as_view(), name="payment_gateway_credentials_list"),
path("payment-gateway-credentials/update/", PaymentGatewayCredentialsUpdateAPI.as_view(), name="payment_gateway_credentials_update"),
path("payment-gateway-credentials/delete/", PaymentGatewayCredentialsDeleteAPI.as_view(), name="payment_gateway_credentials_delete"),
]

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -0,0 +1,101 @@
# Generated by Django 4.2.27 on 2026-03-13 16:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('events', '0007_event_gst_percentage_1_event_gst_percentage_2_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Booking',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('booking_id', models.CharField(max_length=250)),
('quantity', models.IntegerField()),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('created_date', models.DateField(auto_now_add=True)),
('updated_date', models.DateField(auto_now=True)),
('transaction_id', models.CharField(blank=True, max_length=250, null=True)),
],
),
migrations.CreateModel(
name='TicketMeta',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticket_name', models.CharField(max_length=250)),
('maximum_quantity', models.IntegerField()),
('available_quantity', models.IntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('created_date', models.DateField(auto_now_add=True)),
('updated_date', models.DateField(auto_now=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')),
],
),
migrations.CreateModel(
name='TicketType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticket_type', models.CharField(max_length=250)),
('ticket_type_description', models.TextField()),
('ticket_type_quantity', models.IntegerField()),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('is_active', models.BooleanField(default=True)),
('created_date', models.DateField(auto_now_add=True)),
('updated_date', models.DateField(auto_now=True)),
('is_offer', models.BooleanField(default=False)),
('offer_percentage', models.IntegerField(default=0)),
('offer_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('offer_start_date', models.DateField(blank=True, null=True)),
('offer_end_date', models.DateField(blank=True, null=True)),
('ticket_meta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.ticketmeta')),
],
),
migrations.CreateModel(
name='Ticket',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticket_id', models.CharField(max_length=250)),
('is_checked_in', models.BooleanField(default=False)),
('checked_in_date_time', models.DateTimeField(blank=True, null=True)),
('booking', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.booking')),
],
),
migrations.CreateModel(
name='Cart',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.IntegerField()),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('is_active', models.BooleanField(default=True)),
('created_date', models.DateField(auto_now_add=True)),
('updated_date', models.DateField(auto_now=True)),
('ticket_meta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.ticketmeta')),
('ticket_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.tickettype')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='booking',
name='ticket_meta',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.ticketmeta'),
),
migrations.AddField(
model_name='booking',
name='ticket_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.tickettype'),
),
migrations.AddField(
model_name='booking',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -1,3 +1,97 @@
from django.db import models
import uuid
from events.models import Event
from accounts.models import User
# Create your models here.
class TicketMeta(models.Model):
event = models.ForeignKey(Event, on_delete=models.CASCADE)
ticket_name = models.CharField(max_length=250)
maximum_quantity = models.IntegerField()
available_quantity = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
created_date = models.DateField(auto_now_add=True)
updated_date = models.DateField(auto_now=True)
def __str__(self):
return self.ticket_name
class TicketType(models.Model):
ticket_meta = models.ForeignKey(TicketMeta, on_delete=models.CASCADE)
ticket_type = models.CharField(max_length=250)
ticket_type_description = models.TextField()
ticket_type_quantity = models.IntegerField()
price = models.DecimalField(max_digits=10, decimal_places=2)
is_active = models.BooleanField(default=True)
created_date = models.DateField(auto_now_add=True)
updated_date = models.DateField(auto_now=True)
is_offer = models.BooleanField(default=False)
offer_percentage = models.IntegerField(default=0)
offer_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
offer_start_date = models.DateField(blank=True, null=True)
offer_end_date = models.DateField(blank=True, null=True)
def __str__(self):
return self.ticket_type
class Cart(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
ticket_meta = models.ForeignKey(TicketMeta, on_delete=models.CASCADE)
ticket_type = models.ForeignKey(TicketType, on_delete=models.CASCADE)
quantity = models.IntegerField()
price = models.DecimalField(max_digits=10, decimal_places=2)
is_active = models.BooleanField(default=True)
created_date = models.DateField(auto_now_add=True)
updated_date = models.DateField(auto_now=True)
def __str__(self):
return self.user.username + " - " + self.ticket.event.name
class Booking(models.Model):
booking_id = models.CharField(max_length=250)
user = models.ForeignKey(User, on_delete=models.CASCADE)
ticket_meta = models.ForeignKey(TicketMeta, on_delete=models.CASCADE)
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, db_index=True)
updated_date = models.DateField(auto_now=True)
transaction_id = models.CharField(max_length=250, blank=True, null=True)
def __save__(self):
if not self.booking_id:
self.booking_id = str(self.ticket.event.name[:3].upper()) + str(uuid.uuid4().hex[:10]).upper()
super().save(*args, **kwargs)
def __str__(self):
return self.booking_id
class Ticket(models.Model):
booking = models.ForeignKey(Booking, on_delete=models.CASCADE)
ticket_id = models.CharField(max_length=250)
is_checked_in = models.BooleanField(default=False)
checked_in_date_time = models.DateTimeField(blank=True, null=True)
def __save__(self):
if not self.ticket_id:
self.ticket_id = str(self.booking.ticket_meta.event.name[:3].upper()) + str(uuid.uuid4().hex[:10]).upper()
super().save(*args, **kwargs)
def __str__(self):
return self.ticket_id
def check_in(self, ticket_id):
if self.ticket_id == ticket_id:
self.is_checked_in = True
self.checked_in_date_time = datetime.now()
self.save()
return True
return False

61
bookings/services.py Normal file
View File

@@ -0,0 +1,61 @@
from typing import List
import uuid
from django.utils import timezone
from bookings.models import Booking, Ticket
def _generate_ticket_id(booking: Booking) -> str:
"""
Generate a ticket_id based on the event name and a random UUID segment.
Pattern: <EVT><RANDOM_HEX>
- EVT: first 3 characters of event name (uppercase), or 'EVT' fallback
- RANDOM_HEX: first 10 chars of uuid4 hex (uppercase)
"""
event = getattr(booking.ticket_meta, "event", None)
if event and getattr(event, "name", None):
prefix = (event.name or "EVT")[:3].upper()
else:
prefix = "EVT"
return prefix + uuid.uuid4().hex[:10].upper()
def generate_tickets_for_booking(booking: Booking) -> List[Ticket]:
"""
Generate Ticket instances for a given Booking based on its quantity.
This function does NOT perform any payment or business-rule validation.
It simply creates one Ticket per quantity on the booking.
Args:
booking: Booking instance for which tickets should be generated.
Returns:
List[Ticket]: List of created Ticket instances.
"""
if not isinstance(booking, Booking):
raise TypeError("booking must be a Booking instance")
if booking.quantity <= 0:
return []
tickets: List[Ticket] = []
for _ in range(booking.quantity):
tickets.append(
Ticket(
booking=booking,
ticket_id=_generate_ticket_id(booking),
is_checked_in=False,
checked_in_date_time=None,
)
)
# Bulk create for efficiency
Ticket.objects.bulk_create(tickets)
# Refresh from DB to ensure we have primary keys and any defaults
return list[Ticket](Ticket.objects.filter(booking=booking).order_by("id"))

View File

@@ -0,0 +1,2 @@
from . import ticket_meta_type
from . import booking_api

View File

@@ -0,0 +1,369 @@
import uuid
from decimal import Decimal
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.forms.models import model_to_dict
from django.utils import timezone
from datetime import date
from rest_framework.views import APIView
from bookings.models import Cart, TicketType, TicketMeta, Booking, Ticket
from mobile_api.utils import validate_token_and_get_user
from banking_operations.services import transaction_initiate
from eventify_logger.services import log
def _cart_to_dict(cart):
"""Serialize Cart for JSON."""
data = model_to_dict(
cart,
fields=["id", "quantity", "price", "created_date", "updated_date"],
)
data["user_id"] = cart.user_id
data["ticket_meta_id"] = cart.ticket_meta_id
data["ticket_type_id"] = cart.ticket_type_id
return data
@method_decorator(csrf_exempt, name="dispatch")
class AddToCartAPI(APIView):
"""
Add TicketType to Cart (when customer clicks plus button).
Body: token, username, ticket_type_id, quantity (optional, defaults to 1).
If cart item already exists for this user + ticket_type, quantity is incremented.
Price is taken from TicketType (offer_price if offer is active, else price).
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
ticket_type_id = data.get("ticket_type_id")
quantity = data.get("quantity", 1) # Default to 1 for plus button click
if not ticket_type_id:
return JsonResponse(
{"status": "error", "message": "ticket_type_id is required."},
status=400,
)
try:
ticket_type = TicketType.objects.select_related("ticket_meta").get(id=ticket_type_id)
except TicketType.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "TicketType not found."},
status=404,
)
if not ticket_type.is_active:
return JsonResponse(
{"status": "error", "message": "TicketType is not active."},
status=400,
)
try:
quantity = int(quantity)
if quantity <= 0:
return JsonResponse(
{"status": "error", "message": "quantity must be greater than zero."},
status=400,
)
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "quantity must be a positive integer."},
status=400,
)
# Determine price: use offer_price if offer is active, else regular price
price = ticket_type.price
if ticket_type.is_offer:
# Check if offer is currently valid (if dates are set)
today = date.today()
offer_valid = True
if ticket_type.offer_start_date and ticket_type.offer_start_date > today:
offer_valid = False
if ticket_type.offer_end_date and ticket_type.offer_end_date < today:
offer_valid = False
if offer_valid and ticket_type.offer_price > 0:
price = ticket_type.offer_price
# Check if cart item already exists for this user + ticket_type
cart_item, created = Cart.objects.get_or_create(
user=user,
ticket_type=ticket_type,
defaults={
"ticket_meta": ticket_type.ticket_meta,
"quantity": quantity,
"price": price,
},
)
if not created:
# Update existing cart item: increment quantity
cart_item.quantity += quantity
cart_item.price = price # Update price in case offer changed
cart_item.save()
return JsonResponse(
{
"status": "success",
"message": "TicketType added to cart." if created else "Cart item updated.",
"cart_item": _cart_to_dict(cart_item),
},
status=201 if created else 200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class DeleteFromCartAPI(APIView):
"""
Remove or decrement TicketType from Cart (when customer clicks minus button).
Body: token, username, ticket_type_id, quantity (optional, defaults to 1).
Decrements quantity by 1 (or by given quantity). If quantity becomes 0 or less,
the cart item is deleted.
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
ticket_type_id = data.get("ticket_type_id")
quantity = data.get("quantity", 1) # Default to 1 for minus button click
if not ticket_type_id:
return JsonResponse(
{"status": "error", "message": "ticket_type_id is required."},
status=400,
)
try:
quantity = int(quantity)
if quantity <= 0:
return JsonResponse(
{"status": "error", "message": "quantity must be greater than zero."},
status=400,
)
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "quantity must be a positive integer."},
status=400,
)
try:
cart_item = Cart.objects.get(user=user, ticket_type_id=ticket_type_id)
except Cart.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "Cart item not found for this ticket type."},
status=404,
)
cart_item.quantity -= quantity
if cart_item.quantity <= 0:
cart_item.delete()
return JsonResponse(
{
"status": "success",
"message": "TicketType removed from cart.",
"cart_item": None,
"removed": True,
},
status=200,
)
cart_item.save()
return JsonResponse(
{
"status": "success",
"message": "Cart quantity updated.",
"cart_item": _cart_to_dict(cart_item),
"removed": False,
},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class CheckoutAPI(APIView):
"""
Checkout the authenticated user's cart: create one Booking per cart line,
call transaction_initiate in banking_operations, then clear the checked-out cart items.
Body: token, username.
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
cart_items = list(
Cart.objects.filter(user=user, is_active=True).select_related(
"ticket_meta", "ticket_type", "ticket_meta__event"
)
)
if not cart_items:
return JsonResponse(
{"status": "error", "message": "Cart is empty. Add ticket types before checkout."},
status=400,
)
total_amount = Decimal("0")
created_bookings = []
cart_ids_to_clear = []
for item in cart_items:
event_name = (
(item.ticket_meta.event.name or "EVT")[:3].upper()
if item.ticket_meta.event_id else "EVT"
)
booking_id = event_name + uuid.uuid4().hex[:10].upper()
line_total = Decimal(str(item.price)) * item.quantity
total_amount += line_total
booking = Booking.objects.create(
booking_id=booking_id,
user=user,
ticket_meta=item.ticket_meta,
ticket_type=item.ticket_type,
quantity=item.quantity,
price=item.price,
)
created_bookings.append(booking)
cart_ids_to_clear.append(item.id)
reference_id = ",".join(b.booking_id for b in created_bookings)
result = transaction_initiate(
request=request,
user=user,
amount=float(total_amount),
currency="INR",
reference_type="checkout",
reference_id=reference_id,
bookings=created_bookings,
extra_data=None,
)
if not result.get("success"):
for b in created_bookings:
b.delete()
return JsonResponse(
{
"status": "error",
"message": result.get("message", "Transaction initiation failed."),
},
status=502,
)
transaction_id = result.get("transaction_id")
if transaction_id:
for b in created_bookings:
b.transaction_id = transaction_id
b.save(update_fields=["transaction_id"])
Cart.objects.filter(id__in=cart_ids_to_clear).delete()
log("info", "Checkout complete", request=request, user=user, logger_data={
"booking_ids": [b.booking_id for b in created_bookings],
"total_amount": str(total_amount),
})
response_payload = {
"status": "success",
"message": "Checkout complete. Proceed to payment.",
"booking_ids": [b.booking_id for b in created_bookings],
"total_amount": str(total_amount),
"transaction_id": result.get("transaction_id"),
"payment_url": result.get("payment_url"),
}
return JsonResponse(response_payload, status=200)
except Exception as e:
log("error", "Checkout exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class CheckInAPI(APIView):
"""
Check-in a ticket by scanning QR code (ticket_id).
Body: token, username, ticket_id (required).
Looks up Ticket by ticket_id. If found and not already checked in,
sets is_checked_in=True and checked_in_date_time=now. Returns success or
appropriate error (ticket not found / already checked in).
"""
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
ticket_id = data.get("ticket_id")
if not ticket_id or not str(ticket_id).strip():
return JsonResponse(
{"status": "error", "message": "ticket_id is required."},
status=400,
)
ticket_id = str(ticket_id).strip()
try:
ticket = Ticket.objects.select_related(
"booking", "booking__ticket_meta", "booking__ticket_meta__event", "booking__ticket_type"
).get(ticket_id=ticket_id)
except Ticket.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "Ticket not found."},
status=404,
)
if ticket.is_checked_in:
log("info", "Check-in duplicate - ticket already checked in", request=request, user=user, logger_data={"ticket_id": ticket_id})
return JsonResponse(
{
"status": "success",
"message": "Ticket already checked in.",
"ticket_id": ticket.ticket_id,
"is_checked_in": True,
"checked_in_date_time": ticket.checked_in_date_time.isoformat() if ticket.checked_in_date_time else None,
"event_name": ticket.booking.ticket_meta.event.name if ticket.booking.ticket_meta_id else None,
"booking_id": ticket.booking.booking_id,
},
status=200,
)
ticket.is_checked_in = True
ticket.checked_in_date_time = timezone.now()
ticket.save(update_fields=["is_checked_in", "checked_in_date_time"])
log("info", "Check-in successful", request=request, user=user, logger_data={"ticket_id": ticket_id, "booking_id": ticket.booking.booking_id})
return JsonResponse(
{
"status": "success",
"message": "Check-in successful.",
"ticket_id": ticket.ticket_id,
"is_checked_in": True,
"checked_in_date_time": ticket.checked_in_date_time.isoformat(),
"event_name": ticket.booking.ticket_meta.event.name if ticket.booking.ticket_meta_id else None,
"booking_id": ticket.booking.booking_id,
},
status=200,
)
except Exception as e:
log("error", "Check-in exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": str(e)}, status=500)

View File

@@ -0,0 +1,489 @@
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.forms.models import model_to_dict
from rest_framework.views import APIView
from bookings.models import TicketMeta, TicketType
from events.models import Event
from mobile_api.utils import validate_token_and_get_user
def _ticket_meta_to_dict(meta):
"""Serialize TicketMeta for JSON."""
data = model_to_dict(
meta,
fields=[
"id",
"ticket_name",
"maximum_quantity",
"available_quantity",
"is_active",
"created_date",
"updated_date",
],
)
data["event_id"] = meta.event_id
return data
def _ticket_type_to_dict(tt):
"""Serialize TicketType for JSON."""
data = model_to_dict(
tt,
fields=[
"id",
"ticket_type",
"ticket_type_description",
"quantity",
"price",
"is_active",
"created_date",
"updated_date",
"is_offer",
"offer_percentage",
"offer_price",
"offer_start_date",
"offer_end_date",
],
)
data["ticket_meta_id"] = tt.ticket_meta_id
return data
# ---------- TicketMeta (event-level ticket config) CRUD ----------
@method_decorator(csrf_exempt, name="dispatch")
class TicketMetaCreateAPI(APIView):
"""
Create a new TicketMeta (event-level ticket config).
Body: token, username, event_id, ticket_name, maximum_quantity,
available_quantity (optional, defaults to maximum_quantity), is_active (optional, default true).
"""
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")
ticket_name = data.get("ticket_name")
maximum_quantity = data.get("maximum_quantity")
available_quantity = data.get("available_quantity")
is_active = data.get("is_active", True)
if not event_id or not ticket_name or maximum_quantity is None:
return JsonResponse(
{
"status": "error",
"message": "event_id, ticket_name and maximum_quantity are required.",
},
status=400,
)
try:
event = Event.objects.get(id=event_id)
except Event.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "Event not found."},
status=404,
)
try:
maximum_quantity = int(maximum_quantity)
available_quantity = int(available_quantity) if available_quantity is not None else maximum_quantity
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "maximum_quantity and available_quantity must be integers."},
status=400,
)
if maximum_quantity <= 0:
return JsonResponse(
{"status": "error", "message": "maximum_quantity must be greater than zero."},
status=400,
)
meta = TicketMeta.objects.create(
event=event,
ticket_name=ticket_name,
maximum_quantity=maximum_quantity,
available_quantity=available_quantity,
is_active=bool(is_active),
)
return JsonResponse(
{"status": "success", "ticket_meta": _ticket_meta_to_dict(meta)},
status=201,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class TicketMetaListAPI(APIView):
"""List TicketMeta, optionally filtered by event_id. Body: token, username, event_id (optional)."""
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")
qs = TicketMeta.objects.filter(is_active=True).order_by("-created_date")
if event_id:
qs = qs.filter(event_id=event_id)
items = [_ticket_meta_to_dict(m) for m in qs]
return JsonResponse({"status": "success", "ticket_metas": items}, status=200)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class TicketMetaUpdateAPI(APIView):
"""
Update TicketMeta. Body: token, username, ticket_meta_id (required);
ticket_name, maximum_quantity, available_quantity, is_active (optional).
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
pk = data.get("ticket_meta_id")
if not pk:
return JsonResponse({"status": "error", "message": "ticket_meta_id is required."}, status=400)
try:
meta = TicketMeta.objects.get(id=pk)
except TicketMeta.DoesNotExist:
return JsonResponse({"status": "error", "message": "TicketMeta not found."}, status=404)
if data.get("ticket_name") is not None:
meta.ticket_name = data["ticket_name"]
if data.get("maximum_quantity") is not None:
try:
val = int(data["maximum_quantity"])
if val <= 0:
return JsonResponse(
{"status": "error", "message": "maximum_quantity must be greater than zero."},
status=400,
)
meta.maximum_quantity = val
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "maximum_quantity must be an integer."},
status=400,
)
if data.get("available_quantity") is not None:
try:
meta.available_quantity = int(data["available_quantity"])
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "available_quantity must be an integer."},
status=400,
)
if data.get("is_active") is not None:
meta.is_active = bool(data["is_active"])
meta.save()
return JsonResponse({"status": "success", "ticket_meta": _ticket_meta_to_dict(meta)}, status=200)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class TicketMetaDeleteAPI(APIView):
"""Delete TicketMeta. Body: token, username, ticket_meta_id."""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
pk = data.get("ticket_meta_id")
if not pk:
return JsonResponse({"status": "error", "message": "ticket_meta_id is required."}, status=400)
try:
meta = TicketMeta.objects.get(id=pk)
except TicketMeta.DoesNotExist:
return JsonResponse({"status": "error", "message": "TicketMeta not found."}, status=404)
meta.delete()
return JsonResponse({"status": "success", "message": "TicketMeta deleted successfully."}, status=200)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class TicketMetaDeactivateAPI(APIView):
"""Deactivate a TicketMeta (set is_active=False). Body: token, username, ticket_meta_id."""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
pk = data.get("ticket_meta_id")
if not pk:
return JsonResponse({"status": "error", "message": "ticket_meta_id is required."}, status=400)
try:
meta = TicketMeta.objects.get(id=pk)
except TicketMeta.DoesNotExist:
return JsonResponse({"status": "error", "message": "TicketMeta not found."}, status=404)
meta.is_active = False
meta.save()
return JsonResponse(
{"status": "success", "message": "TicketMeta deactivated.", "ticket_meta": _ticket_meta_to_dict(meta)},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
# ---------- TicketType CRUD ----------
@method_decorator(csrf_exempt, name="dispatch")
class TicketTypeCreateAPI(APIView):
"""
Create a new TicketType.
Body: token, username, ticket_meta_id, ticket_type, ticket_type_description, quantity, price (required);
is_active (optional, default true); is_offer, offer_percentage, offer_price, offer_start_date, offer_end_date (optional).
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
ticket_meta_id = data.get("ticket_meta_id")
ticket_type_name = data.get("ticket_type")
ticket_type_description = data.get("ticket_type_description")
quantity = data.get("quantity")
price = data.get("price")
is_active = data.get("is_active", True)
is_offer = data.get("is_offer", False)
offer_percentage = data.get("offer_percentage", 0)
offer_price = data.get("offer_price", 0)
offer_start_date = data.get("offer_start_date")
offer_end_date = data.get("offer_end_date")
if not ticket_meta_id or not ticket_type_name or quantity is None or price is None:
return JsonResponse(
{
"status": "error",
"message": "ticket_meta_id, ticket_type, ticket_type_description, quantity and price are required.",
},
status=400,
)
try:
ticket_meta = TicketMeta.objects.get(id=ticket_meta_id)
except TicketMeta.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "TicketMeta not found."},
status=404,
)
try:
quantity = int(quantity)
price = float(price)
offer_percentage = int(offer_percentage) if offer_percentage is not None else 0
offer_price = float(offer_price) if offer_price is not None else 0
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "quantity, price, offer_percentage and offer_price must be numeric."},
status=400,
)
if quantity <= 0:
return JsonResponse(
{"status": "error", "message": "quantity must be greater than zero."},
status=400,
)
tt = TicketType.objects.create(
ticket_meta=ticket_meta,
ticket_type=ticket_type_name,
ticket_type_description=ticket_type_description or "",
quantity=quantity,
price=price,
is_active=bool(is_active),
is_offer=bool(is_offer),
offer_percentage=offer_percentage,
offer_price=offer_price,
offer_start_date=offer_start_date,
offer_end_date=offer_end_date,
)
return JsonResponse(
{"status": "success", "ticket_type": _ticket_type_to_dict(tt)},
status=201,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class TicketTypeListAPI(APIView):
"""List TicketType, optionally filtered by ticket_meta_id. Body: token, username, ticket_meta_id (optional)."""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
ticket_meta_id = data.get("ticket_meta_id")
qs = TicketType.objects.filter(is_active=True).order_by("-created_date")
if ticket_meta_id:
qs = qs.filter(ticket_meta_id=ticket_meta_id)
items = [_ticket_type_to_dict(tt) for tt in qs]
return JsonResponse({"status": "success", "ticket_types": items}, status=200)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class TicketTypeUpdateAPI(APIView):
"""
Update TicketType. Body: token, username, ticket_type_id (required);
ticket_type, ticket_type_description, quantity, price, is_active,
is_offer, offer_percentage, offer_price, offer_start_date, offer_end_date (optional).
"""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
pk = data.get("ticket_type_id")
if not pk:
return JsonResponse({"status": "error", "message": "ticket_type_id is required."}, status=400)
try:
tt = TicketType.objects.get(id=pk)
except TicketType.DoesNotExist:
return JsonResponse({"status": "error", "message": "TicketType not found."}, status=404)
if data.get("ticket_type") is not None:
tt.ticket_type = data["ticket_type"]
if data.get("ticket_type_description") is not None:
tt.ticket_type_description = data["ticket_type_description"]
if data.get("quantity") is not None:
try:
val = int(data["quantity"])
if val <= 0:
return JsonResponse(
{"status": "error", "message": "quantity must be greater than zero."},
status=400,
)
tt.quantity = val
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "quantity must be an integer."},
status=400,
)
if data.get("price") is not None:
try:
tt.price = float(data["price"])
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "price must be numeric."},
status=400,
)
if data.get("is_active") is not None:
tt.is_active = bool(data["is_active"])
if data.get("is_offer") is not None:
tt.is_offer = bool(data["is_offer"])
if data.get("offer_percentage") is not None:
try:
tt.offer_percentage = int(data["offer_percentage"])
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "offer_percentage must be an integer."},
status=400,
)
if data.get("offer_price") is not None:
try:
tt.offer_price = float(data["offer_price"])
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "offer_price must be numeric."},
status=400,
)
if "offer_start_date" in data:
tt.offer_start_date = data["offer_start_date"]
if "offer_end_date" in data:
tt.offer_end_date = data["offer_end_date"]
tt.save()
return JsonResponse({"status": "success", "ticket_type": _ticket_type_to_dict(tt)}, status=200)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class TicketTypeDeactivateAPI(APIView):
"""Deactivate a TicketType (set is_active=False). Body: token, username, ticket_type_id."""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
pk = data.get("ticket_type_id")
if not pk:
return JsonResponse({"status": "error", "message": "ticket_type_id is required."}, status=400)
try:
tt = TicketType.objects.get(id=pk)
except TicketType.DoesNotExist:
return JsonResponse({"status": "error", "message": "TicketType not found."}, status=404)
tt.is_active = False
tt.save()
return JsonResponse(
{"status": "success", "message": "TicketType deactivated.", "ticket_type": _ticket_type_to_dict(tt)},
status=200,
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@method_decorator(csrf_exempt, name="dispatch")
class TicketTypeDeleteAPI(APIView):
"""Delete TicketType. Body: token, username, ticket_type_id."""
def post(self, request):
try:
user, token, data, error_response = validate_token_and_get_user(request)
if error_response:
return error_response
pk = data.get("ticket_type_id")
if not pk:
return JsonResponse({"status": "error", "message": "ticket_type_id is required."}, status=400)
try:
tt = TicketType.objects.get(id=pk)
except TicketType.DoesNotExist:
return JsonResponse({"status": "error", "message": "TicketType not found."}, status=404)
tt.delete()
return JsonResponse({"status": "success", "message": "TicketType deleted successfully."}, status=200)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)

34
bookings/urls.py Normal file
View File

@@ -0,0 +1,34 @@
from django.urls import path
from bookings.tickets_view.ticket_meta_type import (
TicketMetaCreateAPI,
TicketMetaListAPI,
TicketMetaUpdateAPI,
TicketMetaDeleteAPI,
TicketMetaDeactivateAPI,
TicketTypeCreateAPI,
TicketTypeListAPI,
TicketTypeUpdateAPI,
TicketTypeDeleteAPI,
TicketTypeDeactivateAPI,
)
from bookings.tickets_view.booking_api import AddToCartAPI, DeleteFromCartAPI, CheckoutAPI, CheckInAPI
urlpatterns = [
path("ticket-meta/create/", TicketMetaCreateAPI.as_view(), name="ticket_meta_create"),
path("ticket-meta/list/", TicketMetaListAPI.as_view(), name="ticket_meta_list"),
path("ticket-meta/update/", TicketMetaUpdateAPI.as_view(), name="ticket_meta_update"),
path("ticket-meta/delete/", TicketMetaDeleteAPI.as_view(), name="ticket_meta_delete"),
path("ticket-meta/deactivate/", TicketMetaDeactivateAPI.as_view(), name="ticket_meta_deactivate"),
path("ticket-type/create/", TicketTypeCreateAPI.as_view(), name="ticket_type_create"),
path("ticket-type/list/", TicketTypeListAPI.as_view(), name="ticket_type_list"),
path("ticket-type/update/", TicketTypeUpdateAPI.as_view(), name="ticket_type_update"),
path("ticket-type/delete/", TicketTypeDeleteAPI.as_view(), name="ticket_type_delete"),
path("ticket-type/deactivate/", TicketTypeDeactivateAPI.as_view(), name="ticket_type_deactivate"),
path("cart/add/", AddToCartAPI.as_view(), name="add_to_cart"),
path("cart/delete/", DeleteFromCartAPI.as_view(), name="delete_from_cart"),
path("checkout/", CheckoutAPI.as_view(), name="checkout"),
path("check-in/", CheckInAPI.as_view(), name="check_in"),
]

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.")

Binary file not shown.

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 = [
@@ -22,14 +32,24 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'eventify_logger',
'master_data',
'events',
'accounts',
'partner',
'templatetags',
'mobile_api',
'web_api',
'bookings',
'banking_operations',
'rest_framework',
'rest_framework.authtoken'
'rest_framework.authtoken',
'rest_framework_simplejwt',
'admin_api',
'django_summernote',
'ledger',
'notifications',
'ad_control',
]
INSTALLED_APPS += [
@@ -42,6 +62,7 @@ MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'eventify_logger.middleware.EventifyLoggingMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'corsheaders.middleware.CorsMiddleware',
@@ -49,9 +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",
"https://prototype.eventifyplus.com/",
"https://eventifyplus.com/"
"http://localhost:3001",
"http://localhost:3000",
"http://localhost:8080",
"https://prototype.eventifyplus.com",
"https://eventifyplus.com",
"https://mv.eventifyplus.com",
"https://db.eventifyplus.com",
"https://test.eventifyplus.com",
"https://em.eventifyplus.com"
]
ROOT_URLCONF = 'eventify.urls'
@@ -74,24 +107,27 @@ TEMPLATES = [
WSGI_APPLICATION = 'eventify.wsgi.application'
# DATABASES = {
# 'default': {
# 'ENGINE': 'django.db.backends.sqlite3',
# 'NAME': BASE_DIR / 'db.sqlite3',
# }
# }
DATABASES = {
'default': {
'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
'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'),
}
}
# DATABASES = {
# 'default': {
# 'ENGINE': 'django.db.backends.postgresql',
# 'NAME': 'eventify_uat_db', # your DB name
# 'USER': 'eventify_uat', # your DB user
# 'HOST': '0.0.0.0', # or IP/domain
# 'PORT': '5440', # default PostgreSQL port
# }
# }
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
@@ -112,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

@@ -31,8 +31,15 @@ urlpatterns = [
path('master-data/', include('master_data.urls')),
path('events/', include('events.urls')),
path('accounts/', include('accounts.urls')),
path('bookings/', include('bookings.urls')),
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

10
eventify_logger/admin.py Normal file
View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from .models import EventifyLogger
@admin.register(EventifyLogger)
class EventifyLoggerAdmin(admin.ModelAdmin):
list_display = ("logger_type", "logger_message", "logged_user", "logger_created_at")
list_filter = ("logger_type", "logger_created_at")
search_fields = ("logger_message",)
readonly_fields = ("logger_created_at",)

6
eventify_logger/apps.py Normal file
View File

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

View File

@@ -0,0 +1,28 @@
"""
Request logging middleware - logs every HTTP request to EventifyLogger.
"""
from eventify_logger.services import log
class EventifyLoggingMiddleware:
"""Log each request (method, path, status) after response."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
try:
status = getattr(response, "status_code", 0)
if 500 <= status < 600:
logger_type = "error"
elif 400 <= status < 500:
logger_type = "warning"
else:
logger_type = "info"
message = f"{request.method} {request.path} -> {status}"
logger_data = {"path": request.path, "method": request.method, "status_code": status}
log(logger_type=logger_type, logger_message=message, request=request, logger_data=logger_data)
except Exception:
pass
return response

View File

@@ -0,0 +1,31 @@
# Generated by Django 4.2.27 on 2026-03-09 04:26
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='EventifyLogger',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('logger_type', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('critical', 'Critical')], max_length=250)),
('logger_message', models.TextField()),
('logger_data', models.TextField(blank=True, null=True)),
('logger_created_at', models.DateTimeField(auto_now_add=True)),
('logged_ip_address', models.GenericIPAddressField(blank=True, null=True)),
('logged_user_device', models.CharField(blank=True, max_length=250, null=True)),
('logged_user_browser', models.CharField(blank=True, max_length=250, null=True)),
('logged_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

27
eventify_logger/models.py Normal file
View File

@@ -0,0 +1,27 @@
from django.conf import settings
from django.db import models
class EventifyLogger(models.Model):
logger_type = models.CharField(max_length=250, choices=[
('info', 'Info'),
('warning', 'Warning'),
('error', 'Error'),
('critical', 'Critical'),
])
logger_message = models.TextField()
logger_data = models.TextField(blank=True, null=True)
logger_created_at = models.DateTimeField(auto_now_add=True)
logged_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
blank=True,
null=True,
)
logged_ip_address = models.GenericIPAddressField(blank=True, null=True)
logged_user_device = models.CharField(max_length=250, blank=True, null=True)
logged_user_browser = models.CharField(max_length=250, blank=True, null=True)
def __str__(self):
user_str = str(self.logged_user) if self.logged_user else "anonymous"
return f"{user_str}-{self.logger_type} - {self.logger_message}"

View File

@@ -0,0 +1,58 @@
"""
Central logging service for EventifyLogger.
"""
import json
from eventify_logger.models import EventifyLogger
def _get_client_ip(request):
"""Extract client IP from request."""
if not request:
return None
x_forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded:
return x_forwarded.split(",")[0].strip() or None
return request.META.get("REMOTE_ADDR")
def _get_user_agent(request):
"""Extract User-Agent from request."""
if not request:
return None
return request.META.get("HTTP_USER_AGENT", "")
def log(logger_type, logger_message, request=None, user=None, logger_data=None):
"""
Create an EventifyLogger record.
Args:
logger_type: 'info' | 'warning' | 'error' | 'critical'
logger_message: str
request: optional HttpRequest (used for IP, user-agent, user if not provided)
user: optional User (overrides request.user)
logger_data: optional str or dict (dict will be JSON-serialized)
"""
try:
resolved_user = user
if resolved_user is None and request and hasattr(request, "user"):
resolved_user = getattr(request.user, "is_authenticated", False) and request.user or None
ip_address = _get_client_ip(request) if request else None
user_agent = _get_user_agent(request) if request else None
if isinstance(logger_data, dict):
logger_data = json.dumps(logger_data)
EventifyLogger.objects.create(
logger_type=logger_type,
logger_message=str(logger_message)[:10000], # cap message length
logger_data=logger_data[:10000] if logger_data else None, # cap data length
logged_user=resolved_user,
logged_ip_address=ip_address,
logged_user_device=None, # defer UA parsing
logged_user_browser=user_agent[:250] if user_agent else None,
)
except Exception:
pass # Never let logging break the app

3
eventify_logger/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
eventify_logger/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -3,10 +3,11 @@ from .models import Event, EventImages
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ('id','name','start_date','end_date','event_type','event_status')
list_filter = ('event_status','event_type')
search_fields = ('name','place','district')
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)
class EventImagesAdmin(admin.ModelAdmin):
list_display = ('id','event','is_primary')
list_display = ('id', 'event', 'is_primary')

246
events/api.py Normal file
View File

@@ -0,0 +1,246 @@
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.forms.models import model_to_dict
from rest_framework.views import APIView
from datetime import datetime
from events.models import Event
from master_data.models import EventType
from mobile_api.utils import validate_token_and_get_user
from eventify_logger.services import log
def _event_to_dict(event, request=None):
"""Serialize Event for JSON."""
data = model_to_dict(
event,
fields=[
"id",
"name",
"description",
"start_date",
"end_date",
"start_time",
"end_time",
"all_year_event",
"latitude",
"longitude",
"pincode",
"district",
"state",
"place",
"venue_name",
"event_status",
"cancelled_reason",
"important_information",
"source",
"created_date",
],
)
# Add event_type info
data["event_type"] = {
"id": event.event_type.id,
"event_type": event.event_type.event_type,
}
if event.event_type.event_type_icon:
if request:
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:
data["event_type"]["event_type_icon"] = None
return data
@method_decorator(csrf_exempt, name="dispatch")
class EventCreateAPI(APIView):
"""
Create Event API.
Body: token, username (required);
name, description, latitude, longitude, pincode, place, event_type_id (required);
start_date, end_date, start_time, end_time, all_year_event, venue_name,
event_status, cancelled_reason, important_information, source, district, state (optional).
Returns: created event data.
"""
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
# Extract required fields
name = data.get("name")
description = data.get("description")
latitude = data.get("latitude")
longitude = data.get("longitude")
pincode = data.get("pincode")
place = data.get("place")
event_type_id = data.get("event_type_id") or data.get("event_type")
# Validate required fields
if not all([name, description, latitude, longitude, pincode, place, event_type_id]):
return JsonResponse(
{
"status": "error",
"message": "name, description, latitude, longitude, pincode, place, and event_type_id are required.",
},
status=400,
)
# Validate event_type exists
try:
event_type = EventType.objects.get(id=event_type_id)
except EventType.DoesNotExist:
return JsonResponse(
{"status": "error", "message": "EventType not found."},
status=404,
)
# Validate latitude and longitude
try:
latitude = float(latitude)
if latitude < -90 or latitude > 90:
return JsonResponse(
{"status": "error", "message": "latitude must be between -90 and 90."},
status=400,
)
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "latitude must be numeric."},
status=400,
)
try:
longitude = float(longitude)
if longitude < -180 or longitude > 180:
return JsonResponse(
{"status": "error", "message": "longitude must be between -180 and 180."},
status=400,
)
except (TypeError, ValueError):
return JsonResponse(
{"status": "error", "message": "longitude must be numeric."},
status=400,
)
# Handle all_year_event
all_year_event = data.get("all_year_event", False)
if isinstance(all_year_event, str):
all_year_event = all_year_event.lower() in ['true', '1', 'yes', 'on']
# Handle dates and times - clear if all_year_event is True
start_date = None
end_date = None
start_time = None
end_time = None
if not all_year_event:
# Parse start_date
if data.get("start_date"):
try:
start_date = datetime.strptime(data["start_date"], "%Y-%m-%d").date()
except ValueError:
return JsonResponse(
{"status": "error", "message": "Invalid start_date format. Expected YYYY-MM-DD."},
status=400,
)
# Parse end_date
if data.get("end_date"):
try:
end_date = datetime.strptime(data["end_date"], "%Y-%m-%d").date()
except ValueError:
return JsonResponse(
{"status": "error", "message": "Invalid end_date format. Expected YYYY-MM-DD."},
status=400,
)
# Parse start_time
if data.get("start_time"):
try:
start_time = datetime.strptime(data["start_time"], "%H:%M:%S").time()
except ValueError:
try:
start_time = datetime.strptime(data["start_time"], "%H:%M").time()
except ValueError:
return JsonResponse(
{"status": "error", "message": "Invalid start_time format. Expected HH:MM or HH:MM:SS."},
status=400,
)
# Parse end_time
if data.get("end_time"):
try:
end_time = datetime.strptime(data["end_time"], "%H:%M:%S").time()
except ValueError:
try:
end_time = datetime.strptime(data["end_time"], "%H:%M").time()
except ValueError:
return JsonResponse(
{"status": "error", "message": "Invalid end_time format. Expected HH:MM or HH:MM:SS."},
status=400,
)
# Validate event_status if provided
event_status = data.get("event_status", "pending")
valid_statuses = ['created', 'cancelled', 'pending', 'completed', 'postponed']
if event_status not in valid_statuses:
return JsonResponse(
{
"status": "error",
"message": f"Invalid event_status. Must be one of: {', '.join(valid_statuses)}",
},
status=400,
)
# Validate source if provided
source = data.get("source", "official")
valid_sources = ['official', 'community']
if source not in valid_sources:
return JsonResponse(
{
"status": "error",
"message": f"Invalid source. Must be one of: {', '.join(valid_sources)}",
},
status=400,
)
# Create event
event = Event.objects.create(
name=name,
description=description,
start_date=start_date,
end_date=end_date,
start_time=start_time,
end_time=end_time,
all_year_event=all_year_event,
latitude=latitude,
longitude=longitude,
pincode=pincode,
district=data.get("district", ""),
state=data.get("state", ""),
place=place,
venue_name=data.get("venue_name", ""),
event_type=event_type,
event_status=event_status,
cancelled_reason=data.get("cancelled_reason", "NA"),
important_information=data.get("important_information", ""),
source=source,
)
log("info", "Event created", request=request, user=user, logger_data={"event_id": event.id, "event_name": name})
return JsonResponse(
{
"status": "success",
"message": "Event created successfully.",
"event": _event_to_dict(event, request),
},
status=201,
)
except Exception as e:
log("error", "Event create exception", request=request, logger_data={"error": str(e)})
return JsonResponse({"status": "error", "message": str(e)}, status=500)

View File

@@ -1,7 +1,7 @@
from django import forms
from .models import Event
from .models import EventImages
from django_summernote.widgets import SummernoteWidget
class EventForm(forms.ModelForm):
class Meta:
@@ -11,12 +11,13 @@ class EventForm(forms.ModelForm):
'name': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control'}),
'title': forms.TextInput(attrs={'class': 'form-control'}),
'important_information': forms.Textarea(attrs={'class': 'form-control'}),
'important_information': SummernoteWidget(attrs={'summernote': {'width': '100%', 'height': '400px'}}),
'venue_name': forms.TextInput(attrs={'class': 'form-control'}),
'start_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'end_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'start_time': forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
'end_time': forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
'start_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date', 'id': 'id_start_date'}),
'end_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date', 'id': 'id_end_date'}),
'start_time': forms.TimeInput(attrs={'class': 'form-control', 'type': 'time', 'id': 'id_start_time'}),
'end_time': forms.TimeInput(attrs={'class': 'form-control', 'type': 'time', 'id': 'id_end_time'}),
'all_year_event': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_all_year_event'}),
'latitude': forms.NumberInput(attrs={'class': 'form-control'}),
'longitude': forms.NumberInput(attrs={'class': 'form-control'}),
'pincode': forms.TextInput(attrs={'class': 'form-control'}),
@@ -29,8 +30,51 @@ class EventForm(forms.ModelForm):
'cancelled_reason': forms.Textarea(attrs={'class': 'form-control'}),
'is_bookable': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_eventify_event': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_featured': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_featured'}),
'is_top_event': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_top_event'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 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
if self.instance and self.instance.pk:
all_year_event = self.instance.all_year_event
elif 'all_year_event' in self.initial:
all_year_event = self.initial['all_year_event']
elif self.data and 'all_year_event' in self.data:
all_year_event = self.data.get('all_year_event') == 'on' or self.data.get('all_year_event') == 'True'
# If all_year_event is True, disable date/time fields
if all_year_event:
self.fields['start_date'].widget.attrs['disabled'] = True
self.fields['end_date'].widget.attrs['disabled'] = True
self.fields['start_time'].widget.attrs['disabled'] = True
self.fields['end_time'].widget.attrs['disabled'] = True
def clean(self):
cleaned_data = super().clean()
all_year_event = cleaned_data.get('all_year_event', False)
# Source is now user-selectable (eventify/community/partner)
# If all_year_event is True, clear date/time fields
if all_year_event:
cleaned_data['start_date'] = None
cleaned_data['end_date'] = None
cleaned_data['start_time'] = None
cleaned_data['end_time'] = None
return cleaned_data
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = 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,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 = [
]

Some files were not shown because too many files have changed in this diff Show More