40 Commits

Author SHA1 Message Date
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
84 changed files with 6632 additions and 257 deletions

164
CHANGELOG.md Normal file
View File

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

19
Dockerfile Normal file
View File

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

275
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

0
ad_control/__init__.py Normal file
View File

24
ad_control/admin.py Normal file
View File

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

7
ad_control/apps.py Normal file
View File

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

View File

View File

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

View File

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

View File

107
ad_control/models.py Normal file
View File

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

18
ad_control/urls.py Normal file
View File

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

566
ad_control/views.py Normal file
View File

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

0
admin_api/__init__.py Normal file
View File

4
admin_api/apps.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

246
admin_api/models.py Normal file
View File

@@ -0,0 +1,246 @@
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', 'settings']
SCOPE_TO_MODULE = {
'users': 'users',
'events': 'events',
'finance': 'financials',
'partners': 'partners',
'tickets': 'dashboard',
'settings': 'settings',
'ads': 'ad-control',
'contributions': 'contributions',
'leads': 'leads',
}
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']
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})'

18
admin_api/serializers.py Normal file
View File

@@ -0,0 +1,18 @@
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()
class Meta:
model = User
fields = ['id', 'email', 'username', 'name', 'role']
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')

86
admin_api/urls.py Normal file
View File

@@ -0,0 +1,86 @@
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/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'),
path('partners/<int:partner_id>/staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'),
path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
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'),
# Ad Control
path('ad-control/', include('ad_control.urls')),
]

2518
admin_api/views.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

25
create_temp_user.py Normal file
View File

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

View File

@@ -3,16 +3,23 @@ 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',
'localhost',
'127.0.0.1',
]
INSTALLED_APPS = [
@@ -33,7 +40,13 @@ INSTALLED_APPS = [
'bookings',
'banking_operations',
'rest_framework',
'rest_framework.authtoken'
'rest_framework.authtoken',
'rest_framework_simplejwt',
'admin_api',
'django_summernote',
'ledger',
'notifications',
'ad_control',
]
INSTALLED_APPS += [
@@ -54,10 +67,21 @@ MIDDLEWARE = [
]
CORS_ALLOWED_ORIGINS = [
"https://app.eventifyplus.com",
"https://admin.eventifyplus.com",
"https://uat.eventifyplus.com",
"http://localhost:5178",
"http://localhost:5179",
"http://localhost:5173",
"http://localhost:3001",
"http://localhost:3000",
"http://localhost:8080",
"https://prototype.eventifyplus.com",
"https://eventifyplus.com",
"https://mv.eventifyplus.com"
"https://mv.eventifyplus.com",
"https://db.eventifyplus.com",
"https://test.eventifyplus.com",
"https://em.eventifyplus.com"
]
ROOT_URLCONF = 'eventify.urls'
@@ -82,8 +106,12 @@ WSGI_APPLICATION = 'eventify.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'),
'NAME': os.environ.get('DB_NAME', str(BASE_DIR / 'db.sqlite3')),
'USER': os.environ.get('DB_USER', ''),
'PASSWORD': os.environ.get('DB_PASS', ''),
'HOST': os.environ.get('DB_HOST', ''),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
@@ -92,7 +120,6 @@ DATABASES = {
# 'ENGINE': 'django.db.backends.postgresql',
# 'NAME': 'eventify_uat_db', # your DB name
# 'USER': 'eventify_uat', # your DB user
# 'PASSWORD': 'eventifyplus@!@#$', # your DB password
# 'HOST': '0.0.0.0', # or IP/domain
# 'PORT': '5440', # default PostgreSQL port
# }
@@ -118,11 +145,54 @@ 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',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

10
notifications/admin.py Normal file
View File

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

6
notifications/apps.py Normal file
View File

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

25
notifications/models.py Normal file
View File

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

8
notifications/urls.py Normal file
View File

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

85
notifications/views.py Normal file
View File

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

View File

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

View File

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

9
requirements-docker.txt Normal file
View File

@@ -0,0 +1,9 @@
Django==4.2.21
Pillow==10.1.0
django-summernote
djangorestframework==3.14.0
django-cors-headers==4.3.0
gunicorn==21.2.0
django-extensions==3.2.3
psycopg2-binary==2.9.9
djangorestframework-simplejwt==5.3.1

View File

@@ -1,2 +1,4 @@
Django>=4.2
Pillow
django-summernote
google-auth>=2.0.0

View File

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

View File

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

View File

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

View File

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

29
update_events.py Normal file
View File

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

19
user.py Normal file
View File

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