10 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:42:02 +05:30
10 changed files with 922 additions and 12 deletions

View File

@@ -5,6 +5,66 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), version
---
## [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

View File

@@ -142,6 +142,7 @@ class StaffProfile(models.Model):
'contributions': 'contributions',
'leads': 'leads',
'audit': 'audit-log',
'reviews': 'reviews',
}
modules = {'dashboard'}
for scope in scopes:

View File

@@ -5,9 +5,10 @@ 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']
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):

View File

@@ -229,3 +229,97 @@ class UserStatusAuditEmissionTests(_AuditTestBase):
).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',
)

View File

@@ -18,8 +18,18 @@ urlpatterns = [
path('partners/<int:pk>/', views.PartnerDetailView.as_view(), name='partner-detail'),
path('partners/<int:pk>/status/', views.PartnerStatusView.as_view(), name='partner-status'),
path('partners/<int:pk>/kyc/review/', views.PartnerKYCReviewView.as_view(), name='partner-kyc-review'),
path('partners/<int:pk>/impersonate/', views.PartnerImpersonateView.as_view(), name='partner-impersonate'),
path('partners/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'),
path('partners/<int:partner_id>/staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'),
# Partner-Me: partner portal self-service (Sprint 1)
path('partners/me/profile/', views.PartnerMeProfileView.as_view(), name='partner-me-profile'),
path('partners/me/notifications/', views.PartnerMeNotificationsView.as_view(), name='partner-me-notifications'),
path('partners/me/payout/', views.PartnerMePayoutView.as_view(), name='partner-me-payout'),
path('partners/me/change-password/', views.PartnerMeChangePasswordView.as_view(), name='partner-me-change-password'),
# Partner-Me: events (Sprint 2)
path('partners/me/events/', views.PartnerMeEventsView.as_view(), name='partner-me-events'),
path('partners/me/events/<int:pk>/', views.PartnerMeEventDetailView.as_view(), name='partner-me-event-detail'),
path('partners/me/events/<int:pk>/duplicate/', views.PartnerMeEventDuplicateView.as_view(), name='partner-me-event-duplicate'),
path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
path('users/', views.UserListView.as_view(), name='user-list'),
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),

View File

