Compare commits
77 Commits
5e4a1765de
...
sprint/7-c
| Author | SHA1 | Date | |
|---|---|---|---|
| b6c2b93fd0 | |||
| fee67385d5 | |||
| f587c4dd24 | |||
| 4669907a02 | |||
| 611d653938 | |||
| 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 | ||
|
|
7bda3fd905 | ||
|
|
aaaab190da | ||
| b54439a4c2 | |||
|
|
c04395afc9 | ||
|
|
88b3aafb0b | ||
|
|
7fee636fca | ||
|
|
c43ea6b0c7 | ||
|
|
8488df7c14 | ||
|
|
2cd2c763f6 | ||
|
|
1d821bf981 | ||
|
|
1f9269467c | ||
|
|
d1e618e06b | ||
|
|
2d43d4b1e3 |
1
.claude/worktrees/strange-ellis
Submodule
1
.claude/worktrees/strange-ellis
Submodule
Submodule .claude/worktrees/strange-ellis added at 88b3aafb0b
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ db.sqlite3
|
||||
/media/
|
||||
/staticfiles/
|
||||
.env
|
||||
venv/
|
||||
|
||||
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
|
||||
# Clone
|
||||
git clone https://code.bshtech.net/Sicherhaven/eventify_backend.git
|
||||
cd eventify_backend
|
||||
|
||||
# Virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
2. Run migrations and create superuser
|
||||
```bash
|
||||
pip install -r requirements-docker.txt
|
||||
|
||||
# Environment variables
|
||||
cp .env.example .env # set DJANGO_SECRET_KEY, DB_* vars
|
||||
|
||||
# Database
|
||||
python manage.py migrate
|
||||
python manage.py createsuperuser
|
||||
|
||||
# Run
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
## Production notes
|
||||
- Set `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`, and `DJANGO_ALLOWED_HOSTS` environment variables
|
||||
- Collect static files with `python manage.py collectstatic`
|
||||
- Serve via uWSGI/gunicorn + nginx or any WSGI server
|
||||
---
|
||||
|
||||
## ✦ Production Deployment
|
||||
|
||||
```bash
|
||||
# Files are deployed via docker cp (no volume mount)
|
||||
scp admin_api/views.py eventify:/tmp/
|
||||
ssh eventify docker cp /tmp/views.py eventify-backend:/app/admin_api/views.py
|
||||
|
||||
# Reload gunicorn (graceful — no downtime)
|
||||
ssh eventify docker exec eventify-backend kill -HUP 1
|
||||
|
||||
# Verify
|
||||
ssh eventify curl -s http://localhost:3001/api/v1/health/
|
||||
```
|
||||
|
||||
**Containers:**
|
||||
|
||||
| Container | Image | Port | Role |
|
||||
|-----------|-------|------|------|
|
||||
| `eventify-backend` | eventify-django | :3001 | Django API |
|
||||
| `eventify-postgres` | postgres:16-alpine | internal | Database |
|
||||
| `admin-frontend` | admin-prototype | :8084 | React SPA |
|
||||
|
||||
---
|
||||
|
||||
## ✦ Environment Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `DJANGO_SECRET_KEY` | Django secret key | `django-insecure-...` |
|
||||
| `DJANGO_DEBUG` | Debug mode | `False` |
|
||||
| `DJANGO_ALLOWED_HOSTS` | Allowed hostnames | `admin.eventifyplus.com` |
|
||||
| `DB_NAME` | PostgreSQL database | `event_dashboard` |
|
||||
| `DB_USER` | PostgreSQL user | `event_user` |
|
||||
| `DB_PASSWORD` | PostgreSQL password | — |
|
||||
| `DB_HOST` | PostgreSQL host | `eventify-postgres` |
|
||||
| `DB_PORT` | PostgreSQL port | `5432` |
|
||||
|
||||
---
|
||||
|
||||
## ✦ Status Mappings
|
||||
|
||||
### Partner KYC
|
||||
| Backend | Frontend |
|
||||
|---------|----------|
|
||||
| `approved` | `Verified` |
|
||||
| `rejected` | `Rejected` |
|
||||
| `pending` / `high_risk` / `medium_risk` | `Pending` |
|
||||
|
||||
### Event Status
|
||||
| Backend | Frontend |
|
||||
|---------|----------|
|
||||
| `live` | `live` |
|
||||
| `published` | `published` |
|
||||
| `pending` / `created` | `draft` |
|
||||
| `flagged` | `flagged` |
|
||||
| `cancelled` / `postponed` | `cancelled` |
|
||||
| `completed` | `completed` |
|
||||
|
||||
---
|
||||
|
||||
<div align=center>
|
||||
|
||||
**Eventify Admin Backend** · Built by [BSH Technologies](https://bshtechnologies.in)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
947
accounts/api.py
Normal file
947
accounts/api.py
Normal file
@@ -0,0 +1,947 @@
|
||||
from django.http import JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.forms.models import model_to_dict
|
||||
from django.contrib.auth import authenticate, logout
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.authtoken.models import Token
|
||||
import json
|
||||
|
||||
from .models import User
|
||||
from mobile_api.utils import validate_token_and_get_user
|
||||
|
||||
|
||||
def _partner_user_to_dict(user, request=None):
|
||||
"""Serialize partner-related User for JSON (same structure as _user_to_dict)."""
|
||||
data = model_to_dict(
|
||||
user,
|
||||
fields=[
|
||||
"id",
|
||||
"eventify_id",
|
||||
"username",
|
||||
"email",
|
||||
"phone_number",
|
||||
"role",
|
||||
"is_staff",
|
||||
"is_customer",
|
||||
"is_user",
|
||||
"pincode",
|
||||
"district",
|
||||
"state",
|
||||
"country",
|
||||
"place",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"first_name",
|
||||
"last_name",
|
||||
],
|
||||
)
|
||||
# Add profile picture URL if exists
|
||||
if getattr(user, "profile_picture", None):
|
||||
if request:
|
||||
data["profile_picture"] = user.profile_picture.url
|
||||
else:
|
||||
data["profile_picture"] = user.profile_picture.url
|
||||
else:
|
||||
data["profile_picture"] = None
|
||||
return data
|
||||
|
||||
|
||||
def _user_to_dict(user, request=None):
|
||||
"""Serialize any User for JSON (admin/staff or partner)."""
|
||||
return _partner_user_to_dict(user, request)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PartnerLoginAPI(APIView):
|
||||
"""
|
||||
Partner Login API.
|
||||
Body: username (or email), password (required).
|
||||
Returns: token, user data.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
# Parse JSON or form data
|
||||
is_multipart = request.content_type and "multipart/form-data" in request.content_type
|
||||
if is_multipart:
|
||||
data = request.POST.dict()
|
||||
else:
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid JSON"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
username = data.get("username") or data.get("email")
|
||||
password = data.get("password")
|
||||
|
||||
if not username or not password:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "username and password are required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Authenticate user
|
||||
user = authenticate(request, username=username, password=password)
|
||||
if not user:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid username or password."},
|
||||
status=401,
|
||||
)
|
||||
|
||||
# Check if user has partner role
|
||||
partner_roles = ["partner", "partner_manager", "partner_staff"]
|
||||
if user.role not in partner_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to access partner portal."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Get or create token
|
||||
token, _ = Token.objects.get_or_create(user=user)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Login successful",
|
||||
"token": token.key,
|
||||
"user": _partner_user_to_dict(user, request),
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PartnerLogoutAPI(APIView):
|
||||
"""
|
||||
Partner Logout API.
|
||||
Body: token, username (required).
|
||||
Returns: success message.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
# Check if user has partner role
|
||||
partner_roles = ["partner", "partner_manager", "partner_staff"]
|
||||
if user.role not in partner_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to access partner portal."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Delete token
|
||||
token.delete()
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Logged out successfully.",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PartnerDashboardAPI(APIView):
|
||||
"""
|
||||
Partner Dashboard API.
|
||||
Body: token, username (required).
|
||||
Returns: dashboard statistics.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
# Check if user has partner role
|
||||
partner_roles = ["partner", "partner_manager", "partner_staff"]
|
||||
if user.role not in partner_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to access this page."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Get statistics for partner users (including partner_customer)
|
||||
all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"]
|
||||
partner_users = User.objects.filter(role__in=all_partner_roles)
|
||||
total_partner_users = partner_users.count()
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"dashboard": {
|
||||
"total_partner_users": total_partner_users,
|
||||
},
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PartnerListUsersAPI(APIView):
|
||||
"""
|
||||
Partner List Users API.
|
||||
Body: token, username (required);
|
||||
role (optional filter: partner, partner_manager, partner_staff, partner_customer).
|
||||
Returns: list of partner users.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
# Check if user has partner role
|
||||
partner_roles = ["partner", "partner_manager", "partner_staff"]
|
||||
if user.role not in partner_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to access this page."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Filter users by partner-related roles
|
||||
all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"]
|
||||
qs = User.objects.filter(role__in=all_partner_roles).order_by("-id")
|
||||
|
||||
# Optional role filter
|
||||
role_filter = data.get("role")
|
||||
if role_filter:
|
||||
if role_filter not in all_partner_roles:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"Invalid role filter. Must be one of: {', '.join(all_partner_roles)}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
qs = qs.filter(role=role_filter)
|
||||
|
||||
users = [_partner_user_to_dict(u, request) for u in qs]
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"users": users,
|
||||
"total_count": len(users),
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PartnerCreateUserAPI(APIView):
|
||||
"""
|
||||
Partner Create User API.
|
||||
Body: token, username, username (for new user), email, password, role (required);
|
||||
full_name, phone_number, pincode, district, state, country, place, latitude, longitude (optional).
|
||||
Returns: created user data.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
# Check if user has partner role
|
||||
partner_roles = ["partner", "partner_manager", "partner_staff"]
|
||||
if user.role not in partner_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to access this page."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Extract user data
|
||||
new_username = data.get("username")
|
||||
email = data.get("email")
|
||||
password = data.get("password")
|
||||
role = data.get("role")
|
||||
full_name = data.get("full_name", "").strip()
|
||||
|
||||
if not all([new_username, email, password, role]):
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "username, email, password, and role are required.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Validate role - must be one of the partner-related roles
|
||||
valid_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"]
|
||||
if role not in valid_partner_roles:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"Invalid role. Must be one of: {', '.join(valid_partner_roles)}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Check if username already exists
|
||||
if User.objects.filter(username=new_username).exists():
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Username already exists."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
if User.objects.filter(email=email).exists():
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Email already exists."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Create user
|
||||
new_user = User.objects.create_user(
|
||||
username=new_username,
|
||||
email=email,
|
||||
password=password,
|
||||
role=role,
|
||||
phone_number=data.get("phone_number"),
|
||||
pincode=data.get("pincode"),
|
||||
district=data.get("district"),
|
||||
state=data.get("state"),
|
||||
country=data.get("country"),
|
||||
place=data.get("place"),
|
||||
)
|
||||
|
||||
# Handle full_name - split into first_name and last_name
|
||||
if full_name:
|
||||
parts = full_name.split(None, 1)
|
||||
new_user.first_name = parts[0]
|
||||
if len(parts) > 1:
|
||||
new_user.last_name = parts[1]
|
||||
|
||||
# Set location coordinates if provided
|
||||
if data.get("latitude") is not None:
|
||||
try:
|
||||
latitude = float(data["latitude"])
|
||||
if latitude < -90 or latitude > 90:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "latitude must be between -90 and 90."},
|
||||
status=400,
|
||||
)
|
||||
new_user.latitude = latitude
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "latitude must be numeric."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if data.get("longitude") is not None:
|
||||
try:
|
||||
longitude = float(data["longitude"])
|
||||
if longitude < -180 or longitude > 180:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "longitude must be between -180 and 180."},
|
||||
status=400,
|
||||
)
|
||||
new_user.longitude = longitude
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "longitude must be numeric."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Handle profile picture upload if provided
|
||||
if "profile_picture" in request.FILES:
|
||||
new_user.profile_picture = request.FILES["profile_picture"]
|
||||
|
||||
new_user.save()
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"User created successfully with role: {role}.",
|
||||
"user": _partner_user_to_dict(new_user, request),
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PartnerUpdateUserAPI(APIView):
|
||||
"""
|
||||
Partner Update User API.
|
||||
Body: token, username, user_id (required);
|
||||
email, phone_number, role, full_name, pincode, district, state,
|
||||
country, place, latitude, longitude, password, profile_picture (optional).
|
||||
Returns: updated user data.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
# Check if user has partner role
|
||||
partner_roles = ["partner", "partner_manager", "partner_staff"]
|
||||
if user.role not in partner_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to access this page."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "user_id is required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
target_user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "User not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
# Validate that the user has a partner-related role
|
||||
all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"]
|
||||
if target_user.role not in all_partner_roles:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "User is not a partner-related user. Only users with partner roles can be updated.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Update fields if provided
|
||||
if data.get("email") is not None:
|
||||
new_email = data["email"]
|
||||
# Check if email already exists for another user
|
||||
if User.objects.filter(email=new_email).exclude(id=user_id).exists():
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Email already exists for another user."},
|
||||
status=400,
|
||||
)
|
||||
target_user.email = new_email
|
||||
|
||||
if data.get("phone_number") is not None:
|
||||
target_user.phone_number = data["phone_number"] or None
|
||||
|
||||
if data.get("role") is not None:
|
||||
new_role = data["role"]
|
||||
if new_role not in all_partner_roles:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"Invalid role. Must be one of: {', '.join(all_partner_roles)}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
target_user.role = new_role
|
||||
|
||||
# Handle full_name
|
||||
if data.get("full_name"):
|
||||
full_name = data["full_name"].strip()
|
||||
if full_name:
|
||||
parts = full_name.split(None, 1)
|
||||
target_user.first_name = parts[0]
|
||||
if len(parts) > 1:
|
||||
target_user.last_name = parts[1]
|
||||
else:
|
||||
target_user.last_name = ""
|
||||
|
||||
if "pincode" in data:
|
||||
target_user.pincode = data["pincode"] or None
|
||||
if "district" in data:
|
||||
target_user.district = data["district"] or None
|
||||
if "state" in data:
|
||||
target_user.state = data["state"] or None
|
||||
if "country" in data:
|
||||
target_user.country = data["country"] or None
|
||||
if "place" in data:
|
||||
target_user.place = data["place"] or None
|
||||
|
||||
if data.get("latitude") is not None:
|
||||
try:
|
||||
latitude = float(data["latitude"])
|
||||
if latitude < -90 or latitude > 90:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "latitude must be between -90 and 90."},
|
||||
status=400,
|
||||
)
|
||||
target_user.latitude = latitude
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "latitude must be numeric."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if data.get("longitude") is not None:
|
||||
try:
|
||||
longitude = float(data["longitude"])
|
||||
if longitude < -180 or longitude > 180:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "longitude must be between -180 and 180."},
|
||||
status=400,
|
||||
)
|
||||
target_user.longitude = longitude
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "longitude must be numeric."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Handle profile picture upload if provided
|
||||
if "profile_picture" in request.FILES:
|
||||
target_user.profile_picture = request.FILES["profile_picture"]
|
||||
|
||||
# Handle password update if provided
|
||||
if data.get("password"):
|
||||
target_user.set_password(data["password"])
|
||||
|
||||
target_user.save()
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Partner user updated successfully.",
|
||||
"user": _partner_user_to_dict(target_user, request),
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PartnerDeleteUserAPI(APIView):
|
||||
"""
|
||||
Partner Delete User API.
|
||||
Body: token, username, user_id (required).
|
||||
Returns: success message.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
# Check if user has partner role
|
||||
partner_roles = ["partner", "partner_manager", "partner_staff"]
|
||||
if user.role not in partner_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to access this page."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "user_id is required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
target_user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "User not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
# Validate that the user has a partner-related role
|
||||
all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"]
|
||||
if target_user.role not in all_partner_roles:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "User is not a partner-related user. Only users with partner roles can be deleted.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Prevent deleting yourself
|
||||
if target_user.id == user.id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You cannot delete your own account."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
username = target_user.username
|
||||
target_user.delete()
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"Partner user '{username}' deleted successfully.",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class LoginAPI(APIView):
|
||||
"""
|
||||
Admin/Staff Login API (accounts).
|
||||
Body: username (or email), password (required).
|
||||
Returns: token and user details for admin/manager/staff roles.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
# Parse JSON or form data
|
||||
is_multipart = request.content_type and "multipart/form-data" in request.content_type
|
||||
if is_multipart:
|
||||
data = request.POST.dict()
|
||||
else:
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid JSON"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
username = data.get("username") or data.get("email")
|
||||
password = data.get("password")
|
||||
|
||||
if not username or not password:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "username and password are required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
user = authenticate(request, username=username, password=password)
|
||||
if not user:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid username or password."},
|
||||
status=401,
|
||||
)
|
||||
|
||||
# Only allow admin/manager/staff to use this login
|
||||
allowed_roles = ["admin", "manager", "staff"]
|
||||
if user.role not in allowed_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to access the admin portal."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
token, _ = Token.objects.get_or_create(user=user)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Login successful",
|
||||
"token": token.key,
|
||||
"user": _user_to_dict(user, request),
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class LogoutAPI(APIView):
|
||||
"""
|
||||
Logout API for token-based sessions.
|
||||
Body: token, username (required).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
logout(request)
|
||||
token.delete()
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Logout successful.",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class UserListAPI(APIView):
|
||||
"""
|
||||
List users (admin / manager / staff only).
|
||||
Body: token, username (required); optional role filter.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
# Only allow admin/manager/staff to list users
|
||||
allowed_roles = ["admin", "manager", "staff"]
|
||||
if user.role not in allowed_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to list users."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
qs = User.objects.all().order_by("-id")
|
||||
role_filter = data.get("role")
|
||||
if role_filter:
|
||||
qs = qs.filter(role=role_filter)
|
||||
|
||||
users = [_user_to_dict(u, request) for u in qs]
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"users": users,
|
||||
"total_count": len(users),
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class UserCreateAPI(APIView):
|
||||
"""
|
||||
Create a user (admin / manager / staff only).
|
||||
Body: token, username, new_username, email, password, role ('admin'|'manager'|'staff'), phone_number (optional).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
allowed_roles = ["admin", "manager", "staff"]
|
||||
if user.role not in allowed_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to create users."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
new_username = data.get("username") or data.get("new_username")
|
||||
email = data.get("email")
|
||||
password = data.get("password")
|
||||
role = data.get("role")
|
||||
|
||||
if not all([new_username, email, password, role]):
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "username, email, password, and role are required.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
valid_roles = ["admin", "manager", "staff"]
|
||||
if role not in valid_roles:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"Invalid role. Must be one of: {', '.join(valid_roles)}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if User.objects.filter(username=new_username).exists():
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Username already exists."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if User.objects.filter(email=email).exists():
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Email already exists."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
new_user = User.objects.create_user(
|
||||
username=new_username,
|
||||
email=email,
|
||||
password=password,
|
||||
role=role,
|
||||
phone_number=data.get("phone_number"),
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"User created successfully with role: {role}.",
|
||||
"user": _user_to_dict(new_user, request),
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class UserUpdateAPI(APIView):
|
||||
"""
|
||||
Update a user (admin / manager / staff only).
|
||||
Body: token, username, user_id (required); email, phone_number, role (optional).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
allowed_roles = ["admin", "manager", "staff"]
|
||||
if user.role not in allowed_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to update users."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "user_id is required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
target_user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "User not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
if data.get("email") is not None:
|
||||
new_email = data["email"]
|
||||
if User.objects.filter(email=new_email).exclude(id=user_id).exists():
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Email already exists for another user."},
|
||||
status=400,
|
||||
)
|
||||
target_user.email = new_email
|
||||
|
||||
if data.get("phone_number") is not None:
|
||||
target_user.phone_number = data["phone_number"] or None
|
||||
|
||||
if data.get("role") is not None:
|
||||
new_role = data["role"]
|
||||
valid_roles = ["admin", "manager", "staff"]
|
||||
if new_role not in valid_roles:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"Invalid role. Must be one of: {', '.join(valid_roles)}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
target_user.role = new_role
|
||||
|
||||
target_user.save()
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User updated successfully.",
|
||||
"user": _user_to_dict(target_user, request),
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class UserDeleteAPI(APIView):
|
||||
"""
|
||||
Delete a user (admin / manager / staff only).
|
||||
Body: token, username, user_id (required).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
allowed_roles = ["admin", "manager", "staff"]
|
||||
if user.role not in allowed_roles:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You are not authorized to delete users."},
|
||||
status=403,
|
||||
)
|
||||
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "user_id is required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
target_user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "User not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
if target_user.id == user.id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "You cannot delete your own account."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
username = target_user.username
|
||||
target_user.delete()
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"User '{username}' deleted successfully.",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@@ -88,3 +88,110 @@ class LoginForm(AuthenticationForm):
|
||||
"placeholder": "Enter password"
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
class PartnerUserForm(forms.ModelForm):
|
||||
full_name = forms.CharField(
|
||||
max_length=150,
|
||||
required=True,
|
||||
label="Full Name"
|
||||
)
|
||||
password = forms.CharField(
|
||||
widget=forms.PasswordInput,
|
||||
label="Password",
|
||||
required=True,
|
||||
help_text="Required for new users. Leave blank if you don't want to change the password when editing."
|
||||
)
|
||||
confirm_password = forms.CharField(
|
||||
widget=forms.PasswordInput,
|
||||
label="Confirm Password",
|
||||
required=True
|
||||
)
|
||||
|
||||
phone_number = forms.CharField(
|
||||
max_length=15,
|
||||
required=False,
|
||||
label="Phone Number"
|
||||
)
|
||||
|
||||
ROLE_CHOICES = [
|
||||
('partner', 'Partner'),
|
||||
('partner_manager', 'Partner Manager'),
|
||||
('partner_staff', 'Partner Staff'),
|
||||
('partner_customer', 'Partner Customer'),
|
||||
]
|
||||
|
||||
role = forms.ChoiceField(
|
||||
choices=ROLE_CHOICES,
|
||||
required=True,
|
||||
label="Role"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["username", "email", "phone_number", "role"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({"class": "form-control"})
|
||||
|
||||
# Make password fields optional for updates, required for new users
|
||||
if self.instance and self.instance.pk:
|
||||
self.fields['password'].required = False
|
||||
self.fields['confirm_password'].required = False
|
||||
# Pre-populate full_name from first_name and last_name
|
||||
if self.instance.first_name or self.instance.last_name:
|
||||
self.fields['full_name'].initial = f"{self.instance.first_name} {self.instance.last_name}".strip()
|
||||
else:
|
||||
# For new users, password is required
|
||||
self.fields['password'].required = True
|
||||
self.fields['confirm_password'].required = True
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
password = cleaned_data.get("password")
|
||||
confirm_password = cleaned_data.get("confirm_password")
|
||||
|
||||
# For new users, password is required
|
||||
if not self.instance or not self.instance.pk:
|
||||
if not password:
|
||||
self.add_error("password", "Password is required for new users.")
|
||||
if not confirm_password:
|
||||
self.add_error("confirm_password", "Please confirm your password.")
|
||||
|
||||
# Validate password match if password is provided
|
||||
if password or confirm_password:
|
||||
if password != confirm_password:
|
||||
self.add_error("confirm_password", "Passwords do not match!")
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
|
||||
# Set password - required for new users, optional for updates
|
||||
password = self.cleaned_data.get('password')
|
||||
if password:
|
||||
user.set_password(password)
|
||||
elif not user.pk:
|
||||
# New user must have a password
|
||||
raise ValueError("Password is required for new users.")
|
||||
|
||||
# Save phone_number and role to the User model
|
||||
user.phone_number = self.cleaned_data.get("phone_number")
|
||||
user.role = self.cleaned_data.get("role")
|
||||
|
||||
# Handle full_name - split into first_name and last_name
|
||||
full_name = self.cleaned_data.get("full_name", "").strip()
|
||||
if full_name:
|
||||
parts = full_name.split(None, 1)
|
||||
user.first_name = parts[0]
|
||||
if len(parts) > 1:
|
||||
user.last_name = parts[1]
|
||||
else:
|
||||
user.last_name = ""
|
||||
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
18
accounts/migrations/0007_alter_user_profile_picture.py
Normal file
18
accounts/migrations/0007_alter_user_profile_picture.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0 on 2025-12-19 22:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0006_user_profile_picture'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='profile_picture',
|
||||
field=models.ImageField(blank=True, default='default.png', null=True, upload_to='profile_pictures/'),
|
||||
),
|
||||
]
|
||||
18
accounts/migrations/0008_alter_user_role.py
Normal file
18
accounts/migrations/0008_alter_user_role.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.27 on 2026-03-13 16:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0007_alter_user_profile_picture'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('admin', 'Admin'), ('manager', 'Manager'), ('staff', 'Staff'), ('customer', 'Customer'), ('partner', 'Partner'), ('partner_manager', 'Partner Manager'), ('partner_staff', 'Partner Staff'), ('partner_customer', 'Partner Customer')], default='Staff', max_length=20),
|
||||
),
|
||||
]
|
||||
20
accounts/migrations/0009_user_partner.py
Normal file
20
accounts/migrations/0009_user_partner.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.27 on 2026-03-14 07:00
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('partner', '0001_initial'),
|
||||
('accounts', '0008_alter_user_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='partner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='partner.partner'),
|
||||
),
|
||||
]
|
||||
18
accounts/migrations/0010_alter_user_id.py
Normal file
18
accounts/migrations/0010_alter_user_id.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-03-14 19:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0009_user_partner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
@@ -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,19 +1,50 @@
|
||||
import secrets
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
from accounts.manager import UserManager
|
||||
from partner.models import Partner
|
||||
|
||||
EVENTIFY_ID_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' # no I, O, 0, 1
|
||||
|
||||
|
||||
def generate_eventify_id():
|
||||
return 'EVT-' + ''.join(secrets.choice(EVENTIFY_ID_CHARS) for _ in range(8))
|
||||
|
||||
|
||||
ROLE_CHOICES = (
|
||||
('admin', 'Admin'),
|
||||
('manager', 'Manager'),
|
||||
('staff', 'Staff'),
|
||||
('customer', 'Customer'),
|
||||
('partner', 'Partner'),
|
||||
('partner_manager', 'Partner Manager'),
|
||||
('partner_staff', 'Partner Staff'),
|
||||
('partner_customer', 'Partner Customer'),
|
||||
)
|
||||
|
||||
VALID_DISTRICTS = [
|
||||
"Thiruvananthapuram", "Kollam", "Pathanamthitta", "Alappuzha", "Kottayam",
|
||||
"Idukki", "Ernakulam", "Thrissur", "Palakkad", "Malappuram",
|
||||
"Kozhikode", "Wayanad", "Kannur", "Kasaragod",
|
||||
]
|
||||
|
||||
class User(AbstractUser):
|
||||
eventify_id = models.CharField(
|
||||
max_length=12,
|
||||
unique=True,
|
||||
editable=False,
|
||||
db_index=True,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
phone_number = models.CharField(max_length=15, blank=True, null=True)
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='Staff')
|
||||
|
||||
partner = models.ForeignKey(Partner, on_delete=models.CASCADE, blank=True, null=True)
|
||||
|
||||
is_staff = models.BooleanField(default=False)
|
||||
is_customer = models.BooleanField(default=False)
|
||||
is_user = models.BooleanField(default=False)
|
||||
@@ -24,6 +55,7 @@ class User(AbstractUser):
|
||||
state = models.CharField(max_length=100, blank=True, null=True)
|
||||
country = models.CharField(max_length=100, blank=True, null=True)
|
||||
place = models.CharField(max_length=200, blank=True, null=True)
|
||||
district_changed_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
# Location fields
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
|
||||
@@ -31,7 +63,33 @@ class User(AbstractUser):
|
||||
|
||||
profile_picture = models.ImageField(upload_to='profile_pictures/', blank=True, null=True, default='default.png')
|
||||
|
||||
allowed_modules = models.TextField(
|
||||
blank=True, null=True,
|
||||
help_text='Comma-separated module slugs this user can access',
|
||||
)
|
||||
|
||||
ALL_MODULES = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "audit-log", "settings"]
|
||||
|
||||
def get_allowed_modules(self):
|
||||
ALL = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "audit-log", "settings"]
|
||||
if self.is_superuser or self.role == "admin":
|
||||
return ALL
|
||||
if self.allowed_modules:
|
||||
return [m.strip() for m in self.allowed_modules.split(",") if m.strip()]
|
||||
if self.role == "manager":
|
||||
return ALL
|
||||
return []
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.eventify_id:
|
||||
for _ in range(10):
|
||||
candidate = generate_eventify_id()
|
||||
if not User.objects.filter(eventify_id=candidate).exists():
|
||||
self.eventify_id = candidate
|
||||
break
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
from . import views, api
|
||||
|
||||
app_name = 'accounts'
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.login_view, name='login'),
|
||||
path('logout/', views.logout_view, name='logout'),
|
||||
path('dashboard/', views.dashboard, name='dashboard'),
|
||||
path('users/', views.UserListView.as_view(), name='user_list'),
|
||||
path('users/add/', views.UserCreateView.as_view(), name='user_add'),
|
||||
path('users/<int:pk>/edit/', views.UserUpdateView.as_view(), name='user_edit'),
|
||||
path('users/<int:pk>/delete/', views.UserDeleteView.as_view(), name='user_delete'),
|
||||
path("login/", views.login_view, name="login"),
|
||||
path("logout/", views.logout_view, name="logout"),
|
||||
path("dashboard/", views.dashboard, name="dashboard"),
|
||||
path("users/", views.UserListView.as_view(), name="user_list"),
|
||||
path("users/add/", views.UserCreateView.as_view(), name="user_add"),
|
||||
path("users/<int:pk>/edit/", views.UserUpdateView.as_view(), name="user_edit"),
|
||||
path("users/<int:pk>/delete/", views.UserDeleteView.as_view(), name="user_delete"),
|
||||
]
|
||||
|
||||
|
||||
# Core account APIs (admin/staff)
|
||||
urlpatterns += [
|
||||
path("api/login/", api.LoginAPI.as_view(), name="api_login"),
|
||||
path("api/logout/", api.LogoutAPI.as_view(), name="api_logout"),
|
||||
path("api/users/list/", api.UserListAPI.as_view(), name="api_user_list"),
|
||||
path("api/users/create/", api.UserCreateAPI.as_view(), name="api_user_create"),
|
||||
path("api/users/update/", api.UserUpdateAPI.as_view(), name="api_user_update"),
|
||||
path("api/users/delete/", api.UserDeleteAPI.as_view(), name="api_user_delete"),
|
||||
]
|
||||
|
||||
|
||||
# Partner APIs
|
||||
urlpatterns += [
|
||||
path("api/partner/login/", api.PartnerLoginAPI.as_view(), name="partner_api_login"),
|
||||
path("api/partner/logout/", api.PartnerLogoutAPI.as_view(), name="partner_api_logout"),
|
||||
path("api/partner/dashboard/", api.PartnerDashboardAPI.as_view(), name="partner_api_dashboard"),
|
||||
path("api/partner/users/list/", api.PartnerListUsersAPI.as_view(), name="partner_api_user_list"),
|
||||
path("api/partner/users/create/", api.PartnerCreateUserAPI.as_view(), name="partner_api_user_create"),
|
||||
path("api/partner/users/update/", api.PartnerUpdateUserAPI.as_view(), name="partner_api_user_update"),
|
||||
path("api/partner/users/delete/", api.PartnerDeleteUserAPI.as_view(), name="partner_api_user_delete"),
|
||||
]
|
||||
@@ -1,16 +1,16 @@
|
||||
from django.shortcuts import render
|
||||
from django.shortcuts import render, redirect
|
||||
from django.views import generic
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
|
||||
from .models import User
|
||||
from .forms import LoginForm
|
||||
from .forms import UserForm
|
||||
from .forms import LoginForm, UserForm, PartnerUserForm
|
||||
from events.models import Event
|
||||
from master_data.models import EventType
|
||||
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from eventify_logger.services import log
|
||||
|
||||
|
||||
def dashboard(request):
|
||||
@@ -62,16 +62,150 @@ def login_view(request):
|
||||
user = form.get_user()
|
||||
login(request, user)
|
||||
if user.role == 'admin' or user.role == 'manager' or user.role == 'staff':
|
||||
log("info", "Admin/Manager/Staff login", request=request, user=user)
|
||||
return redirect("accounts:dashboard")
|
||||
else:
|
||||
log("warning", "Login attempt - user not authorized", request=request, user=user)
|
||||
messages.error(request, "You are not authorized to access this page.")
|
||||
else:
|
||||
log("warning", "Invalid login attempt", request=request)
|
||||
messages.error(request, "Invalid username or password")
|
||||
|
||||
return render(request, "accounts/login.html", {"form": form})
|
||||
|
||||
|
||||
def logout_view(request):
|
||||
if request.user.is_authenticated:
|
||||
log("info", "User logout", request=request, user=request.user)
|
||||
logout(request)
|
||||
messages.success(request, "You have been logged out successfully.")
|
||||
return redirect("accounts:login")
|
||||
return redirect("accounts:login")
|
||||
|
||||
|
||||
# Partner Views Mixin
|
||||
class PartnerRequiredMixin(LoginRequiredMixin):
|
||||
"""Mixin to ensure user has partner role (partner, partner_manager, partner_staff)"""
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
partner_roles = ['partner', 'partner_manager', 'partner_staff']
|
||||
if request.user.role not in partner_roles:
|
||||
raise PermissionDenied("You are not authorized to access this page.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
# Partner Login/Logout/Dashboard
|
||||
def partner_login_view(request):
|
||||
if request.user.is_authenticated:
|
||||
partner_roles = ['partner', 'partner_manager', 'partner_staff']
|
||||
if request.user.role in partner_roles:
|
||||
return redirect("accounts:partner_dashboard")
|
||||
else:
|
||||
messages.error(request, "You are not authorized to access partner portal.")
|
||||
return redirect("accounts:login")
|
||||
|
||||
form = LoginForm(request, data=request.POST or None)
|
||||
|
||||
if request.method == "POST":
|
||||
if form.is_valid():
|
||||
user = form.get_user()
|
||||
partner_roles = ['partner', 'partner_manager', 'partner_staff']
|
||||
if user.role in partner_roles:
|
||||
log("info", "Partner portal login", request=request, user=user)
|
||||
login(request, user)
|
||||
return redirect("accounts:partner_dashboard")
|
||||
else:
|
||||
log("warning", "Partner login - user not authorized", request=request, user=user)
|
||||
messages.error(request, "You are not authorized to access partner portal.")
|
||||
else:
|
||||
log("warning", "Partner portal - invalid login attempt", request=request)
|
||||
messages.error(request, "Invalid username or password")
|
||||
|
||||
return render(request, "partner/login.html", {"form": form})
|
||||
|
||||
|
||||
def partner_logout_view(request):
|
||||
if request.user.is_authenticated:
|
||||
log("info", "Partner portal logout", request=request, user=request.user)
|
||||
logout(request)
|
||||
messages.success(request, "You have been logged out successfully.")
|
||||
return redirect("accounts:partner_login")
|
||||
|
||||
|
||||
def partner_dashboard(request):
|
||||
"""Partner dashboard view"""
|
||||
partner_roles = ['partner', 'partner_manager', 'partner_staff']
|
||||
if not request.user.is_authenticated or request.user.role not in partner_roles:
|
||||
messages.error(request, "You are not authorized to access this page.")
|
||||
return redirect("accounts:partner_login")
|
||||
|
||||
# Get statistics for partner users (including partner_customer)
|
||||
all_partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer']
|
||||
partner_users = User.objects.filter(role__in=all_partner_roles)
|
||||
total_partner_users = partner_users.count()
|
||||
|
||||
# You can add more partner-specific statistics here
|
||||
# For example, events created by partner, bookings, etc.
|
||||
|
||||
return render(request, 'partner/dashboard.html', {
|
||||
'total_partner_users': total_partner_users,
|
||||
})
|
||||
|
||||
|
||||
# Partner User Management Views
|
||||
class PartnerUserListView(PartnerRequiredMixin, generic.ListView):
|
||||
model = User
|
||||
template_name = 'partner/user_list.html'
|
||||
context_object_name = 'users'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter users to show only partner-related roles"""
|
||||
partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer']
|
||||
return User.objects.filter(role__in=partner_roles).order_by('-id')
|
||||
|
||||
|
||||
class PartnerUserCreateView(PartnerRequiredMixin, generic.CreateView):
|
||||
model = User
|
||||
form_class = PartnerUserForm
|
||||
template_name = 'partner/user_form.html'
|
||||
success_url = reverse_lazy('accounts:partner_user_list')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, "Partner user created successfully.")
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class PartnerUserUpdateView(PartnerRequiredMixin, generic.UpdateView):
|
||||
model = User
|
||||
form_class = PartnerUserForm
|
||||
template_name = 'partner/user_form.html'
|
||||
success_url = reverse_lazy('accounts:partner_user_list')
|
||||
|
||||
def get_queryset(self):
|
||||
"""Only allow editing users with partner-related roles"""
|
||||
partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer']
|
||||
return User.objects.filter(role__in=partner_roles)
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, "Partner user updated successfully.")
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class PartnerUserDeleteView(PartnerRequiredMixin, generic.DeleteView):
|
||||
model = User
|
||||
template_name = 'partner/user_confirm_delete.html'
|
||||
success_url = reverse_lazy('accounts:partner_user_list')
|
||||
|
||||
def get_queryset(self):
|
||||
"""Only allow deleting users with partner-related roles"""
|
||||
partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer']
|
||||
return User.objects.filter(role__in=partner_roles)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
# Prevent users from deleting themselves
|
||||
if self.get_object().id == request.user.id:
|
||||
messages.error(request, "You cannot delete your own account.")
|
||||
return redirect(self.success_url)
|
||||
messages.success(request, "Partner user deleted successfully.")
|
||||
return super().delete(request, *args, **kwargs)
|
||||
0
ad_control/__init__.py
Normal file
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',
|
||||
)
|
||||
118
admin_api/urls.py
Normal file
118
admin_api/urls.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/auth/login/', views.AdminLoginView.as_view(), name='admin_login'),
|
||||
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('auth/me/', views.MeView.as_view(), name='auth_me'),
|
||||
path('health/', views.HealthView.as_view(), name='health'),
|
||||
# Phase 2: Dashboard endpoints
|
||||
path('dashboard/metrics/', views.DashboardMetricsView.as_view(), name='dashboard-metrics'),
|
||||
path('dashboard/revenue/', views.DashboardRevenueView.as_view(), name='dashboard-revenue'),
|
||||
path('dashboard/activity/', views.DashboardActivityView.as_view(), name='dashboard-activity'),
|
||||
path('dashboard/actions/', views.DashboardActionsView.as_view(), name='dashboard-actions'),
|
||||
# Phase 3: Partner endpoints
|
||||
path('partners/stats/', views.PartnerStatsView.as_view(), name='partner-stats'),
|
||||
path('partners/', views.PartnerListView.as_view(), name='partner-list'),
|
||||
path('partners/<int:pk>/', views.PartnerDetailView.as_view(), name='partner-detail'),
|
||||
path('partners/<int:pk>/status/', views.PartnerStatusView.as_view(), name='partner-status'),
|
||||
path('partners/<int:pk>/kyc/review/', views.PartnerKYCReviewView.as_view(), name='partner-kyc-review'),
|
||||
path('partners/<int:pk>/impersonate/', views.PartnerImpersonateView.as_view(), name='partner-impersonate'),
|
||||
path('partners/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'),
|
||||
path('partners/<int:partner_id>/staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'),
|
||||
# Partner-Me: partner portal self-service (Sprint 1)
|
||||
path('partners/me/profile/', views.PartnerMeProfileView.as_view(), name='partner-me-profile'),
|
||||
path('partners/me/notifications/', views.PartnerMeNotificationsView.as_view(), name='partner-me-notifications'),
|
||||
path('partners/me/payout/', views.PartnerMePayoutView.as_view(), name='partner-me-payout'),
|
||||
path('partners/me/change-password/', views.PartnerMeChangePasswordView.as_view(), name='partner-me-change-password'),
|
||||
# Partner-Me: events (Sprint 2)
|
||||
path('partners/me/events/', views.PartnerMeEventsView.as_view(), name='partner-me-events'),
|
||||
path('partners/me/events/<int:pk>/', views.PartnerMeEventDetailView.as_view(), name='partner-me-event-detail'),
|
||||
path('partners/me/events/<int:pk>/duplicate/', views.PartnerMeEventDuplicateView.as_view(), name='partner-me-event-duplicate'),
|
||||
# Partner-Me: ticket tiers (Sprint 3)
|
||||
path('partners/me/events/<int:event_pk>/tiers/', views.PartnerMeEventTiersView.as_view(), name='partner-me-event-tiers'),
|
||||
path('partners/me/events/<int:event_pk>/tiers/<int:tier_pk>/', views.PartnerMeEventTierDetailView.as_view(), name='partner-me-event-tier-detail'),
|
||||
# Partner-Me: bookings (Sprint 4)
|
||||
path('partners/me/bookings/', views.PartnerBookingListView.as_view(), name='partner-me-bookings'),
|
||||
# Partner-Me: customers (Sprint 5)
|
||||
path('partners/me/customers/', views.PartnerCustomerListView.as_view(), name='partner-me-customers'),
|
||||
# Partner-Me: staff CRUD (Sprint 6)
|
||||
path('partners/me/staff/', views.PartnerMeStaffListView.as_view(), name='partner-me-staff-list'),
|
||||
path('partners/me/staff/<int:pk>/', views.PartnerMeStaffDetailView.as_view(), name='partner-me-staff-detail'),
|
||||
# Partner-Me: check-in (Sprint 7)
|
||||
path('partners/me/check-in/', views.PartnerMeCheckInView.as_view(), name='partner-me-check-in'),
|
||||
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')),
|
||||
]
|
||||
4284
admin_api/views.py
Normal file
4284
admin_api/views.py
Normal file
File diff suppressed because it is too large
Load Diff
0
banking_operations/__init__.py
Normal file
0
banking_operations/__init__.py
Normal file
3
banking_operations/admin.py
Normal file
3
banking_operations/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
432
banking_operations/api.py
Normal file
432
banking_operations/api.py
Normal file
@@ -0,0 +1,432 @@
|
||||
import uuid
|
||||
from django.http import JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.forms.models import model_to_dict
|
||||
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from banking_operations.models import PaymentGateway, PaymentGatewayCredentials
|
||||
from mobile_api.utils import validate_token_and_get_user
|
||||
|
||||
|
||||
def _payment_gateway_to_dict(gateway, request=None):
|
||||
"""Serialize PaymentGateway for JSON."""
|
||||
data = model_to_dict(
|
||||
gateway,
|
||||
fields=[
|
||||
"id",
|
||||
"payment_gateway_id",
|
||||
"payment_gateway_name",
|
||||
"payment_gateway_description",
|
||||
"payment_gateway_url",
|
||||
"payment_gateway_api_key",
|
||||
"payment_gateway_api_secret",
|
||||
"payment_gateway_api_url",
|
||||
"payment_gateway_api_version",
|
||||
"payment_gateway_api_method",
|
||||
"is_active",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"gateway_priority",
|
||||
],
|
||||
)
|
||||
# Add logo URL if exists
|
||||
if gateway.payment_gateway_logo:
|
||||
if request:
|
||||
data["payment_gateway_logo"] = gateway.payment_gateway_logo.url
|
||||
else:
|
||||
data["payment_gateway_logo"] = gateway.payment_gateway_logo.url
|
||||
else:
|
||||
data["payment_gateway_logo"] = None
|
||||
return data
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PaymentGatewayCreateAPI(APIView):
|
||||
"""
|
||||
Create a new PaymentGateway.
|
||||
Body: token, username, payment_gateway_name, payment_gateway_description,
|
||||
payment_gateway_api_key, payment_gateway_api_secret, payment_gateway_api_version,
|
||||
payment_gateway_api_method (required);
|
||||
payment_gateway_logo (file upload), payment_gateway_url, payment_gateway_api_url,
|
||||
is_active, gateway_priority (optional).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
payment_gateway_name = data.get("payment_gateway_name")
|
||||
payment_gateway_description = data.get("payment_gateway_description")
|
||||
payment_gateway_api_key = data.get("payment_gateway_api_key")
|
||||
payment_gateway_api_secret = data.get("payment_gateway_api_secret")
|
||||
payment_gateway_api_version = data.get("payment_gateway_api_version")
|
||||
payment_gateway_api_method = data.get("payment_gateway_api_method")
|
||||
|
||||
if not all([
|
||||
payment_gateway_name,
|
||||
payment_gateway_description,
|
||||
payment_gateway_api_key,
|
||||
payment_gateway_api_secret,
|
||||
payment_gateway_api_version,
|
||||
payment_gateway_api_method,
|
||||
]):
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "payment_gateway_name, payment_gateway_description, payment_gateway_api_key, "
|
||||
"payment_gateway_api_secret, payment_gateway_api_version, and "
|
||||
"payment_gateway_api_method are required.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Generate payment_gateway_id if not provided
|
||||
payment_gateway_id = data.get("payment_gateway_id")
|
||||
if not payment_gateway_id:
|
||||
payment_gateway_id = str(uuid.uuid4().hex[:10]).upper()
|
||||
|
||||
gateway = PaymentGateway.objects.create(
|
||||
payment_gateway_id=payment_gateway_id,
|
||||
payment_gateway_name=payment_gateway_name,
|
||||
payment_gateway_description=payment_gateway_description,
|
||||
payment_gateway_url=data.get("payment_gateway_url"),
|
||||
payment_gateway_api_key=payment_gateway_api_key,
|
||||
payment_gateway_api_secret=payment_gateway_api_secret,
|
||||
payment_gateway_api_url=data.get("payment_gateway_api_url"),
|
||||
payment_gateway_api_version=payment_gateway_api_version,
|
||||
payment_gateway_api_method=payment_gateway_api_method,
|
||||
is_active=data.get("is_active", True),
|
||||
gateway_priority=data.get("gateway_priority", 0),
|
||||
)
|
||||
|
||||
# Handle logo upload if provided
|
||||
if "payment_gateway_logo" in request.FILES:
|
||||
gateway.payment_gateway_logo = request.FILES["payment_gateway_logo"]
|
||||
gateway.save()
|
||||
|
||||
return JsonResponse(
|
||||
{"status": "success", "payment_gateway": _payment_gateway_to_dict(gateway, request)},
|
||||
status=201,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PaymentGatewayListAPI(APIView):
|
||||
"""
|
||||
List PaymentGateways, optionally filtered by is_active.
|
||||
Body: token, username, is_active (optional).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
qs = PaymentGateway.objects.all().order_by("-gateway_priority", "-created_date")
|
||||
|
||||
is_active = data.get("is_active")
|
||||
if is_active is not None:
|
||||
qs = qs.filter(is_active=bool(is_active))
|
||||
|
||||
gateways = [_payment_gateway_to_dict(g, request) for g in qs]
|
||||
return JsonResponse({"status": "success", "payment_gateways": gateways}, status=200)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PaymentGatewayUpdateAPI(APIView):
|
||||
"""
|
||||
Update an existing PaymentGateway.
|
||||
Body: token, username, payment_gateway_id (required);
|
||||
payment_gateway_name, payment_gateway_description, payment_gateway_url,
|
||||
payment_gateway_api_key, payment_gateway_api_secret, payment_gateway_api_url,
|
||||
payment_gateway_api_version, payment_gateway_api_method, is_active,
|
||||
gateway_priority (optional);
|
||||
payment_gateway_logo (file upload, optional).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
payment_gateway_id = data.get("payment_gateway_id")
|
||||
if not payment_gateway_id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "payment_gateway_id is required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id)
|
||||
except PaymentGateway.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "PaymentGateway not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
# Update fields if provided
|
||||
if data.get("payment_gateway_name") is not None:
|
||||
gateway.payment_gateway_name = data["payment_gateway_name"]
|
||||
if data.get("payment_gateway_description") is not None:
|
||||
gateway.payment_gateway_description = data["payment_gateway_description"]
|
||||
if "payment_gateway_url" in data:
|
||||
gateway.payment_gateway_url = data["payment_gateway_url"] or None
|
||||
if data.get("payment_gateway_api_key") is not None:
|
||||
gateway.payment_gateway_api_key = data["payment_gateway_api_key"]
|
||||
if data.get("payment_gateway_api_secret") is not None:
|
||||
gateway.payment_gateway_api_secret = data["payment_gateway_api_secret"]
|
||||
if "payment_gateway_api_url" in data:
|
||||
gateway.payment_gateway_api_url = data["payment_gateway_api_url"] or None
|
||||
if data.get("payment_gateway_api_version") is not None:
|
||||
gateway.payment_gateway_api_version = data["payment_gateway_api_version"]
|
||||
if data.get("payment_gateway_api_method") is not None:
|
||||
gateway.payment_gateway_api_method = data["payment_gateway_api_method"]
|
||||
if data.get("is_active") is not None:
|
||||
gateway.is_active = bool(data["is_active"])
|
||||
if data.get("gateway_priority") is not None:
|
||||
try:
|
||||
gateway.gateway_priority = int(data["gateway_priority"])
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "gateway_priority must be an integer."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Handle logo upload if provided
|
||||
if "payment_gateway_logo" in request.FILES:
|
||||
gateway.payment_gateway_logo = request.FILES["payment_gateway_logo"]
|
||||
|
||||
gateway.save()
|
||||
return JsonResponse(
|
||||
{"status": "success", "payment_gateway": _payment_gateway_to_dict(gateway, request)},
|
||||
status=200,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PaymentGatewayDeleteAPI(APIView):
|
||||
"""
|
||||
Delete an existing PaymentGateway.
|
||||
Body: token, username, payment_gateway_id.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
payment_gateway_id = data.get("payment_gateway_id")
|
||||
if not payment_gateway_id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "payment_gateway_id is required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id)
|
||||
except PaymentGateway.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "PaymentGateway not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
gateway_name = gateway.payment_gateway_name
|
||||
gateway.delete()
|
||||
return JsonResponse(
|
||||
{"status": "success", "message": f"PaymentGateway '{gateway_name}' deleted successfully."},
|
||||
status=200,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
def _payment_gateway_credentials_to_dict(credentials, request=None):
|
||||
"""Serialize PaymentGatewayCredentials for JSON."""
|
||||
data = model_to_dict(
|
||||
credentials,
|
||||
fields=[
|
||||
"id",
|
||||
"payment_gateway_credentials_value",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
],
|
||||
)
|
||||
data["payment_gateway_id"] = credentials.payment_gateway_id
|
||||
data["payment_gateway_name"] = credentials.payment_gateway.payment_gateway_name
|
||||
data["payment_gateway_payment_gateway_id"] = credentials.payment_gateway.payment_gateway_id
|
||||
return data
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PaymentGatewayCredentialsCreateAPI(APIView):
|
||||
"""
|
||||
Create a new PaymentGatewayCredentials.
|
||||
Body: token, username, payment_gateway_id (or payment_gateway_payment_gateway_id),
|
||||
payment_gateway_credentials_value (required).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
payment_gateway_id = data.get("payment_gateway_id") or data.get("payment_gateway_payment_gateway_id")
|
||||
payment_gateway_credentials_value = data.get("payment_gateway_credentials_value")
|
||||
|
||||
if not payment_gateway_id or not payment_gateway_credentials_value:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "payment_gateway_id (or payment_gateway_payment_gateway_id) and payment_gateway_credentials_value are required.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id)
|
||||
except PaymentGateway.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "PaymentGateway not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
credentials = PaymentGatewayCredentials.objects.create(
|
||||
payment_gateway=gateway,
|
||||
payment_gateway_credentials_value=payment_gateway_credentials_value,
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{"status": "success", "credentials": _payment_gateway_credentials_to_dict(credentials, request)},
|
||||
status=201,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PaymentGatewayCredentialsListAPI(APIView):
|
||||
"""
|
||||
List PaymentGatewayCredentials, optionally filtered by payment_gateway_id.
|
||||
Body: token, username, payment_gateway_id (optional).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
qs = PaymentGatewayCredentials.objects.select_related("payment_gateway").all().order_by("-created_date")
|
||||
|
||||
payment_gateway_id = data.get("payment_gateway_id") or data.get("payment_gateway_payment_gateway_id")
|
||||
if payment_gateway_id:
|
||||
try:
|
||||
gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id)
|
||||
qs = qs.filter(payment_gateway=gateway)
|
||||
except PaymentGateway.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "PaymentGateway not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
credentials_list = [_payment_gateway_credentials_to_dict(c, request) for c in qs]
|
||||
return JsonResponse({"status": "success", "credentials": credentials_list}, status=200)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PaymentGatewayCredentialsUpdateAPI(APIView):
|
||||
"""
|
||||
Update an existing PaymentGatewayCredentials.
|
||||
Body: token, username, credentials_id (required);
|
||||
payment_gateway_credentials_value (optional).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
credentials_id = data.get("credentials_id")
|
||||
if not credentials_id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "credentials_id is required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
credentials = PaymentGatewayCredentials.objects.select_related("payment_gateway").get(id=credentials_id)
|
||||
except PaymentGatewayCredentials.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "PaymentGatewayCredentials not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
# Update credentials value if provided
|
||||
if data.get("payment_gateway_credentials_value") is not None:
|
||||
credentials.payment_gateway_credentials_value = data["payment_gateway_credentials_value"]
|
||||
|
||||
credentials.save()
|
||||
return JsonResponse(
|
||||
{"status": "success", "credentials": _payment_gateway_credentials_to_dict(credentials, request)},
|
||||
status=200,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class PaymentGatewayCredentialsDeleteAPI(APIView):
|
||||
"""
|
||||
Delete an existing PaymentGatewayCredentials.
|
||||
Body: token, username, credentials_id.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
credentials_id = data.get("credentials_id")
|
||||
if not credentials_id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "credentials_id is required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
credentials = PaymentGatewayCredentials.objects.select_related("payment_gateway").get(id=credentials_id)
|
||||
except PaymentGatewayCredentials.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "PaymentGatewayCredentials not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
gateway_name = credentials.payment_gateway.payment_gateway_name
|
||||
credentials.delete()
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"PaymentGatewayCredentials for '{gateway_name}' deleted successfully.",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
6
banking_operations/apps.py
Normal file
6
banking_operations/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BankingOperationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'banking_operations'
|
||||
69
banking_operations/migrations/0001_initial.py
Normal file
69
banking_operations/migrations/0001_initial.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# Generated by Django 4.2.27 on 2026-03-13 16:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PaymentGateway',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('payment_gateway_id', models.CharField(max_length=250)),
|
||||
('payment_gateway_name', models.CharField(max_length=250)),
|
||||
('payment_gateway_description', models.TextField()),
|
||||
('payment_gateway_logo', models.ImageField(blank=True, null=True, upload_to='payment_gateways/')),
|
||||
('payment_gateway_url', models.URLField(blank=True, null=True)),
|
||||
('payment_gateway_api_key', models.CharField(max_length=250)),
|
||||
('payment_gateway_api_secret', models.CharField(max_length=250)),
|
||||
('payment_gateway_api_url', models.URLField(blank=True, null=True)),
|
||||
('payment_gateway_api_version', models.CharField(max_length=250)),
|
||||
('payment_gateway_api_method', models.CharField(max_length=250)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_date', models.DateField(auto_now_add=True)),
|
||||
('updated_date', models.DateField(auto_now=True)),
|
||||
('gateway_priority', models.IntegerField(default=0)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentTransaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('payment_transaction_id', models.CharField(max_length=250)),
|
||||
('payment_type', models.CharField(choices=[('credit', 'Credit'), ('debit', 'Debit'), ('transfer', 'Transfer'), ('other', 'Other')], max_length=250)),
|
||||
('payment_sub_type', models.CharField(choices=[('online', 'Online'), ('offline', 'Offline'), ('other', 'Other')], max_length=250)),
|
||||
('payment_transaction_amount', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('payment_transaction_currency', models.CharField(max_length=10)),
|
||||
('payment_transaction_status', models.CharField(choices=[('pending', 'Pending'), ('completed', 'Completed'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('cancelled', 'Cancelled')], max_length=250)),
|
||||
('payment_transaction_date', models.DateField(auto_now_add=True)),
|
||||
('payment_transaction_time', models.TimeField(auto_now_add=True)),
|
||||
('payment_transaction_notes', models.TextField(blank=True, null=True)),
|
||||
('payment_transaction_raw_data', models.JSONField(blank=True, null=True)),
|
||||
('payment_transaction_response', models.JSONField(blank=True, null=True)),
|
||||
('payment_transaction_error', models.JSONField(blank=True, null=True)),
|
||||
('last_updated_date', models.DateField(blank=True, null=True)),
|
||||
('last_updated_time', models.TimeField(blank=True, null=True)),
|
||||
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('payment_gateway', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='banking_operations.paymentgateway')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentGatewayCredentials',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('payment_gateway_credentials_value', models.TextField()),
|
||||
('created_date', models.DateField(auto_now_add=True)),
|
||||
('updated_date', models.DateField(auto_now=True)),
|
||||
('payment_gateway', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='banking_operations.paymentgateway')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
banking_operations/migrations/__init__.py
Normal file
0
banking_operations/migrations/__init__.py
Normal file
85
banking_operations/models.py
Normal file
85
banking_operations/models.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
import uuid
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Create your models here.
|
||||
class PaymentGateway(models.Model):
|
||||
payment_gateway_id = models.CharField(max_length=250)
|
||||
payment_gateway_name = models.CharField(max_length=250)
|
||||
payment_gateway_description = models.TextField()
|
||||
payment_gateway_logo = models.ImageField(upload_to='payment_gateways/', blank=True, null=True)
|
||||
payment_gateway_url = models.URLField(blank=True, null=True)
|
||||
payment_gateway_api_key = models.CharField(max_length=250)
|
||||
payment_gateway_api_secret = models.CharField(max_length=250)
|
||||
payment_gateway_api_url = models.URLField(blank=True, null=True)
|
||||
payment_gateway_api_version = models.CharField(max_length=250)
|
||||
payment_gateway_api_method = models.CharField(max_length=250)
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
created_date = models.DateField(auto_now_add=True)
|
||||
updated_date = models.DateField(auto_now=True)
|
||||
|
||||
gateway_priority = models.IntegerField(default=0)
|
||||
|
||||
def __str__(self):
|
||||
return self.payment_gateway_name
|
||||
|
||||
def __save__(self):
|
||||
if not self.payment_gateway_id:
|
||||
self.payment_gateway_id = str(uuid.uuid4().hex[:10]).upper()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class PaymentGatewayCredentials(models.Model):
|
||||
payment_gateway = models.ForeignKey(PaymentGateway, on_delete=models.CASCADE)
|
||||
payment_gateway_credentials_value = models.TextField()
|
||||
created_date = models.DateField(auto_now_add=True)
|
||||
updated_date = models.DateField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.payment_gateway.payment_gateway_name + " - " + self.payment_gateway_credentials_value
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
payment_transaction_id = models.CharField(max_length=250)
|
||||
payment_type = models.CharField(max_length=250, db_index=True, choices=[
|
||||
('credit', 'Credit'),
|
||||
('debit', 'Debit'),
|
||||
('transfer', 'Transfer'),
|
||||
('other', 'Other'),
|
||||
])
|
||||
payment_sub_type = models.CharField(max_length=250, choices=[
|
||||
('online', 'Online'),
|
||||
('offline', 'Offline'),
|
||||
('other', 'Other'),
|
||||
])
|
||||
payment_gateway = models.ForeignKey(PaymentGateway, on_delete=models.CASCADE)
|
||||
payment_transaction_amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
payment_transaction_currency = models.CharField(max_length=10)
|
||||
payment_transaction_status = models.CharField(max_length=250, db_index=True, choices=[
|
||||
('pending', 'Pending'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
('refunded', 'Refunded'),
|
||||
('cancelled', 'Cancelled'),
|
||||
])
|
||||
payment_transaction_date = models.DateField(auto_now_add=True, db_index=True)
|
||||
payment_transaction_time = models.TimeField(auto_now_add=True)
|
||||
payment_transaction_notes = models.TextField(blank=True, null=True)
|
||||
payment_transaction_raw_data = models.JSONField(blank=True, null=True)
|
||||
payment_transaction_response = models.JSONField(blank=True, null=True)
|
||||
payment_transaction_error = models.JSONField(blank=True, null=True)
|
||||
|
||||
last_updated_date = models.DateField(blank=True, null=True)
|
||||
last_updated_time = models.TimeField(blank=True, null=True)
|
||||
last_updated_by = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.payment_gateway.payment_gateway_name + " - " + self.payment_transaction_id
|
||||
|
||||
def __save__(self):
|
||||
if not self.payment_transaction_id:
|
||||
self.payment_transaction_id = str(self.payment_gateway.payment_gateway_name[:3].upper()) + str(uuid.uuid4().hex[:10]).upper()
|
||||
super().save(*args, **kwargs)
|
||||
170
banking_operations/services.py
Normal file
170
banking_operations/services.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Banking/payment services. transaction_initiate is called by checkout (and others)
|
||||
to start a payment flow. Replace the stub with real gateway integration (e.g. Razorpay).
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from django.contrib.auth import get_user_model
|
||||
from banking_operations.models import PaymentTransaction, PaymentGateway
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def transaction_initiate(
|
||||
request,
|
||||
user,
|
||||
amount,
|
||||
currency="INR",
|
||||
reference_type="checkout",
|
||||
reference_id=None,
|
||||
bookings=None,
|
||||
extra_data=None,
|
||||
):
|
||||
"""
|
||||
Initiate a payment transaction (e.g. create Razorpay order and return payment URL).
|
||||
|
||||
Args:
|
||||
request: Django request (for building URLs, gateway config, etc.).
|
||||
user: User instance (customer).
|
||||
amount: Total amount in payment currency (Decimal or float).
|
||||
currency: Currency code, e.g. "INR".
|
||||
reference_type: Application reference type, e.g. "checkout".
|
||||
reference_id: Application reference id (e.g. booking_ids or order id).
|
||||
bookings: Optional list of Booking instances or IDs linked to this transaction.
|
||||
extra_data: Optional dict for gateway-specific data.
|
||||
|
||||
Returns:
|
||||
dict: On success: {"success": True, "transaction_id": "...", "payment_url": "...", "message": "..."}
|
||||
On failure: {"success": False, "message": "..."}
|
||||
"""
|
||||
# Stub: replace with real gateway call when banking_operations payment flow is implemented.
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Transaction initiation stub",
|
||||
"transaction_id": None,
|
||||
"payment_url": None,
|
||||
}
|
||||
|
||||
|
||||
def create_payment_transaction(
|
||||
payment_type,
|
||||
payment_sub_type,
|
||||
payment_gateway,
|
||||
transaction_amount,
|
||||
currency="INR",
|
||||
notes=None,
|
||||
raw_data=None,
|
||||
user=None,
|
||||
):
|
||||
"""
|
||||
Create a PaymentTransaction with pending status.
|
||||
|
||||
Args:
|
||||
payment_type: Payment type - 'credit', 'debit', 'transfer', or 'other'
|
||||
payment_sub_type: Payment sub type - 'online', 'offline', or 'other'
|
||||
payment_gateway: PaymentGateway instance or payment_gateway_id (str)
|
||||
transaction_amount: Transaction amount (Decimal, float, or string)
|
||||
currency: Currency code, e.g. "INR" (default: "INR")
|
||||
notes: Optional transaction notes (str)
|
||||
raw_data: Optional raw data dict to store in payment_transaction_raw_data (dict)
|
||||
user: Optional User instance for last_updated_by
|
||||
|
||||
Returns:
|
||||
tuple: (success: bool, transaction: PaymentTransaction or None, error_message: str or None)
|
||||
"""
|
||||
try:
|
||||
# Validate payment_type
|
||||
valid_payment_types = ['credit', 'debit', 'transfer', 'other']
|
||||
if payment_type not in valid_payment_types:
|
||||
return False, None, f"Invalid payment_type. Must be one of: {', '.join(valid_payment_types)}"
|
||||
|
||||
# Validate payment_sub_type
|
||||
valid_payment_sub_types = ['online', 'offline', 'other']
|
||||
if payment_sub_type not in valid_payment_sub_types:
|
||||
return False, None, f"Invalid payment_sub_type. Must be one of: {', '.join(valid_payment_sub_types)}"
|
||||
|
||||
# Get PaymentGateway instance
|
||||
if isinstance(payment_gateway, PaymentGateway):
|
||||
gateway = payment_gateway
|
||||
elif isinstance(payment_gateway, str):
|
||||
try:
|
||||
gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway)
|
||||
except PaymentGateway.DoesNotExist:
|
||||
return False, None, f"PaymentGateway with id '{payment_gateway}' not found."
|
||||
else:
|
||||
return False, None, "payment_gateway must be a PaymentGateway instance or payment_gateway_id string."
|
||||
|
||||
# Validate transaction_amount
|
||||
try:
|
||||
amount = Decimal(str(transaction_amount))
|
||||
if amount <= 0:
|
||||
return False, None, "transaction_amount must be greater than zero."
|
||||
except (ValueError, TypeError):
|
||||
return False, None, "transaction_amount must be a valid number."
|
||||
|
||||
# Validate currency
|
||||
if not currency or len(currency) > 10:
|
||||
return False, None, "currency must be a valid currency code (max 10 characters)."
|
||||
|
||||
# Create PaymentTransaction
|
||||
transaction = PaymentTransaction.objects.create(
|
||||
payment_type=payment_type,
|
||||
payment_sub_type=payment_sub_type,
|
||||
payment_gateway=gateway,
|
||||
payment_transaction_amount=amount,
|
||||
payment_transaction_currency=currency,
|
||||
payment_transaction_status='pending', # Initial state as requested
|
||||
payment_transaction_notes=notes,
|
||||
payment_transaction_raw_data=raw_data,
|
||||
last_updated_by=user if isinstance(user, User) else None,
|
||||
)
|
||||
|
||||
return True, transaction.payment_transaction_id, None
|
||||
|
||||
except Exception as e:
|
||||
return False, None, str(e)
|
||||
|
||||
def update_payment_transaction(
|
||||
payment_transaction_id,
|
||||
payment_transaction_status,
|
||||
payment_transaction_notes=None,
|
||||
payment_transaction_raw_data=None,
|
||||
payment_transaction_response=None,
|
||||
payment_transaction_error=None,
|
||||
user=None,
|
||||
):
|
||||
"""
|
||||
Update a PaymentTransaction with the given status and notes.
|
||||
Args:
|
||||
payment_transaction_id: PaymentTransaction id (str)
|
||||
payment_transaction_status: PaymentTransaction status - 'pending', 'completed', 'failed', 'refunded', 'cancelled'
|
||||
payment_transaction_notes: Optional transaction notes (str)
|
||||
payment_transaction_raw_data: Optional raw data dict to store in payment_transaction_raw_data (dict)
|
||||
payment_transaction_response: Optional response dict to store in payment_transaction_response (dict)
|
||||
payment_transaction_error: Optional error dict to store in payment_transaction_error (dict)
|
||||
user: Optional User instance for last_updated_by
|
||||
"""
|
||||
try:
|
||||
# Get PaymentTransaction instance
|
||||
if isinstance(payment_transaction_id, PaymentTransaction):
|
||||
transaction = payment_transaction_id
|
||||
elif isinstance(payment_transaction_id, str):
|
||||
try:
|
||||
transaction = PaymentTransaction.objects.get(payment_transaction_id=payment_transaction_id)
|
||||
except PaymentTransaction.DoesNotExist:
|
||||
return False, None, f"PaymentTransaction with id '{payment_transaction_id}' not found."
|
||||
else:
|
||||
return False, None, "payment_transaction_id must be a PaymentTransaction instance or payment_transaction_id string."
|
||||
|
||||
# Update PaymentTransaction
|
||||
transaction.payment_transaction_status = payment_transaction_status
|
||||
transaction.payment_transaction_notes = payment_transaction_notes
|
||||
transaction.payment_transaction_raw_data = payment_transaction_raw_data
|
||||
transaction.payment_transaction_response = payment_transaction_response
|
||||
transaction.payment_transaction_error = payment_transaction_error
|
||||
transaction.last_updated_by = user if isinstance(user, User) else None
|
||||
transaction.save()
|
||||
|
||||
return True, transaction.payment_transaction_id, None
|
||||
|
||||
except Exception as e:
|
||||
return False, None, str(e)
|
||||
3
banking_operations/tests.py
Normal file
3
banking_operations/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
24
banking_operations/urls.py
Normal file
24
banking_operations/urls.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.urls import path
|
||||
|
||||
from banking_operations.api import (
|
||||
PaymentGatewayCreateAPI,
|
||||
PaymentGatewayListAPI,
|
||||
PaymentGatewayUpdateAPI,
|
||||
PaymentGatewayDeleteAPI,
|
||||
PaymentGatewayCredentialsCreateAPI,
|
||||
PaymentGatewayCredentialsListAPI,
|
||||
PaymentGatewayCredentialsUpdateAPI,
|
||||
PaymentGatewayCredentialsDeleteAPI,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("payment-gateway/create/", PaymentGatewayCreateAPI.as_view(), name="payment_gateway_create"),
|
||||
path("payment-gateway/list/", PaymentGatewayListAPI.as_view(), name="payment_gateway_list"),
|
||||
path("payment-gateway/update/", PaymentGatewayUpdateAPI.as_view(), name="payment_gateway_update"),
|
||||
path("payment-gateway/delete/", PaymentGatewayDeleteAPI.as_view(), name="payment_gateway_delete"),
|
||||
path("payment-gateway-credentials/create/", PaymentGatewayCredentialsCreateAPI.as_view(), name="payment_gateway_credentials_create"),
|
||||
path("payment-gateway-credentials/list/", PaymentGatewayCredentialsListAPI.as_view(), name="payment_gateway_credentials_list"),
|
||||
path("payment-gateway-credentials/update/", PaymentGatewayCredentialsUpdateAPI.as_view(), name="payment_gateway_credentials_update"),
|
||||
path("payment-gateway-credentials/delete/", PaymentGatewayCredentialsDeleteAPI.as_view(), name="payment_gateway_credentials_delete"),
|
||||
]
|
||||
3
banking_operations/views.py
Normal file
3
banking_operations/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
101
bookings/migrations/0001_initial.py
Normal file
101
bookings/migrations/0001_initial.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# Generated by Django 4.2.27 on 2026-03-13 16:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('events', '0007_event_gst_percentage_1_event_gst_percentage_2_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Booking',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('booking_id', models.CharField(max_length=250)),
|
||||
('quantity', models.IntegerField()),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('created_date', models.DateField(auto_now_add=True)),
|
||||
('updated_date', models.DateField(auto_now=True)),
|
||||
('transaction_id', models.CharField(blank=True, max_length=250, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketMeta',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ticket_name', models.CharField(max_length=250)),
|
||||
('maximum_quantity', models.IntegerField()),
|
||||
('available_quantity', models.IntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_date', models.DateField(auto_now_add=True)),
|
||||
('updated_date', models.DateField(auto_now=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ticket_type', models.CharField(max_length=250)),
|
||||
('ticket_type_description', models.TextField()),
|
||||
('ticket_type_quantity', models.IntegerField()),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_date', models.DateField(auto_now_add=True)),
|
||||
('updated_date', models.DateField(auto_now=True)),
|
||||
('is_offer', models.BooleanField(default=False)),
|
||||
('offer_percentage', models.IntegerField(default=0)),
|
||||
('offer_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||
('offer_start_date', models.DateField(blank=True, null=True)),
|
||||
('offer_end_date', models.DateField(blank=True, null=True)),
|
||||
('ticket_meta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.ticketmeta')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Ticket',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ticket_id', models.CharField(max_length=250)),
|
||||
('is_checked_in', models.BooleanField(default=False)),
|
||||
('checked_in_date_time', models.DateTimeField(blank=True, null=True)),
|
||||
('booking', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.booking')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Cart',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.IntegerField()),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_date', models.DateField(auto_now_add=True)),
|
||||
('updated_date', models.DateField(auto_now=True)),
|
||||
('ticket_meta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.ticketmeta')),
|
||||
('ticket_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.tickettype')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='ticket_meta',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.ticketmeta'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='ticket_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.tickettype'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,97 @@
|
||||
from django.db import models
|
||||
import uuid
|
||||
from events.models import Event
|
||||
from accounts.models import User
|
||||
|
||||
# Create your models here.
|
||||
class TicketMeta(models.Model):
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE)
|
||||
ticket_name = models.CharField(max_length=250)
|
||||
maximum_quantity = models.IntegerField()
|
||||
available_quantity = models.IntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_date = models.DateField(auto_now_add=True)
|
||||
updated_date = models.DateField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.ticket_name
|
||||
|
||||
|
||||
class TicketType(models.Model):
|
||||
ticket_meta = models.ForeignKey(TicketMeta, on_delete=models.CASCADE)
|
||||
ticket_type = models.CharField(max_length=250)
|
||||
ticket_type_description = models.TextField()
|
||||
ticket_type_quantity = models.IntegerField()
|
||||
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_date = models.DateField(auto_now_add=True)
|
||||
updated_date = models.DateField(auto_now=True)
|
||||
|
||||
is_offer = models.BooleanField(default=False)
|
||||
offer_percentage = models.IntegerField(default=0)
|
||||
offer_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||
offer_start_date = models.DateField(blank=True, null=True)
|
||||
offer_end_date = models.DateField(blank=True, null=True)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.ticket_type
|
||||
|
||||
|
||||
class Cart(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
ticket_meta = models.ForeignKey(TicketMeta, on_delete=models.CASCADE)
|
||||
ticket_type = models.ForeignKey(TicketType, on_delete=models.CASCADE)
|
||||
quantity = models.IntegerField()
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_date = models.DateField(auto_now_add=True)
|
||||
updated_date = models.DateField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.user.username + " - " + self.ticket.event.name
|
||||
|
||||
|
||||
class Booking(models.Model):
|
||||
booking_id = models.CharField(max_length=250)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
ticket_meta = models.ForeignKey(TicketMeta, on_delete=models.CASCADE)
|
||||
ticket_type = models.ForeignKey(TicketType, on_delete=models.CASCADE)
|
||||
quantity = models.IntegerField()
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
created_date = models.DateField(auto_now_add=True, db_index=True)
|
||||
updated_date = models.DateField(auto_now=True)
|
||||
|
||||
transaction_id = models.CharField(max_length=250, blank=True, null=True)
|
||||
|
||||
def __save__(self):
|
||||
if not self.booking_id:
|
||||
self.booking_id = str(self.ticket.event.name[:3].upper()) + str(uuid.uuid4().hex[:10]).upper()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.booking_id
|
||||
|
||||
class Ticket(models.Model):
|
||||
booking = models.ForeignKey(Booking, on_delete=models.CASCADE)
|
||||
ticket_id = models.CharField(max_length=250)
|
||||
is_checked_in = models.BooleanField(default=False)
|
||||
checked_in_date_time = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
def __save__(self):
|
||||
if not self.ticket_id:
|
||||
self.ticket_id = str(self.booking.ticket_meta.event.name[:3].upper()) + str(uuid.uuid4().hex[:10]).upper()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.ticket_id
|
||||
|
||||
def check_in(self, ticket_id):
|
||||
if self.ticket_id == ticket_id:
|
||||
self.is_checked_in = True
|
||||
self.checked_in_date_time = datetime.now()
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
61
bookings/services.py
Normal file
61
bookings/services.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from typing import List
|
||||
import uuid
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from bookings.models import Booking, Ticket
|
||||
|
||||
|
||||
def _generate_ticket_id(booking: Booking) -> str:
|
||||
"""
|
||||
Generate a ticket_id based on the event name and a random UUID segment.
|
||||
|
||||
Pattern: <EVT><RANDOM_HEX>
|
||||
- EVT: first 3 characters of event name (uppercase), or 'EVT' fallback
|
||||
- RANDOM_HEX: first 10 chars of uuid4 hex (uppercase)
|
||||
"""
|
||||
event = getattr(booking.ticket_meta, "event", None)
|
||||
if event and getattr(event, "name", None):
|
||||
prefix = (event.name or "EVT")[:3].upper()
|
||||
else:
|
||||
prefix = "EVT"
|
||||
|
||||
return prefix + uuid.uuid4().hex[:10].upper()
|
||||
|
||||
|
||||
def generate_tickets_for_booking(booking: Booking) -> List[Ticket]:
|
||||
"""
|
||||
Generate Ticket instances for a given Booking based on its quantity.
|
||||
|
||||
This function does NOT perform any payment or business-rule validation.
|
||||
It simply creates one Ticket per quantity on the booking.
|
||||
|
||||
Args:
|
||||
booking: Booking instance for which tickets should be generated.
|
||||
|
||||
Returns:
|
||||
List[Ticket]: List of created Ticket instances.
|
||||
"""
|
||||
if not isinstance(booking, Booking):
|
||||
raise TypeError("booking must be a Booking instance")
|
||||
|
||||
if booking.quantity <= 0:
|
||||
return []
|
||||
|
||||
tickets: List[Ticket] = []
|
||||
for _ in range(booking.quantity):
|
||||
tickets.append(
|
||||
Ticket(
|
||||
booking=booking,
|
||||
ticket_id=_generate_ticket_id(booking),
|
||||
is_checked_in=False,
|
||||
checked_in_date_time=None,
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create for efficiency
|
||||
Ticket.objects.bulk_create(tickets)
|
||||
|
||||
# Refresh from DB to ensure we have primary keys and any defaults
|
||||
return list[Ticket](Ticket.objects.filter(booking=booking).order_by("id"))
|
||||
|
||||
2
bookings/tickets_view/__init__.py
Normal file
2
bookings/tickets_view/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import ticket_meta_type
|
||||
from . import booking_api
|
||||
369
bookings/tickets_view/booking_api.py
Normal file
369
bookings/tickets_view/booking_api.py
Normal file
@@ -0,0 +1,369 @@
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from django.http import JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.forms.models import model_to_dict
|
||||
from django.utils import timezone
|
||||
from datetime import date
|
||||
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from bookings.models import Cart, TicketType, TicketMeta, Booking, Ticket
|
||||
from mobile_api.utils import validate_token_and_get_user
|
||||
from banking_operations.services import transaction_initiate
|
||||
from eventify_logger.services import log
|
||||
|
||||
|
||||
def _cart_to_dict(cart):
|
||||
"""Serialize Cart for JSON."""
|
||||
data = model_to_dict(
|
||||
cart,
|
||||
fields=["id", "quantity", "price", "created_date", "updated_date"],
|
||||
)
|
||||
data["user_id"] = cart.user_id
|
||||
data["ticket_meta_id"] = cart.ticket_meta_id
|
||||
data["ticket_type_id"] = cart.ticket_type_id
|
||||
return data
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class AddToCartAPI(APIView):
|
||||
"""
|
||||
Add TicketType to Cart (when customer clicks plus button).
|
||||
Body: token, username, ticket_type_id, quantity (optional, defaults to 1).
|
||||
|
||||
If cart item already exists for this user + ticket_type, quantity is incremented.
|
||||
Price is taken from TicketType (offer_price if offer is active, else price).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
ticket_type_id = data.get("ticket_type_id")
|
||||
quantity = data.get("quantity", 1) # Default to 1 for plus button click
|
||||
|
||||
if not ticket_type_id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "ticket_type_id is required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
ticket_type = TicketType.objects.select_related("ticket_meta").get(id=ticket_type_id)
|
||||
except TicketType.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "TicketType not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
if not ticket_type.is_active:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "TicketType is not active."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
quantity = int(quantity)
|
||||
if quantity <= 0:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "quantity must be greater than zero."},
|
||||
status=400,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "quantity must be a positive integer."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Determine price: use offer_price if offer is active, else regular price
|
||||
price = ticket_type.price
|
||||
if ticket_type.is_offer:
|
||||
# Check if offer is currently valid (if dates are set)
|
||||
today = date.today()
|
||||
offer_valid = True
|
||||
if ticket_type.offer_start_date and ticket_type.offer_start_date > today:
|
||||
offer_valid = False
|
||||
if ticket_type.offer_end_date and ticket_type.offer_end_date < today:
|
||||
offer_valid = False
|
||||
|
||||
if offer_valid and ticket_type.offer_price > 0:
|
||||
price = ticket_type.offer_price
|
||||
|
||||
# Check if cart item already exists for this user + ticket_type
|
||||
cart_item, created = Cart.objects.get_or_create(
|
||||
user=user,
|
||||
ticket_type=ticket_type,
|
||||
defaults={
|
||||
"ticket_meta": ticket_type.ticket_meta,
|
||||
"quantity": quantity,
|
||||
"price": price,
|
||||
},
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Update existing cart item: increment quantity
|
||||
cart_item.quantity += quantity
|
||||
cart_item.price = price # Update price in case offer changed
|
||||
cart_item.save()
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "TicketType added to cart." if created else "Cart item updated.",
|
||||
"cart_item": _cart_to_dict(cart_item),
|
||||
},
|
||||
status=201 if created else 200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class DeleteFromCartAPI(APIView):
|
||||
"""
|
||||
Remove or decrement TicketType from Cart (when customer clicks minus button).
|
||||
Body: token, username, ticket_type_id, quantity (optional, defaults to 1).
|
||||
|
||||
Decrements quantity by 1 (or by given quantity). If quantity becomes 0 or less,
|
||||
the cart item is deleted.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
ticket_type_id = data.get("ticket_type_id")
|
||||
quantity = data.get("quantity", 1) # Default to 1 for minus button click
|
||||
|
||||
if not ticket_type_id:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "ticket_type_id is required."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
quantity = int(quantity)
|
||||
if quantity <= 0:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "quantity must be greater than zero."},
|
||||
status=400,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "quantity must be a positive integer."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
cart_item = Cart.objects.get(user=user, ticket_type_id=ticket_type_id)
|
||||
except Cart.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Cart item not found for this ticket type."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
cart_item.quantity -= quantity
|
||||
if cart_item.quantity <= 0:
|
||||
cart_item.delete()
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "TicketType removed from cart.",
|
||||
"cart_item": None,
|
||||
"removed": True,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
cart_item.save()
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Cart quantity updated.",
|
||||
"cart_item": _cart_to_dict(cart_item),
|
||||
"removed": False,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class CheckoutAPI(APIView):
|
||||
"""
|
||||
Checkout the authenticated user's cart: create one Booking per cart line,
|
||||
call transaction_initiate in banking_operations, then clear the checked-out cart items.
|
||||
Body: token, username.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
cart_items = list(
|
||||
Cart.objects.filter(user=user, is_active=True).select_related(
|
||||
"ticket_meta", "ticket_type", "ticket_meta__event"
|
||||
)
|
||||
)
|
||||
if not cart_items:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Cart is empty. Add ticket types before checkout."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
total_amount = Decimal("0")
|
||||
created_bookings = []
|
||||
cart_ids_to_clear = []
|
||||
|
||||
for item in cart_items:
|
||||
event_name = (
|
||||
(item.ticket_meta.event.name or "EVT")[:3].upper()
|
||||
if item.ticket_meta.event_id else "EVT"
|
||||
)
|
||||
booking_id = event_name + uuid.uuid4().hex[:10].upper()
|
||||
line_total = Decimal(str(item.price)) * item.quantity
|
||||
total_amount += line_total
|
||||
|
||||
booking = Booking.objects.create(
|
||||
booking_id=booking_id,
|
||||
user=user,
|
||||
ticket_meta=item.ticket_meta,
|
||||
ticket_type=item.ticket_type,
|
||||
quantity=item.quantity,
|
||||
price=item.price,
|
||||
)
|
||||
created_bookings.append(booking)
|
||||
cart_ids_to_clear.append(item.id)
|
||||
|
||||
reference_id = ",".join(b.booking_id for b in created_bookings)
|
||||
result = transaction_initiate(
|
||||
request=request,
|
||||
user=user,
|
||||
amount=float(total_amount),
|
||||
currency="INR",
|
||||
reference_type="checkout",
|
||||
reference_id=reference_id,
|
||||
bookings=created_bookings,
|
||||
extra_data=None,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
for b in created_bookings:
|
||||
b.delete()
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": result.get("message", "Transaction initiation failed."),
|
||||
},
|
||||
status=502,
|
||||
)
|
||||
|
||||
transaction_id = result.get("transaction_id")
|
||||
if transaction_id:
|
||||
for b in created_bookings:
|
||||
b.transaction_id = transaction_id
|
||||
b.save(update_fields=["transaction_id"])
|
||||
|
||||
Cart.objects.filter(id__in=cart_ids_to_clear).delete()
|
||||
|
||||
log("info", "Checkout complete", request=request, user=user, logger_data={
|
||||
"booking_ids": [b.booking_id for b in created_bookings],
|
||||
"total_amount": str(total_amount),
|
||||
})
|
||||
response_payload = {
|
||||
"status": "success",
|
||||
"message": "Checkout complete. Proceed to payment.",
|
||||
"booking_ids": [b.booking_id for b in created_bookings],
|
||||
"total_amount": str(total_amount),
|
||||
"transaction_id": result.get("transaction_id"),
|
||||
"payment_url": result.get("payment_url"),
|
||||
}
|
||||
return JsonResponse(response_payload, status=200)
|
||||
|
||||
except Exception as e:
|
||||
log("error", "Checkout exception", request=request, logger_data={"error": str(e)})
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class CheckInAPI(APIView):
|
||||
"""
|
||||
Check-in a ticket by scanning QR code (ticket_id).
|
||||
Body: token, username, ticket_id (required).
|
||||
|
||||
Looks up Ticket by ticket_id. If found and not already checked in,
|
||||
sets is_checked_in=True and checked_in_date_time=now. Returns success or
|
||||
appropriate error (ticket not found / already checked in).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
ticket_id = data.get("ticket_id")
|
||||
if not ticket_id or not str(ticket_id).strip():
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "ticket_id is required."},
|
||||
status=400,
|
||||
)
|
||||
ticket_id = str(ticket_id).strip()
|
||||
|
||||
try:
|
||||
ticket = Ticket.objects.select_related(
|
||||
"booking", "booking__ticket_meta", "booking__ticket_meta__event", "booking__ticket_type"
|
||||
).get(ticket_id=ticket_id)
|
||||
except Ticket.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Ticket not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
if ticket.is_checked_in:
|
||||
log("info", "Check-in duplicate - ticket already checked in", request=request, user=user, logger_data={"ticket_id": ticket_id})
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Ticket already checked in.",
|
||||
"ticket_id": ticket.ticket_id,
|
||||
"is_checked_in": True,
|
||||
"checked_in_date_time": ticket.checked_in_date_time.isoformat() if ticket.checked_in_date_time else None,
|
||||
"event_name": ticket.booking.ticket_meta.event.name if ticket.booking.ticket_meta_id else None,
|
||||
"booking_id": ticket.booking.booking_id,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
ticket.is_checked_in = True
|
||||
ticket.checked_in_date_time = timezone.now()
|
||||
ticket.save(update_fields=["is_checked_in", "checked_in_date_time"])
|
||||
|
||||
log("info", "Check-in successful", request=request, user=user, logger_data={"ticket_id": ticket_id, "booking_id": ticket.booking.booking_id})
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Check-in successful.",
|
||||
"ticket_id": ticket.ticket_id,
|
||||
"is_checked_in": True,
|
||||
"checked_in_date_time": ticket.checked_in_date_time.isoformat(),
|
||||
"event_name": ticket.booking.ticket_meta.event.name if ticket.booking.ticket_meta_id else None,
|
||||
"booking_id": ticket.booking.booking_id,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log("error", "Check-in exception", request=request, logger_data={"error": str(e)})
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
489
bookings/tickets_view/ticket_meta_type.py
Normal file
489
bookings/tickets_view/ticket_meta_type.py
Normal file
@@ -0,0 +1,489 @@
|
||||
from django.http import JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.forms.models import model_to_dict
|
||||
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from bookings.models import TicketMeta, TicketType
|
||||
from events.models import Event
|
||||
from mobile_api.utils import validate_token_and_get_user
|
||||
|
||||
|
||||
def _ticket_meta_to_dict(meta):
|
||||
"""Serialize TicketMeta for JSON."""
|
||||
data = model_to_dict(
|
||||
meta,
|
||||
fields=[
|
||||
"id",
|
||||
"ticket_name",
|
||||
"maximum_quantity",
|
||||
"available_quantity",
|
||||
"is_active",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
],
|
||||
)
|
||||
data["event_id"] = meta.event_id
|
||||
return data
|
||||
|
||||
|
||||
def _ticket_type_to_dict(tt):
|
||||
"""Serialize TicketType for JSON."""
|
||||
data = model_to_dict(
|
||||
tt,
|
||||
fields=[
|
||||
"id",
|
||||
"ticket_type",
|
||||
"ticket_type_description",
|
||||
"quantity",
|
||||
"price",
|
||||
"is_active",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"is_offer",
|
||||
"offer_percentage",
|
||||
"offer_price",
|
||||
"offer_start_date",
|
||||
"offer_end_date",
|
||||
],
|
||||
)
|
||||
data["ticket_meta_id"] = tt.ticket_meta_id
|
||||
return data
|
||||
|
||||
|
||||
# ---------- TicketMeta (event-level ticket config) CRUD ----------
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TicketMetaCreateAPI(APIView):
|
||||
"""
|
||||
Create a new TicketMeta (event-level ticket config).
|
||||
Body: token, username, event_id, ticket_name, maximum_quantity,
|
||||
available_quantity (optional, defaults to maximum_quantity), is_active (optional, default true).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
event_id = data.get("event_id")
|
||||
ticket_name = data.get("ticket_name")
|
||||
maximum_quantity = data.get("maximum_quantity")
|
||||
available_quantity = data.get("available_quantity")
|
||||
is_active = data.get("is_active", True)
|
||||
|
||||
if not event_id or not ticket_name or maximum_quantity is None:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "event_id, ticket_name and maximum_quantity are required.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
event = Event.objects.get(id=event_id)
|
||||
except Event.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Event not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
try:
|
||||
maximum_quantity = int(maximum_quantity)
|
||||
available_quantity = int(available_quantity) if available_quantity is not None else maximum_quantity
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "maximum_quantity and available_quantity must be integers."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if maximum_quantity <= 0:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "maximum_quantity must be greater than zero."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
meta = TicketMeta.objects.create(
|
||||
event=event,
|
||||
ticket_name=ticket_name,
|
||||
maximum_quantity=maximum_quantity,
|
||||
available_quantity=available_quantity,
|
||||
is_active=bool(is_active),
|
||||
)
|
||||
return JsonResponse(
|
||||
{"status": "success", "ticket_meta": _ticket_meta_to_dict(meta)},
|
||||
status=201,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TicketMetaListAPI(APIView):
|
||||
"""List TicketMeta, optionally filtered by event_id. Body: token, username, event_id (optional)."""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
event_id = data.get("event_id")
|
||||
qs = TicketMeta.objects.filter(is_active=True).order_by("-created_date")
|
||||
if event_id:
|
||||
qs = qs.filter(event_id=event_id)
|
||||
items = [_ticket_meta_to_dict(m) for m in qs]
|
||||
return JsonResponse({"status": "success", "ticket_metas": items}, status=200)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TicketMetaUpdateAPI(APIView):
|
||||
"""
|
||||
Update TicketMeta. Body: token, username, ticket_meta_id (required);
|
||||
ticket_name, maximum_quantity, available_quantity, is_active (optional).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
pk = data.get("ticket_meta_id")
|
||||
if not pk:
|
||||
return JsonResponse({"status": "error", "message": "ticket_meta_id is required."}, status=400)
|
||||
|
||||
try:
|
||||
meta = TicketMeta.objects.get(id=pk)
|
||||
except TicketMeta.DoesNotExist:
|
||||
return JsonResponse({"status": "error", "message": "TicketMeta not found."}, status=404)
|
||||
|
||||
if data.get("ticket_name") is not None:
|
||||
meta.ticket_name = data["ticket_name"]
|
||||
if data.get("maximum_quantity") is not None:
|
||||
try:
|
||||
val = int(data["maximum_quantity"])
|
||||
if val <= 0:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "maximum_quantity must be greater than zero."},
|
||||
status=400,
|
||||
)
|
||||
meta.maximum_quantity = val
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "maximum_quantity must be an integer."},
|
||||
status=400,
|
||||
)
|
||||
if data.get("available_quantity") is not None:
|
||||
try:
|
||||
meta.available_quantity = int(data["available_quantity"])
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "available_quantity must be an integer."},
|
||||
status=400,
|
||||
)
|
||||
if data.get("is_active") is not None:
|
||||
meta.is_active = bool(data["is_active"])
|
||||
|
||||
meta.save()
|
||||
return JsonResponse({"status": "success", "ticket_meta": _ticket_meta_to_dict(meta)}, status=200)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TicketMetaDeleteAPI(APIView):
|
||||
"""Delete TicketMeta. Body: token, username, ticket_meta_id."""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
pk = data.get("ticket_meta_id")
|
||||
if not pk:
|
||||
return JsonResponse({"status": "error", "message": "ticket_meta_id is required."}, status=400)
|
||||
|
||||
try:
|
||||
meta = TicketMeta.objects.get(id=pk)
|
||||
except TicketMeta.DoesNotExist:
|
||||
return JsonResponse({"status": "error", "message": "TicketMeta not found."}, status=404)
|
||||
|
||||
meta.delete()
|
||||
return JsonResponse({"status": "success", "message": "TicketMeta deleted successfully."}, status=200)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TicketMetaDeactivateAPI(APIView):
|
||||
"""Deactivate a TicketMeta (set is_active=False). Body: token, username, ticket_meta_id."""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
pk = data.get("ticket_meta_id")
|
||||
if not pk:
|
||||
return JsonResponse({"status": "error", "message": "ticket_meta_id is required."}, status=400)
|
||||
|
||||
try:
|
||||
meta = TicketMeta.objects.get(id=pk)
|
||||
except TicketMeta.DoesNotExist:
|
||||
return JsonResponse({"status": "error", "message": "TicketMeta not found."}, status=404)
|
||||
|
||||
meta.is_active = False
|
||||
meta.save()
|
||||
return JsonResponse(
|
||||
{"status": "success", "message": "TicketMeta deactivated.", "ticket_meta": _ticket_meta_to_dict(meta)},
|
||||
status=200,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
# ---------- TicketType CRUD ----------
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TicketTypeCreateAPI(APIView):
|
||||
"""
|
||||
Create a new TicketType.
|
||||
Body: token, username, ticket_meta_id, ticket_type, ticket_type_description, quantity, price (required);
|
||||
is_active (optional, default true); is_offer, offer_percentage, offer_price, offer_start_date, offer_end_date (optional).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
ticket_meta_id = data.get("ticket_meta_id")
|
||||
ticket_type_name = data.get("ticket_type")
|
||||
ticket_type_description = data.get("ticket_type_description")
|
||||
quantity = data.get("quantity")
|
||||
price = data.get("price")
|
||||
is_active = data.get("is_active", True)
|
||||
is_offer = data.get("is_offer", False)
|
||||
offer_percentage = data.get("offer_percentage", 0)
|
||||
offer_price = data.get("offer_price", 0)
|
||||
offer_start_date = data.get("offer_start_date")
|
||||
offer_end_date = data.get("offer_end_date")
|
||||
|
||||
if not ticket_meta_id or not ticket_type_name or quantity is None or price is None:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "ticket_meta_id, ticket_type, ticket_type_description, quantity and price are required.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
ticket_meta = TicketMeta.objects.get(id=ticket_meta_id)
|
||||
except TicketMeta.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "TicketMeta not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
try:
|
||||
quantity = int(quantity)
|
||||
price = float(price)
|
||||
offer_percentage = int(offer_percentage) if offer_percentage is not None else 0
|
||||
offer_price = float(offer_price) if offer_price is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "quantity, price, offer_percentage and offer_price must be numeric."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if quantity <= 0:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "quantity must be greater than zero."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
tt = TicketType.objects.create(
|
||||
ticket_meta=ticket_meta,
|
||||
ticket_type=ticket_type_name,
|
||||
ticket_type_description=ticket_type_description or "",
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
is_active=bool(is_active),
|
||||
is_offer=bool(is_offer),
|
||||
offer_percentage=offer_percentage,
|
||||
offer_price=offer_price,
|
||||
offer_start_date=offer_start_date,
|
||||
offer_end_date=offer_end_date,
|
||||
)
|
||||
return JsonResponse(
|
||||
{"status": "success", "ticket_type": _ticket_type_to_dict(tt)},
|
||||
status=201,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TicketTypeListAPI(APIView):
|
||||
"""List TicketType, optionally filtered by ticket_meta_id. Body: token, username, ticket_meta_id (optional)."""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
ticket_meta_id = data.get("ticket_meta_id")
|
||||
qs = TicketType.objects.filter(is_active=True).order_by("-created_date")
|
||||
if ticket_meta_id:
|
||||
qs = qs.filter(ticket_meta_id=ticket_meta_id)
|
||||
items = [_ticket_type_to_dict(tt) for tt in qs]
|
||||
return JsonResponse({"status": "success", "ticket_types": items}, status=200)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TicketTypeUpdateAPI(APIView):
|
||||
"""
|
||||
Update TicketType. Body: token, username, ticket_type_id (required);
|
||||
ticket_type, ticket_type_description, quantity, price, is_active,
|
||||
is_offer, offer_percentage, offer_price, offer_start_date, offer_end_date (optional).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
pk = data.get("ticket_type_id")
|
||||
if not pk:
|
||||
return JsonResponse({"status": "error", "message": "ticket_type_id is required."}, status=400)
|
||||
|
||||
try:
|
||||
tt = TicketType.objects.get(id=pk)
|
||||
except TicketType.DoesNotExist:
|
||||
return JsonResponse({"status": "error", "message": "TicketType not found."}, status=404)
|
||||
|
||||
if data.get("ticket_type") is not None:
|
||||
tt.ticket_type = data["ticket_type"]
|
||||
if data.get("ticket_type_description") is not None:
|
||||
tt.ticket_type_description = data["ticket_type_description"]
|
||||
if data.get("quantity") is not None:
|
||||
try:
|
||||
val = int(data["quantity"])
|
||||
if val <= 0:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "quantity must be greater than zero."},
|
||||
status=400,
|
||||
)
|
||||
tt.quantity = val
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "quantity must be an integer."},
|
||||
status=400,
|
||||
)
|
||||
if data.get("price") is not None:
|
||||
try:
|
||||
tt.price = float(data["price"])
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "price must be numeric."},
|
||||
status=400,
|
||||
)
|
||||
if data.get("is_active") is not None:
|
||||
tt.is_active = bool(data["is_active"])
|
||||
if data.get("is_offer") is not None:
|
||||
tt.is_offer = bool(data["is_offer"])
|
||||
if data.get("offer_percentage") is not None:
|
||||
try:
|
||||
tt.offer_percentage = int(data["offer_percentage"])
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "offer_percentage must be an integer."},
|
||||
status=400,
|
||||
)
|
||||
if data.get("offer_price") is not None:
|
||||
try:
|
||||
tt.offer_price = float(data["offer_price"])
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "offer_price must be numeric."},
|
||||
status=400,
|
||||
)
|
||||
if "offer_start_date" in data:
|
||||
tt.offer_start_date = data["offer_start_date"]
|
||||
if "offer_end_date" in data:
|
||||
tt.offer_end_date = data["offer_end_date"]
|
||||
|
||||
tt.save()
|
||||
return JsonResponse({"status": "success", "ticket_type": _ticket_type_to_dict(tt)}, status=200)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TicketTypeDeactivateAPI(APIView):
|
||||
"""Deactivate a TicketType (set is_active=False). Body: token, username, ticket_type_id."""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
pk = data.get("ticket_type_id")
|
||||
if not pk:
|
||||
return JsonResponse({"status": "error", "message": "ticket_type_id is required."}, status=400)
|
||||
|
||||
try:
|
||||
tt = TicketType.objects.get(id=pk)
|
||||
except TicketType.DoesNotExist:
|
||||
return JsonResponse({"status": "error", "message": "TicketType not found."}, status=404)
|
||||
|
||||
tt.is_active = False
|
||||
tt.save()
|
||||
return JsonResponse(
|
||||
{"status": "success", "message": "TicketType deactivated.", "ticket_type": _ticket_type_to_dict(tt)},
|
||||
status=200,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TicketTypeDeleteAPI(APIView):
|
||||
"""Delete TicketType. Body: token, username, ticket_type_id."""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
pk = data.get("ticket_type_id")
|
||||
if not pk:
|
||||
return JsonResponse({"status": "error", "message": "ticket_type_id is required."}, status=400)
|
||||
|
||||
try:
|
||||
tt = TicketType.objects.get(id=pk)
|
||||
except TicketType.DoesNotExist:
|
||||
return JsonResponse({"status": "error", "message": "TicketType not found."}, status=404)
|
||||
|
||||
tt.delete()
|
||||
return JsonResponse({"status": "success", "message": "TicketType deleted successfully."}, status=200)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
34
bookings/urls.py
Normal file
34
bookings/urls.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.urls import path
|
||||
|
||||
from bookings.tickets_view.ticket_meta_type import (
|
||||
TicketMetaCreateAPI,
|
||||
TicketMetaListAPI,
|
||||
TicketMetaUpdateAPI,
|
||||
TicketMetaDeleteAPI,
|
||||
TicketMetaDeactivateAPI,
|
||||
TicketTypeCreateAPI,
|
||||
TicketTypeListAPI,
|
||||
TicketTypeUpdateAPI,
|
||||
TicketTypeDeleteAPI,
|
||||
TicketTypeDeactivateAPI,
|
||||
)
|
||||
from bookings.tickets_view.booking_api import AddToCartAPI, DeleteFromCartAPI, CheckoutAPI, CheckInAPI
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("ticket-meta/create/", TicketMetaCreateAPI.as_view(), name="ticket_meta_create"),
|
||||
path("ticket-meta/list/", TicketMetaListAPI.as_view(), name="ticket_meta_list"),
|
||||
path("ticket-meta/update/", TicketMetaUpdateAPI.as_view(), name="ticket_meta_update"),
|
||||
path("ticket-meta/delete/", TicketMetaDeleteAPI.as_view(), name="ticket_meta_delete"),
|
||||
path("ticket-meta/deactivate/", TicketMetaDeactivateAPI.as_view(), name="ticket_meta_deactivate"),
|
||||
path("ticket-type/create/", TicketTypeCreateAPI.as_view(), name="ticket_type_create"),
|
||||
path("ticket-type/list/", TicketTypeListAPI.as_view(), name="ticket_type_list"),
|
||||
path("ticket-type/update/", TicketTypeUpdateAPI.as_view(), name="ticket_type_update"),
|
||||
path("ticket-type/delete/", TicketTypeDeleteAPI.as_view(), name="ticket_type_delete"),
|
||||
path("ticket-type/deactivate/", TicketTypeDeactivateAPI.as_view(), name="ticket_type_deactivate"),
|
||||
path("cart/add/", AddToCartAPI.as_view(), name="add_to_cart"),
|
||||
path("cart/delete/", DeleteFromCartAPI.as_view(), name="delete_from_cart"),
|
||||
path("checkout/", CheckoutAPI.as_view(), name="checkout"),
|
||||
path("check-in/", CheckInAPI.as_view(), name="check_in"),
|
||||
]
|
||||
|
||||
25
create_temp_user.py
Normal file
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.")
|
||||
BIN
db_1.sqlite3
BIN
db_1.sqlite3
Binary file not shown.
@@ -3,16 +3,26 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'change-me-in-production')
|
||||
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
|
||||
|
||||
# DEBUG = os.environ.get('DJANGO_DEBUG', 'False') == 'True'
|
||||
#
|
||||
# ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split(',')
|
||||
|
||||
DEBUG = True
|
||||
DEBUG = False
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
'*'
|
||||
'db.eventifyplus.com',
|
||||
'uat.eventifyplus.com',
|
||||
'em.eventifyplus.com',
|
||||
'backend.eventifyplus.com',
|
||||
'admin.eventifyplus.com',
|
||||
'app.eventifyplus.com',
|
||||
'partner.eventifyplus.com',
|
||||
'eventify-backend',
|
||||
'eventify-django',
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
@@ -22,14 +32,24 @@ INSTALLED_APPS = [
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'eventify_logger',
|
||||
'master_data',
|
||||
'events',
|
||||
'accounts',
|
||||
'partner',
|
||||
'templatetags',
|
||||
'mobile_api',
|
||||
'web_api',
|
||||
'bookings',
|
||||
'banking_operations',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken'
|
||||
'rest_framework.authtoken',
|
||||
'rest_framework_simplejwt',
|
||||
'admin_api',
|
||||
'django_summernote',
|
||||
'ledger',
|
||||
'notifications',
|
||||
'ad_control',
|
||||
]
|
||||
|
||||
INSTALLED_APPS += [
|
||||
@@ -42,6 +62,7 @@ MIDDLEWARE = [
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'eventify_logger.middleware.EventifyLoggingMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
@@ -49,9 +70,21 @@ MIDDLEWARE = [
|
||||
]
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"https://app.eventifyplus.com",
|
||||
"https://admin.eventifyplus.com",
|
||||
"https://uat.eventifyplus.com",
|
||||
"http://localhost:5178",
|
||||
"http://localhost:5179",
|
||||
"http://localhost:5173",
|
||||
"https://prototype.eventifyplus.com/",
|
||||
"https://eventifyplus.com/"
|
||||
"http://localhost:3001",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:8080",
|
||||
"https://prototype.eventifyplus.com",
|
||||
"https://eventifyplus.com",
|
||||
"https://mv.eventifyplus.com",
|
||||
"https://db.eventifyplus.com",
|
||||
"https://test.eventifyplus.com",
|
||||
"https://em.eventifyplus.com"
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'eventify.urls'
|
||||
@@ -74,24 +107,27 @@ TEMPLATES = [
|
||||
|
||||
WSGI_APPLICATION = 'eventify.wsgi.application'
|
||||
|
||||
# DATABASES = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'django.db.backends.sqlite3',
|
||||
# 'NAME': BASE_DIR / 'db.sqlite3',
|
||||
# }
|
||||
# }
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'eventify_uat_db', # your DB name
|
||||
'USER': 'eventify_uat', # your DB user
|
||||
'PASSWORD': 'eventifyplus@!@#$', # your DB password
|
||||
'HOST': '0.0.0.0', # or IP/domain
|
||||
'PORT': '5440', # default PostgreSQL port
|
||||
'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'),
|
||||
'NAME': os.environ.get('DB_NAME', str(BASE_DIR / 'db.sqlite3')),
|
||||
'USER': os.environ.get('DB_USER', ''),
|
||||
'PASSWORD': os.environ.get('DB_PASS', ''),
|
||||
'HOST': os.environ.get('DB_HOST', ''),
|
||||
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||
}
|
||||
}
|
||||
|
||||
# DATABASES = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'django.db.backends.postgresql',
|
||||
# 'NAME': 'eventify_uat_db', # your DB name
|
||||
# 'USER': 'eventify_uat', # your DB user
|
||||
# 'HOST': '0.0.0.0', # or IP/domain
|
||||
# 'PORT': '5440', # default PostgreSQL port
|
||||
# }
|
||||
# }
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||
@@ -112,11 +148,60 @@ STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
X_FRAME_OPTIONS = 'SAMEORIGIN'
|
||||
|
||||
AUTH_USER_MODEL = 'accounts.User'
|
||||
|
||||
LOGIN_URL = 'login'
|
||||
LOGIN_REDIRECT_URL = 'dashboard'
|
||||
LOGOUT_REDIRECT_URL = 'login'
|
||||
|
||||
# EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
# DEFAULT_FROM_EMAIL = 'no-reply@example.com'
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_HOST = 'mail.bshtech.net'
|
||||
EMAIL_PORT = 587
|
||||
EMAIL_USE_TLS = True
|
||||
EMAIL_HOST_USER = 'no-reply@eventifyplus.com'
|
||||
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
|
||||
DEFAULT_FROM_EMAIL = 'Eventify <no-reply@eventifyplus.com>'
|
||||
|
||||
SUMMERNOTE_THEME = 'bs5'
|
||||
|
||||
# Reverse proxy / CSRF fix
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
'https://app.eventifyplus.com',
|
||||
'https://admin.eventifyplus.com',
|
||||
'https://db.eventifyplus.com',
|
||||
'https://uat.eventifyplus.com',
|
||||
'https://test.eventifyplus.com',
|
||||
'https://eventifyplus.com',
|
||||
]
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
USE_X_FORWARDED_HOST = True
|
||||
|
||||
# --- JWT Auth (Phase 1) ---
|
||||
from datetime import timedelta
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), # Reduced from 1 day for security
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
'USER_ID_FIELD': 'id',
|
||||
'USER_ID_CLAIM': 'user_id',
|
||||
}
|
||||
|
||||
# --- Google OAuth (Sign in with Google via GIS ID-token flow) -----------
|
||||
# The Client ID is public (safe in VITE_* env vars and the SPA bundle).
|
||||
# There is NO client secret — we use the ID-token flow, not auth-code flow.
|
||||
# Set the SAME value in the Django container .env and in SPA .env.local.
|
||||
GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '')
|
||||
|
||||
@@ -31,8 +31,15 @@ urlpatterns = [
|
||||
path('master-data/', include('master_data.urls')),
|
||||
path('events/', include('events.urls')),
|
||||
path('accounts/', include('accounts.urls')),
|
||||
path('bookings/', include('bookings.urls')),
|
||||
path('partner/', include('partner.urls')),
|
||||
path('banking/', include('banking_operations.urls')),
|
||||
path('api/', include('mobile_api.urls')),
|
||||
path('api/v1/', include('admin_api.urls')),
|
||||
path('api/notifications/', include('notifications.urls')),
|
||||
# path('web-api/', include('web_api.urls')),
|
||||
|
||||
path('summernote/', include('django_summernote.urls')),
|
||||
]
|
||||
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
0
eventify_logger/__init__.py
Normal file
0
eventify_logger/__init__.py
Normal file
10
eventify_logger/admin.py
Normal file
10
eventify_logger/admin.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.contrib import admin
|
||||
from .models import EventifyLogger
|
||||
|
||||
|
||||
@admin.register(EventifyLogger)
|
||||
class EventifyLoggerAdmin(admin.ModelAdmin):
|
||||
list_display = ("logger_type", "logger_message", "logged_user", "logger_created_at")
|
||||
list_filter = ("logger_type", "logger_created_at")
|
||||
search_fields = ("logger_message",)
|
||||
readonly_fields = ("logger_created_at",)
|
||||
6
eventify_logger/apps.py
Normal file
6
eventify_logger/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EventifyLoggerConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'eventify_logger'
|
||||
28
eventify_logger/middleware.py
Normal file
28
eventify_logger/middleware.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Request logging middleware - logs every HTTP request to EventifyLogger.
|
||||
"""
|
||||
from eventify_logger.services import log
|
||||
|
||||
|
||||
class EventifyLoggingMiddleware:
|
||||
"""Log each request (method, path, status) after response."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
try:
|
||||
status = getattr(response, "status_code", 0)
|
||||
if 500 <= status < 600:
|
||||
logger_type = "error"
|
||||
elif 400 <= status < 500:
|
||||
logger_type = "warning"
|
||||
else:
|
||||
logger_type = "info"
|
||||
message = f"{request.method} {request.path} -> {status}"
|
||||
logger_data = {"path": request.path, "method": request.method, "status_code": status}
|
||||
log(logger_type=logger_type, logger_message=message, request=request, logger_data=logger_data)
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
31
eventify_logger/migrations/0001_initial.py
Normal file
31
eventify_logger/migrations/0001_initial.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 4.2.27 on 2026-03-09 04:26
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EventifyLogger',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('logger_type', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('critical', 'Critical')], max_length=250)),
|
||||
('logger_message', models.TextField()),
|
||||
('logger_data', models.TextField(blank=True, null=True)),
|
||||
('logger_created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('logged_ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('logged_user_device', models.CharField(blank=True, max_length=250, null=True)),
|
||||
('logged_user_browser', models.CharField(blank=True, max_length=250, null=True)),
|
||||
('logged_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
eventify_logger/migrations/__init__.py
Normal file
0
eventify_logger/migrations/__init__.py
Normal file
27
eventify_logger/models.py
Normal file
27
eventify_logger/models.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class EventifyLogger(models.Model):
|
||||
logger_type = models.CharField(max_length=250, choices=[
|
||||
('info', 'Info'),
|
||||
('warning', 'Warning'),
|
||||
('error', 'Error'),
|
||||
('critical', 'Critical'),
|
||||
])
|
||||
logger_message = models.TextField()
|
||||
logger_data = models.TextField(blank=True, null=True)
|
||||
logger_created_at = models.DateTimeField(auto_now_add=True)
|
||||
logged_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
logged_ip_address = models.GenericIPAddressField(blank=True, null=True)
|
||||
logged_user_device = models.CharField(max_length=250, blank=True, null=True)
|
||||
logged_user_browser = models.CharField(max_length=250, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
user_str = str(self.logged_user) if self.logged_user else "anonymous"
|
||||
return f"{user_str}-{self.logger_type} - {self.logger_message}"
|
||||
58
eventify_logger/services.py
Normal file
58
eventify_logger/services.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Central logging service for EventifyLogger.
|
||||
"""
|
||||
import json
|
||||
|
||||
from eventify_logger.models import EventifyLogger
|
||||
|
||||
|
||||
def _get_client_ip(request):
|
||||
"""Extract client IP from request."""
|
||||
if not request:
|
||||
return None
|
||||
x_forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
if x_forwarded:
|
||||
return x_forwarded.split(",")[0].strip() or None
|
||||
return request.META.get("REMOTE_ADDR")
|
||||
|
||||
|
||||
def _get_user_agent(request):
|
||||
"""Extract User-Agent from request."""
|
||||
if not request:
|
||||
return None
|
||||
return request.META.get("HTTP_USER_AGENT", "")
|
||||
|
||||
|
||||
def log(logger_type, logger_message, request=None, user=None, logger_data=None):
|
||||
"""
|
||||
Create an EventifyLogger record.
|
||||
|
||||
Args:
|
||||
logger_type: 'info' | 'warning' | 'error' | 'critical'
|
||||
logger_message: str
|
||||
request: optional HttpRequest (used for IP, user-agent, user if not provided)
|
||||
user: optional User (overrides request.user)
|
||||
logger_data: optional str or dict (dict will be JSON-serialized)
|
||||
"""
|
||||
try:
|
||||
resolved_user = user
|
||||
if resolved_user is None and request and hasattr(request, "user"):
|
||||
resolved_user = getattr(request.user, "is_authenticated", False) and request.user or None
|
||||
|
||||
ip_address = _get_client_ip(request) if request else None
|
||||
user_agent = _get_user_agent(request) if request else None
|
||||
|
||||
if isinstance(logger_data, dict):
|
||||
logger_data = json.dumps(logger_data)
|
||||
|
||||
EventifyLogger.objects.create(
|
||||
logger_type=logger_type,
|
||||
logger_message=str(logger_message)[:10000], # cap message length
|
||||
logger_data=logger_data[:10000] if logger_data else None, # cap data length
|
||||
logged_user=resolved_user,
|
||||
logged_ip_address=ip_address,
|
||||
logged_user_device=None, # defer UA parsing
|
||||
logged_user_browser=user_agent[:250] if user_agent else None,
|
||||
)
|
||||
except Exception:
|
||||
pass # Never let logging break the app
|
||||
3
eventify_logger/tests.py
Normal file
3
eventify_logger/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
eventify_logger/views.py
Normal file
3
eventify_logger/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -3,10 +3,11 @@ from .models import Event, EventImages
|
||||
|
||||
@admin.register(Event)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
list_display = ('id','name','start_date','end_date','event_type','event_status')
|
||||
list_filter = ('event_status','event_type')
|
||||
search_fields = ('name','place','district')
|
||||
list_display = ('id', 'name', 'start_date', 'end_date', 'event_type', 'event_status', 'source', 'is_featured', 'is_top_event')
|
||||
list_filter = ('event_status', 'event_type', 'source', 'is_featured', 'is_top_event')
|
||||
list_editable = ('is_featured', 'is_top_event', 'source')
|
||||
search_fields = ('name', 'place', 'district')
|
||||
|
||||
@admin.register(EventImages)
|
||||
class EventImagesAdmin(admin.ModelAdmin):
|
||||
list_display = ('id','event','is_primary')
|
||||
list_display = ('id', 'event', 'is_primary')
|
||||
|
||||
246
events/api.py
Normal file
246
events/api.py
Normal file
@@ -0,0 +1,246 @@
|
||||
from django.http import JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.forms.models import model_to_dict
|
||||
from rest_framework.views import APIView
|
||||
from datetime import datetime
|
||||
|
||||
from events.models import Event
|
||||
from master_data.models import EventType
|
||||
from mobile_api.utils import validate_token_and_get_user
|
||||
from eventify_logger.services import log
|
||||
|
||||
|
||||
def _event_to_dict(event, request=None):
|
||||
"""Serialize Event for JSON."""
|
||||
data = model_to_dict(
|
||||
event,
|
||||
fields=[
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"all_year_event",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"pincode",
|
||||
"district",
|
||||
"state",
|
||||
"place",
|
||||
"venue_name",
|
||||
"event_status",
|
||||
"cancelled_reason",
|
||||
"important_information",
|
||||
"source",
|
||||
"created_date",
|
||||
],
|
||||
)
|
||||
# Add event_type info
|
||||
data["event_type"] = {
|
||||
"id": event.event_type.id,
|
||||
"event_type": event.event_type.event_type,
|
||||
}
|
||||
if event.event_type.event_type_icon:
|
||||
if request:
|
||||
data["event_type"]["event_type_icon"] = event.event_type.event_type_icon.url
|
||||
else:
|
||||
data["event_type"]["event_type_icon"] = event.event_type.event_type_icon.url
|
||||
else:
|
||||
data["event_type"]["event_type_icon"] = None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class EventCreateAPI(APIView):
|
||||
"""
|
||||
Create Event API.
|
||||
Body: token, username (required);
|
||||
name, description, latitude, longitude, pincode, place, event_type_id (required);
|
||||
start_date, end_date, start_time, end_time, all_year_event, venue_name,
|
||||
event_status, cancelled_reason, important_information, source, district, state (optional).
|
||||
Returns: created event data.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
# Extract required fields
|
||||
name = data.get("name")
|
||||
description = data.get("description")
|
||||
latitude = data.get("latitude")
|
||||
longitude = data.get("longitude")
|
||||
pincode = data.get("pincode")
|
||||
place = data.get("place")
|
||||
event_type_id = data.get("event_type_id") or data.get("event_type")
|
||||
|
||||
# Validate required fields
|
||||
if not all([name, description, latitude, longitude, pincode, place, event_type_id]):
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "name, description, latitude, longitude, pincode, place, and event_type_id are required.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Validate event_type exists
|
||||
try:
|
||||
event_type = EventType.objects.get(id=event_type_id)
|
||||
except EventType.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "EventType not found."},
|
||||
status=404,
|
||||
)
|
||||
|
||||
# Validate latitude and longitude
|
||||
try:
|
||||
latitude = float(latitude)
|
||||
if latitude < -90 or latitude > 90:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "latitude must be between -90 and 90."},
|
||||
status=400,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "latitude must be numeric."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
longitude = float(longitude)
|
||||
if longitude < -180 or longitude > 180:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "longitude must be between -180 and 180."},
|
||||
status=400,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "longitude must be numeric."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Handle all_year_event
|
||||
all_year_event = data.get("all_year_event", False)
|
||||
if isinstance(all_year_event, str):
|
||||
all_year_event = all_year_event.lower() in ['true', '1', 'yes', 'on']
|
||||
|
||||
# Handle dates and times - clear if all_year_event is True
|
||||
start_date = None
|
||||
end_date = None
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
if not all_year_event:
|
||||
# Parse start_date
|
||||
if data.get("start_date"):
|
||||
try:
|
||||
start_date = datetime.strptime(data["start_date"], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid start_date format. Expected YYYY-MM-DD."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Parse end_date
|
||||
if data.get("end_date"):
|
||||
try:
|
||||
end_date = datetime.strptime(data["end_date"], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid end_date format. Expected YYYY-MM-DD."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Parse start_time
|
||||
if data.get("start_time"):
|
||||
try:
|
||||
start_time = datetime.strptime(data["start_time"], "%H:%M:%S").time()
|
||||
except ValueError:
|
||||
try:
|
||||
start_time = datetime.strptime(data["start_time"], "%H:%M").time()
|
||||
except ValueError:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid start_time format. Expected HH:MM or HH:MM:SS."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Parse end_time
|
||||
if data.get("end_time"):
|
||||
try:
|
||||
end_time = datetime.strptime(data["end_time"], "%H:%M:%S").time()
|
||||
except ValueError:
|
||||
try:
|
||||
end_time = datetime.strptime(data["end_time"], "%H:%M").time()
|
||||
except ValueError:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid end_time format. Expected HH:MM or HH:MM:SS."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Validate event_status if provided
|
||||
event_status = data.get("event_status", "pending")
|
||||
valid_statuses = ['created', 'cancelled', 'pending', 'completed', 'postponed']
|
||||
if event_status not in valid_statuses:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"Invalid event_status. Must be one of: {', '.join(valid_statuses)}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Validate source if provided
|
||||
source = data.get("source", "official")
|
||||
valid_sources = ['official', 'community']
|
||||
if source not in valid_sources:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"Invalid source. Must be one of: {', '.join(valid_sources)}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Create event
|
||||
event = Event.objects.create(
|
||||
name=name,
|
||||
description=description,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
all_year_event=all_year_event,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
pincode=pincode,
|
||||
district=data.get("district", ""),
|
||||
state=data.get("state", ""),
|
||||
place=place,
|
||||
venue_name=data.get("venue_name", ""),
|
||||
event_type=event_type,
|
||||
event_status=event_status,
|
||||
cancelled_reason=data.get("cancelled_reason", "NA"),
|
||||
important_information=data.get("important_information", ""),
|
||||
source=source,
|
||||
)
|
||||
|
||||
log("info", "Event created", request=request, user=user, logger_data={"event_id": event.id, "event_name": name})
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Event created successfully.",
|
||||
"event": _event_to_dict(event, request),
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log("error", "Event create exception", request=request, logger_data={"error": str(e)})
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
@@ -1,7 +1,7 @@
|
||||
from django import forms
|
||||
from .models import Event
|
||||
from .models import EventImages
|
||||
|
||||
from django_summernote.widgets import SummernoteWidget
|
||||
|
||||
class EventForm(forms.ModelForm):
|
||||
class Meta:
|
||||
@@ -11,12 +11,13 @@ class EventForm(forms.ModelForm):
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'description': forms.Textarea(attrs={'class': 'form-control'}),
|
||||
'title': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'important_information': forms.Textarea(attrs={'class': 'form-control'}),
|
||||
'important_information': SummernoteWidget(attrs={'summernote': {'width': '100%', 'height': '400px'}}),
|
||||
'venue_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'start_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||
'end_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||
'start_time': forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
|
||||
'end_time': forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
|
||||
'start_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date', 'id': 'id_start_date'}),
|
||||
'end_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date', 'id': 'id_end_date'}),
|
||||
'start_time': forms.TimeInput(attrs={'class': 'form-control', 'type': 'time', 'id': 'id_start_time'}),
|
||||
'end_time': forms.TimeInput(attrs={'class': 'form-control', 'type': 'time', 'id': 'id_end_time'}),
|
||||
'all_year_event': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_all_year_event'}),
|
||||
'latitude': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
'longitude': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
'pincode': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
@@ -29,8 +30,51 @@ class EventForm(forms.ModelForm):
|
||||
'cancelled_reason': forms.Textarea(attrs={'class': 'form-control'}),
|
||||
'is_bookable': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'is_eventify_event': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'is_featured': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_featured'}),
|
||||
'is_top_event': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_top_event'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Show source as visible radio buttons with Bootstrap styling
|
||||
self.fields['source'].widget = forms.RadioSelect(
|
||||
choices=self.fields['source'].choices,
|
||||
attrs={'class': 'form-check-input'}
|
||||
)
|
||||
if not self.instance.pk:
|
||||
self.fields['source'].initial = 'eventify'
|
||||
|
||||
# Check if all_year_event is True (from instance or initial data)
|
||||
all_year_event = False
|
||||
if self.instance and self.instance.pk:
|
||||
all_year_event = self.instance.all_year_event
|
||||
elif 'all_year_event' in self.initial:
|
||||
all_year_event = self.initial['all_year_event']
|
||||
elif self.data and 'all_year_event' in self.data:
|
||||
all_year_event = self.data.get('all_year_event') == 'on' or self.data.get('all_year_event') == 'True'
|
||||
|
||||
# If all_year_event is True, disable date/time fields
|
||||
if all_year_event:
|
||||
self.fields['start_date'].widget.attrs['disabled'] = True
|
||||
self.fields['end_date'].widget.attrs['disabled'] = True
|
||||
self.fields['start_time'].widget.attrs['disabled'] = True
|
||||
self.fields['end_time'].widget.attrs['disabled'] = True
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
all_year_event = cleaned_data.get('all_year_event', False)
|
||||
|
||||
# Source is now user-selectable (eventify/community/partner)
|
||||
|
||||
# If all_year_event is True, clear date/time fields
|
||||
if all_year_event:
|
||||
cleaned_data['start_date'] = None
|
||||
cleaned_data['end_date'] = None
|
||||
cleaned_data['start_time'] = None
|
||||
cleaned_data['end_time'] = None
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
allow_multiple_selected = True
|
||||
|
||||
@@ -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/0005_event_source.py
Normal file
18
events/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/0006_alter_event_source.py
Normal file
18
events/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),
|
||||
),
|
||||
]
|
||||
21
events/migrations/0007_add_is_featured_is_top_event.py
Normal file
21
events/migrations/0007_add_is_featured_is_top_event.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0006_alter_event_source'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='is_featured',
|
||||
field=models.BooleanField(default=False, help_text='Show this event in the featured section'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='is_top_event',
|
||||
field=models.BooleanField(default=False, help_text='Show this event in the Top Events section'),
|
||||
),
|
||||
]
|
||||
@@ -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/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 = [
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user