Compare commits
59 Commits
8c9ad49387
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 16c21c17d2 | |||
| 761b702e57 | |||
| 46b391bd51 | |||
| d9a2af7168 | |||
| f75d4f2915 | |||
| 05de552820 | |||
| f85188ca6b | |||
| 64ff08b2b2 | |||
| 4a9f754fda | |||
| 66e41ba647 | |||
| 2c60a82704 | |||
| 9cde886bd4 | |||
| a8751b5183 | |||
| 170208d3e5 | |||
| ca24a4cb23 | |||
| e0a491e8cb | |||
| aa2846b884 | |||
| 086bbbf546 | |||
| 60d98f1ae8 | |||
| 9aa7c01efe | |||
| d04891c064 | |||
| 9142b8fedb | |||
| 14c474ea87 | |||
| 8d0e801d86 | |||
| a29e8d2892 | |||
| 2fefdd16c9 | |||
| 8ae97dcdc7 | |||
| 05770d6d21 | |||
| b8a69ceae2 | |||
| b2a2cbad5f | |||
| 635a1224cd | |||
| 3a3f6d4179 | |||
| c9afbcf3cc | |||
| ac2b2ba242 | |||
| a208ddf1f7 | |||
| 4a24e9cdca | |||
| bae9ac9e23 | |||
| a5bdde278d | |||
| fc5aa555e5 | |||
| 9d61967350 | |||
| 99f376506d | |||
| 384797551f | |||
| 255519473b | |||
| 1b6185c758 | |||
| 43123d0ff1 | |||
| 388057b641 | |||
| 5a2752a2de | |||
| b12f4952b3 | |||
| ea378f19b1 | |||
| 54aa7ce06e | |||
| a3d1bbad30 | |||
| 54315408eb | |||
| 3103eff949 | |||
| bc0a9ad5c8 | |||
| d921dde598 | |||
| 54d31dd3b1 | |||
|
|
cbe06e9c8f | ||
|
|
b60d03142c | ||
|
|
37001f8e70 |
312
CHANGELOG.md
Normal file
312
CHANGELOG.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the Eventify Backend are documented here.
|
||||||
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), versioning follows [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.14.2] — 2026-04-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Admin "Login as Partner" impersonation now completes into `/dashboard`** instead of bouncing back to `/login?error=ImpersonationFailed`. Two linked issues:
|
||||||
|
- **`ALLOWED_HOSTS`** (`eventify/settings.py`) — partner portal's server-side `authorize()` (Next.js) calls `${BACKEND_API_URL}/api/v1/auth/me/` with `BACKEND_API_URL=http://eventify-backend:8000`, so the HTTP `Host` header was `eventify-backend` — not in the Django allowlist. `SecurityMiddleware` rejected with HTTP 400 DisallowedHost, `authorize()` returned null, `signIn()` failed, and the `/impersonate` page redirected to the login error. Added `partner.eventifyplus.com`, `eventify-backend`, and `eventify-django` to `ALLOWED_HOSTS`. Same Host issue was silently breaking regular partner password login too — fixed as a side effect.
|
||||||
|
- **`UserSerializer` missing `partner` field** (`admin_api/serializers.py`) — `MeView` returned `/api/v1/auth/me/` payload with no `partner` key, so the partner portal's `auth.ts` set `partnerId: ""` on the NextAuth session. Downstream dashboard queries that filter by `partnerId` would then return empty/403. Added `partner = PrimaryKeyRelatedField(read_only=True)` to the serializer's `fields` list. Payload now includes `"partner": <id>`.
|
||||||
|
- Deploy: `docker cp` both files into **both** `eventify-backend` and `eventify-django` containers + `kill -HUP 1` on each (per shared admin_api rule).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.14.1] — 2026-04-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`_serialize_review()` now returns `profile_photo`** (`mobile_api/views/reviews.py`) — `/api/reviews/list` payload was missing the reviewer's photo URL, so the consumer app had no choice but to render DiceBear placeholders for every reviewer regardless of whether they had uploaded a real profile picture
|
||||||
|
- Resolves `r.reviewer.profile_picture.url` when the field is non-empty and the file name is not `default.png` (the model's placeholder default); returns empty string otherwise so the frontend can fall back cleanly to DiceBear
|
||||||
|
- Mirrors the existing pattern in `mobile_api/views/user.py` (`LoginView`, `StatusView`, `UpdateProfileView`) — same defensive try/except around FK dereference
|
||||||
|
- Pure serializer change — no migration, no URL change, no permission change; `gunicorn kill -HUP 1` picks it up
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.14.0] — 2026-04-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Module-level RBAC scopes for Reviews, Contributions, Leads, Audit Log** — `SCOPE_DEFINITIONS` in `admin_api/views.py` extended with 13 new entries so the admin dashboard's Roles & Permissions grid and the new Base Permissions tab can grant/revoke access at module granularity:
|
||||||
|
- Reviews: `reviews.read`, `reviews.moderate`, `reviews.delete`
|
||||||
|
- Contributions: `contributions.read`, `contributions.approve`, `contributions.reject`, `contributions.award`
|
||||||
|
- Leads: `leads.read`, `leads.write`, `leads.assign`, `leads.convert`
|
||||||
|
- Audit Log: `audit.read`, `audit.export`
|
||||||
|
- **`NotificationSchedule` audit emissions** in `admin_api/views.py` — `NotificationScheduleListView.post` and `NotificationScheduleDetailView.patch` / `.delete` now write `notification.schedule.created` / `.updated` / `.deleted` `AuditLog` rows. Update emits only when at least one field actually changed. Delete captures `name`/`notification_type`/`cron_expression` before the row is deleted so the audit trail survives the deletion
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`StaffProfile.get_allowed_modules()`** in `admin_api/models.py` — `SCOPE_TO_MODULE` was missing the `'reviews': 'reviews'` entry, so staff granted `reviews.*` scopes could not see the Reviews module in their sidebar. Added
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.13.0] — 2026-04-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Full admin interaction audit coverage** — `_audit_log()` calls added to 12 views; every meaningful admin state change now writes an `AuditLog` row:
|
||||||
|
|
||||||
|
| View | Action slug(s) | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `AdminLoginView` | `auth.admin_login`, `auth.admin_login_failed` | Uses new `user=` kwarg (anonymous at login time) |
|
||||||
|
| `PartnerStatusView` | `partner.status_changed` | Wrapped in `transaction.atomic()` |
|
||||||
|
| `PartnerOnboardView` | `partner.onboarded` | Inside existing `transaction.atomic()` block |
|
||||||
|
| `PartnerStaffCreateView` | `partner.staff.created` | Logged after `staff_user.save()` |
|
||||||
|
| `EventCreateView` | `event.created` | title, partner_id, source in details |
|
||||||
|
| `EventUpdateView` | `event.updated` | changed_fields list in details, wrapped in `transaction.atomic()` |
|
||||||
|
| `EventDeleteView` | `event.deleted` | title + partner_id captured BEFORE delete, wrapped in `transaction.atomic()` |
|
||||||
|
| `SettlementReleaseView` | `settlement.released` | prev/new status in details, `transaction.atomic()` |
|
||||||
|
| `ReviewDeleteView` | `review.deleted` | reviewer_user_id + event_id + rating captured BEFORE delete |
|
||||||
|
| `PaymentGatewaySettingsView` | `gateway.created`, `gateway.updated`, `gateway.deleted` | changed_fields on update |
|
||||||
|
| `EventPrimaryImageView` | `event.primary_image_changed` | prev + new primary image id in details |
|
||||||
|
| `LeadUpdateView` | `lead.updated` | changed_fields list; only emits if any field was changed |
|
||||||
|
|
||||||
|
- **`_audit_log` helper** — optional `user=None` kwarg so `AdminLoginView` can supply the authenticated user explicitly (request.user is still anonymous at that point in the login flow). All 20+ existing callers are unaffected (no kwarg = falls through to `request.user`).
|
||||||
|
- **`admin_api/tests.py`** — `AuthAuditEmissionTests` (login success + failed login) and `EventCrudAuditTests` (create/update/delete) bring total test count to 16, all green
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.12.0] — 2026-04-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Audit coverage for four moderation endpoints** — every admin state change now leaves a matching row in `AuditLog`, written in the same `transaction.atomic()` block as the state change so the log can never disagree with the database:
|
||||||
|
- `UserStatusView` (`PATCH /api/v1/users/<id>/status/`) — `user.suspended`, `user.banned`, `user.reinstated`, `user.flagged`; details capture `reason`, `previous_status`, `new_status`
|
||||||
|
- `EventModerationView` (`PATCH /api/v1/events/<id>/moderate/`) — `event.approved`, `event.rejected`, `event.flagged`, `event.featured`, `event.unfeatured`; details include `reason`, `partner_id`, `previous_status`/`new_status`, `previous_is_featured`/`new_is_featured`
|
||||||
|
- `ReviewModerationView` (`PATCH /api/v1/reviews/<id>/moderate/`) — `review.approved`, `review.rejected`, `review.edited`; details include `reject_reason`, `edited_text` flag, `original_text` on edits
|
||||||
|
- `PartnerKYCReviewView` (`POST /api/v1/partners/<id>/kyc/review/`) — `partner.kyc.approved`, `partner.kyc.rejected`, `partner.kyc.requested_info` (new `requested_info` decision leaves compliance state intact and only records the info request)
|
||||||
|
- **`GET /api/v1/rbac/audit-log/metrics/`** — `AuditLogMetricsView` returns `total`, `today`, `week`, `distinct_users`, and a `by_action_group` breakdown (`create`/`update`/`delete`/`moderate`/`auth`/`other`). Cached 60 s under key `admin_api:audit_log:metrics:v1`; pass `?nocache=1` to bypass (useful from the Django shell during incident response)
|
||||||
|
- **`GET /api/v1/rbac/audit-log/`** — free-text `search` parameter (Q-filter over `action`, `target_type`, `target_id`, `user__username`, `user__email`); `page_size` now bounded to `[1, 200]` with defensive fallback to defaults on non-integer input
|
||||||
|
- **`accounts.User.ALL_MODULES`** — appended `audit-log`; `StaffProfile.get_allowed_modules()` adds `'audit'` → `'audit-log'` to `SCOPE_TO_MODULE` so scope-based staff resolve the module correctly
|
||||||
|
- **`admin_api/migrations/0005_auditlog_indexes.py`** — composite indexes `(action, -created_at)` and `(target_type, target_id)` on `AuditLog` to keep the /audit-log page fast past ~10k rows; reversible via Django's default `RemoveIndex` reverse op
|
||||||
|
- **`admin_api/tests.py`** — `AuditLogListViewTests`, `AuditLogMetricsViewTests`, `UserStatusAuditEmissionTests` covering list shape, search, pagination bounds, metrics shape + `nocache`, and audit emission on suspend / ban / reinstate
|
||||||
|
|
||||||
|
### Deploy notes
|
||||||
|
Admin users created before this release won't have `audit-log` in their `allowed_modules` TextField. Backfill with:
|
||||||
|
```python
|
||||||
|
# Django shell
|
||||||
|
from accounts.models import User
|
||||||
|
for u in User.objects.filter(role__in=['admin', 'manager']):
|
||||||
|
mods = [m.strip() for m in (u.allowed_modules or '').split(',') if m.strip()]
|
||||||
|
if 'audit-log' not in mods and mods: # only touch users with explicit lists
|
||||||
|
u.allowed_modules = ','.join(mods + ['audit-log'])
|
||||||
|
u.save(update_fields=['allowed_modules'])
|
||||||
|
```
|
||||||
|
Users on the implicit full-access list (empty `allowed_modules` + admin role) pick up the new module automatically via `get_allowed_modules()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.11.0] — 2026-04-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Worldline Connect payment integration** (`banking_operations/worldline/`)
|
||||||
|
- `client.py` — `WorldlineClient`: HMAC-SHA256 signed requests, `create_hosted_checkout()`, `get_hosted_checkout_status()`, `verify_webhook_signature()`
|
||||||
|
- `views.py` — `POST /api/payments/webhook/` (CSRF-exempt, signature-verified Worldline server callback) + `POST /api/payments/verify/` (frontend polls on return URL)
|
||||||
|
- `emails.py` — HTML ticket confirmation email with per-ticket QR codes embedded as base64 inline images
|
||||||
|
- `WorldlineOrder` model in `banking_operations/models.py` — tracks each hosted-checkout session (hosted_checkout_id, reference_id, status, raw_response, webhook_payload)
|
||||||
|
- **`Booking.payment_status`** field — `pending / paid / failed / cancelled` (default `pending`); migration `bookings/0002_booking_payment_status`
|
||||||
|
- **`banking_operations/services.py::transaction_initiate`** — implemented (was a stub); calls Worldline API, creates `WorldlineOrder`, returns `payment_url` back to `CheckoutAPI`
|
||||||
|
- **Settings**: `WORLDLINE_MERCHANT_ID`, `WORLDLINE_API_KEY_ID`, `WORLDLINE_API_SECRET_KEY`, `WORLDLINE_WEBHOOK_SECRET_KEY`, `WORLDLINE_API_ENDPOINT` (default: sandbox), `WORLDLINE_RETURN_URL`
|
||||||
|
- **Requirements**: `requests>=2.31.0`, `qrcode[pil]>=7.4.2`
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
1. User adds tickets to cart → `POST /api/bookings/checkout/` creates Bookings + calls `transaction_initiate`
|
||||||
|
2. `transaction_initiate` creates `WorldlineOrder` + calls Worldline → returns redirect URL
|
||||||
|
3. Frontend redirects user to Worldline hosted checkout page
|
||||||
|
4. After payment, Worldline redirects to `WORLDLINE_RETURN_URL` (`app.eventifyplus.com/booking/confirm?hostedCheckoutId=...`)
|
||||||
|
5. SPA calls `POST /api/payments/verify/` — checks local status; if still pending, polls Worldline API directly
|
||||||
|
6. Worldline webhook fires `POST /api/payments/webhook/` → generates Tickets (one per quantity), marks Booking `paid`, sends confirmation email with QR codes
|
||||||
|
7. Partner scans QR code at event → existing `POST /api/bookings/check-in/` marks `Ticket.is_checked_in=True`
|
||||||
|
|
||||||
|
### Deploy requirement
|
||||||
|
Set in Django container `.env`:
|
||||||
|
```
|
||||||
|
WORLDLINE_MERCHANT_ID=...
|
||||||
|
WORLDLINE_API_KEY_ID=...
|
||||||
|
WORLDLINE_API_SECRET_KEY=...
|
||||||
|
WORLDLINE_WEBHOOK_SECRET_KEY=...
|
||||||
|
```
|
||||||
|
`WORLDLINE_API_ENDPOINT` defaults to sandbox — set to production URL when going live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.10.0] — 2026-04-10
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **`GoogleLoginView` audience-check fix** (`POST /api/user/google-login/`) — **CRITICAL security patch**
|
||||||
|
- `verify_oauth2_token(token, google_requests.Request())` was called **without** the third `audience` argument, meaning any valid Google-signed ID token from *any* OAuth client was accepted — token spoofing from external apps was trivially possible
|
||||||
|
- Fixed to `verify_oauth2_token(token, google_requests.Request(), settings.GOOGLE_CLIENT_ID)` — only tokens whose `aud` claim matches our registered Client ID are now accepted
|
||||||
|
- Added fail-closed guard: if `settings.GOOGLE_CLIENT_ID` is empty the view returns HTTP 503 instead of silently accepting all tokens
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Removed Clerk scaffolding** — the `@clerk/react` broker approach added in a prior iteration has been replaced with direct Google Identity Services (GIS) ID-token flow on the frontend. Simpler architecture: one trust boundary instead of three.
|
||||||
|
- Removed `ClerkLoginView`, `_clerk_jwks_client`, `_get_clerk_jwks_client()` from `mobile_api/views/user.py`
|
||||||
|
- Removed `path('user/clerk-login/', ...)` from `mobile_api/urls.py`
|
||||||
|
- Removed `CLERK_JWKS_URL` / `CLERK_ISSUER` / `CLERK_SECRET_KEY` from `eventify/settings.py`; replaced with `GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')`
|
||||||
|
- Removed `PyJWT[crypto]>=2.8.0` and `requests>=2.31.0` from `requirements.txt` + `requirements-docker.txt` (no longer needed; `google-auth>=2.0.0` handles verification)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Settings**: `GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')` in `eventify/settings.py`
|
||||||
|
- **Tests**: `mobile_api/tests.py::GoogleLoginViewTests` — 4 cases: valid token creates user (audience arg verified), missing `id_token` → 400, `ValueError` (wrong sig / wrong aud) → 401, existing user reuses DRF token
|
||||||
|
|
||||||
|
### Context
|
||||||
|
- The consumer SPA (`app.eventifyplus.com`) now loads the Google Identity Services script dynamically and POSTs a Google ID token to the existing `/api/user/google-login/` endpoint. Django is the sole session authority. `localStorage.event_token` / `event_user` are unchanged.
|
||||||
|
- Deploy requirement: set `GOOGLE_CLIENT_ID` in the Django container `.env` **before** deploying — without it the view returns 503 (fail-closed by design).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.9.0] — 2026-04-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Lead Manager** — new `Lead` model in `admin_api` for tracking Schedule-a-Call form submissions and sales inquiries
|
||||||
|
- Fields: name, email, phone, event_type, message, status (new/contacted/qualified/converted/closed), source (schedule_call/website/manual), priority (low/medium/high), assigned_to (FK User), notes
|
||||||
|
- Migration `admin_api/0003_lead` with indexes on status, priority, created_at, email
|
||||||
|
- **Consumer endpoint** `POST /api/leads/schedule-call/` — public (AllowAny, CSRF-exempt) endpoint for the Schedule a Call modal; creates Lead with status=new, source=schedule_call
|
||||||
|
- **Admin API endpoints** (all IsAuthenticated):
|
||||||
|
- `GET /api/v1/leads/metrics/` — total, new today, counts per status
|
||||||
|
- `GET /api/v1/leads/` — paginated list with filters (status, priority, source, search, date_from, date_to)
|
||||||
|
- `GET /api/v1/leads/<id>/` — single lead detail
|
||||||
|
- `PATCH /api/v1/leads/<id>/update/` — update status, priority, assigned_to, notes
|
||||||
|
- **RBAC**: `leads` added to `ALL_MODULES`, `get_allowed_modules()`, and `StaffProfile.SCOPE_TO_MODULE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.8.3] — 2026-04-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`TopEventsAPI` now works without authentication** — `POST /api/events/top-events/` had `AllowAny` permission but still called `validate_token_and_get_user()`, returning `{"status":"error","message":"token and username required"}` for unauthenticated requests
|
||||||
|
- Removed `validate_token_and_get_user()` call entirely
|
||||||
|
- Added `event_status='published'` filter (was `is_top_event=True` only)
|
||||||
|
- Added `event_type_name` field resolution: `e.event_type.event_type if e.event_type else ''` — `model_to_dict()` only returns the FK integer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.8.2] — 2026-04-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`FeaturedEventsAPI` now returns `event_type_name` string** — `model_to_dict()` serialises the `event_type` FK as an integer ID; the hero slider frontend reads `ev.event_type_name` to display the category badge, which was always `null`
|
||||||
|
- Added `data_dict['event_type_name'] = e.event_type.event_type if e.event_type else ''` after `model_to_dict(e)` to resolve the FK to its human-readable name (e.g. `"Festivals"`)
|
||||||
|
- No frontend changes required — `fetchHeroSlides()` already falls back to `ev.event_type_name`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.8.1] — 2026-04-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`FeaturedEventsAPI` now works without authentication** — `POST /api/events/featured-events/` had `AllowAny` permission but still called `validate_token_and_get_user()`, causing the endpoint to return HTTP 200 + `{"status":"error","message":"token and username required"}` for unauthenticated requests (e.g. the desktop hero slider)
|
||||||
|
- Removed the `validate_token_and_get_user()` call entirely — the endpoint is public by design and requires no token
|
||||||
|
- Also tightened the queryset to `event_status='published'` (was `is_featured=True` only) to match `ConsumerFeaturedEventsView` behaviour and avoid returning draft/cancelled events
|
||||||
|
- Root cause: host Nginx routes `/api/` → `eventify-backend` container (port 3001), not `eventify-django` (port 8085); the `validate_token_and_get_user` gate in this container was silently blocking all hero slider requests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.8.0] — 2026-04-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`BulkUserPublicInfoView`** (`POST /api/user/bulk-public-info/`)
|
||||||
|
- Internal endpoint for the Node.js gamification server to resolve user details
|
||||||
|
- Accepts `{ emails: [...] }` (max 500), returns `{ users: { email: { display_name, district, eventify_id } } }`
|
||||||
|
- Used for leaderboard data bridge (syncing user names/districts into gamification DB)
|
||||||
|
- CSRF-exempt, returns only public-safe fields (no passwords, tokens, or sensitive PII)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.0] — 2026-04-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Home District with 6-month cooldown**
|
||||||
|
- `district_changed_at` DateTimeField on User model (migration `0013_user_district_changed_at`) — nullable, no backfill; NULL means "eligible to change immediately"
|
||||||
|
- `VALID_DISTRICTS` constant (14 Kerala districts) in `accounts/models.py` for server-side validation
|
||||||
|
- `WebRegisterForm` now accepts optional `district` field; stamps `district_changed_at` on valid selection during signup
|
||||||
|
- `UpdateProfileView` enforces 183-day (~6 months) cooldown — rejects district changes within the window with a human-readable "Next change: {date}" error
|
||||||
|
- `district_changed_at` included in all relevant API responses: `LoginView`, `WebRegisterView`, `StatusView`, `UpdateProfileView`
|
||||||
|
- `StatusView` now also returns `district` field (was previously missing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.6.2] — 2026-04-03
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **Internal exceptions no longer exposed to API callers** — all 15 `except Exception as e` blocks across `mobile_api/views/user.py` and `mobile_api/views/events.py` now log the real error via `eventify_logger` and return a generic `"An unexpected server error occurred."` to the caller
|
||||||
|
- Affected views: `RegisterView`, `WebRegisterView`, `LoginView`, `StatusView`, `LogoutView`, `UpdateProfileView`, `EventTypeAPI`, `EventListAPI`, `EventDetailAPI`, `EventImagesListAPI`, `EventsByDateAPI`, `DateSheetAPI`, `PincodeEventsAPI`, `FeaturedEventsAPI`, `TopEventsAPI`
|
||||||
|
- `StatusView` and `UpdateProfileView` were also missing `log(...)` calls entirely — added
|
||||||
|
- `from eventify_logger.services import log` import added to `events.py` (was absent)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.6.1] — 2026-04-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`eventify_id` in `StatusView` response** (`/api/user/status/`) — consumer app uses this to refresh the Eventify ID badge (`EVT-XXXXXXXX`) for sessions that pre-date the `eventify_id` login field
|
||||||
|
- **`accounts` migration `0012_user_eventify_id` deployed to production containers** — backfilled all existing users with unique Eventify IDs; previously the migration existed locally but had not been applied in production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.6.0] — 2026-04-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Unique Eventify ID system** (`EVT-XXXXXXXX` format)
|
||||||
|
- New `eventify_id` field on `User` model — `CharField(max_length=12, unique=True, editable=False, db_index=True)`
|
||||||
|
- Charset `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (no ambiguous characters I/O/0/1) giving ~1.78T combinations
|
||||||
|
- Auto-generated on first `save()` via a 10-attempt retry loop using `secrets.choice()`
|
||||||
|
- Migration `0012_user_eventify_id`: add nullable → backfill all existing users → make non-null
|
||||||
|
- `eventify_id` exposed in `accounts/api.py` → `_partner_user_to_dict()` fields list
|
||||||
|
- `eventify_id` exposed in `partner/api.py` → `_user_to_dict()` fields list
|
||||||
|
- `eventify_id` exposed in `mobile_api/views/user.py` → `LoginView` response (populates `localStorage.event_user.eventify_id`)
|
||||||
|
- `eventifyId` exposed in `admin_api/views.py` → `_serialize_user()` (camelCase for direct TypeScript compatibility)
|
||||||
|
- Server-side search in `UserListView` now also filters on `eventify_id__icontains`
|
||||||
|
- Synced migration `0011_user_allowed_modules_alter_user_id` (pulled from server, was missing from local repo)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `accounts/models.py`: merged `allowed_modules` field + `get_allowed_modules()` + `ALL_MODULES` constant from server (previously only existed on server)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.5.0] — 2026-03-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `allowed_modules` TextField on `User` model — comma-separated module slug access control
|
||||||
|
- `get_allowed_modules()` method on `User` — returns list of accessible modules based on role or explicit list
|
||||||
|
- `ALL_MODULES` class constant listing all platform module slugs
|
||||||
|
- Migration `0011_user_allowed_modules_alter_user_id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.0] — 2026-03-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Partner portal login/logout APIs (`accounts/api.py`) — `PartnerLoginAPI`, `PartnerLogoutAPI`, `PartnerMeAPI`
|
||||||
|
- `_partner_user_to_dict()` serializer for partner-scoped user data
|
||||||
|
- Partner CRUD, KYC review, and user management endpoints in `partner/api.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.0] — 2026-03-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- User `id` field changed from `AutoField` to `BigAutoField` (migration `0010_alter_user_id`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.0] — 2026-03-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `partner` ForeignKey on `User` model linking users to partners (migration `0009_user_partner`)
|
||||||
|
- Profile picture upload support (`ImageField`) with `default.png` fallback (migration `0006–0007`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
19
Dockerfile
Normal 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
275
README.md
@@ -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
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
**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 1–5)
|
||||||
|
│ ├── 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
|
```bash
|
||||||
|
# Clone
|
||||||
|
git clone https://code.bshtech.net/Sicherhaven/eventify_backend.git
|
||||||
|
cd eventify_backend
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements-docker.txt
|
||||||
```
|
|
||||||
2. Run migrations and create superuser
|
# Environment variables
|
||||||
```bash
|
cp .env.example .env # set DJANGO_SECRET_KEY, DB_* vars
|
||||||
|
|
||||||
|
# Database
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
python manage.py createsuperuser
|
python manage.py createsuperuser
|
||||||
|
|
||||||
|
# Run
|
||||||
python manage.py runserver
|
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)
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ def _partner_user_to_dict(user, request=None):
|
|||||||
user,
|
user,
|
||||||
fields=[
|
fields=[
|
||||||
"id",
|
"id",
|
||||||
|
"eventify_id",
|
||||||
"username",
|
"username",
|
||||||
"email",
|
"email",
|
||||||
"phone_number",
|
"phone_number",
|
||||||
@@ -38,7 +39,7 @@ def _partner_user_to_dict(user, request=None):
|
|||||||
# Add profile picture URL if exists
|
# Add profile picture URL if exists
|
||||||
if getattr(user, "profile_picture", None):
|
if getattr(user, "profile_picture", None):
|
||||||
if request:
|
if request:
|
||||||
data["profile_picture"] = request.build_absolute_uri(user.profile_picture.url)
|
data["profile_picture"] = user.profile_picture.url
|
||||||
else:
|
else:
|
||||||
data["profile_picture"] = user.profile_picture.url
|
data["profile_picture"] = user.profile_picture.url
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
55
accounts/migrations/0012_user_eventify_id.py
Normal file
55
accounts/migrations/0012_user_eventify_id.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
13
accounts/migrations/0013_merge_eventify_id.py
Normal file
13
accounts/migrations/0013_merge_eventify_id.py
Normal 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 = [
|
||||||
|
]
|
||||||
16
accounts/migrations/0013_user_district_changed_at.py
Normal file
16
accounts/migrations/0013_user_district_changed_at.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
13
accounts/migrations/0014_merge_0013.py
Normal file
13
accounts/migrations/0014_merge_0013.py
Normal 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 = [
|
||||||
|
]
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
|
import secrets
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from accounts.manager import UserManager
|
from accounts.manager import UserManager
|
||||||
from partner.models import Partner
|
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 = (
|
ROLE_CHOICES = (
|
||||||
('admin', 'Admin'),
|
('admin', 'Admin'),
|
||||||
('manager', 'Manager'),
|
('manager', 'Manager'),
|
||||||
@@ -14,7 +24,22 @@ ROLE_CHOICES = (
|
|||||||
('partner_customer', 'Partner Customer'),
|
('partner_customer', 'Partner Customer'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
VALID_DISTRICTS = [
|
||||||
|
"Thiruvananthapuram", "Kollam", "Pathanamthitta", "Alappuzha", "Kottayam",
|
||||||
|
"Idukki", "Ernakulam", "Thrissur", "Palakkad", "Malappuram",
|
||||||
|
"Kozhikode", "Wayanad", "Kannur", "Kasaragod",
|
||||||
|
]
|
||||||
|
|
||||||
class User(AbstractUser):
|
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)
|
phone_number = models.CharField(max_length=15, blank=True, null=True)
|
||||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='Staff')
|
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)
|
state = models.CharField(max_length=100, blank=True, null=True)
|
||||||
country = 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)
|
place = models.CharField(max_length=200, blank=True, null=True)
|
||||||
|
district_changed_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
# Location fields
|
# Location fields
|
||||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
|
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')
|
profile_picture = models.ImageField(upload_to='profile_pictures/', blank=True, null=True, default='default.png')
|
||||||
|
|
||||||
|
allowed_modules = models.TextField(
|
||||||
|
blank=True, null=True,
|
||||||
|
help_text='Comma-separated module slugs this user can access',
|
||||||
|
)
|
||||||
|
|
||||||
|
ALL_MODULES = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "audit-log", "settings"]
|
||||||
|
|
||||||
|
def get_allowed_modules(self):
|
||||||
|
ALL = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "audit-log", "settings"]
|
||||||
|
if self.is_superuser or self.role == "admin":
|
||||||
|
return ALL
|
||||||
|
if self.allowed_modules:
|
||||||
|
return [m.strip() for m in self.allowed_modules.split(",") if m.strip()]
|
||||||
|
if self.role == "manager":
|
||||||
|
return ALL
|
||||||
|
return []
|
||||||
|
|
||||||
objects = UserManager()
|
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):
|
def __str__(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
|||||||
0
ad_control/__init__.py
Normal file
0
ad_control/__init__.py
Normal file
24
ad_control/admin.py
Normal file
24
ad_control/admin.py
Normal 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
7
ad_control/apps.py
Normal 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'
|
||||||
0
ad_control/management/__init__.py
Normal file
0
ad_control/management/__init__.py
Normal file
0
ad_control/management/commands/__init__.py
Normal file
0
ad_control/management/commands/__init__.py
Normal file
100
ad_control/management/commands/seed_surfaces.py
Normal file
100
ad_control/management/commands/seed_surfaces.py
Normal 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.'))
|
||||||
104
ad_control/migrations/0001_initial.py
Normal file
104
ad_control/migrations/0001_initial.py
Normal 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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
ad_control/migrations/__init__.py
Normal file
0
ad_control/migrations/__init__.py
Normal file
107
ad_control/models.py
Normal file
107
ad_control/models.py
Normal 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
18
ad_control/urls.py
Normal 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
566
ad_control/views.py
Normal 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
0
admin_api/__init__.py
Normal file
4
admin_api/apps.py
Normal file
4
admin_api/apps.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
class AdminApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'admin_api'
|
||||||
36
admin_api/migrations/0001_initial.py
Normal file
36
admin_api/migrations/0001_initial.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
92
admin_api/migrations/0002_rbac_models.py
Normal file
92
admin_api/migrations/0002_rbac_models.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
53
admin_api/migrations/0003_lead.py
Normal file
53
admin_api/migrations/0003_lead.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
28
admin_api/migrations/0004_lead_user_account.py
Normal file
28
admin_api/migrations/0004_lead_user_account.py
Normal 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)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
31
admin_api/migrations/0005_auditlog_indexes.py
Normal file
31
admin_api/migrations/0005_auditlog_indexes.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 4.2.21 for the Audit Log module (admin_api v1.12.0).
|
||||||
|
#
|
||||||
|
# Adds two composite indexes to `AuditLog` so the new /audit-log admin page
|
||||||
|
# can filter by action and resolve "related entries" lookups without a full
|
||||||
|
# table scan once the log grows past a few thousand rows.
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('admin_api', '0004_lead_user_account'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='auditlog',
|
||||||
|
index=models.Index(
|
||||||
|
fields=['action', '-created_at'],
|
||||||
|
name='auditlog_action_time_idx',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='auditlog',
|
||||||
|
index=models.Index(
|
||||||
|
fields=['target_type', 'target_id'],
|
||||||
|
name='auditlog_target_idx',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
admin_api/migrations/__init__.py
Normal file
0
admin_api/migrations/__init__.py
Normal file
254
admin_api/models.py
Normal file
254
admin_api/models.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
|
||||||
|
|
||||||
|
class Review(models.Model):
|
||||||
|
STATUS_PENDING = 'pending'
|
||||||
|
STATUS_LIVE = 'live'
|
||||||
|
STATUS_REJECTED = 'rejected'
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
(STATUS_PENDING, 'Pending'),
|
||||||
|
(STATUS_LIVE, 'Live'),
|
||||||
|
(STATUS_REJECTED, 'Rejected'),
|
||||||
|
]
|
||||||
|
REJECT_CHOICES = [
|
||||||
|
('spam', 'Spam'),
|
||||||
|
('inappropriate', 'Inappropriate'),
|
||||||
|
('fake', 'Fake'),
|
||||||
|
]
|
||||||
|
|
||||||
|
reviewer = models.ForeignKey(
|
||||||
|
'accounts.User', on_delete=models.CASCADE, related_name='admin_reviews'
|
||||||
|
)
|
||||||
|
event = models.ForeignKey(
|
||||||
|
'events.Event', on_delete=models.CASCADE, related_name='admin_reviews'
|
||||||
|
)
|
||||||
|
rating = models.IntegerField(
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(5)]
|
||||||
|
)
|
||||||
|
review_text = models.TextField()
|
||||||
|
submission_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDING
|
||||||
|
)
|
||||||
|
reject_reason = models.CharField(
|
||||||
|
max_length=15, choices=REJECT_CHOICES, null=True, blank=True
|
||||||
|
)
|
||||||
|
display_name = models.CharField(max_length=100, blank=True, default='')
|
||||||
|
is_verified = models.BooleanField(default=False)
|
||||||
|
helpful_count = models.IntegerField(default=0)
|
||||||
|
flag_count = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-submission_date']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['submission_date']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Review #{self.pk} by {self.reviewer_id} — {self.status}'
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewInteraction(models.Model):
|
||||||
|
INTERACTION_CHOICES = [('HELPFUL', 'Helpful'), ('FLAG', 'Flag')]
|
||||||
|
|
||||||
|
review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name='interactions')
|
||||||
|
username = models.CharField(max_length=255)
|
||||||
|
interaction_type = models.CharField(max_length=20, choices=INTERACTION_CHOICES)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('review', 'username', 'interaction_type')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.username} {self.interaction_type} on Review #{self.review_id}'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RBAC Models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class Department(models.Model):
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
description = models.TextField(blank=True, default='')
|
||||||
|
base_scopes = models.JSONField(default=list)
|
||||||
|
color = models.CharField(max_length=7, default='#3B82F6')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Squad(models.Model):
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name='squads')
|
||||||
|
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_squads')
|
||||||
|
extra_scopes = models.JSONField(default=list)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.department.name} > {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class StaffProfile(models.Model):
|
||||||
|
ROLE_CHOICES = [('SUPER_ADMIN', 'Super Admin'), ('MANAGER', 'Manager'), ('MEMBER', 'Member')]
|
||||||
|
STATUS_CHOICES = [('active', 'Active'), ('invited', 'Invited'), ('deactivated', 'Deactivated')]
|
||||||
|
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='staff_profile')
|
||||||
|
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, related_name='staff_members')
|
||||||
|
squad = models.ForeignKey(Squad, on_delete=models.SET_NULL, null=True, blank=True, related_name='members')
|
||||||
|
staff_role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='MEMBER')
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
|
||||||
|
joined_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['user__first_name']
|
||||||
|
|
||||||
|
def get_effective_scopes(self):
|
||||||
|
if self.staff_role == 'SUPER_ADMIN' or self.user.is_superuser:
|
||||||
|
return ['*']
|
||||||
|
scopes = set()
|
||||||
|
if self.department:
|
||||||
|
scopes.update(self.department.base_scopes or [])
|
||||||
|
if self.squad:
|
||||||
|
scopes.update(self.squad.extra_scopes or [])
|
||||||
|
if self.staff_role == 'MANAGER':
|
||||||
|
scopes.add('settings.staff')
|
||||||
|
return list(scopes)
|
||||||
|
|
||||||
|
def get_allowed_modules(self):
|
||||||
|
scopes = self.get_effective_scopes()
|
||||||
|
if '*' in scopes:
|
||||||
|
return ['dashboard', 'partners', 'events', 'ad-control', 'users', 'reviews', 'contributions', 'leads', 'financials', 'audit-log', 'settings']
|
||||||
|
SCOPE_TO_MODULE = {
|
||||||
|
'users': 'users',
|
||||||
|
'events': 'events',
|
||||||
|
'finance': 'financials',
|
||||||
|
'partners': 'partners',
|
||||||
|
'tickets': 'dashboard',
|
||||||
|
'settings': 'settings',
|
||||||
|
'ads': 'ad-control',
|
||||||
|
'contributions': 'contributions',
|
||||||
|
'leads': 'leads',
|
||||||
|
'audit': 'audit-log',
|
||||||
|
'reviews': 'reviews',
|
||||||
|
}
|
||||||
|
modules = {'dashboard'}
|
||||||
|
for scope in scopes:
|
||||||
|
prefix = scope.split('.')[0]
|
||||||
|
if prefix in SCOPE_TO_MODULE:
|
||||||
|
modules.add(SCOPE_TO_MODULE[prefix])
|
||||||
|
return list(modules)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} ({self.staff_role})"
|
||||||
|
|
||||||
|
|
||||||
|
class CustomRole(models.Model):
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
description = models.TextField(blank=True, default='')
|
||||||
|
scopes = models.JSONField(default=list)
|
||||||
|
is_system = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='audit_logs')
|
||||||
|
action = models.CharField(max_length=100)
|
||||||
|
target_type = models.CharField(max_length=50)
|
||||||
|
target_id = models.CharField(max_length=50)
|
||||||
|
details = models.JSONField(default=dict)
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
# Fast filter-by-action ordered by time (audit log page default view)
|
||||||
|
models.Index(fields=['action', '-created_at'], name='auditlog_action_time_idx'),
|
||||||
|
# Fast "related entries for this target" lookups in the detail panel
|
||||||
|
models.Index(fields=['target_type', 'target_id'], name='auditlog_target_idx'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.action} by {self.user} at {self.created_at}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lead Manager
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Lead(models.Model):
|
||||||
|
EVENT_TYPE_CHOICES = [
|
||||||
|
('private', 'Private Event'),
|
||||||
|
('ticketed', 'Ticketed Event'),
|
||||||
|
('corporate', 'Corporate Event'),
|
||||||
|
('wedding', 'Wedding'),
|
||||||
|
('other', 'Other'),
|
||||||
|
]
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('new', 'New'),
|
||||||
|
('contacted', 'Contacted'),
|
||||||
|
('qualified', 'Qualified'),
|
||||||
|
('converted', 'Converted'),
|
||||||
|
('closed', 'Closed'),
|
||||||
|
]
|
||||||
|
SOURCE_CHOICES = [
|
||||||
|
('schedule_call', 'Schedule a Call'),
|
||||||
|
('website', 'Website'),
|
||||||
|
('manual', 'Manual'),
|
||||||
|
]
|
||||||
|
PRIORITY_CHOICES = [
|
||||||
|
('low', 'Low'),
|
||||||
|
('medium', 'Medium'),
|
||||||
|
('high', 'High'),
|
||||||
|
]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
email = models.EmailField()
|
||||||
|
phone = models.CharField(max_length=20)
|
||||||
|
event_type = models.CharField(max_length=20, choices=EVENT_TYPE_CHOICES, default='private')
|
||||||
|
message = models.TextField(blank=True, default='')
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='new')
|
||||||
|
source = models.CharField(max_length=20, choices=SOURCE_CHOICES, default='schedule_call')
|
||||||
|
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='medium')
|
||||||
|
assigned_to = models.ForeignKey(
|
||||||
|
User, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_leads'
|
||||||
|
)
|
||||||
|
user_account = models.ForeignKey(
|
||||||
|
User, on_delete=models.SET_NULL, null=True, blank=True, related_name='submitted_leads',
|
||||||
|
help_text='Consumer platform account that submitted this lead (auto-matched by email)'
|
||||||
|
)
|
||||||
|
notes = models.TextField(blank=True, default='')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['priority']),
|
||||||
|
models.Index(fields=['created_at']),
|
||||||
|
models.Index(fields=['email']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Lead #{self.pk} — {self.name} ({self.status})'
|
||||||
19
admin_api/serializers.py
Normal file
19
admin_api/serializers.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
name = serializers.SerializerMethodField()
|
||||||
|
role = serializers.SerializerMethodField()
|
||||||
|
partner = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['id', 'email', 'username', 'name', 'role', 'partner']
|
||||||
|
def get_name(self, obj):
|
||||||
|
return f"{obj.first_name} {obj.last_name}".strip() or obj.username
|
||||||
|
def get_role(self, obj):
|
||||||
|
if obj.is_superuser:
|
||||||
|
return 'superadmin'
|
||||||
|
if obj.is_staff:
|
||||||
|
return 'admin'
|
||||||
|
return getattr(obj, 'role', 'user')
|
||||||
325
admin_api/tests.py
Normal file
325
admin_api/tests.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"""Tests for the Audit Log module (admin_api v1.12.0).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
* `AuditLogListView` — list + search + filter shape
|
||||||
|
* `AuditLogMetricsView` — shape + `?nocache=1` bypass
|
||||||
|
* `UserStatusView` — emits a row into `AuditLog` for every status change,
|
||||||
|
inside the same transaction as the state change
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
python manage.py test admin_api.tests
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
from admin_api.models import AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Base — auth helper shared across cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _AuditTestBase(TestCase):
|
||||||
|
"""Gives each subclass an admin user + pre-issued JWT."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.admin = User.objects.create_user(
|
||||||
|
username='audit.admin@eventifyplus.com',
|
||||||
|
email='audit.admin@eventifyplus.com',
|
||||||
|
password='irrelevant',
|
||||||
|
role='admin',
|
||||||
|
)
|
||||||
|
cls.admin.is_superuser = True
|
||||||
|
cls.admin.save()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Metrics view caches by key; reset to keep cases independent.
|
||||||
|
cache.delete('admin_api:audit_log:metrics:v1')
|
||||||
|
access = str(RefreshToken.for_user(self.admin).access_token)
|
||||||
|
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {access}'}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AuditLogListView
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogListViewTests(_AuditTestBase):
|
||||||
|
url = '/api/v1/rbac/audit-log/'
|
||||||
|
|
||||||
|
def test_unauthenticated_returns_401(self):
|
||||||
|
resp = self.client.get(self.url)
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|
||||||
|
def test_authenticated_returns_paginated_shape(self):
|
||||||
|
AuditLog.objects.create(
|
||||||
|
user=self.admin,
|
||||||
|
action='user.suspended',
|
||||||
|
target_type='user',
|
||||||
|
target_id='42',
|
||||||
|
details={'reason': 'spam'},
|
||||||
|
)
|
||||||
|
resp = self.client.get(self.url, **self.auth)
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
body = resp.json()
|
||||||
|
for key in ('results', 'total', 'page', 'page_size', 'total_pages'):
|
||||||
|
self.assertIn(key, body)
|
||||||
|
self.assertEqual(body['total'], 1)
|
||||||
|
self.assertEqual(body['results'][0]['action'], 'user.suspended')
|
||||||
|
self.assertEqual(body['results'][0]['user']['email'], self.admin.email)
|
||||||
|
|
||||||
|
def test_search_narrows_results(self):
|
||||||
|
AuditLog.objects.create(
|
||||||
|
user=self.admin, action='user.suspended',
|
||||||
|
target_type='user', target_id='1', details={},
|
||||||
|
)
|
||||||
|
AuditLog.objects.create(
|
||||||
|
user=self.admin, action='event.approved',
|
||||||
|
target_type='event', target_id='1', details={},
|
||||||
|
)
|
||||||
|
resp = self.client.get(self.url, {'search': 'suspend'}, **self.auth)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
body = resp.json()
|
||||||
|
self.assertEqual(body['total'], 1)
|
||||||
|
self.assertEqual(body['results'][0]['action'], 'user.suspended')
|
||||||
|
|
||||||
|
def test_page_size_is_bounded(self):
|
||||||
|
# page_size=999 must be clamped to the 200-row upper bound.
|
||||||
|
resp = self.client.get(self.url, {'page_size': '999'}, **self.auth)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.json()['page_size'], 200)
|
||||||
|
|
||||||
|
def test_invalid_pagination_falls_back_to_defaults(self):
|
||||||
|
resp = self.client.get(self.url, {'page': 'x', 'page_size': 'y'}, **self.auth)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
body = resp.json()
|
||||||
|
self.assertEqual(body['page'], 1)
|
||||||
|
self.assertEqual(body['page_size'], 50)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AuditLogMetricsView
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogMetricsViewTests(_AuditTestBase):
|
||||||
|
url = '/api/v1/rbac/audit-log/metrics/'
|
||||||
|
|
||||||
|
def test_unauthenticated_returns_401(self):
|
||||||
|
resp = self.client.get(self.url)
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|
||||||
|
def test_returns_expected_shape(self):
|
||||||
|
AuditLog.objects.create(
|
||||||
|
user=self.admin, action='event.approved',
|
||||||
|
target_type='event', target_id='7', details={},
|
||||||
|
)
|
||||||
|
AuditLog.objects.create(
|
||||||
|
user=self.admin, action='department.created',
|
||||||
|
target_type='department', target_id='3', details={},
|
||||||
|
)
|
||||||
|
resp = self.client.get(self.url, **self.auth)
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
body = resp.json()
|
||||||
|
for key in ('total', 'today', 'week', 'distinct_users', 'by_action_group'):
|
||||||
|
self.assertIn(key, body)
|
||||||
|
self.assertEqual(body['total'], 2)
|
||||||
|
self.assertEqual(body['distinct_users'], 1)
|
||||||
|
# Each group present so frontend tooltip can render all 6 rows.
|
||||||
|
for group in ('create', 'update', 'delete', 'moderate', 'auth', 'other'):
|
||||||
|
self.assertIn(group, body['by_action_group'])
|
||||||
|
self.assertEqual(body['by_action_group']['moderate'], 1)
|
||||||
|
self.assertEqual(body['by_action_group']['create'], 1)
|
||||||
|
|
||||||
|
def test_nocache_bypasses_stale_cache(self):
|
||||||
|
# Prime cache with a fake payload.
|
||||||
|
cache.set(
|
||||||
|
'admin_api:audit_log:metrics:v1',
|
||||||
|
{
|
||||||
|
'total': 999,
|
||||||
|
'today': 0, 'week': 0, 'distinct_users': 0,
|
||||||
|
'by_action_group': {
|
||||||
|
'create': 0, 'update': 0, 'delete': 0,
|
||||||
|
'moderate': 0, 'auth': 0, 'other': 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp_cached = self.client.get(self.url, **self.auth)
|
||||||
|
self.assertEqual(resp_cached.json()['total'], 999)
|
||||||
|
|
||||||
|
resp_fresh = self.client.get(self.url, {'nocache': '1'}, **self.auth)
|
||||||
|
self.assertEqual(resp_fresh.json()['total'], 0) # real DB state
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UserStatusView — audit emission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class UserStatusAuditEmissionTests(_AuditTestBase):
|
||||||
|
"""Each status transition must leave a matching row in `AuditLog`.
|
||||||
|
|
||||||
|
The endpoint wraps the state change + audit log in `transaction.atomic()`
|
||||||
|
so the two can never disagree. These assertions catch regressions where a
|
||||||
|
new branch forgets the audit call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _url(self, user_id: int) -> str:
|
||||||
|
return f'/api/v1/users/{user_id}/status/'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.target = User.objects.create_user(
|
||||||
|
username='target@example.com',
|
||||||
|
email='target@example.com',
|
||||||
|
password='irrelevant',
|
||||||
|
role='customer',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_suspend_emits_audit_row(self):
|
||||||
|
resp = self.client.patch(
|
||||||
|
self._url(self.target.id),
|
||||||
|
data={'action': 'suspend', 'reason': 'spam flood'},
|
||||||
|
content_type='application/json',
|
||||||
|
**self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
log = AuditLog.objects.filter(
|
||||||
|
action='user.suspended', target_id=str(self.target.id),
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(log, 'suspend did not emit audit log')
|
||||||
|
self.assertEqual(log.details.get('reason'), 'spam flood')
|
||||||
|
|
||||||
|
def test_ban_emits_audit_row(self):
|
||||||
|
resp = self.client.patch(
|
||||||
|
self._url(self.target.id),
|
||||||
|
data={'action': 'ban'},
|
||||||
|
content_type='application/json',
|
||||||
|
**self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
self.assertTrue(
|
||||||
|
AuditLog.objects.filter(
|
||||||
|
action='user.banned', target_id=str(self.target.id),
|
||||||
|
).exists(),
|
||||||
|
'ban did not emit audit log',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reinstate_emits_audit_row(self):
|
||||||
|
self.target.is_active = False
|
||||||
|
self.target.save()
|
||||||
|
resp = self.client.patch(
|
||||||
|
self._url(self.target.id),
|
||||||
|
data={'action': 'reinstate'},
|
||||||
|
content_type='application/json',
|
||||||
|
**self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
self.assertTrue(
|
||||||
|
AuditLog.objects.filter(
|
||||||
|
action='user.reinstated', target_id=str(self.target.id),
|
||||||
|
).exists(),
|
||||||
|
'reinstate did not emit audit log',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AdminLoginView — audit emission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AuthAuditEmissionTests(_AuditTestBase):
|
||||||
|
"""Successful and failed logins must leave matching rows in AuditLog."""
|
||||||
|
|
||||||
|
url = '/api/v1/admin/auth/login/'
|
||||||
|
|
||||||
|
def test_successful_login_emits_audit_row(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data={'username': self.admin.username, 'password': 'irrelevant'},
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
log = AuditLog.objects.filter(
|
||||||
|
action='auth.admin_login', target_id=str(self.admin.id),
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(log, 'successful login did not emit audit log')
|
||||||
|
self.assertEqual(log.details.get('username'), self.admin.username)
|
||||||
|
|
||||||
|
def test_failed_login_emits_audit_row(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data={'username': self.admin.username, 'password': 'wrong-password'},
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 401, resp.content)
|
||||||
|
self.assertTrue(
|
||||||
|
AuditLog.objects.filter(action='auth.admin_login_failed').exists(),
|
||||||
|
'failed login did not emit audit log',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EventCreateView / EventUpdateView / EventDeleteView — audit emission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class EventCrudAuditTests(_AuditTestBase):
|
||||||
|
"""Event CRUD operations must emit matching audit rows."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
from events.models import EventType
|
||||||
|
self.event_type = EventType.objects.create(event_type='Test Category')
|
||||||
|
|
||||||
|
def _create_event_id(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
'/api/v1/events/create/',
|
||||||
|
data={'title': 'Test Event', 'eventType': self.event_type.id},
|
||||||
|
content_type='application/json',
|
||||||
|
**self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 201, resp.content)
|
||||||
|
return resp.json()['id']
|
||||||
|
|
||||||
|
def test_create_event_emits_audit_row(self):
|
||||||
|
event_id = self._create_event_id()
|
||||||
|
self.assertTrue(
|
||||||
|
AuditLog.objects.filter(action='event.created', target_id=str(event_id)).exists(),
|
||||||
|
'event create did not emit audit log',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_event_emits_audit_row(self):
|
||||||
|
event_id = self._create_event_id()
|
||||||
|
AuditLog.objects.all().delete()
|
||||||
|
resp = self.client.patch(
|
||||||
|
f'/api/v1/events/{event_id}/update/',
|
||||||
|
data={'title': 'Updated Title'},
|
||||||
|
content_type='application/json',
|
||||||
|
**self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
log = AuditLog.objects.filter(action='event.updated', target_id=str(event_id)).first()
|
||||||
|
self.assertIsNotNone(log, 'event update did not emit audit log')
|
||||||
|
self.assertIn('title', log.details.get('changed_fields', []))
|
||||||
|
|
||||||
|
def test_delete_event_emits_audit_row(self):
|
||||||
|
event_id = self._create_event_id()
|
||||||
|
AuditLog.objects.all().delete()
|
||||||
|
resp = self.client.delete(
|
||||||
|
f'/api/v1/events/{event_id}/delete/',
|
||||||
|
**self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 204)
|
||||||
|
self.assertTrue(
|
||||||
|
AuditLog.objects.filter(action='event.deleted', target_id=str(event_id)).exists(),
|
||||||
|
'event delete did not emit audit log',
|
||||||
|
)
|
||||||
106
admin_api/urls.py
Normal file
106
admin_api/urls.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/auth/login/', views.AdminLoginView.as_view(), name='admin_login'),
|
||||||
|
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('auth/me/', views.MeView.as_view(), name='auth_me'),
|
||||||
|
path('health/', views.HealthView.as_view(), name='health'),
|
||||||
|
# Phase 2: Dashboard endpoints
|
||||||
|
path('dashboard/metrics/', views.DashboardMetricsView.as_view(), name='dashboard-metrics'),
|
||||||
|
path('dashboard/revenue/', views.DashboardRevenueView.as_view(), name='dashboard-revenue'),
|
||||||
|
path('dashboard/activity/', views.DashboardActivityView.as_view(), name='dashboard-activity'),
|
||||||
|
path('dashboard/actions/', views.DashboardActionsView.as_view(), name='dashboard-actions'),
|
||||||
|
# Phase 3: Partner endpoints
|
||||||
|
path('partners/stats/', views.PartnerStatsView.as_view(), name='partner-stats'),
|
||||||
|
path('partners/', views.PartnerListView.as_view(), name='partner-list'),
|
||||||
|
path('partners/<int:pk>/', views.PartnerDetailView.as_view(), name='partner-detail'),
|
||||||
|
path('partners/<int:pk>/status/', views.PartnerStatusView.as_view(), name='partner-status'),
|
||||||
|
path('partners/<int:pk>/kyc/review/', views.PartnerKYCReviewView.as_view(), name='partner-kyc-review'),
|
||||||
|
path('partners/<int:pk>/impersonate/', views.PartnerImpersonateView.as_view(), name='partner-impersonate'),
|
||||||
|
path('partners/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'),
|
||||||
|
path('partners/<int:partner_id>/staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'),
|
||||||
|
# Partner-Me: partner portal self-service (Sprint 1)
|
||||||
|
path('partners/me/profile/', views.PartnerMeProfileView.as_view(), name='partner-me-profile'),
|
||||||
|
path('partners/me/notifications/', views.PartnerMeNotificationsView.as_view(), name='partner-me-notifications'),
|
||||||
|
path('partners/me/payout/', views.PartnerMePayoutView.as_view(), name='partner-me-payout'),
|
||||||
|
path('partners/me/change-password/', views.PartnerMeChangePasswordView.as_view(), name='partner-me-change-password'),
|
||||||
|
# Partner-Me: events (Sprint 2)
|
||||||
|
path('partners/me/events/', views.PartnerMeEventsView.as_view(), name='partner-me-events'),
|
||||||
|
path('partners/me/events/<int:pk>/', views.PartnerMeEventDetailView.as_view(), name='partner-me-event-detail'),
|
||||||
|
path('partners/me/events/<int:pk>/duplicate/', views.PartnerMeEventDuplicateView.as_view(), name='partner-me-event-duplicate'),
|
||||||
|
path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
|
||||||
|
path('users/', views.UserListView.as_view(), name='user-list'),
|
||||||
|
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),
|
||||||
|
path('users/<int:pk>/status/', views.UserStatusView.as_view(), name='user-status'),
|
||||||
|
# Phase 5: Events endpoints
|
||||||
|
path('events/stats/', views.EventStatsView.as_view(), name='event-stats'),
|
||||||
|
path('events/', views.EventListView.as_view(), name='event-list'),
|
||||||
|
path('events/<int:pk>/', views.EventDetailView.as_view(), name='event-detail'),
|
||||||
|
path('events/<int:pk>/update/', views.EventUpdateView.as_view(), name='event-update'),
|
||||||
|
path('events/<int:pk>/moderate/', views.EventModerationView.as_view(), name='event-moderate'),
|
||||||
|
path('events/<int:pk>/delete/', views.EventDeleteView.as_view(), name='event-delete'),
|
||||||
|
path('events/create/', views.EventCreateView.as_view(), name='event-create'),
|
||||||
|
path('events/types/', views.EventTypesView.as_view(), name='event-types'),
|
||||||
|
path('events/<int:pk>/primary-image/', views.EventPrimaryImageView.as_view(), name='event-primary-image'),
|
||||||
|
path('financials/metrics/', views.FinancialMetricsView.as_view(), name='financial-metrics'),
|
||||||
|
path('financials/transactions/', views.TransactionListView.as_view(), name='transaction-list'),
|
||||||
|
path('financials/settlements/', views.SettlementListView.as_view(), name='settlement-list'),
|
||||||
|
path('financials/settlements/<int:pk>/release/', views.SettlementReleaseView.as_view(), name='settlement-release'),
|
||||||
|
|
||||||
|
path('reviews/metrics/', views.ReviewMetricsView.as_view(), name='review-metrics'),
|
||||||
|
path('reviews/', views.ReviewListView.as_view(), name='review-list'),
|
||||||
|
path('reviews/<int:pk>/moderate/', views.ReviewModerationView.as_view(), name='review-moderate'),
|
||||||
|
path('reviews/<int:pk>/', views.ReviewDeleteView.as_view(), name='review-delete'),
|
||||||
|
|
||||||
|
# Lead Manager
|
||||||
|
path('leads/metrics/', views.LeadMetricsView.as_view(), name='lead-metrics'),
|
||||||
|
path('leads/', views.LeadListView.as_view(), name='lead-list'),
|
||||||
|
path('leads/<int:pk>/', views.LeadDetailView.as_view(), name='lead-detail'),
|
||||||
|
path('leads/<int:pk>/update/', views.LeadUpdateView.as_view(), name='lead-update'),
|
||||||
|
|
||||||
|
path('gamification/submit-event/', views.GamificationSubmitEventView.as_view(), name='gamification-submit-event'),
|
||||||
|
path('gamification/submit-event', views.GamificationSubmitEventView.as_view()),
|
||||||
|
path('shop/items/', views.ShopItemsView.as_view(), name='shop-items'),
|
||||||
|
path('shop/items', views.ShopItemsView.as_view()),
|
||||||
|
path('shop/redeem/', views.ShopRedeemView.as_view(), name='shop-redeem'),
|
||||||
|
path('shop/redeem', views.ShopRedeemView.as_view()),
|
||||||
|
|
||||||
|
path('gamification/dashboard/', views.GamificationDashboardView.as_view(), name='gamification-dashboard'),
|
||||||
|
path('gamification/dashboard', views.GamificationDashboardView.as_view()),
|
||||||
|
|
||||||
|
# Payment gateway settings
|
||||||
|
path('settings/payment-gateway/active/', views.ActivePaymentGatewayView.as_view(), name='active-payment-gateway'),
|
||||||
|
path('settings/payment-gateways/', views.PaymentGatewaySettingsView.as_view(), name='payment-gateways'),
|
||||||
|
path('settings/payment-gateways/<int:pk>/', views.PaymentGatewaySettingsView.as_view(), name='payment-gateway-detail'),
|
||||||
|
|
||||||
|
# RBAC
|
||||||
|
path('rbac/departments/', views.DepartmentListCreateView.as_view(), name='rbac-department-list'),
|
||||||
|
path('rbac/departments/<int:pk>/', views.DepartmentDetailView.as_view(), name='rbac-department-detail'),
|
||||||
|
path('rbac/squads/', views.SquadListCreateView.as_view(), name='rbac-squad-list'),
|
||||||
|
path('rbac/squads/<int:pk>/', views.SquadDetailView.as_view(), name='rbac-squad-detail'),
|
||||||
|
path('rbac/staff/', views.StaffListView.as_view(), name='rbac-staff-list'),
|
||||||
|
path('rbac/staff/invite/', views.StaffInviteView.as_view(), name='rbac-staff-invite'),
|
||||||
|
path('rbac/staff/<int:pk>/', views.StaffUpdateView.as_view(), name='rbac-staff-update'),
|
||||||
|
path('rbac/staff/<int:pk>/deactivate/', views.StaffDeactivateView.as_view(), name='rbac-staff-deactivate'),
|
||||||
|
path('rbac/staff/<int:pk>/move/', views.StaffMoveView.as_view(), name='rbac-staff-move'),
|
||||||
|
path('rbac/roles/', views.RoleListCreateView.as_view(), name='rbac-role-list'),
|
||||||
|
path('rbac/roles/<int:pk>/', views.RoleDetailView.as_view(), name='rbac-role-detail'),
|
||||||
|
path('rbac/scopes/', views.ScopeListView.as_view(), name='rbac-scope-list'),
|
||||||
|
path('rbac/org-tree/', views.OrgTreeView.as_view(), name='rbac-org-tree'),
|
||||||
|
path('rbac/audit-log/', views.AuditLogListView.as_view(), name='rbac-audit-log'),
|
||||||
|
path('rbac/audit-log/metrics/', views.AuditLogMetricsView.as_view(), name='rbac-audit-log-metrics'),
|
||||||
|
|
||||||
|
# Notifications (admin-side recurring email jobs)
|
||||||
|
path('notifications/types/', views.NotificationTypesView.as_view(), name='notification-types'),
|
||||||
|
path('notifications/schedules/', views.NotificationScheduleListView.as_view(), name='notification-schedule-list'),
|
||||||
|
path('notifications/schedules/<int:pk>/', views.NotificationScheduleDetailView.as_view(), name='notification-schedule-detail'),
|
||||||
|
path('notifications/schedules/<int:pk>/recipients/', views.NotificationRecipientView.as_view(), name='notification-recipient-create'),
|
||||||
|
path('notifications/schedules/<int:pk>/recipients/<int:rid>/', views.NotificationRecipientDetailView.as_view(), name='notification-recipient-detail'),
|
||||||
|
path('notifications/schedules/<int:pk>/send-now/', views.NotificationScheduleSendNowView.as_view(), name='notification-schedule-send-now'),
|
||||||
|
path('notifications/schedules/<int:pk>/test-send/', views.NotificationScheduleTestSendView.as_view(), name='notification-schedule-test-send'),
|
||||||
|
|
||||||
|
# Ad Control
|
||||||
|
path('ad-control/', include('ad_control.urls')),
|
||||||
|
]
|
||||||
3721
admin_api/views.py
Normal file
3721
admin_api/views.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ def _payment_gateway_to_dict(gateway, request=None):
|
|||||||
# Add logo URL if exists
|
# Add logo URL if exists
|
||||||
if gateway.payment_gateway_logo:
|
if gateway.payment_gateway_logo:
|
||||||
if request:
|
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:
|
else:
|
||||||
data["payment_gateway_logo"] = gateway.payment_gateway_logo.url
|
data["payment_gateway_logo"] = gateway.payment_gateway_logo.url
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class PaymentGatewayCredentials(models.Model):
|
|||||||
|
|
||||||
class PaymentTransaction(models.Model):
|
class PaymentTransaction(models.Model):
|
||||||
payment_transaction_id = models.CharField(max_length=250)
|
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'),
|
('credit', 'Credit'),
|
||||||
('debit', 'Debit'),
|
('debit', 'Debit'),
|
||||||
('transfer', 'Transfer'),
|
('transfer', 'Transfer'),
|
||||||
@@ -58,14 +58,14 @@ class PaymentTransaction(models.Model):
|
|||||||
payment_gateway = models.ForeignKey(PaymentGateway, on_delete=models.CASCADE)
|
payment_gateway = models.ForeignKey(PaymentGateway, on_delete=models.CASCADE)
|
||||||
payment_transaction_amount = models.DecimalField(max_digits=10, decimal_places=2)
|
payment_transaction_amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
payment_transaction_currency = models.CharField(max_length=10)
|
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'),
|
('pending', 'Pending'),
|
||||||
('completed', 'Completed'),
|
('completed', 'Completed'),
|
||||||
('failed', 'Failed'),
|
('failed', 'Failed'),
|
||||||
('refunded', 'Refunded'),
|
('refunded', 'Refunded'),
|
||||||
('cancelled', 'Cancelled'),
|
('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_time = models.TimeField(auto_now_add=True)
|
||||||
payment_transaction_notes = models.TextField(blank=True, null=True)
|
payment_transaction_notes = models.TextField(blank=True, null=True)
|
||||||
payment_transaction_raw_data = models.JSONField(blank=True, null=True)
|
payment_transaction_raw_data = models.JSONField(blank=True, null=True)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class Booking(models.Model):
|
|||||||
ticket_type = models.ForeignKey(TicketType, on_delete=models.CASCADE)
|
ticket_type = models.ForeignKey(TicketType, on_delete=models.CASCADE)
|
||||||
quantity = models.IntegerField()
|
quantity = models.IntegerField()
|
||||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
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)
|
updated_date = models.DateField(auto_now=True)
|
||||||
|
|
||||||
transaction_id = models.CharField(max_length=250, blank=True, null=True)
|
transaction_id = models.CharField(max_length=250, blank=True, null=True)
|
||||||
|
|||||||
25
create_temp_user.py
Normal file
25
create_temp_user.py
Normal 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.")
|
||||||
@@ -3,16 +3,26 @@ from pathlib import Path
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
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'
|
# DEBUG = os.environ.get('DJANGO_DEBUG', 'False') == 'True'
|
||||||
#
|
#
|
||||||
# ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split(',')
|
# ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split(',')
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = False
|
||||||
|
|
||||||
ALLOWED_HOSTS = [
|
ALLOWED_HOSTS = [
|
||||||
'*'
|
'db.eventifyplus.com',
|
||||||
|
'uat.eventifyplus.com',
|
||||||
|
'em.eventifyplus.com',
|
||||||
|
'backend.eventifyplus.com',
|
||||||
|
'admin.eventifyplus.com',
|
||||||
|
'app.eventifyplus.com',
|
||||||
|
'partner.eventifyplus.com',
|
||||||
|
'eventify-backend',
|
||||||
|
'eventify-django',
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
@@ -33,7 +43,13 @@ INSTALLED_APPS = [
|
|||||||
'bookings',
|
'bookings',
|
||||||
'banking_operations',
|
'banking_operations',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework.authtoken'
|
'rest_framework.authtoken',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'admin_api',
|
||||||
|
'django_summernote',
|
||||||
|
'ledger',
|
||||||
|
'notifications',
|
||||||
|
'ad_control',
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS += [
|
INSTALLED_APPS += [
|
||||||
@@ -54,10 +70,21 @@ MIDDLEWARE = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
CORS_ALLOWED_ORIGINS = [
|
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:5173",
|
||||||
|
"http://localhost:3001",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://localhost:8080",
|
||||||
"https://prototype.eventifyplus.com",
|
"https://prototype.eventifyplus.com",
|
||||||
"https://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'
|
ROOT_URLCONF = 'eventify.urls'
|
||||||
@@ -82,8 +109,12 @@ WSGI_APPLICATION = 'eventify.wsgi.application'
|
|||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'),
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
'NAME': os.environ.get('DB_NAME', str(BASE_DIR / 'db.sqlite3')),
|
||||||
|
'USER': os.environ.get('DB_USER', ''),
|
||||||
|
'PASSWORD': os.environ.get('DB_PASS', ''),
|
||||||
|
'HOST': os.environ.get('DB_HOST', ''),
|
||||||
|
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +123,6 @@ DATABASES = {
|
|||||||
# 'ENGINE': 'django.db.backends.postgresql',
|
# 'ENGINE': 'django.db.backends.postgresql',
|
||||||
# 'NAME': 'eventify_uat_db', # your DB name
|
# 'NAME': 'eventify_uat_db', # your DB name
|
||||||
# 'USER': 'eventify_uat', # your DB user
|
# 'USER': 'eventify_uat', # your DB user
|
||||||
# 'PASSWORD': 'eventifyplus@!@#$', # your DB password
|
|
||||||
# 'HOST': '0.0.0.0', # or IP/domain
|
# 'HOST': '0.0.0.0', # or IP/domain
|
||||||
# 'PORT': '5440', # default PostgreSQL port
|
# 'PORT': '5440', # default PostgreSQL port
|
||||||
# }
|
# }
|
||||||
@@ -118,11 +148,60 @@ STATICFILES_DIRS = [BASE_DIR / 'static']
|
|||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
X_FRAME_OPTIONS = 'SAMEORIGIN'
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'accounts.User'
|
AUTH_USER_MODEL = 'accounts.User'
|
||||||
|
|
||||||
LOGIN_URL = 'login'
|
LOGIN_URL = 'login'
|
||||||
LOGIN_REDIRECT_URL = 'dashboard'
|
LOGIN_REDIRECT_URL = 'dashboard'
|
||||||
LOGOUT_REDIRECT_URL = 'login'
|
LOGOUT_REDIRECT_URL = 'login'
|
||||||
|
|
||||||
# EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
# DEFAULT_FROM_EMAIL = 'no-reply@example.com'
|
EMAIL_HOST = 'mail.bshtech.net'
|
||||||
|
EMAIL_PORT = 587
|
||||||
|
EMAIL_USE_TLS = True
|
||||||
|
EMAIL_HOST_USER = 'no-reply@eventifyplus.com'
|
||||||
|
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
|
||||||
|
DEFAULT_FROM_EMAIL = 'Eventify <no-reply@eventifyplus.com>'
|
||||||
|
|
||||||
|
SUMMERNOTE_THEME = 'bs5'
|
||||||
|
|
||||||
|
# Reverse proxy / CSRF fix
|
||||||
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
'https://app.eventifyplus.com',
|
||||||
|
'https://admin.eventifyplus.com',
|
||||||
|
'https://db.eventifyplus.com',
|
||||||
|
'https://uat.eventifyplus.com',
|
||||||
|
'https://test.eventifyplus.com',
|
||||||
|
'https://eventifyplus.com',
|
||||||
|
]
|
||||||
|
|
||||||
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
|
USE_X_FORWARDED_HOST = True
|
||||||
|
|
||||||
|
# --- JWT Auth (Phase 1) ---
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), # Reduced from 1 day for security
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||||
|
'ROTATE_REFRESH_TOKENS': True,
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
'USER_ID_FIELD': 'id',
|
||||||
|
'USER_ID_CLAIM': 'user_id',
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Google OAuth (Sign in with Google via GIS ID-token flow) -----------
|
||||||
|
# The Client ID is public (safe in VITE_* env vars and the SPA bundle).
|
||||||
|
# There is NO client secret — we use the ID-token flow, not auth-code flow.
|
||||||
|
# Set the SAME value in the Django container .env and in SPA .env.local.
|
||||||
|
GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ urlpatterns = [
|
|||||||
path('partner/', include('partner.urls')),
|
path('partner/', include('partner.urls')),
|
||||||
path('banking/', include('banking_operations.urls')),
|
path('banking/', include('banking_operations.urls')),
|
||||||
path('api/', include('mobile_api.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('web-api/', include('web_api.urls')),
|
||||||
|
|
||||||
|
path('summernote/', include('django_summernote.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ from .models import Event, EventImages
|
|||||||
|
|
||||||
@admin.register(Event)
|
@admin.register(Event)
|
||||||
class EventAdmin(admin.ModelAdmin):
|
class EventAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'name', 'start_date', 'end_date', 'event_type', 'event_status', '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', 'is_featured', 'is_top_event')
|
list_filter = ('event_status', 'event_type', 'source', 'is_featured', 'is_top_event')
|
||||||
list_editable = ('is_featured', 'is_top_event')
|
list_editable = ('is_featured', 'is_top_event', 'source')
|
||||||
search_fields = ('name', 'place', 'district')
|
search_fields = ('name', 'place', 'district')
|
||||||
|
|
||||||
@admin.register(EventImages)
|
@admin.register(EventImages)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def _event_to_dict(event, request=None):
|
|||||||
}
|
}
|
||||||
if event.event_type.event_type_icon:
|
if event.event_type.event_type_icon:
|
||||||
if request:
|
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:
|
else:
|
||||||
data["event_type"]["event_type_icon"] = event.event_type.event_type_icon.url
|
data["event_type"]["event_type_icon"] = event.event_type.event_type_icon.url
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ class EventForm(forms.ModelForm):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Set source to 'official' only and hide the field
|
# Show source as visible radio buttons with Bootstrap styling
|
||||||
self.fields['source'].initial = 'official'
|
self.fields['source'].widget = forms.RadioSelect(
|
||||||
self.fields['source'].widget = forms.HiddenInput()
|
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)
|
# Check if all_year_event is True (from instance or initial data)
|
||||||
all_year_event = False
|
all_year_event = False
|
||||||
@@ -60,8 +64,7 @@ class EventForm(forms.ModelForm):
|
|||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
all_year_event = cleaned_data.get('all_year_event', False)
|
all_year_event = cleaned_data.get('all_year_event', False)
|
||||||
|
|
||||||
# Force source to be 'official' only
|
# Source is now user-selectable (eventify/community/partner)
|
||||||
cleaned_data['source'] = 'official'
|
|
||||||
|
|
||||||
# If all_year_event is True, clear date/time fields
|
# If all_year_event is True, clear date/time fields
|
||||||
if all_year_event:
|
if all_year_event:
|
||||||
|
|||||||
14
events/migrations/0010_merge_20260324_1443.py
Normal file
14
events/migrations/0010_merge_20260324_1443.py
Normal 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 = [
|
||||||
|
]
|
||||||
73
events/migrations/0011_event_contributed_by.py
Normal file
73
events/migrations/0011_event_contributed_by.py
Normal 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),
|
||||||
|
]
|
||||||
38
events/migrations/0012_eventlike.py
Normal file
38
events/migrations/0012_eventlike.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
48
events/migrations/migrations/0001_initial.py
Normal file
48
events/migrations/migrations/0001_initial.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
events/migrations/migrations/0005_event_source.py
Normal file
18
events/migrations/migrations/0005_event_source.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
events/migrations/migrations/0006_alter_event_source.py
Normal file
18
events/migrations/migrations/0006_alter_event_source.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
14
events/migrations/migrations/0010_merge_20260324_1443.py
Normal file
14
events/migrations/migrations/0010_merge_20260324_1443.py
Normal 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 = [
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
events/migrations/migrations/__init__.py
Normal file
0
events/migrations/migrations/__init__.py
Normal file
@@ -5,10 +5,10 @@ from partner.models import Partner
|
|||||||
|
|
||||||
|
|
||||||
class Event(models.Model):
|
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)
|
name = models.CharField(max_length=200)
|
||||||
description = models.TextField()
|
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)
|
end_date = models.DateField(blank=True, null=True)
|
||||||
start_time = models.TimeField(blank=True, null=True)
|
start_time = models.TimeField(blank=True, null=True)
|
||||||
end_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'),
|
('published', 'Published'),
|
||||||
('live', 'Live'),
|
('live', 'Live'),
|
||||||
('flagged', 'Flagged'),
|
('flagged', 'Flagged'),
|
||||||
], default='pending')
|
], default='pending', db_index=True)
|
||||||
cancelled_reason = models.TextField(default='NA')
|
cancelled_reason = models.TextField(default='NA')
|
||||||
|
|
||||||
title = models.CharField(max_length=250, blank=True)
|
title = models.CharField(max_length=250, blank=True)
|
||||||
important_information = models.TextField(blank=True)
|
important_information = models.TextField(blank=True)
|
||||||
venue_name = models.CharField(max_length=250, blank=True)
|
venue_name = models.CharField(max_length=250, blank=True)
|
||||||
|
|
||||||
source = models.CharField(max_length=250, blank=True, choices=[
|
source = models.CharField(max_length=50, default='eventify', choices=[
|
||||||
('official', 'Official'),
|
('eventify', 'Added by Eventify'),
|
||||||
('community', 'Community'),
|
('community', 'Community Contribution'),
|
||||||
|
('partner', 'Partner Event'),
|
||||||
])
|
])
|
||||||
|
|
||||||
is_featured = models.BooleanField(default=False, help_text='Show this event in the featured section')
|
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')
|
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):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.start_date})"
|
return f"{self.name} ({self.start_date})"
|
||||||
|
|
||||||
@@ -70,3 +76,26 @@ class EventImages(models.Model):
|
|||||||
return f"{self.event_image}"
|
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}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.views import generic
|
from django.views import generic
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
from django.db.models import Q
|
||||||
from .models import Event
|
from .models import Event
|
||||||
from .models import EventImages
|
from .models import EventImages
|
||||||
from .forms import EventForm
|
from .forms import EventForm
|
||||||
@@ -18,6 +19,17 @@ class EventListView(LoginRequiredMixin, generic.ListView):
|
|||||||
template_name = 'events/event_list.html'
|
template_name = 'events/event_list.html'
|
||||||
paginate_by = 10
|
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):
|
class EventCreateView(LoginRequiredMixin, generic.CreateView):
|
||||||
model = Event
|
model = Event
|
||||||
@@ -91,5 +103,3 @@ def delete_event_image(request, pk, img_id):
|
|||||||
image.delete()
|
image.delete()
|
||||||
messages.success(request, "Image deleted!")
|
messages.success(request, "Image deleted!")
|
||||||
return redirect("events:event_images", pk=pk)
|
return redirect("events:event_images", pk=pk)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class RazorpayTransaction(models.Model):
|
|||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
help_text="created/authorized/captured/failed/refunded",
|
help_text="created/authorized/captured/failed/refunded",
|
||||||
|
db_index=True,
|
||||||
)
|
)
|
||||||
method = models.CharField(
|
method = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@@ -59,7 +60,7 @@ class RazorpayTransaction(models.Model):
|
|||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=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):
|
def __str__(self):
|
||||||
return f"{self.razorpay_payment_id or self.razorpay_order_id} - {self.status}"
|
return f"{self.razorpay_payment_id or self.razorpay_order_id} - {self.status}"
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ class RegisterForm(forms.ModelForm):
|
|||||||
# Set username equal to email to avoid separate username errors
|
# Set username equal to email to avoid separate username errors
|
||||||
user.username = self.cleaned_data['email']
|
user.username = self.cleaned_data['email']
|
||||||
user.set_password(self.cleaned_data['password'])
|
user.set_password(self.cleaned_data['password'])
|
||||||
|
# Mark as a customer / end-user
|
||||||
|
user.is_customer = True
|
||||||
|
user.role = 'customer'
|
||||||
if commit:
|
if commit:
|
||||||
user.save()
|
user.save()
|
||||||
return user
|
return user
|
||||||
@@ -42,7 +45,7 @@ class WebRegisterForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
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):
|
def clean_email(self):
|
||||||
email = self.cleaned_data.get('email')
|
email = self.cleaned_data.get('email')
|
||||||
@@ -70,9 +73,15 @@ class WebRegisterForm(forms.ModelForm):
|
|||||||
# Set username equal to email to avoid separate username errors
|
# Set username equal to email to avoid separate username errors
|
||||||
user.username = self.cleaned_data['email']
|
user.username = self.cleaned_data['email']
|
||||||
user.set_password(self.cleaned_data['password'])
|
user.set_password(self.cleaned_data['password'])
|
||||||
print('*' * 100)
|
# Mark as a customer / end-user
|
||||||
print(user.username)
|
user.is_customer = True
|
||||||
print('*' * 100)
|
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:
|
if commit:
|
||||||
user.save()
|
user.save()
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -1,3 +1,89 @@
|
|||||||
from django.test import TestCase
|
"""Unit tests for GoogleLoginView.
|
||||||
|
|
||||||
# Create your tests here.
|
Run with:
|
||||||
|
python manage.py test mobile_api.tests
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(GOOGLE_CLIENT_ID='test-client-id.apps.googleusercontent.com')
|
||||||
|
class GoogleLoginViewTests(TestCase):
|
||||||
|
url = '/api/user/google-login/'
|
||||||
|
|
||||||
|
def _valid_idinfo(self, email='new.user@example.com'):
|
||||||
|
return {
|
||||||
|
'email': email,
|
||||||
|
'given_name': 'New',
|
||||||
|
'family_name': 'User',
|
||||||
|
'aud': 'test-client-id.apps.googleusercontent.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch('google.oauth2.id_token.verify_oauth2_token')
|
||||||
|
def test_valid_token_creates_user(self, mock_verify):
|
||||||
|
mock_verify.return_value = self._valid_idinfo('fresh@example.com')
|
||||||
|
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=json.dumps({'id_token': 'fake.google.jwt'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200, resp.content)
|
||||||
|
body = resp.json()
|
||||||
|
self.assertEqual(body['email'], 'fresh@example.com')
|
||||||
|
self.assertEqual(body['role'], 'customer')
|
||||||
|
self.assertTrue(body['token'])
|
||||||
|
|
||||||
|
user = User.objects.get(email='fresh@example.com')
|
||||||
|
self.assertTrue(Token.objects.filter(user=user).exists())
|
||||||
|
# Confirm audience was passed to verify_oauth2_token
|
||||||
|
_, call_kwargs = mock_verify.call_args[0], mock_verify.call_args
|
||||||
|
self.assertEqual(mock_verify.call_args[0][2], 'test-client-id.apps.googleusercontent.com')
|
||||||
|
|
||||||
|
def test_missing_id_token_returns_400(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 400)
|
||||||
|
self.assertEqual(resp.json()['error'], 'id_token is required')
|
||||||
|
|
||||||
|
@patch('google.oauth2.id_token.verify_oauth2_token')
|
||||||
|
def test_invalid_token_returns_401(self, mock_verify):
|
||||||
|
mock_verify.side_effect = ValueError('Token audience mismatch')
|
||||||
|
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=json.dumps({'id_token': 'tampered.or.wrong-aud.jwt'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
self.assertEqual(resp.json()['error'], 'Invalid Google token')
|
||||||
|
|
||||||
|
@patch('google.oauth2.id_token.verify_oauth2_token')
|
||||||
|
def test_existing_user_reuses_token(self, mock_verify):
|
||||||
|
existing = User.objects.create_user(
|
||||||
|
username='returning@example.com',
|
||||||
|
email='returning@example.com',
|
||||||
|
password='irrelevant',
|
||||||
|
role='customer',
|
||||||
|
)
|
||||||
|
existing_auth_token = Token.objects.create(user=existing)
|
||||||
|
mock_verify.return_value = self._valid_idinfo('returning@example.com')
|
||||||
|
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=json.dumps({'id_token': 'returning.user.jwt'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.json()['token'], existing_auth_token.key)
|
||||||
|
# No duplicate user created
|
||||||
|
self.assertEqual(User.objects.filter(email='returning@example.com').count(), 1)
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import *
|
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
|
# Customer URLS
|
||||||
@@ -9,6 +13,9 @@ urlpatterns = [
|
|||||||
path('user/status/', StatusView.as_view(), name='user_status'),
|
path('user/status/', StatusView.as_view(), name='user_status'),
|
||||||
path('user/logout/', LogoutView.as_view(), name='user_logout'),
|
path('user/logout/', LogoutView.as_view(), name='user_logout'),
|
||||||
path('user/update-profile/', UpdateProfileView.as_view(), name='update_profile'),
|
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
|
# Event URLS
|
||||||
@@ -21,6 +28,22 @@ urlpatterns += [
|
|||||||
path('events/events-by-category/', EventsByCategoryAPI.as_view(), name='api_events_by_category'),
|
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-month-year/', EventsByMonthYearAPI.as_view(), name='events_by_month_year'),
|
||||||
path('events/events-by-date/', EventsByDateAPI.as_view(), name='events_by_date'),
|
path('events/events-by-date/', EventsByDateAPI.as_view(), name='events_by_date'),
|
||||||
path('events/featured-events/', FeaturedEventsAPI.as_view(), name='featured_events'),
|
path('events/featured-events/', ConsumerFeaturedEventsView.as_view(), name='featured_events'),
|
||||||
path('events/top-events/', TopEventsAPI.as_view(), name='top_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()),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ def validate_token_and_get_user(request, error_status_code=None):
|
|||||||
status=status
|
status=status
|
||||||
))
|
))
|
||||||
|
|
||||||
# Verify username matches token user
|
# Verify token belongs to this user
|
||||||
# if user.username != username:
|
if token.user_id != user.id:
|
||||||
# status = 401 if error_status_code else None
|
status = 401 if error_status_code else None
|
||||||
# return (None, None, None, JsonResponse(
|
return (None, None, None, JsonResponse(
|
||||||
# {"status": "error", "message": "token does not match user"},
|
{"status": "error", "message": "token does not match user"},
|
||||||
# status=status
|
status=status
|
||||||
# ))
|
))
|
||||||
|
|
||||||
# Success - return user, token, data, and None for error_response
|
# Success - return user, token, data, and None for error_response
|
||||||
return (user, token, data, None)
|
return (user, token, data, None)
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from .user import *
|
from .user import *
|
||||||
from .events import *
|
from .events import *
|
||||||
|
from .reviews import *
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import json
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.authentication import TokenAuthentication
|
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 events.models import Event, EventImages
|
||||||
from master_data.models import EventType
|
from master_data.models import EventType
|
||||||
from django.forms.models import model_to_dict
|
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 django.db.models import Q
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import calendar
|
import calendar
|
||||||
|
import math
|
||||||
from mobile_api.utils import validate_token_and_get_user
|
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')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
class EventTypeListAPIView(APIView):
|
class EventTypeListAPIView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
try:
|
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_queryset = EventType.objects.all()
|
||||||
event_types = []
|
event_types = []
|
||||||
|
|
||||||
for event_type in event_types_queryset:
|
for event_type in event_types_queryset:
|
||||||
event_type_data = {
|
event_type_data = {
|
||||||
"id": event_type.id,
|
"id": event_type.id,
|
||||||
"event_type": event_type.event_type,
|
"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)
|
event_types.append(event_type_data)
|
||||||
|
return JsonResponse({"status": "success", "event_types": event_types})
|
||||||
print(event_types)
|
|
||||||
|
|
||||||
return JsonResponse({
|
|
||||||
"status": "success",
|
|
||||||
"event_types": event_types
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse(
|
log("error", "EventTypeAPI exception", request=request, logger_data={"error": str(e)})
|
||||||
{"status": "error", "message": str(e)},
|
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EventListAPI(APIView):
|
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):
|
def post(self, request):
|
||||||
try:
|
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
|
|
||||||
|
|
||||||
pincode = data.get("pincode")
|
|
||||||
print('*' * 100)
|
|
||||||
print(pincode)
|
|
||||||
print('*' * 100)
|
|
||||||
# pincode is optional - if not provided or 'all', return all events
|
|
||||||
|
|
||||||
events = Event.objects.all().order_by('-created_date')
|
|
||||||
|
|
||||||
event_list = []
|
|
||||||
|
|
||||||
for e in events:
|
|
||||||
data_dict = model_to_dict(e)
|
|
||||||
try:
|
try:
|
||||||
thumb_img = EventImages.objects.get(event=e.id, is_primary=True)
|
data = json.loads(request.body) if request.body else {}
|
||||||
data_dict['thumb_img'] = request.build_absolute_uri(thumb_img.event_image.url)
|
except Exception:
|
||||||
except EventImages.DoesNotExist:
|
data = {}
|
||||||
data_dict['thumb_img'] = ''
|
|
||||||
|
|
||||||
event_list.append(data_dict)
|
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()
|
||||||
|
|
||||||
print('*' * 100)
|
# New optional geo params
|
||||||
print(event_list)
|
user_lat = data.get("latitude")
|
||||||
print('*' * 100)
|
user_lng = data.get("longitude")
|
||||||
|
try:
|
||||||
|
radius_km = float(data.get("radius_km", 10))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
radius_km = 10
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
user_lat = float(user_lat)
|
||||||
|
user_lng = float(user_lng)
|
||||||
|
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
candidates = qs.filter(
|
||||||
|
latitude__gte=user_lat - lat_delta,
|
||||||
|
latitude__lte=user_lat + lat_delta,
|
||||||
|
longitude__gte=user_lng - lng_delta,
|
||||||
|
longitude__lte=user_lng + lng_delta,
|
||||||
|
latitude__isnull=False,
|
||||||
|
longitude__isnull=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exact Haversine filter in Python
|
||||||
|
nearby_ids = []
|
||||||
|
for e in candidates:
|
||||||
|
if e.latitude is not None and e.longitude is not None:
|
||||||
|
dist = _haversine_km(user_lat, user_lng, float(e.latitude), float(e.longitude))
|
||||||
|
if dist <= radius_km:
|
||||||
|
nearby_ids.append(e.id)
|
||||||
|
|
||||||
|
# Progressive radius expansion if too few results
|
||||||
|
if len(nearby_ids) < MIN_EVENTS_THRESHOLD:
|
||||||
|
for expanded_r in [r for r in [25, 50, 100] if r > radius_km]:
|
||||||
|
lat_delta_ex = expanded_r / 111.0
|
||||||
|
lng_delta_ex = expanded_r / (111.0 * max(math.cos(math.radians(user_lat)), 0.01))
|
||||||
|
candidates_ex = qs.filter(
|
||||||
|
latitude__gte=user_lat - lat_delta_ex,
|
||||||
|
latitude__lte=user_lat + lat_delta_ex,
|
||||||
|
longitude__gte=user_lng - lng_delta_ex,
|
||||||
|
longitude__lte=user_lng + lng_delta_ex,
|
||||||
|
latitude__isnull=False,
|
||||||
|
longitude__isnull=False,
|
||||||
|
)
|
||||||
|
nearby_ids = []
|
||||||
|
for e in candidates_ex:
|
||||||
|
if e.latitude is not None and e.longitude is not None:
|
||||||
|
dist = _haversine_km(user_lat, user_lng, float(e.latitude), float(e.longitude))
|
||||||
|
if dist <= expanded_r:
|
||||||
|
nearby_ids.append(e.id)
|
||||||
|
if len(nearby_ids) >= MIN_EVENTS_THRESHOLD:
|
||||||
|
radius_km = expanded_r
|
||||||
|
break
|
||||||
|
|
||||||
|
if nearby_ids:
|
||||||
|
qs = qs.filter(id__in=nearby_ids)
|
||||||
|
used_radius = radius_km
|
||||||
|
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Invalid lat/lng — fall back to pincode
|
||||||
|
|
||||||
|
# Priority 2: Pincode filtering (backward compatible fallback)
|
||||||
|
if used_radius is None and pincode and pincode != 'all':
|
||||||
|
pincode_qs = qs.filter(pincode=pincode)
|
||||||
|
if pincode_qs.count() >= MIN_EVENTS_THRESHOLD:
|
||||||
|
qs = pincode_qs
|
||||||
|
|
||||||
|
# Priority 3: Full-text search on title / name / description
|
||||||
|
if q:
|
||||||
|
qs = qs.filter(Q(title__icontains=q) | Q(name__icontains=q) | Q(description__icontains=q))
|
||||||
|
|
||||||
|
if per_type > 0 and page == 1:
|
||||||
|
type_ids = list(qs.values_list('event_type_id', flat=True).distinct())
|
||||||
|
events_page = []
|
||||||
|
for tid in sorted(type_ids):
|
||||||
|
chunk = list(qs.filter(event_type_id=tid).order_by('-created_date')[:per_type])
|
||||||
|
events_page.extend(chunk)
|
||||||
|
total_count = qs.count()
|
||||||
|
end = len(events_page)
|
||||||
|
else:
|
||||||
|
total_count = qs.count()
|
||||||
|
qs = qs.order_by('-created_date')
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
end = start + page_size
|
||||||
|
events_page = list(qs[start:end])
|
||||||
|
|
||||||
|
page_ids = [e.id for e in events_page]
|
||||||
|
primary_images = EventImages.objects.filter(event_id__in=page_ids, is_primary=True)
|
||||||
|
thumb_map = {img.event_id: img for img in primary_images}
|
||||||
|
|
||||||
|
event_list = [self._serialize_event(e, thumb_map) for e in events_page]
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"status": "success",
|
"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:
|
except Exception as e:
|
||||||
return JsonResponse(
|
log("error", "EventListAPI exception", request=request, logger_data={"error": str(e)})
|
||||||
{"status": "error", "message": str(e)},
|
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EventDetailAPI(APIView):
|
class EventDetailAPI(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
try:
|
try:
|
||||||
user, token, data, error_response = validate_token_and_get_user(request)
|
try:
|
||||||
if error_response:
|
data = json.loads(request.body) if request.body else {}
|
||||||
return error_response
|
except Exception:
|
||||||
|
data = {}
|
||||||
event_id = data.get("event_id")
|
event_id = data.get("event_id")
|
||||||
|
|
||||||
events = Event.objects.get(id=event_id)
|
events = Event.objects.get(id=event_id)
|
||||||
event_images = EventImages.objects.filter(event=event_id)
|
event_images = EventImages.objects.filter(event=event_id)
|
||||||
event_data = model_to_dict(events)
|
event_data = model_to_dict(events)
|
||||||
@@ -110,21 +301,26 @@ class EventDetailAPI(APIView):
|
|||||||
for ei in event_images:
|
for ei in event_images:
|
||||||
event_img = {}
|
event_img = {}
|
||||||
event_img['is_primary'] = ei.is_primary
|
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_images_list.append(event_img)
|
||||||
event_data["images"] = event_images_list
|
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)
|
return JsonResponse(event_data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse(
|
log("error", "EventDetailAPI exception", request=request, logger_data={"error": str(e)})
|
||||||
{"status": "error", "message": str(e)},
|
return JsonResponse({"status": "error", "message": "An unexpected server error occurred."})
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EventImagesListAPI(APIView):
|
class EventImagesListAPI(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
try:
|
try:
|
||||||
user, token, data, error_response = validate_token_and_get_user(request)
|
user, token, data, error_response = validate_token_and_get_user(request)
|
||||||
@@ -138,7 +334,7 @@ class EventImagesListAPI(APIView):
|
|||||||
res_data["status"] = "success"
|
res_data["status"] = "success"
|
||||||
event_images_list = []
|
event_images_list = []
|
||||||
for ei in event_images:
|
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
|
res_data["images"] = event_images_list
|
||||||
|
|
||||||
@@ -147,13 +343,16 @@ class EventImagesListAPI(APIView):
|
|||||||
return JsonResponse(res_data)
|
return JsonResponse(res_data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log("error", "EventImagesListAPI exception", request=request, logger_data={"error": str(e)})
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{"status": "error", "message": str(e)},
|
{"status": "error", "message": "An unexpected server error occurred."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name='dispatch')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
class EventsByCategoryAPI(APIView):
|
class EventsByCategoryAPI(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
try:
|
try:
|
||||||
user, token, data, error_response = validate_token_and_get_user(request)
|
user, token, data, error_response = validate_token_and_get_user(request)
|
||||||
@@ -172,9 +371,7 @@ class EventsByCategoryAPI(APIView):
|
|||||||
|
|
||||||
for event in events_dict:
|
for event in events_dict:
|
||||||
try:
|
try:
|
||||||
event['event_image'] = request.build_absolute_uri(
|
event['event_image'] = EventImages.objects.get(event=event['id'], is_primary=True).event_image.url
|
||||||
EventImages.objects.get(event=event['id'], is_primary=True).event_image.url
|
|
||||||
)
|
|
||||||
except EventImages.DoesNotExist:
|
except EventImages.DoesNotExist:
|
||||||
event['event_image'] = ''
|
event['event_image'] = ''
|
||||||
# event['start_date'] = convert_date_to_dd_mm_yyyy(event['start_date'])
|
# event['start_date'] = convert_date_to_dd_mm_yyyy(event['start_date'])
|
||||||
@@ -186,13 +383,16 @@ class EventsByCategoryAPI(APIView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log("error", "EventsByDateAPI exception", request=request, logger_data={"error": str(e)})
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{"status": "error", "message": str(e)},
|
{"status": "error", "message": "An unexpected server error occurred."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name='dispatch')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
class EventsByMonthYearAPI(APIView):
|
class EventsByMonthYearAPI(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
"""
|
"""
|
||||||
API to get events by month and year.
|
API to get events by month and year.
|
||||||
Returns dates that have events, total count, and date-wise breakdown.
|
Returns dates that have events, total count, and date-wise breakdown.
|
||||||
@@ -308,13 +508,16 @@ class EventsByMonthYearAPI(APIView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log("error", "DateSheetAPI exception", request=request, logger_data={"error": str(e)})
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{"status": "error", "message": str(e)},
|
{"status": "error", "message": "An unexpected server error occurred."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name='dispatch')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
class EventsByDateAPI(APIView):
|
class EventsByDateAPI(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
"""
|
"""
|
||||||
API to get events occurring on a specific date.
|
API to get events occurring on a specific date.
|
||||||
Returns complete event information with primary images.
|
Returns complete event information with primary images.
|
||||||
@@ -352,7 +555,7 @@ class EventsByDateAPI(APIView):
|
|||||||
data_dict = model_to_dict(e)
|
data_dict = model_to_dict(e)
|
||||||
try:
|
try:
|
||||||
thumb_img = EventImages.objects.get(event=e.id, is_primary=True)
|
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:
|
except EventImages.DoesNotExist:
|
||||||
data_dict['thumb_img'] = ''
|
data_dict['thumb_img'] = ''
|
||||||
|
|
||||||
@@ -364,58 +567,111 @@ class EventsByDateAPI(APIView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log("error", "PincodeEventsAPI exception", request=request, logger_data={"error": str(e)})
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{"status": "error", "message": str(e)},
|
{"status": "error", "message": "An unexpected server error occurred."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name='dispatch')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
class FeaturedEventsAPI(APIView):
|
class FeaturedEventsAPI(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
"""Returns events where is_featured=True — used for the homepage hero carousel."""
|
"""Returns events where is_featured=True — used for the homepage hero carousel."""
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
try:
|
try:
|
||||||
user, token, data, error_response = validate_token_and_get_user(request)
|
events = Event.objects.filter(is_featured=True, event_status='published').order_by('-created_date')
|
||||||
if error_response:
|
|
||||||
return error_response
|
|
||||||
|
|
||||||
events = Event.objects.filter(is_featured=True).order_by('-created_date')
|
|
||||||
event_list = []
|
event_list = []
|
||||||
for e in events:
|
for e in events:
|
||||||
data_dict = model_to_dict(e)
|
data_dict = model_to_dict(e)
|
||||||
|
data_dict['event_type_name'] = e.event_type.event_type if e.event_type else ''
|
||||||
try:
|
try:
|
||||||
thumb = EventImages.objects.get(event=e.id, is_primary=True)
|
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:
|
except EventImages.DoesNotExist:
|
||||||
data_dict['thumb_img'] = ''
|
data_dict['thumb_img'] = ''
|
||||||
event_list.append(data_dict)
|
event_list.append(data_dict)
|
||||||
|
|
||||||
return JsonResponse({"status": "success", "events": event_list})
|
return JsonResponse({"status": "success", "events": event_list})
|
||||||
except Exception as e:
|
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')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
class TopEventsAPI(APIView):
|
class TopEventsAPI(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
"""Returns events where is_top_event=True — used for the Top Events section."""
|
"""Returns events where is_top_event=True — used for the Top Events section."""
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
try:
|
try:
|
||||||
user, token, data, error_response = validate_token_and_get_user(request)
|
events = Event.objects.filter(is_top_event=True, event_status='published').order_by('-created_date')
|
||||||
if error_response:
|
|
||||||
return error_response
|
|
||||||
|
|
||||||
events = Event.objects.filter(is_top_event=True).order_by('-created_date')
|
|
||||||
event_list = []
|
event_list = []
|
||||||
for e in events:
|
for e in events:
|
||||||
data_dict = model_to_dict(e)
|
data_dict = model_to_dict(e)
|
||||||
|
data_dict['event_type_name'] = e.event_type.event_type if e.event_type else ''
|
||||||
try:
|
try:
|
||||||
thumb = EventImages.objects.get(event=e.id, is_primary=True)
|
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:
|
except EventImages.DoesNotExist:
|
||||||
data_dict['thumb_img'] = ''
|
data_dict['thumb_img'] = ''
|
||||||
event_list.append(data_dict)
|
event_list.append(data_dict)
|
||||||
|
|
||||||
return JsonResponse({"status": "success", "events": event_list})
|
return JsonResponse({"status": "success", "events": event_list})
|
||||||
except Exception as e:
|
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."})
|
||||||
|
|||||||
146
mobile_api/views/favorites.py
Normal file
146
mobile_api/views/favorites.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
from django.views import View
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
|
||||||
|
from events.models import Event, EventLike, EventImages
|
||||||
|
from mobile_api.utils import validate_token_and_get_user
|
||||||
|
from eventify_logger.services import log
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_liked_event(event):
|
||||||
|
"""Serialize an Event for the liked-events list."""
|
||||||
|
primary_img = EventImages.objects.filter(
|
||||||
|
event=event, is_primary=True
|
||||||
|
).first()
|
||||||
|
if not primary_img:
|
||||||
|
primary_img = EventImages.objects.filter(event=event).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': event.id,
|
||||||
|
'title': event.title or event.name,
|
||||||
|
'image': primary_img.event_image.url if primary_img else '',
|
||||||
|
'date': str(event.start_date) if event.start_date else None,
|
||||||
|
'location': event.place or '',
|
||||||
|
'venue': event.venue_name or '',
|
||||||
|
'event_type': event.event_type.event_type if event.event_type else '',
|
||||||
|
'event_status': event.event_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class ToggleLikeView(View):
|
||||||
|
"""POST /api/events/like/ — toggle like on/off for an event."""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
user, token, data, error_response = validate_token_and_get_user(request)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
event_id = data.get('event_id')
|
||||||
|
if not event_id:
|
||||||
|
return JsonResponse(
|
||||||
|
{'status': 'error', 'message': 'event_id is required'},
|
||||||
|
status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
return JsonResponse(
|
||||||
|
{'status': 'error', 'message': 'Event not found'},
|
||||||
|
status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
like, created = EventLike.objects.get_or_create(user=user, event=event)
|
||||||
|
if not created:
|
||||||
|
like.delete()
|
||||||
|
return JsonResponse({'status': 'success', 'liked': False})
|
||||||
|
|
||||||
|
return JsonResponse({'status': 'success', 'liked': True})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log("error", "ToggleLikeView exception", request=request,
|
||||||
|
logger_data={"error": str(e)})
|
||||||
|
return JsonResponse(
|
||||||
|
{'status': 'error', 'message': 'An unexpected server error occurred.'},
|
||||||
|
status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class MyLikedIdsView(View):
|
||||||
|
"""POST /api/events/my-likes/ — return all liked event IDs for the user."""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
user, token, data, error_response = validate_token_and_get_user(request)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
liked_ids = list(
|
||||||
|
EventLike.objects.filter(user=user)
|
||||||
|
.values_list('event_id', flat=True)
|
||||||
|
)
|
||||||
|
return JsonResponse({'status': 'success', 'liked_event_ids': liked_ids})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log("error", "MyLikedIdsView exception", request=request,
|
||||||
|
logger_data={"error": str(e)})
|
||||||
|
return JsonResponse(
|
||||||
|
{'status': 'error', 'message': 'An unexpected server error occurred.'},
|
||||||
|
status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class MyLikedEventsView(View):
|
||||||
|
"""POST /api/events/my-liked-events/ — paginated liked events with full data."""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
user, token, data, error_response = validate_token_and_get_user(request)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
page = int(data.get('page', 1))
|
||||||
|
page_size = min(int(data.get('page_size', 20)), 50)
|
||||||
|
|
||||||
|
# Event IDs liked by this user, newest first
|
||||||
|
liked_event_ids = list(
|
||||||
|
EventLike.objects.filter(user=user)
|
||||||
|
.order_by('-created_at')
|
||||||
|
.values_list('event_id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preserve ordering from liked_event_ids
|
||||||
|
from django.db.models import Case, When, IntegerField
|
||||||
|
ordering = Case(
|
||||||
|
*[When(pk=pk, then=pos) for pos, pk in enumerate(liked_event_ids)],
|
||||||
|
output_field=IntegerField()
|
||||||
|
)
|
||||||
|
events_qs = Event.objects.filter(id__in=liked_event_ids).order_by(ordering)
|
||||||
|
|
||||||
|
paginator = Paginator(events_qs, page_size)
|
||||||
|
page_obj = paginator.get_page(page)
|
||||||
|
|
||||||
|
events_data = [_serialize_liked_event(e) for e in page_obj]
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'events': events_data,
|
||||||
|
'total': paginator.count,
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'has_next': page_obj.has_next(),
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log("error", "MyLikedEventsView exception", request=request,
|
||||||
|
logger_data={"error": str(e)})
|
||||||
|
return JsonResponse(
|
||||||
|
{'status': 'error', 'message': 'An unexpected server error occurred.'},
|
||||||
|
status=500
|
||||||
|
)
|
||||||
283
mobile_api/views/reviews.py
Normal file
283
mobile_api/views/reviews.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
"""
|
||||||
|
Customer-facing review API views.
|
||||||
|
Writes to admin_api.Review so admin panel sees reviews immediately.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from django.db.models import Avg, Count, Q
|
||||||
|
from admin_api.models import Review, ReviewInteraction
|
||||||
|
from events.models import Event
|
||||||
|
from mobile_api.utils import validate_token_and_get_user
|
||||||
|
|
||||||
|
|
||||||
|
_STATUS_TO_JSON = {'live': 'PUBLISHED', 'pending': 'PENDING', 'rejected': 'FLAGGED'}
|
||||||
|
_JSON_TO_STATUS = {'PUBLISHED': 'live', 'PENDING': 'pending', 'FLAGGED': 'rejected'}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_review(r, user_interactions=None):
|
||||||
|
"""Serialize a Review to match the customer app's expected shape."""
|
||||||
|
interactions = user_interactions or {}
|
||||||
|
try:
|
||||||
|
display = r.display_name or r.reviewer.get_full_name() or r.reviewer.username
|
||||||
|
except Exception:
|
||||||
|
display = r.display_name or ''
|
||||||
|
try:
|
||||||
|
uname = r.reviewer.username
|
||||||
|
except Exception:
|
||||||
|
uname = ''
|
||||||
|
try:
|
||||||
|
pic = r.reviewer.profile_picture
|
||||||
|
if pic and pic.name and 'default.png' not in pic.name:
|
||||||
|
profile_photo = pic.url
|
||||||
|
else:
|
||||||
|
profile_photo = ''
|
||||||
|
except Exception:
|
||||||
|
profile_photo = ''
|
||||||
|
return {
|
||||||
|
'id': r.id,
|
||||||
|
'event_id': r.event_id,
|
||||||
|
'username': uname,
|
||||||
|
'display_name': display,
|
||||||
|
'profile_photo': profile_photo,
|
||||||
|
'rating': r.rating,
|
||||||
|
'comment': r.review_text,
|
||||||
|
'status': _STATUS_TO_JSON.get(r.status, r.status),
|
||||||
|
'is_verified': r.is_verified,
|
||||||
|
'helpful_count': r.helpful_count,
|
||||||
|
'flag_count': r.flag_count,
|
||||||
|
'has_helpful': interactions.get('HELPFUL', False),
|
||||||
|
'has_flagged': interactions.get('FLAG', False),
|
||||||
|
'created_at': r.submission_date.isoformat() if r.submission_date else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rating_distribution(event_id):
|
||||||
|
"""Return {1:count, 2:count, ..., 5:count} for live reviews."""
|
||||||
|
dist = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
||||||
|
qs = Review.objects.filter(event_id=event_id, status='live').values('rating').annotate(c=Count('id'))
|
||||||
|
for row in qs:
|
||||||
|
dist[row['rating']] = row['c']
|
||||||
|
return dist
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregates(event_id):
|
||||||
|
"""Return (average_rating, review_count) for live reviews."""
|
||||||
|
agg = Review.objects.filter(event_id=event_id, status='live').aggregate(
|
||||||
|
avg=Avg('rating'), cnt=Count('id')
|
||||||
|
)
|
||||||
|
return round(float(agg['avg'] or 0), 1), agg['cnt'] or 0
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class ReviewSubmitView(APIView):
|
||||||
|
"""POST /api/reviews/submit -- Submit or update a review."""
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
user, token, data, error_response = validate_token_and_get_user(request)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
event_id = data.get('event_id')
|
||||||
|
rating = data.get('rating')
|
||||||
|
comment = data.get('comment', '') or ''
|
||||||
|
is_verified = bool(data.get('is_verified', False))
|
||||||
|
|
||||||
|
if not event_id or not rating:
|
||||||
|
return JsonResponse({'status': 'error', 'message': 'event_id and rating are required'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rating = int(rating)
|
||||||
|
if rating < 1 or rating > 5:
|
||||||
|
raise ValueError
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return JsonResponse({'status': 'error', 'message': 'Rating must be 1-5'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
return JsonResponse({'status': 'error', 'message': 'Event not found'}, status=404)
|
||||||
|
|
||||||
|
# Upsert: one review per user per event
|
||||||
|
review, created = Review.objects.update_or_create(
|
||||||
|
reviewer=user,
|
||||||
|
event=event,
|
||||||
|
defaults={
|
||||||
|
'rating': rating,
|
||||||
|
'review_text': comment,
|
||||||
|
'is_verified': is_verified,
|
||||||
|
'status': 'live', # auto-approve for customer submissions
|
||||||
|
'display_name': user.get_full_name() or user.username,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
avg_rating, review_count = _aggregates(event_id)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'review': _serialize_review(review),
|
||||||
|
'average_rating': avg_rating,
|
||||||
|
'review_count': review_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class MobileReviewListView(APIView):
|
||||||
|
"""POST /api/reviews/list -- Get published reviews for an event."""
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body) if request.body else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
event_id = body.get('event_id')
|
||||||
|
username = body.get('username', '')
|
||||||
|
page = int(body.get('page', 1))
|
||||||
|
page_size = min(int(body.get('page_size', 10)), 50)
|
||||||
|
|
||||||
|
if not event_id:
|
||||||
|
return JsonResponse({'status': 'error', 'message': 'event_id is required'}, status=400)
|
||||||
|
|
||||||
|
qs = Review.objects.filter(event_id=event_id, status='live').select_related('reviewer')
|
||||||
|
qs = qs.order_by('-is_verified', '-helpful_count', '-submission_date')
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
reviews = list(qs[(page - 1) * page_size: page * page_size])
|
||||||
|
|
||||||
|
# Bulk lookup interactions for current user
|
||||||
|
user_interactions = {}
|
||||||
|
if username and reviews:
|
||||||
|
review_ids = [r.id for r in reviews]
|
||||||
|
interactions = ReviewInteraction.objects.filter(
|
||||||
|
username=username, review_id__in=review_ids
|
||||||
|
)
|
||||||
|
for inter in interactions:
|
||||||
|
if inter.review_id not in user_interactions:
|
||||||
|
user_interactions[inter.review_id] = {}
|
||||||
|
user_interactions[inter.review_id][inter.interaction_type] = True
|
||||||
|
|
||||||
|
formatted = [
|
||||||
|
_serialize_review(r, user_interactions.get(r.id, {}))
|
||||||
|
for r in reviews
|
||||||
|
]
|
||||||
|
|
||||||
|
avg_rating, review_count = _aggregates(event_id)
|
||||||
|
distribution = _rating_distribution(event_id)
|
||||||
|
|
||||||
|
# Get user's own review (any status)
|
||||||
|
user_review = None
|
||||||
|
if username:
|
||||||
|
try:
|
||||||
|
from accounts.models import User
|
||||||
|
u = User.objects.get(username=username)
|
||||||
|
ur = Review.objects.filter(event_id=event_id, reviewer=u).first()
|
||||||
|
if ur:
|
||||||
|
user_review = _serialize_review(ur)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'reviews': formatted,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'average_rating': avg_rating,
|
||||||
|
'review_count': review_count,
|
||||||
|
'distribution': distribution,
|
||||||
|
'user_review': user_review,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class ReviewHelpfulView(APIView):
|
||||||
|
"""POST /api/reviews/helpful -- Mark a review as helpful."""
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
user, token, data, error_response = validate_token_and_get_user(request)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
review_id = data.get('review_id')
|
||||||
|
if not review_id:
|
||||||
|
return JsonResponse({'status': 'error', 'message': 'review_id is required'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
review = Review.objects.get(pk=review_id)
|
||||||
|
except Review.DoesNotExist:
|
||||||
|
return JsonResponse({'status': 'error', 'message': 'Review not found'}, status=404)
|
||||||
|
|
||||||
|
if review.reviewer == user:
|
||||||
|
return JsonResponse({'status': 'error', 'message': 'Cannot mark your own review as helpful'}, status=400)
|
||||||
|
|
||||||
|
_, created = ReviewInteraction.objects.get_or_create(
|
||||||
|
review=review,
|
||||||
|
username=user.username,
|
||||||
|
interaction_type='HELPFUL'
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
new_count = ReviewInteraction.objects.filter(review=review, interaction_type='HELPFUL').count()
|
||||||
|
review.helpful_count = new_count
|
||||||
|
review.save(update_fields=['helpful_count'])
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'helpful_count': review.helpful_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class ReviewFlagView(APIView):
|
||||||
|
"""POST /api/reviews/flag -- Flag/report a review."""
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
user, token, data, error_response = validate_token_and_get_user(request)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
review_id = data.get('review_id')
|
||||||
|
if not review_id:
|
||||||
|
return JsonResponse({'status': 'error', 'message': 'review_id is required'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
review = Review.objects.get(pk=review_id)
|
||||||
|
except Review.DoesNotExist:
|
||||||
|
return JsonResponse({'status': 'error', 'message': 'Review not found'}, status=404)
|
||||||
|
|
||||||
|
if review.reviewer == user:
|
||||||
|
return JsonResponse({'status': 'error', 'message': 'Cannot flag your own review'}, status=400)
|
||||||
|
|
||||||
|
_, created = ReviewInteraction.objects.get_or_create(
|
||||||
|
review=review,
|
||||||
|
username=user.username,
|
||||||
|
interaction_type='FLAG'
|
||||||
|
)
|
||||||
|
|
||||||
|
new_count = ReviewInteraction.objects.filter(review=review, interaction_type='FLAG').count()
|
||||||
|
new_status = review.status
|
||||||
|
if new_count >= 3 and review.status == 'live':
|
||||||
|
new_status = 'rejected'
|
||||||
|
review.status = 'rejected'
|
||||||
|
review.reject_reason = 'inappropriate'
|
||||||
|
|
||||||
|
review.flag_count = new_count
|
||||||
|
review.save(update_fields=['flag_count', 'status', 'reject_reason'])
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'flag_count': new_count,
|
||||||
|
'review_status': _STATUS_TO_JSON.get(new_status, new_status),
|
||||||
|
})
|
||||||
@@ -1,19 +1,41 @@
|
|||||||
# accounts/views.py
|
# accounts/views.py
|
||||||
import json
|
import json
|
||||||
|
import secrets
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
from rest_framework.views import APIView
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from mobile_api.forms import RegisterForm, LoginForm, WebRegisterForm
|
from mobile_api.forms import RegisterForm, LoginForm, WebRegisterForm
|
||||||
from rest_framework.authentication import TokenAuthentication
|
from rest_framework.authentication import TokenAuthentication
|
||||||
from django.contrib.auth import logout
|
from django.contrib.auth import logout
|
||||||
|
from django.db import connection
|
||||||
from mobile_api.utils import validate_token_and_get_user
|
from mobile_api.utils import validate_token_and_get_user
|
||||||
from utils.errors_json_convertor import simplify_form_errors
|
from utils.errors_json_convertor import simplify_form_errors
|
||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
from eventify_logger.services import log
|
from eventify_logger.services import log
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_gamification_profile(user):
|
||||||
|
"""Insert a gamification profile row for a newly registered user.
|
||||||
|
Non-fatal: if the insert fails for any reason, registration still succeeds."""
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO user_gamification_profiles (user_id, eventify_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET eventify_id = COALESCE(
|
||||||
|
user_gamification_profiles.eventify_id,
|
||||||
|
EXCLUDED.eventify_id
|
||||||
|
)
|
||||||
|
""", [user.email, user.eventify_id])
|
||||||
|
except Exception as e:
|
||||||
|
log("warning", "Failed to seed gamification profile on registration",
|
||||||
|
logger_data={"user": user.email, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name='dispatch')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
class RegisterView(View):
|
class RegisterView(View):
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
@@ -22,6 +44,7 @@ class RegisterView(View):
|
|||||||
form = RegisterForm(data)
|
form = RegisterForm(data)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
user = form.save()
|
user = form.save()
|
||||||
|
_seed_gamification_profile(user)
|
||||||
token, _ = Token.objects.get_or_create(user=user)
|
token, _ = Token.objects.get_or_create(user=user)
|
||||||
log("info", "API user registration", request=request, user=user)
|
log("info", "API user registration", request=request, user=user)
|
||||||
return JsonResponse({'message': 'User registered successfully', 'token': token.key}, status=201)
|
return JsonResponse({'message': 'User registered successfully', 'token': token.key}, status=201)
|
||||||
@@ -29,7 +52,7 @@ class RegisterView(View):
|
|||||||
return JsonResponse({'errors': form.errors}, status=400)
|
return JsonResponse({'errors': form.errors}, status=400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("error", "API registration exception", request=request, logger_data={"error": str(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')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
@@ -49,6 +72,7 @@ class WebRegisterView(View):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
print('2')
|
print('2')
|
||||||
user = form.save()
|
user = form.save()
|
||||||
|
_seed_gamification_profile(user)
|
||||||
token, _ = Token.objects.get_or_create(user=user)
|
token, _ = Token.objects.get_or_create(user=user)
|
||||||
print('3')
|
print('3')
|
||||||
log("info", "Web user registration", request=request, user=user)
|
log("info", "Web user registration", request=request, user=user)
|
||||||
@@ -58,13 +82,18 @@ class WebRegisterView(View):
|
|||||||
'username': user.username,
|
'username': user.username,
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'phone_number': user.phone_number,
|
'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)
|
return JsonResponse(response, status=201)
|
||||||
log("warning", "Web registration failed", request=request, logger_data=dict(errors=form.errors))
|
log("warning", "Web registration failed", request=request, logger_data=dict(errors=form.errors))
|
||||||
return JsonResponse({'errors': form.errors}, status=400)
|
return JsonResponse({'errors': form.errors}, status=400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("error", "Web registration exception", request=request, logger_data={"error": str(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')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
@@ -84,6 +113,7 @@ class LoginView(View):
|
|||||||
response = {
|
response = {
|
||||||
'message': 'Login successful',
|
'message': 'Login successful',
|
||||||
'token': token.key,
|
'token': token.key,
|
||||||
|
'eventify_id': user.eventify_id,
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'phone_number': user.phone_number,
|
'phone_number': user.phone_number,
|
||||||
@@ -92,12 +122,13 @@ class LoginView(View):
|
|||||||
'role': user.role,
|
'role': user.role,
|
||||||
'pincode': user.pincode,
|
'pincode': user.pincode,
|
||||||
'district': user.district,
|
'district': user.district,
|
||||||
|
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
|
||||||
'state': user.state,
|
'state': user.state,
|
||||||
'country': user.country,
|
'country': user.country,
|
||||||
'place': user.place,
|
'place': user.place,
|
||||||
'latitude': user.latitude,
|
'latitude': user.latitude,
|
||||||
'longitude': user.longitude,
|
'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('4')
|
||||||
print(response)
|
print(response)
|
||||||
@@ -107,7 +138,7 @@ class LoginView(View):
|
|||||||
return JsonResponse(simplify_form_errors(form), status=401)
|
return JsonResponse(simplify_form_errors(form), status=401)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("error", "API login exception", request=request, logger_data={"error": str(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')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
@@ -121,11 +152,16 @@ class StatusView(View):
|
|||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"status": "logged_in",
|
"status": "logged_in",
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email
|
"email": user.email,
|
||||||
|
"eventify_id": user.eventify_id or '',
|
||||||
|
"district": user.district or '',
|
||||||
|
"district_changed_at": user.district_changed_at.isoformat() if user.district_changed_at else None,
|
||||||
|
"profile_photo": user.profile_picture.url if user.profile_picture else '',
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
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')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
@@ -150,7 +186,7 @@ class LogoutView(View):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("error", "API logout exception", request=request, logger_data={"error": str(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')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
@@ -242,14 +278,32 @@ class UpdateProfileView(View):
|
|||||||
user.pincode = None
|
user.pincode = None
|
||||||
updated_fields.append('pincode')
|
updated_fields.append('pincode')
|
||||||
|
|
||||||
# Update district
|
# Update district (with 6-month cooldown)
|
||||||
if 'district' in json_data:
|
if 'district' in json_data:
|
||||||
district = json_data.get('district', '').strip()
|
from django.utils import timezone
|
||||||
if district:
|
from datetime import timedelta
|
||||||
user.district = district
|
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')
|
updated_fields.append('district')
|
||||||
elif 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 = None
|
||||||
|
user.district_changed_at = timezone.now()
|
||||||
updated_fields.append('district')
|
updated_fields.append('district')
|
||||||
|
|
||||||
# Update state
|
# Update state
|
||||||
@@ -315,6 +369,7 @@ class UpdateProfileView(View):
|
|||||||
'phone_number': user.phone_number,
|
'phone_number': user.phone_number,
|
||||||
'pincode': user.pincode,
|
'pincode': user.pincode,
|
||||||
'district': user.district,
|
'district': user.district,
|
||||||
|
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
|
||||||
'state': user.state,
|
'state': user.state,
|
||||||
'country': user.country,
|
'country': user.country,
|
||||||
'place': user.place,
|
'place': user.place,
|
||||||
@@ -328,7 +383,174 @@ class UpdateProfileView(View):
|
|||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log("error", "API update profile exception", request=request, logger_data={"error": str(e)})
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': 'An unexpected server error occurred. Please try again.'
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class BulkUserPublicInfoView(APIView):
|
||||||
|
"""Internal endpoint for Node.js gamification server to resolve user details.
|
||||||
|
Accepts POST with { emails: [...] } (max 500).
|
||||||
|
Returns { users: { email: { district, display_name, eventify_id } } }
|
||||||
|
"""
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = []
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
json_data = json.loads(request.body)
|
||||||
|
emails = json_data.get('emails', [])
|
||||||
|
if not emails or not isinstance(emails, list) or len(emails) > 500:
|
||||||
|
return JsonResponse({'error': 'Provide 1-500 emails'}, status=400)
|
||||||
|
|
||||||
|
users_qs = User.objects.filter(email__in=emails).values_list(
|
||||||
|
'email', 'first_name', 'last_name', 'district', 'eventify_id'
|
||||||
|
)
|
||||||
|
result = {}
|
||||||
|
for email, first, last, district, eid in users_qs:
|
||||||
|
name = f"{first} {last}".strip() or email.split('@')[0]
|
||||||
|
result[email] = {
|
||||||
|
'display_name': name,
|
||||||
|
'district': district or '',
|
||||||
|
'eventify_id': eid or '',
|
||||||
|
}
|
||||||
|
return JsonResponse({'users': result})
|
||||||
|
except Exception as e:
|
||||||
|
log("error", "BulkUserPublicInfoView error", logger_data={"error": str(e)})
|
||||||
|
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class GoogleLoginView(View):
|
||||||
|
"""Verify a Google ID token, find or create the user, return the same response shape as LoginView."""
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
from google.oauth2 import id_token as google_id_token
|
||||||
|
from google.auth.transport import requests as google_requests
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
data = json.loads(request.body)
|
||||||
|
token = data.get('id_token')
|
||||||
|
if not token:
|
||||||
|
return JsonResponse({'error': 'id_token is required'}, status=400)
|
||||||
|
|
||||||
|
if not settings.GOOGLE_CLIENT_ID:
|
||||||
|
log("error", "GOOGLE_CLIENT_ID not configured", request=request)
|
||||||
|
return JsonResponse({'error': 'Google login temporarily unavailable'}, status=503)
|
||||||
|
|
||||||
|
idinfo = google_id_token.verify_oauth2_token(
|
||||||
|
token,
|
||||||
|
google_requests.Request(),
|
||||||
|
settings.GOOGLE_CLIENT_ID,
|
||||||
|
)
|
||||||
|
email = idinfo.get('email')
|
||||||
|
if not email:
|
||||||
|
return JsonResponse({'error': 'Email not found in Google token'}, status=400)
|
||||||
|
|
||||||
|
user, created = User.objects.get_or_create(
|
||||||
|
email=email,
|
||||||
|
defaults={
|
||||||
|
'username': email,
|
||||||
|
'first_name': idinfo.get('given_name', ''),
|
||||||
|
'last_name': idinfo.get('family_name', ''),
|
||||||
|
'role': 'customer',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
user.set_password(secrets.token_urlsafe(32))
|
||||||
|
user.save()
|
||||||
|
log("info", "Google OAuth new user created", request=request, user=user)
|
||||||
|
|
||||||
|
auth_token, _ = Token.objects.get_or_create(user=user)
|
||||||
|
log("info", "Google OAuth login", request=request, user=user)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'message': 'Login successful',
|
||||||
|
'token': auth_token.key,
|
||||||
|
'eventify_id': user.eventify_id or '',
|
||||||
|
'username': user.username,
|
||||||
|
'email': user.email,
|
||||||
|
'phone_number': user.phone_number or '',
|
||||||
|
'first_name': user.first_name,
|
||||||
|
'last_name': user.last_name,
|
||||||
|
'role': user.role,
|
||||||
|
'pincode': user.pincode or '',
|
||||||
|
'district': user.district or '',
|
||||||
|
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
|
||||||
|
'state': user.state or '',
|
||||||
|
'country': user.country or '',
|
||||||
|
'place': user.place or '',
|
||||||
|
'latitude': user.latitude or '',
|
||||||
|
'longitude': user.longitude or '',
|
||||||
|
'profile_photo': user.profile_picture.url if user.profile_picture else '',
|
||||||
|
}, status=200)
|
||||||
|
except ValueError as e:
|
||||||
|
log("warning", "Google OAuth invalid token", request=request, logger_data={"error": str(e)})
|
||||||
|
return JsonResponse({'error': 'Invalid Google token'}, status=401)
|
||||||
|
except Exception as e:
|
||||||
|
log("error", "Google OAuth exception", request=request, logger_data={"error": str(e)})
|
||||||
|
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class ScheduleCallView(View):
|
||||||
|
"""Public endpoint for the 'Schedule a Call' form on the consumer app."""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
from admin_api.models import Lead
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
name = (data.get('name') or '').strip()
|
||||||
|
email = (data.get('email') or '').strip()
|
||||||
|
phone = (data.get('phone') or '').strip()
|
||||||
|
event_type = (data.get('eventType') or '').strip()
|
||||||
|
message = (data.get('message') or '').strip()
|
||||||
|
|
||||||
|
errors = {}
|
||||||
|
if not name:
|
||||||
|
errors['name'] = ['This field is required.']
|
||||||
|
if not email:
|
||||||
|
errors['email'] = ['This field is required.']
|
||||||
|
if not phone:
|
||||||
|
errors['phone'] = ['This field is required.']
|
||||||
|
valid_event_types = [c[0] for c in Lead.EVENT_TYPE_CHOICES]
|
||||||
|
if not event_type or event_type not in valid_event_types:
|
||||||
|
errors['eventType'] = [f'Must be one of: {", ".join(valid_event_types)}']
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return JsonResponse({'errors': errors}, status=400)
|
||||||
|
|
||||||
|
# Auto-link to a consumer account if one exists with this email
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
_User = get_user_model()
|
||||||
|
try:
|
||||||
|
consumer_account = _User.objects.get(email=email)
|
||||||
|
except _User.DoesNotExist:
|
||||||
|
consumer_account = None
|
||||||
|
|
||||||
|
lead = Lead.objects.create(
|
||||||
|
name=name,
|
||||||
|
email=email,
|
||||||
|
phone=phone,
|
||||||
|
event_type=event_type,
|
||||||
|
message=message,
|
||||||
|
status='new',
|
||||||
|
source='schedule_call',
|
||||||
|
priority='medium',
|
||||||
|
user_account=consumer_account,
|
||||||
|
)
|
||||||
|
log("info", f"New schedule-call lead #{lead.pk} from {email}", request=request)
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Your request has been submitted. Our team will get back to you soon.',
|
||||||
|
'lead_id': lead.pk,
|
||||||
|
}, status=201)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({'error': 'Invalid JSON body.'}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
log("error", "Schedule call exception", request=request, logger_data={"error": str(e)})
|
||||||
|
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
|
||||||
|
|||||||
0
notifications/__init__.py
Normal file
0
notifications/__init__.py
Normal file
10
notifications/admin.py
Normal file
10
notifications/admin.py
Normal 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
6
notifications/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'notifications'
|
||||||
181
notifications/emails.py
Normal file
181
notifications/emails.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""HTML email builders for scheduled admin notifications.
|
||||||
|
|
||||||
|
Each builder is registered in ``BUILDERS`` keyed by ``NotificationSchedule.notification_type``
|
||||||
|
and returns ``(subject, html_body)``. Add new types by appending to the registry
|
||||||
|
and extending ``NotificationSchedule.TYPE_CHOICES``.
|
||||||
|
|
||||||
|
Week bounds for ``events_expiring_this_week`` are computed in Asia/Kolkata so the
|
||||||
|
"this week" semantics match the operations team's wall-clock week regardless of
|
||||||
|
``settings.TIME_ZONE`` (currently UTC).
|
||||||
|
"""
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
except ImportError: # pragma: no cover — fallback for py<3.9
|
||||||
|
from backports.zoneinfo import ZoneInfo # type: ignore
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import EmailMessage
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from eventify_logger.services import log
|
||||||
|
|
||||||
|
|
||||||
|
IST = ZoneInfo('Asia/Kolkata')
|
||||||
|
|
||||||
|
|
||||||
|
def _today_in_ist() -> date:
|
||||||
|
return datetime.now(IST).date()
|
||||||
|
|
||||||
|
|
||||||
|
def _upcoming_week_bounds(today: date) -> tuple[date, date]:
|
||||||
|
"""Return (next Monday, next Sunday) inclusive.
|
||||||
|
|
||||||
|
If today is Monday the result is *this week* (today..Sunday).
|
||||||
|
If today is any other weekday the result is *next week* (next Monday..next Sunday).
|
||||||
|
Mon=0 per Python ``weekday()``.
|
||||||
|
"""
|
||||||
|
days_until_monday = (7 - today.weekday()) % 7
|
||||||
|
monday = today + timedelta(days=days_until_monday)
|
||||||
|
sunday = monday + timedelta(days=6)
|
||||||
|
return monday, sunday
|
||||||
|
|
||||||
|
|
||||||
|
def _build_events_expiring_this_week(schedule) -> tuple[str, str]:
|
||||||
|
from events.models import Event
|
||||||
|
|
||||||
|
today = _today_in_ist()
|
||||||
|
monday, sunday = _upcoming_week_bounds(today)
|
||||||
|
|
||||||
|
qs = (
|
||||||
|
Event.objects
|
||||||
|
.select_related('partner', 'event_type')
|
||||||
|
.filter(event_status='published')
|
||||||
|
.filter(
|
||||||
|
Q(end_date__isnull=False, end_date__gte=monday, end_date__lte=sunday)
|
||||||
|
| Q(end_date__isnull=True, start_date__gte=monday, start_date__lte=sunday)
|
||||||
|
)
|
||||||
|
.order_by('end_date', 'start_date', 'name')
|
||||||
|
)
|
||||||
|
|
||||||
|
events = list(qs)
|
||||||
|
rows_html = ''
|
||||||
|
for e in events:
|
||||||
|
end = e.end_date or e.start_date
|
||||||
|
title = e.title or e.name or '(untitled)'
|
||||||
|
partner_name = ''
|
||||||
|
if e.partner_id:
|
||||||
|
try:
|
||||||
|
partner_name = e.partner.name or ''
|
||||||
|
except Exception:
|
||||||
|
partner_name = ''
|
||||||
|
category = ''
|
||||||
|
if e.event_type_id and e.event_type:
|
||||||
|
category = getattr(e.event_type, 'event_type', '') or ''
|
||||||
|
rows_html += (
|
||||||
|
'<tr>'
|
||||||
|
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">{escape(title)}</td>'
|
||||||
|
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">{escape(partner_name or "—")}</td>'
|
||||||
|
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">{escape(category or "—")}</td>'
|
||||||
|
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">'
|
||||||
|
f'{end.strftime("%a %d %b %Y") if end else "—"}</td>'
|
||||||
|
'</tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
rows_html = (
|
||||||
|
'<tr><td colspan="4" style="padding:24px;text-align:center;color:#888;">'
|
||||||
|
'No published events are expiring next week.'
|
||||||
|
'</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
subject = (
|
||||||
|
f'[Eventify] {len(events)} event(s) expiring '
|
||||||
|
f'{monday.strftime("%d %b")}–{sunday.strftime("%d %b")}'
|
||||||
|
)
|
||||||
|
|
||||||
|
html = f"""<!doctype html>
|
||||||
|
<html><body style="margin:0;padding:0;background:#f5f5f5;font-family:Arial,Helvetica,sans-serif;color:#1a1a1a;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f5f5;">
|
||||||
|
<tr><td align="center" style="padding:24px 12px;">
|
||||||
|
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:10px;overflow:hidden;box-shadow:0 2px 6px rgba(15,69,207,0.08);">
|
||||||
|
<tr><td style="background:#0F45CF;color:#ffffff;padding:24px 28px;">
|
||||||
|
<h2 style="margin:0;font-size:20px;">Events expiring next week</h2>
|
||||||
|
<p style="margin:6px 0 0;color:#d2dcff;font-size:14px;">
|
||||||
|
{monday.strftime("%A %d %b %Y")} → {sunday.strftime("%A %d %b %Y")}
|
||||||
|
· {len(events)} event(s)
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:20px 24px;">
|
||||||
|
<p style="margin:0 0 12px;font-size:14px;color:#444;">
|
||||||
|
Scheduled notification: <strong>{escape(schedule.name)}</strong>
|
||||||
|
</p>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:14px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f0f4ff;color:#0F45CF;">
|
||||||
|
<th align="left" style="padding:10px 12px;">Title</th>
|
||||||
|
<th align="left" style="padding:10px 12px;">Partner</th>
|
||||||
|
<th align="left" style="padding:10px 12px;">Category</th>
|
||||||
|
<th align="left" style="padding:10px 12px;">End date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{rows_html}</tbody>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:16px 24px 24px;color:#888;font-size:12px;">
|
||||||
|
Sent automatically by Eventify Command Center.
|
||||||
|
To change recipients or the schedule, open
|
||||||
|
<a href="https://admin.eventifyplus.com/settings" style="color:#0F45CF;">admin.eventifyplus.com › Settings › Notifications</a>.
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
return subject, html
|
||||||
|
|
||||||
|
|
||||||
|
BUILDERS: dict = {
|
||||||
|
'events_expiring_this_week': _build_events_expiring_this_week,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_and_send(schedule) -> int:
|
||||||
|
"""Render the email for ``schedule`` and deliver it to active recipients.
|
||||||
|
|
||||||
|
Returns the number of recipients the message was sent to. Raises on SMTP
|
||||||
|
failure so the management command can mark the schedule as errored.
|
||||||
|
"""
|
||||||
|
builder = BUILDERS.get(schedule.notification_type)
|
||||||
|
if builder is None:
|
||||||
|
raise ValueError(f'No builder for notification type: {schedule.notification_type}')
|
||||||
|
|
||||||
|
subject, html = builder(schedule)
|
||||||
|
recipients = list(
|
||||||
|
schedule.recipients.filter(is_active=True).values_list('email', flat=True)
|
||||||
|
)
|
||||||
|
if not recipients:
|
||||||
|
log('warning', 'notification schedule has no active recipients', logger_data={
|
||||||
|
'schedule_id': schedule.id,
|
||||||
|
'schedule_name': schedule.name,
|
||||||
|
})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
msg = EmailMessage(
|
||||||
|
subject=subject,
|
||||||
|
body=html,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=recipients,
|
||||||
|
)
|
||||||
|
msg.content_subtype = 'html'
|
||||||
|
msg.send(fail_silently=False)
|
||||||
|
|
||||||
|
log('info', 'notification email sent', logger_data={
|
||||||
|
'schedule_id': schedule.id,
|
||||||
|
'schedule_name': schedule.name,
|
||||||
|
'type': schedule.notification_type,
|
||||||
|
'recipient_count': len(recipients),
|
||||||
|
})
|
||||||
|
return len(recipients)
|
||||||
0
notifications/management/__init__.py
Normal file
0
notifications/management/__init__.py
Normal file
0
notifications/management/commands/__init__.py
Normal file
0
notifications/management/commands/__init__.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Dispatch due ``NotificationSchedule`` jobs.
|
||||||
|
|
||||||
|
Host cron invokes this every ~15 minutes via ``docker exec``. The command
|
||||||
|
walks all active schedules, evaluates their cron expression against
|
||||||
|
``last_run_at`` using ``croniter``, and fires any that are due. A row-level
|
||||||
|
``select_for_update(skip_locked=True)`` prevents duplicate sends if two cron
|
||||||
|
ticks race or the container is restarted mid-run.
|
||||||
|
|
||||||
|
Evaluation timezone is **Asia/Kolkata** to match
|
||||||
|
``notifications/emails.py::_upcoming_week_bounds`` — the same wall-clock week
|
||||||
|
used in the outgoing email body.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--schedule-id <id> Fire exactly one schedule, ignoring cron check.
|
||||||
|
--dry-run Resolve due schedules + render emails, send nothing.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
except ImportError: # pragma: no cover — py<3.9
|
||||||
|
from backports.zoneinfo import ZoneInfo # type: ignore
|
||||||
|
|
||||||
|
from croniter import croniter
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from eventify_logger.services import log
|
||||||
|
from notifications.emails import BUILDERS, render_and_send
|
||||||
|
from notifications.models import NotificationSchedule
|
||||||
|
|
||||||
|
|
||||||
|
IST = ZoneInfo('Asia/Kolkata')
|
||||||
|
|
||||||
|
|
||||||
|
def _is_due(schedule: NotificationSchedule, now_ist: datetime) -> bool:
|
||||||
|
"""Return True if ``schedule`` should fire at ``now_ist``.
|
||||||
|
|
||||||
|
``croniter`` is seeded with ``last_run_at`` (or one year ago for a fresh
|
||||||
|
schedule) and asked for the next fire time. If that time has already
|
||||||
|
passed relative to ``now_ist`` the schedule is due.
|
||||||
|
"""
|
||||||
|
if not croniter.is_valid(schedule.cron_expression):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if schedule.last_run_at is not None:
|
||||||
|
seed = schedule.last_run_at.astimezone(IST)
|
||||||
|
else:
|
||||||
|
seed = now_ist - timedelta(days=365)
|
||||||
|
|
||||||
|
itr = croniter(schedule.cron_expression, seed)
|
||||||
|
next_fire = itr.get_next(datetime)
|
||||||
|
return next_fire <= now_ist
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Dispatch due NotificationSchedule email jobs.'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--schedule-id', type=int, default=None,
|
||||||
|
help='Force-run a single schedule by ID, ignoring cron check.',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run', action='store_true',
|
||||||
|
help='Render and log but do not send or persist last_run_at.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
schedule_id = opts.get('schedule_id')
|
||||||
|
dry_run = opts.get('dry_run', False)
|
||||||
|
|
||||||
|
now_ist = datetime.now(IST)
|
||||||
|
qs = NotificationSchedule.objects.filter(is_active=True)
|
||||||
|
if schedule_id is not None:
|
||||||
|
qs = qs.filter(id=schedule_id)
|
||||||
|
|
||||||
|
candidate_ids = list(qs.values_list('id', flat=True))
|
||||||
|
if not candidate_ids:
|
||||||
|
self.stdout.write('No active schedules to evaluate.')
|
||||||
|
return
|
||||||
|
|
||||||
|
fired = 0
|
||||||
|
skipped = 0
|
||||||
|
errored = 0
|
||||||
|
|
||||||
|
for sid in candidate_ids:
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_qs = (
|
||||||
|
NotificationSchedule.objects
|
||||||
|
.select_for_update(skip_locked=True)
|
||||||
|
.filter(id=sid, is_active=True)
|
||||||
|
)
|
||||||
|
schedule = locked_qs.first()
|
||||||
|
if schedule is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
forced = schedule_id is not None
|
||||||
|
if not forced and not _is_due(schedule, now_ist):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if schedule.notification_type not in BUILDERS:
|
||||||
|
schedule.last_status = NotificationSchedule.STATUS_ERROR
|
||||||
|
schedule.last_error = (
|
||||||
|
f'No builder registered for {schedule.notification_type!r}'
|
||||||
|
)
|
||||||
|
schedule.save(update_fields=['last_status', 'last_error', 'updated_at'])
|
||||||
|
errored += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(
|
||||||
|
f'[dry-run] would fire schedule {schedule.id} '
|
||||||
|
f'({schedule.name}) type={schedule.notification_type}'
|
||||||
|
)
|
||||||
|
fired += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipient_count = render_and_send(schedule)
|
||||||
|
except Exception as exc: # noqa: BLE001 — wide catch, store msg
|
||||||
|
log('error', 'notification dispatch failed', logger_data={
|
||||||
|
'schedule_id': schedule.id,
|
||||||
|
'schedule_name': schedule.name,
|
||||||
|
'error': str(exc),
|
||||||
|
})
|
||||||
|
schedule.last_status = NotificationSchedule.STATUS_ERROR
|
||||||
|
schedule.last_error = str(exc)[:2000]
|
||||||
|
schedule.save(update_fields=['last_status', 'last_error', 'updated_at'])
|
||||||
|
errored += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
schedule.last_run_at = timezone.now()
|
||||||
|
schedule.last_status = NotificationSchedule.STATUS_SUCCESS
|
||||||
|
schedule.last_error = ''
|
||||||
|
schedule.save(update_fields=[
|
||||||
|
'last_run_at', 'last_status', 'last_error', 'updated_at',
|
||||||
|
])
|
||||||
|
fired += 1
|
||||||
|
self.stdout.write(
|
||||||
|
f'Fired schedule {schedule.id} ({schedule.name}) '
|
||||||
|
f'→ {recipient_count} recipient(s)'
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = f'Done. fired={fired} skipped={skipped} errored={errored}'
|
||||||
|
self.stdout.write(summary)
|
||||||
|
log('info', 'send_scheduled_notifications complete', logger_data={
|
||||||
|
'fired': fired, 'skipped': skipped, 'errored': errored,
|
||||||
|
'dry_run': dry_run, 'forced_id': schedule_id,
|
||||||
|
})
|
||||||
93
notifications/migrations/0001_initial.py
Normal file
93
notifications/migrations/0001_initial.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Notification',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=255)),
|
||||||
|
('message', models.TextField()),
|
||||||
|
('notification_type', models.CharField(
|
||||||
|
choices=[
|
||||||
|
('event', 'Event'),
|
||||||
|
('promo', 'Promotion'),
|
||||||
|
('system', 'System'),
|
||||||
|
('booking', 'Booking'),
|
||||||
|
],
|
||||||
|
default='system', max_length=20,
|
||||||
|
)),
|
||||||
|
('is_read', models.BooleanField(default=False)),
|
||||||
|
('action_url', models.URLField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='notifications',
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
options={'ordering': ['-created_at']},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NotificationSchedule',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('notification_type', models.CharField(
|
||||||
|
choices=[('events_expiring_this_week', 'Events Expiring This Week')],
|
||||||
|
db_index=True, max_length=64,
|
||||||
|
)),
|
||||||
|
('cron_expression', models.CharField(
|
||||||
|
default='0 0 * * 1',
|
||||||
|
help_text=(
|
||||||
|
'Standard 5-field cron (minute hour dom month dow). '
|
||||||
|
'Evaluated in Asia/Kolkata.'
|
||||||
|
),
|
||||||
|
max_length=100,
|
||||||
|
)),
|
||||||
|
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||||
|
('last_run_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('last_status', models.CharField(blank=True, default='', max_length=20)),
|
||||||
|
('last_error', models.TextField(blank=True, default='')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={'ordering': ['-created_at']},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='notificationschedule',
|
||||||
|
index=models.Index(
|
||||||
|
fields=['is_active', 'notification_type'],
|
||||||
|
name='notificatio_is_acti_26dfb5_idx',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NotificationRecipient',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('display_name', models.CharField(blank=True, default='', max_length=200)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('schedule', models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='recipients',
|
||||||
|
to='notifications.notificationschedule',
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['display_name', 'email'],
|
||||||
|
'unique_together': {('schedule', 'email')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
notifications/migrations/__init__.py
Normal file
0
notifications/migrations/__init__.py
Normal file
102
notifications/models.py
Normal file
102
notifications/models.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Two distinct concerns live in this app:
|
||||||
|
|
||||||
|
1. ``Notification`` — consumer-facing in-app inbox entries surfaced on the mobile
|
||||||
|
SPA (/api/notifications/list/). One row per user per alert.
|
||||||
|
|
||||||
|
2. ``NotificationSchedule`` + ``NotificationRecipient`` — admin-side recurring
|
||||||
|
email jobs configured from the Command Center Settings tab and dispatched by
|
||||||
|
the ``send_scheduled_notifications`` management command (host cron).
|
||||||
|
Not user-facing; strictly operational.
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(models.Model):
|
||||||
|
NOTIFICATION_TYPES = [
|
||||||
|
('event', 'Event'),
|
||||||
|
('promo', 'Promotion'),
|
||||||
|
('system', 'System'),
|
||||||
|
('booking', 'Booking'),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications')
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
message = models.TextField()
|
||||||
|
notification_type = models.CharField(max_length=20, choices=NOTIFICATION_TYPES, default='system')
|
||||||
|
is_read = models.BooleanField(default=False)
|
||||||
|
action_url = models.URLField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.notification_type}: {self.title} → {self.user.email}"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSchedule(models.Model):
|
||||||
|
"""One configurable recurring email job.
|
||||||
|
|
||||||
|
New types are added by registering a builder in ``notifications/emails.py``
|
||||||
|
and adding the slug to ``TYPE_CHOICES`` below. Cron expression is evaluated
|
||||||
|
in ``Asia/Kolkata`` by the dispatcher (matches operations team timezone).
|
||||||
|
"""
|
||||||
|
|
||||||
|
TYPE_EVENTS_EXPIRING_THIS_WEEK = 'events_expiring_this_week'
|
||||||
|
|
||||||
|
TYPE_CHOICES = [
|
||||||
|
(TYPE_EVENTS_EXPIRING_THIS_WEEK, 'Events Expiring This Week'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_SUCCESS = 'success'
|
||||||
|
STATUS_ERROR = 'error'
|
||||||
|
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
notification_type = models.CharField(
|
||||||
|
max_length=64, choices=TYPE_CHOICES, db_index=True,
|
||||||
|
)
|
||||||
|
cron_expression = models.CharField(
|
||||||
|
max_length=100, default='0 0 * * 1',
|
||||||
|
help_text='Standard 5-field cron (minute hour dom month dow). '
|
||||||
|
'Evaluated in Asia/Kolkata.',
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True, db_index=True)
|
||||||
|
last_run_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
last_status = models.CharField(max_length=20, blank=True, default='')
|
||||||
|
last_error = models.TextField(blank=True, default='')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [models.Index(fields=['is_active', 'notification_type'])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.name} ({self.notification_type})'
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationRecipient(models.Model):
|
||||||
|
"""Free-form recipient — not tied to a User row so external stakeholders
|
||||||
|
(vendors, partners, sponsors) can receive notifications without needing
|
||||||
|
platform accounts."""
|
||||||
|
|
||||||
|
schedule = models.ForeignKey(
|
||||||
|
NotificationSchedule,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='recipients',
|
||||||
|
)
|
||||||
|
email = models.EmailField()
|
||||||
|
display_name = models.CharField(max_length=200, blank=True, default='')
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [('schedule', 'email')]
|
||||||
|
ordering = ['display_name', 'email']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
label = self.display_name or self.email
|
||||||
|
return f'{label} ({self.schedule.name})'
|
||||||
8
notifications/urls.py
Normal file
8
notifications/urls.py
Normal 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
85
notifications/views.py
Normal 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)
|
||||||
@@ -58,7 +58,7 @@ def _partner_to_dict(partner, request=None):
|
|||||||
# Add document file URL if exists
|
# Add document file URL if exists
|
||||||
if partner.kyc_compliance_document_file:
|
if partner.kyc_compliance_document_file:
|
||||||
if request:
|
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:
|
else:
|
||||||
data["kyc_compliance_document_file"] = partner.kyc_compliance_document_file.url
|
data["kyc_compliance_document_file"] = partner.kyc_compliance_document_file.url
|
||||||
else:
|
else:
|
||||||
@@ -168,7 +168,7 @@ def _build_kyc_documents(partner, request):
|
|||||||
name = f"{type_label} - {partner.name}"
|
name = f"{type_label} - {partner.name}"
|
||||||
if partner.kyc_compliance_document_file:
|
if partner.kyc_compliance_document_file:
|
||||||
if request:
|
if request:
|
||||||
url = request.build_absolute_uri(partner.kyc_compliance_document_file.url)
|
url = partner.kyc_compliance_document_file.url
|
||||||
else:
|
else:
|
||||||
url = partner.kyc_compliance_document_file.url
|
url = partner.kyc_compliance_document_file.url
|
||||||
else:
|
else:
|
||||||
@@ -835,6 +835,7 @@ def _user_to_dict(user, request=None):
|
|||||||
user,
|
user,
|
||||||
fields=[
|
fields=[
|
||||||
"id",
|
"id",
|
||||||
|
"eventify_id",
|
||||||
"username",
|
"username",
|
||||||
"email",
|
"email",
|
||||||
"phone_number",
|
"phone_number",
|
||||||
@@ -854,7 +855,7 @@ def _user_to_dict(user, request=None):
|
|||||||
# Add profile picture URL if exists
|
# Add profile picture URL if exists
|
||||||
if user.profile_picture:
|
if user.profile_picture:
|
||||||
if request:
|
if request:
|
||||||
data["profile_picture"] = request.build_absolute_uri(user.profile_picture.url)
|
data["profile_picture"] = user.profile_picture.url
|
||||||
else:
|
else:
|
||||||
data["profile_picture"] = user.profile_picture.url
|
data["profile_picture"] = user.profile_picture.url
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('partner', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Profile extras
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partner',
|
||||||
|
name='bio',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Payout settings
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partner',
|
||||||
|
name='payout_account_holder_name',
|
||||||
|
field=models.CharField(blank=True, max_length=250, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partner',
|
||||||
|
name='payout_account_number',
|
||||||
|
field=models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partner',
|
||||||
|
name='payout_ifsc_code',
|
||||||
|
field=models.CharField(blank=True, max_length=20, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partner',
|
||||||
|
name='payout_bank_name',
|
||||||
|
field=models.CharField(blank=True, max_length=250, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partner',
|
||||||
|
name='payout_schedule',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[('weekly', 'Weekly'), ('biweekly', 'Bi-weekly'), ('monthly', 'Monthly')],
|
||||||
|
default='monthly',
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Notification preferences
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partner',
|
||||||
|
name='notif_new_booking',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partner',
|
||||||
|
name='notif_event_status',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partner',
|
||||||
|
name='notif_payout_update',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partner',
|
||||||
|
name='notif_weekly_report',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -45,7 +45,7 @@ class Partner(models.Model):
|
|||||||
primary_contact_person_email = models.EmailField()
|
primary_contact_person_email = models.EmailField()
|
||||||
primary_contact_person_phone = models.CharField(max_length=15)
|
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)
|
address = models.TextField(blank=True, null=True)
|
||||||
city = models.CharField(max_length=250, blank=True, null=True)
|
city = models.CharField(max_length=250, blank=True, null=True)
|
||||||
@@ -58,12 +58,35 @@ class Partner(models.Model):
|
|||||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
|
longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
|
||||||
|
|
||||||
is_kyc_compliant = models.BooleanField(default=False)
|
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_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_type = models.CharField(max_length=250, choices=KYC_DOCUMENT_TYPE_CHOICES, blank=True, null=True)
|
||||||
kyc_compliance_document_other_type = models.CharField(max_length=250, blank=True, null=True)
|
kyc_compliance_document_other_type = models.CharField(max_length=250, blank=True, null=True)
|
||||||
kyc_compliance_document_file = models.FileField(upload_to='kyc_documents/', blank=True, null=True)
|
kyc_compliance_document_file = models.FileField(upload_to='kyc_documents/', blank=True, null=True)
|
||||||
kyc_compliance_document_number = models.CharField(max_length=250, blank=True, null=True)
|
kyc_compliance_document_number = models.CharField(max_length=250, blank=True, null=True)
|
||||||
|
|
||||||
|
# Profile extras
|
||||||
|
bio = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Payout settings
|
||||||
|
PAYOUT_SCHEDULE_CHOICES = (
|
||||||
|
('weekly', 'Weekly'),
|
||||||
|
('biweekly', 'Bi-weekly'),
|
||||||
|
('monthly', 'Monthly'),
|
||||||
|
)
|
||||||
|
payout_account_holder_name = models.CharField(max_length=250, blank=True, null=True)
|
||||||
|
payout_account_number = models.CharField(max_length=50, blank=True, null=True)
|
||||||
|
payout_ifsc_code = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
payout_bank_name = models.CharField(max_length=250, blank=True, null=True)
|
||||||
|
payout_schedule = models.CharField(
|
||||||
|
max_length=20, choices=PAYOUT_SCHEDULE_CHOICES, default='monthly'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notification preferences
|
||||||
|
notif_new_booking = models.BooleanField(default=True)
|
||||||
|
notif_event_status = models.BooleanField(default=True)
|
||||||
|
notif_payout_update = models.BooleanField(default=True)
|
||||||
|
notif_weekly_report = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
13
requirements-docker.txt
Normal file
13
requirements-docker.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
Django==4.2.21
|
||||||
|
Pillow==10.1.0
|
||||||
|
django-summernote
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
django-cors-headers==4.3.0
|
||||||
|
gunicorn==21.2.0
|
||||||
|
django-extensions==3.2.3
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
djangorestframework-simplejwt==5.3.1
|
||||||
|
google-auth>=2.0.0
|
||||||
|
requests>=2.28.0
|
||||||
|
qrcode[pil]>=7.4.2
|
||||||
|
croniter>=2.0.0
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
Django>=4.2
|
Django>=4.2
|
||||||
Pillow
|
Pillow
|
||||||
|
django-summernote
|
||||||
|
google-auth>=2.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
qrcode[pil]>=7.4.2
|
||||||
|
croniter>=2.0.0
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset='utf-8'>
|
<meta charset='utf-8'>
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||||
<title>Eventify</title>
|
<title>Eventify</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
@@ -54,7 +58,8 @@
|
|||||||
{{ user.email }}
|
{{ user.email }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a></li>
|
</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-danger" href="{% url 'accounts:logout' %}">Logout</a></li>
|
<li class="nav-item"><a class="nav-link text-danger" href="{% url 'accounts:logout' %}">Logout</a>
|
||||||
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'accounts:login' %}">Login</a></li>
|
<li class="nav-item"><a class="nav-link" href="{% url 'accounts:login' %}">Login</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -72,4 +77,5 @@
|
|||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -16,13 +16,35 @@
|
|||||||
*{box-sizing:border-box}
|
*{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}
|
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}
|
.auth-wrapper{display:flex;min-height:100vh}
|
||||||
|
|
||||||
|
/* LEFT PANEL — video */
|
||||||
.auth-left{
|
.auth-left{
|
||||||
|
position:relative;
|
||||||
width:40%;
|
width:40%;
|
||||||
min-width:320px;
|
min-width:320px;
|
||||||
background:linear-gradient(180deg,var(--blue1),var(--blue2));
|
overflow:hidden;
|
||||||
color:#fff;padding:48px;display:flex;flex-direction:column;justify-content:center;gap:10px;
|
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 h1{font-size:36px;margin:0}
|
||||||
.auth-left p{opacity:.92;margin:0;font-size:16px}
|
.auth-left p{opacity:.92;margin:0;font-size:16px}
|
||||||
|
|
||||||
@@ -52,10 +74,12 @@
|
|||||||
.message.warning{background:#fff7e6;color:#7a4b00}
|
.message.warning{background:#fff7e6;color:#7a4b00}
|
||||||
.errorlist{color:#b00020;margin:6px 0 0 0;font-size:13px}
|
.errorlist{color:#b00020;margin:6px 0 0 0;font-size:13px}
|
||||||
|
|
||||||
/* responsive */
|
/* responsive — mobile: hide video, show compact gradient header */
|
||||||
@media (max-width:900px){
|
@media (max-width:900px){
|
||||||
.auth-wrapper{flex-direction:column}
|
.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-right{padding:20px}
|
||||||
.auth-card{border-radius:14px;padding:28px}
|
.auth-card{border-radius:14px;padding:28px}
|
||||||
}
|
}
|
||||||
@@ -64,9 +88,15 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="auth-wrapper">
|
<div class="auth-wrapper">
|
||||||
<div class="auth-left">
|
<div class="auth-left">
|
||||||
|
<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>
|
<div class="brand">Eventify</div>
|
||||||
<p>{% block left_subtext %}Your events at your fingertips.{% endblock %}</p>
|
<p>{% block left_subtext %}Your events at your fingertips.{% endblock %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="auth-right">
|
<div class="auth-right">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
|
|||||||
@@ -5,19 +5,33 @@
|
|||||||
|
|
||||||
<form method="post" novalidate>
|
<form method="post" novalidate>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{{ form.media }}
|
||||||
|
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ field.label_tag }}
|
{{ 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 }}
|
{{ field }}
|
||||||
|
{% endif %}
|
||||||
{% for error in field.errors %}
|
{% for error in field.errors %}
|
||||||
<div class="text-danger">{{ error }}</div>
|
<div class="text-danger">{{ error }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="mb-3 mt-4">
|
||||||
<button class="btn btn-primary">Save</button>
|
<button class="btn btn-primary">Save</button>
|
||||||
<a class="btn btn-secondary" href="{% url 'events:event_list' %}">Cancel</a>
|
<a class="btn btn-secondary" href="{% url 'events:event_list' %}">Cancel</a>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between mb-3">
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
<h3>Events</h3>
|
<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>
|
<a class="btn btn-success" href="{% url 'events:event_add' %}">Add Event</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class="table table-hover">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -44,4 +55,35 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 %}">« 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 »</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
29
update_events.py
Normal file
29
update_events.py
Normal 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
19
user.py
Normal 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!")
|
||||||
Reference in New Issue
Block a user