@@ -26,8 +26,12 @@ class AdminLoginView(APIView):
except User.DoesNotExist:
pass
if not user:
_audit_log(request, 'auth.admin_login_failed', 'auth', 'failed',
{'identifier': identifier, 'reason': 'invalid_credentials'}, user=None)
return Response({'error': 'Invalid credentials'}, status=status.HTTP_401_UNAUTHORIZED)
if not user.is_active:
_audit_log(request, 'auth.admin_login_failed', 'auth', str(user.id),
{'identifier': identifier, 'reason': 'account_disabled'}, user=user)
return Response({'error': 'Account is disabled'}, status=status.HTTP_403_FORBIDDEN)
refresh = RefreshToken.for_user(user)
user_data = UserSerializer(user).data
@@ -44,6 +48,8 @@ class AdminLoginView(APIView):
effective_scopes = []
user_data['allowed_modules'] = allowed_modules
user_data['effective_scopes'] = effective_scopes
_audit_log(request, 'auth.admin_login', 'auth', str(user.id),
{'username': user.username, 'role': user.role}, user=user)
return Response({
'access': str(refresh.access_token),
'refresh': str(refresh),
@@ -473,9 +479,17 @@ class PartnerStatusView(APIView):
valid = ('active', 'suspended', 'inactive', 'archived')
if new_status not in valid:
return Response({'error': f'status must be one of {valid}'}, status=400)
prev_status = p.status
p.status = new_status
p.kyc_compliance_reason = request.data.get('reason', p.kyc_compliance_reason or '')
p.save(update_fields=['status', 'kyc_compliance_reason'])
with transaction.atomic():
p.save(update_fields=['status', 'kyc_compliance_reason'])
_audit_log(request, 'partner.status_changed', 'partner', p.id, {
'previous_status': prev_status,
'new_status': p.status,
'reason': request.data.get('reason', '') or '',
'partner_name': p.name,
})
return Response({'id': str(p.id), 'status': p.status})
@@ -909,8 +923,13 @@ class EventUpdateView(APIView):
updated_fields.append('event_status')
if updated_fields:
e.save(update_fields=updated_fields)
with transaction.atomic():
e.save(update_fields=updated_fields)
_audit_log(request, 'event.updated', 'event', e.id, {
'changed_fields': updated_fields,
'partner_id': str(e.partner_id) if e.partner_id else None,
})
# Re-fetch with relations for response
e = Event.objects.select_related('partner').prefetch_related('eventimages_set').get(pk=pk)
return Response(_serialize_event_detail(e))
@@ -984,7 +1003,14 @@ class EventDeleteView(APIView):
from events.models import Event
from django.shortcuts import get_object_or_404
e = get_object_or_404(Event, pk=pk)
e.delete()
event_title = e.title or getattr(e, 'name', '') or ''
partner_id = str(e.partner_id) if e.partner_id else None
with transaction.atomic():
e.delete()
_audit_log(request, 'event.deleted', 'event', pk, {
'title': event_title,
'partner_id': partner_id,
})
return Response({'status': 'deleted'}, status=204)
@@ -1107,8 +1133,14 @@ class SettlementReleaseView(APIView):
from banking_operations.models import PaymentTransaction
from django.shortcuts import get_object_or_404
p = get_object_or_404(PaymentTransaction, pk=pk, payment_type='debit')
prev_status = p.payment_transaction_status
p.payment_transaction_status = 'completed'
p.save(update_fields=['payment_transaction_status'])
with transaction.atomic():
p.save(update_fields=['payment_transaction_status'])
_audit_log(request, 'settlement.released', 'settlement', p.id, {
'previous_status': prev_status,
'new_status': 'completed',
})
return Response(_serialize_settlement(p))
@@ -1256,7 +1288,16 @@ class ReviewDeleteView(APIView):
from admin_api.models import Review
from django.shortcuts import get_object_or_404
review = get_object_or_404(Review, pk=pk)
review.delete()
reviewer_id = str(review.reviewer_id) if review.reviewer_id else None
event_id = str(review.event_id) if review.event_id else None
rating = review.rating
with transaction.atomic():
review.delete()
_audit_log(request, 'review.deleted', 'review', pk, {
'reviewer_user_id': reviewer_id,
'event_id': event_id,
'rating': rating,
})
return Response(status=204)
@@ -1334,6 +1375,10 @@ class PaymentGatewaySettingsView(APIView):
is_active=d.get('is_active', True),
gateway_priority=int(d.get('gateway_priority', 0)),
)
_audit_log(request, 'gateway.created', 'gateway', gw.pk, {
'gateway_name': gw.payment_gateway_name,
'is_active': gw.is_active,
})
return Response({'status': 'success', 'payment_gateway': _serialize_gateway(gw, include_secret=True)}, status=201)
def patch(self, request, pk=None):
@@ -1353,17 +1398,26 @@ class PaymentGatewaySettingsView(APIView):
'is_active': 'is_active',
'gateway_priority': 'gateway_priority',
}
changed_fields = [cf for cf in field_map if cf in d]
for client_field, model_field in field_map.items():
if client_field in d:
setattr(gw, model_field, d[client_field])
gw.save()
_audit_log(request, 'gateway.updated', 'gateway', gw.pk, {
'gateway_name': gw.payment_gateway_name,
'changed_fields': changed_fields,
'is_active': gw.is_active,
})
return Response({'status': 'success', 'payment_gateway': _serialize_gateway(gw, include_secret=True)})
def delete(self, request, pk=None):
from banking_operations.models import PaymentGateway
from django.shortcuts import get_object_or_404
gw = get_object_or_404(PaymentGateway, pk=pk)
gw_name = gw.payment_gateway_name
gw_pk = gw.pk
gw.delete()
_audit_log(request, 'gateway.deleted', 'gateway', gw_pk, {'gateway_name': gw_name})
return Response({'status': 'success'}, status=200)
@@ -1437,6 +1491,11 @@ class EventCreateView(APIView):
pass
event.save()
_audit_log(request, 'event.created', 'event', event.id, {
'title': event.title,
'partner_id': str(event.partner_id) if event.partner_id else None,
'source': event.source,
})
return Response(_serialize_event_detail(event), status=201)
@@ -1473,9 +1532,14 @@ class EventPrimaryImageView(APIView):
return Response({"error": "Image not found for this event"}, status=404)
# Clear all primary flags for this event, then set the selected one
prev_primary = EventImages.objects.filter(event=event, is_primary=True).values_list('pk', flat=True).first()
EventImages.objects.filter(event=event).update(is_primary=False)
img.is_primary = True
img.save()
_audit_log(request, 'event.primary_image_changed', 'event', pk, {
'new_primary_image_id': str(image_id),
'previous_primary_image_id': str(prev_primary) if prev_primary else None,
})
return Response({"success": True, "primaryImageId": image_id})
@@ -1519,12 +1583,29 @@ SCOPE_DEFINITIONS = {
'ads.write': {'label': 'Create/Edit Campaigns', 'category': 'Ad Control'},
'ads.approve': {'label': 'Approve Campaigns', 'category': 'Ad Control'},
'ads.report': {'label': 'View Ad Reports', 'category': 'Ad Control'},
# Reviews Module
'reviews.read': {'label': 'View Reviews', 'category': 'Reviews'},
'reviews.moderate': {'label': 'Moderate Reviews', 'category': 'Reviews'},
'reviews.delete': {'label': 'Delete Reviews', 'category': 'Reviews'},
# Contributions Module
'contributions.read': {'label': 'View Contributions', 'category': 'Contributions'},
'contributions.approve': {'label': 'Approve Contributions', 'category': 'Contributions'},
'contributions.reject': {'label': 'Reject Contributions', 'category': 'Contributions'},
'contributions.award': {'label': 'Award EP Points', 'category': 'Contributions'},
# Leads Module
'leads.read': {'label': 'View Leads', 'category': 'Leads'},
'leads.write': {'label': 'Edit Lead Details', 'category': 'Leads'},
'leads.assign': {'label': 'Assign Leads', 'category': 'Leads'},
'leads.convert': {'label': 'Convert Leads', 'category': 'Leads'},
# Audit Log Module
'audit.read': {'label': 'View Audit Log', 'category': 'Audit Log'},
'audit.export': {'label': 'Export Audit Log CSV', 'category': 'Audit Log'},
}
def _audit_log(request, action, target_type, target_id, details=None):
def _audit_log(request, action, target_type, target_id, details=None, user=None):
AuditLog.objects.create(
user=request.user if request.user.is_authenticated else None,
user=user if user is not None else (request.user if request.user.is_authenticated else None),
action=action,
target_type=target_type,
target_id=str(target_id),
@@ -2353,6 +2434,11 @@ class PartnerOnboardView(APIView):
)
manager_user.set_password(manager_password)
manager_user.save()
_audit_log(request, 'partner.onboarded', 'partner', partner.id, {
'partner_name': partner.name,
'manager_user_id': manager_user.id,
'manager_email': manager_user.email,
})
except Exception as e:
return Response(
@@ -2482,6 +2568,12 @@ class PartnerStaffCreateView(APIView):
)
staff_user.set_password(password)
staff_user.save()
_audit_log(request, 'partner.staff.created', 'partner', partner.id, {
'new_user_id': staff_user.id,
'username': staff_user.username,
'email': staff_user.email,
'role': staff_user.role,
})
except Exception as e:
return Response(
{"success": False, "error": "Failed to create staff user: " + str(e)},
@@ -2508,6 +2600,43 @@ class PartnerStaffCreateView(APIView):
)
class PartnerImpersonateView(APIView):
"""
POST /api/v1/partners/<pk>/impersonate/
Admin-only: generate a short-lived JWT for the partner's primary manager user.
Returns access/refresh tokens + user info so the partner portal can create a session.
"""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
from partner.models import Partner as PartnerModel
from django.shortcuts import get_object_or_404
partner = get_object_or_404(PartnerModel, pk=pk)
partner_user = User.objects.filter(partner=partner, role='partner_manager').first()
if not partner_user:
return Response(
{'error': 'No partner_manager user found for this partner.'},
status=status.HTTP_404_NOT_FOUND,
)
refresh = RefreshToken.for_user(partner_user)
_audit_log(request, 'partner.impersonated', 'partner', str(pk), {
'partner_name': partner.name,
'impersonated_user': partner_user.username,
'admin': request.user.username,
})
return Response({
'access': str(refresh.access_token),
'refresh': str(refresh),
'user': {
'id': partner_user.id,
'email': partner_user.email,
'username': partner_user.username,
'role': partner_user.role,
'partnerId': str(pk),
},
})
# ─── Gamification Dashboard (stub) ───────────────────────────────────────────
class GamificationDashboardView(APIView):
permission_classes = [] # public for now; restrict when auth is wired up
@@ -2764,6 +2893,8 @@ class LeadUpdateView(APIView):
lead.save()
log("info", f"Lead #{pk} updated: {', '.join(changed)}", request=request, user=request.user)
if changed:
_audit_log(request, 'lead.updated', 'lead', pk, {'changed_fields': changed})
return Response(_serialize_lead(lead))
@@ -2874,6 +3005,19 @@ class NotificationScheduleListView(APIView):
display_name=(r.get('displayName') or '').strip(),
is_active=bool(r.get('isActive', True)),
)
_audit_log(
request,
'notification.schedule.created',
'NotificationSchedule',
schedule.pk,
details={
'name': schedule.name,
'notification_type': schedule.notification_type,
'cron_expression': schedule.cron_expression,
'is_active': schedule.is_active,
'recipient_count': len(seen_emails),
},
)
log('info', f'Notification schedule #{schedule.pk} created: {schedule.name}',
request=request, user=request.user)
@@ -2923,6 +3067,20 @@ class NotificationScheduleDetailView(APIView):
changed.append('is_active')
s.save()
if changed:
_audit_log(
request,
'notification.schedule.updated',
'NotificationSchedule',
s.pk,
details={
'name': s.name,
'changed_fields': changed,
'cron_expression': s.cron_expression,
'notification_type': s.notification_type,
'is_active': s.is_active,
},
)
log('info', f'Notification schedule #{pk} updated: {", ".join(changed) or "no-op"}',
request=request, user=request.user)
return Response(_serialize_schedule(s))
@@ -2932,7 +3090,21 @@ class NotificationScheduleDetailView(APIView):
from django.shortcuts import get_object_or_404
from eventify_logger.services import log
s = get_object_or_404(NotificationSchedule, pk=pk)
schedule_name = s.name
schedule_type = s.notification_type
schedule_cron = s.cron_expression
s.delete()
_audit_log(
request,
'notification.schedule.deleted',
'NotificationSchedule',
pk,
details={
'name': schedule_name,
'notification_type': schedule_type,
'cron_expression': schedule_cron,
},
)
log('info', f'Notification schedule #{pk} deleted', request=request, user=request.user)
return Response(status=204)
@@ -2957,6 +3129,19 @@ class NotificationRecipientView(APIView):
display_name=(request.data.get('displayName') or '').strip(),
is_active=bool(request.data.get('isActive', True)),
)
_audit_log(
request,
'notification.recipient.added',
'NotificationRecipient',
r.pk,
details={
'schedule_id': schedule.pk,
'schedule_name': schedule.name,
'email': r.email,
'display_name': r.display_name,
'is_active': r.is_active,
},
)
return Response(_serialize_recipient(r), status=201)
@@ -2968,6 +3153,7 @@ class NotificationRecipientDetailView(APIView):
from django.shortcuts import get_object_or_404
r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk)
changed = []
if (email := request.data.get('email')) is not None:
email = str(email).strip().lower()
if not email:
@@ -2977,22 +3163,57 @@ class NotificationRecipientDetailView(APIView):
).exclude(pk=rid).exists()
if clash:
return Response({'error': f'{email} is already a recipient'}, status=409)
r.email = email
if r.email != email:
r.email = email
changed.append('email')
if (display_name := request.data.get('displayName')) is not None:
r.display_name = str(display_name).strip()
new_name = str(display_name).strip()
if r.display_name != new_name:
r.display_name = new_name
changed.append('display_name')
if (is_active := request.data.get('isActive')) is not None:
r.is_active = bool(is_active)
new_active = bool(is_active)
if r.is_active != new_active:
r.is_active = new_active
changed.append('is_active')
r.save()
if changed:
_audit_log(
request,
'notification.recipient.updated',
'NotificationRecipient',
r.pk,
details={
'schedule_id': pk,
'email': r.email,
'display_name': r.display_name,
'is_active': r.is_active,
'changed_fields': changed,
},
)
return Response(_serialize_recipient(r))
def delete(self, request, pk, rid):
from notifications.models import NotificationRecipient
from django.shortcuts import get_object_or_404
r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk)
recipient_email = r.email
recipient_name = r.display_name
r.delete()
_audit_log(
request,
'notification.recipient.removed',
'NotificationRecipient',
rid,
details={
'schedule_id': pk,
'email': recipient_email,
'display_name': recipient_name,
},
)
return Response(status=204)
@@ -3024,6 +3245,18 @@ class NotificationScheduleSendNowView(APIView):
schedule.save(update_fields=[
'last_run_at', 'last_status', 'last_error', 'updated_at',
])
_audit_log(
request,
'notification.schedule.dispatched',
'NotificationSchedule',
schedule.pk,
details={
'schedule_name': schedule.name,
'notification_type': schedule.notification_type,
'recipient_count': recipient_count,
'triggered_at': schedule.last_run_at.isoformat(),
},
)
log('info', f'Send-now fired for schedule #{pk}{recipient_count} recipient(s)',
request=request, user=request.user)
return Response({
@@ -3080,3 +3313,409 @@ class NotificationScheduleTestSendView(APIView):
log('info', f'Test email sent for schedule #{pk}{email}',
request=request, user=request.user)
return Response({'ok': True, 'sentTo': email})
# ===========================================================================
# Partner-Me (Partner Portal self-service endpoints)
# Sprint 1 — Settings wiring
# Auth: simplejwt Bearer token (same as MeView / all admin_api views)
# ===========================================================================
def _require_partner(request):
"""Return (partner, None) or (None, error_response)."""
partner_roles = ['partner', 'partner_manager', 'partner_staff']
if request.user.role not in partner_roles:
return None, Response(
{'error': 'Partner account required.'},
status=status.HTTP_403_FORBIDDEN,
)
partner = getattr(request.user, 'partner', None)
if partner is None:
return None, Response(
{'error': 'No partner organisation linked to this account.'},
status=status.HTTP_403_FORBIDDEN,
)
return partner, None
class PartnerMeProfileView(APIView):
"""
GET /api/v1/partners/me/profile/ — return partner profile
PUT /api/v1/partners/me/profile/ — update partner profile
"""
permission_classes = [IsAuthenticated]
def get(self, request):
partner, err = _require_partner(request)
if err:
return err
return Response({
'name': partner.name or '',
'email': request.user.email or '',
'phone': request.user.phone_number or '',
'website': partner.website_url or '',
'bio': partner.bio or '',
})
def put(self, request):
partner, err = _require_partner(request)
if err:
return err
data = request.data
user = request.user
user_changed = False
partner_changed = False
if 'email' in data and data['email'] != user.email:
new_email = (data['email'] or '').strip()
if new_email:
# Uniqueness check — exclude self
from django.contrib.auth import get_user_model as _gum
_User = _gum()
if _User.objects.filter(email=new_email).exclude(pk=user.pk).exists():
return Response({'error': 'Email already in use by another account.'}, status=400)
user.email = new_email
user_changed = True
if 'phone' in data:
user.phone_number = (data['phone'] or '').strip() or None
user_changed = True
if 'name' in data and data['name']:
partner.name = data['name'].strip()
partner_changed = True
if 'website' in data:
partner.website_url = (data['website'] or '').strip() or None
partner_changed = True
if 'bio' in data:
partner.bio = (data['bio'] or '').strip() or None
partner_changed = True
if user_changed:
user.save(update_fields=[f for f in ['email', 'phone_number'] if f])
if partner_changed:
partner.save(update_fields=[f for f in ['name', 'website_url', 'bio'] if getattr(partner, f, None) is not None or f in data])
return Response({
'name': partner.name or '',
'email': user.email or '',
'phone': user.phone_number or '',
'website': partner.website_url or '',
'bio': partner.bio or '',
})
class PartnerMeNotificationsView(APIView):
"""
GET /api/v1/partners/me/notifications/ — return notification prefs
PUT /api/v1/partners/me/notifications/ — update notification prefs
"""
permission_classes = [IsAuthenticated]
def get(self, request):
partner, err = _require_partner(request)
if err:
return err
return Response({
'newBooking': partner.notif_new_booking,
'eventStatus': partner.notif_event_status,
'payoutUpdate': partner.notif_payout_update,
'weeklyReport': partner.notif_weekly_report,
})
def put(self, request):
partner, err = _require_partner(request)
if err:
return err
data = request.data
if 'newBooking' in data:
partner.notif_new_booking = bool(data['newBooking'])
if 'eventStatus' in data:
partner.notif_event_status = bool(data['eventStatus'])
if 'payoutUpdate' in data:
partner.notif_payout_update = bool(data['payoutUpdate'])
if 'weeklyReport' in data:
partner.notif_weekly_report = bool(data['weeklyReport'])
partner.save(update_fields=[
'notif_new_booking', 'notif_event_status',
'notif_payout_update', 'notif_weekly_report',
])
return Response({
'newBooking': partner.notif_new_booking,
'eventStatus': partner.notif_event_status,
'payoutUpdate': partner.notif_payout_update,
'weeklyReport': partner.notif_weekly_report,
})
class PartnerMePayoutView(APIView):
"""
GET /api/v1/partners/me/payout/ — return payout settings
PUT /api/v1/partners/me/payout/ — update payout settings
"""
permission_classes = [IsAuthenticated]
def get(self, request):
partner, err = _require_partner(request)
if err:
return err
return Response({
'accountHolderName': partner.payout_account_holder_name or '',
'accountNumber': partner.payout_account_number or '',
'ifscCode': partner.payout_ifsc_code or '',
'bankName': partner.payout_bank_name or '',
'payoutSchedule': partner.payout_schedule or 'monthly',
})
def put(self, request):
partner, err = _require_partner(request)
if err:
return err
data = request.data
valid_schedules = ['weekly', 'biweekly', 'monthly']
if 'accountHolderName' in data:
partner.payout_account_holder_name = (data['accountHolderName'] or '').strip() or None
if 'accountNumber' in data:
partner.payout_account_number = (data['accountNumber'] or '').strip() or None
if 'ifscCode' in data:
partner.payout_ifsc_code = (data['ifscCode'] or '').strip().upper() or None
if 'bankName' in data:
partner.payout_bank_name = (data['bankName'] or '').strip() or None
if 'payoutSchedule' in data:
sched = data['payoutSchedule']
if sched not in valid_schedules:
return Response(
{'error': f'payoutSchedule must be one of: {", ".join(valid_schedules)}'},
status=400,
)
partner.payout_schedule = sched
partner.save(update_fields=[
'payout_account_holder_name', 'payout_account_number',
'payout_ifsc_code', 'payout_bank_name', 'payout_schedule',
])
return Response({
'accountHolderName': partner.payout_account_holder_name or '',
'accountNumber': partner.payout_account_number or '',
'ifscCode': partner.payout_ifsc_code or '',
'bankName': partner.payout_bank_name or '',
'payoutSchedule': partner.payout_schedule or 'monthly',
})
class PartnerMeChangePasswordView(APIView):
"""
POST /api/v1/partners/me/change-password/
Body: { current_password, new_password }
"""
permission_classes = [IsAuthenticated]
def post(self, request):
partner_roles = ['partner', 'partner_manager', 'partner_staff']
if request.user.role not in partner_roles:
return Response({'error': 'Partner account required.'}, status=403)
current_password = request.data.get('current_password', '')
new_password = request.data.get('new_password', '')
if not current_password or not new_password:
return Response(
{'error': 'current_password and new_password are required.'},
status=400,
)
if not request.user.check_password(current_password):
return Response({'error': 'Current password is incorrect.'}, status=400)
if len(new_password) < 8:
return Response({'error': 'New password must be at least 8 characters.'}, status=400)
request.user.set_password(new_password)
request.user.save(update_fields=['password'])
return Response({'success': True})
# ===========================================================================
# Partner-Me Events (Sprint 2)
# ===========================================================================
def _require_owned_event(request, pk):
"""Return (event, None) or (None, error_response). Validates partner ownership."""
from events.models import Event
from django.shortcuts import get_object_or_404
partner, err = _require_partner(request)
if err:
return None, err
e = get_object_or_404(
Event.objects.select_related('partner', 'event_type').prefetch_related('eventimages_set'),
pk=pk,
)
if e.partner_id != partner.id:
return None, Response({'error': 'Event not found or access denied.'}, status=404)
return e, None
class PartnerMeEventsView(APIView):
"""
GET /api/v1/partners/me/events/ — list partner's own events
POST /api/v1/partners/me/events/ — create event for this partner
"""
permission_classes = [IsAuthenticated]
def get(self, request):
from events.models import Event
from django.db.models import Q
partner, err = _require_partner(request)
if err:
return err
qs = Event.objects.filter(partner=partner).select_related('event_type')
if s := request.GET.get('status'):
reverse_map = {v: k for k, v in _EVENT_STATUS_MAP.items()}
qs = qs.filter(event_status=reverse_map.get(s, s))
if q := request.GET.get('search'):
qs = qs.filter(Q(title__icontains=q) | Q(name__icontains=q) | Q(venue_name__icontains=q))
try:
page = max(1, int(request.GET.get('page', 1)))
page_size = min(100, int(request.GET.get('page_size', 20)))
except (ValueError, TypeError):
page, page_size = 1, 20
total = qs.count()
events = qs.order_by('-id')[(page - 1) * page_size: page * page_size]
return Response({'count': total, 'results': [_serialize_event(e) for e in events]})
def post(self, request):
from events.models import Event, EventType
partner, err = _require_partner(request)
if err:
return err
data = request.data
title = (data.get('title') or '').strip()
if not title:
return Response({'error': 'title is required'}, status=400)
event_type = None
if eid := data.get('eventType'):
try:
event_type = EventType.objects.get(id=eid)
except EventType.DoesNotExist:
return Response({'error': 'Invalid event type'}, status=400)
status_in = data.get('status', 'draft')
backend_status = {'draft': 'created', 'published': 'published'}.get(status_in, 'created')
event = Event(
title=title,
name=data.get('name') or title,
description=data.get('description', ''),
event_type=event_type,
event_status=backend_status,
venue_name=data.get('venueName', ''),
place=data.get('place', ''),
is_bookable=True,
source='partner',
partner=partner,
)
if data.get('startDate'):
event.start_date = data['startDate']
if data.get('endDate'):
event.end_date = data['endDate']
if data.get('startTime'):
event.start_time = data['startTime']
if data.get('endTime'):
event.end_time = data['endTime']
event.save()
_audit_log(request, 'event.created', 'event', event.id, {
'title': event.title, 'partner_id': str(partner.id), 'source': 'partner',
})
return Response(_serialize_event_detail(event), status=201)
class PartnerMeEventDetailView(APIView):
"""
GET /api/v1/partners/me/events/{pk}/ — detail
PATCH /api/v1/partners/me/events/{pk}/ — update
DELETE /api/v1/partners/me/events/{pk}/ — delete
"""
permission_classes = [IsAuthenticated]
def get(self, request, pk):
e, err = _require_owned_event(request, pk)
if err:
return err
return Response(_serialize_event_detail(e))
def patch(self, request, pk):
from events.models import Event
e, err = _require_owned_event(request, pk)
if err:
return err
data = request.data
field_map = {
'title': 'title', 'name': 'name', 'description': 'description',
'venueName': 'venue_name', 'place': 'place',
'district': 'district', 'state': 'state', 'pincode': 'pincode',
}
updated = []
for api_key, model_field in field_map.items():
if api_key in data:
setattr(e, model_field, data[api_key] or '')
updated.append(model_field)
if 'status' in data:
e.event_status = {'draft': 'created', 'published': 'published'}.get(
data['status'], data['status']
)
updated.append('event_status')
for src_key, model_field in [
('startDate', 'start_date'), ('endDate', 'end_date'),
('startTime', 'start_time'), ('endTime', 'end_time'),
]:
if src_key in data:
setattr(e, model_field, data[src_key] or None)
updated.append(model_field)
if updated:
e.save(update_fields=updated)
e = Event.objects.select_related('partner', 'event_type').prefetch_related('eventimages_set').get(pk=pk)
return Response(_serialize_event_detail(e))
def delete(self, request, pk):
e, err = _require_owned_event(request, pk)
if err:
return err
e.delete()
return Response({'status': 'deleted'}, status=204)
class PartnerMeEventDuplicateView(APIView):
"""POST /api/v1/partners/me/events/{pk}/duplicate/"""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
e, err = _require_owned_event(request, pk)
if err:
return err
# Duplicate by clearing PK
e.pk = None
e.title = f"{e.title} (Copy)"
e.name = f"{e.name} (Copy)"
e.event_status = 'created' # always draft
e.save()
return Response(_serialize_event_detail(e), status=201)

View File

@@ -18,6 +18,9 @@ ALLOWED_HOSTS = [
'backend.eventifyplus.com',
'admin.eventifyplus.com',
'app.eventifyplus.com',
'partner.eventifyplus.com',
'eventify-backend',
'eventify-django',
'localhost',
'127.0.0.1',
]

View File

@@ -29,11 +29,20 @@ def _serialize_review(r, user_interactions=None):
uname = r.reviewer.username
except Exception:
uname = ''
try:
pic = r.reviewer.profile_picture
if pic and pic.name and 'default.png' not in pic.name:
profile_photo = pic.url
else:
profile_photo = ''
except Exception:
profile_photo = ''
return {
'id': r.id,
'event_id': r.event_id,
'username': uname,
'display_name': display,
'profile_photo': profile_photo,
'rating': r.rating,
'comment': r.review_text,
'status': _STATUS_TO_JSON.get(r.status, r.status),

View File

@@ -0,0 +1,70 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('partner', '0001_initial'),
]
operations = [
# Profile extras
migrations.AddField(
model_name='partner',
name='bio',
field=models.TextField(blank=True, null=True),
),
# Payout settings
migrations.AddField(
model_name='partner',
name='payout_account_holder_name',
field=models.CharField(blank=True, max_length=250, null=True),
),
migrations.AddField(
model_name='partner',
name='payout_account_number',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AddField(
model_name='partner',
name='payout_ifsc_code',
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AddField(
model_name='partner',
name='payout_bank_name',
field=models.CharField(blank=True, max_length=250, null=True),
),
migrations.AddField(
model_name='partner',
name='payout_schedule',
field=models.CharField(
choices=[('weekly', 'Weekly'), ('biweekly', 'Bi-weekly'), ('monthly', 'Monthly')],
default='monthly',
max_length=20,
),
),
# Notification preferences
migrations.AddField(
model_name='partner',
name='notif_new_booking',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='partner',
name='notif_event_status',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='partner',
name='notif_payout_update',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='partner',
name='notif_weekly_report',
field=models.BooleanField(default=False),
),
]

View File

@@ -65,5 +65,28 @@ class Partner(models.Model):
kyc_compliance_document_file = models.FileField(upload_to='kyc_documents/', blank=True, null=True)
kyc_compliance_document_number = models.CharField(max_length=250, blank=True, null=True)
# Profile extras
bio = models.TextField(blank=True, null=True)
# Payout settings
PAYOUT_SCHEDULE_CHOICES = (
('weekly', 'Weekly'),
('biweekly', 'Bi-weekly'),
('monthly', 'Monthly'),
)
payout_account_holder_name = models.CharField(max_length=250, blank=True, null=True)
payout_account_number = models.CharField(max_length=50, blank=True, null=True)
payout_ifsc_code = models.CharField(max_length=20, blank=True, null=True)
payout_bank_name = models.CharField(max_length=250, blank=True, null=True)
payout_schedule = models.CharField(
max_length=20, choices=PAYOUT_SCHEDULE_CHOICES, default='monthly'
)
# Notification preferences
notif_new_booking = models.BooleanField(default=True)
notif_event_status = models.BooleanField(default=True)
notif_payout_update = models.BooleanField(default=True)
notif_weekly_report = models.BooleanField(default=False)
def __str__(self):
return self.name