9 Commits

Author SHA1 Message Date
611d653938 Sprint 3: partner ticket tier CRUD endpoints
- admin_api/views.py: add _serialize_tier(), PartnerMeEventTiersView
  (GET list + POST create with get_or_create TicketMeta),
  PartnerMeEventTierDetailView (PATCH update + DELETE)
- admin_api/urls.py: wire partners/me/events/{event_pk}/tiers/ and
  .../tiers/{tier_pk}/ with named routes partner-me-event-tiers,
  partner-me-event-tier-detail
- Deployed to eventify-backend + eventify-django containers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:28:57 +05:30
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
8 changed files with 761 additions and 1 deletions

View File

@@ -5,6 +5,26 @@ 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 ## [1.14.0] — 2026-04-21
### Added ### Added

View File

@@ -5,9 +5,10 @@ User = get_user_model()
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField() name = serializers.SerializerMethodField()
role = serializers.SerializerMethodField() role = serializers.SerializerMethodField()
partner = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta: class Meta:
model = User model = User
fields = ['id', 'email', 'username', 'name', 'role'] fields = ['id', 'email', 'username', 'name', 'role', 'partner']
def get_name(self, obj): def get_name(self, obj):
return f"{obj.first_name} {obj.last_name}".strip() or obj.username return f"{obj.first_name} {obj.last_name}".strip() or obj.username
def get_role(self, obj): def get_role(self, obj):

View File

@@ -18,8 +18,21 @@ urlpatterns = [
path('partners/<int:pk>/', views.PartnerDetailView.as_view(), name='partner-detail'), 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>/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>/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/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'),
path('partners/<int:partner_id>/staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'), 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'),
path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'), path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
path('users/', views.UserListView.as_view(), name='user-list'), path('users/', views.UserListView.as_view(), name='user-list'),
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'), path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),

View File

@@ -2600,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) ─────────────────────────────────────────── # ─── Gamification Dashboard (stub) ───────────────────────────────────────────
class GamificationDashboardView(APIView): class GamificationDashboardView(APIView):
permission_classes = [] # public for now; restrict when auth is wired up permission_classes = [] # public for now; restrict when auth is wired up
@@ -3276,3 +3313,587 @@ class NotificationScheduleTestSendView(APIView):
log('info', f'Test email sent for schedule #{pk}{email}', log('info', f'Test email sent for schedule #{pk}{email}',
request=request, user=request.user) request=request, user=request.user)
return Response({'ok': True, 'sentTo': email}) 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)
# ===========================================================================
# Partner-Me Ticket Tiers (Sprint 3)
# ===========================================================================
def _serialize_tier(tt, sold=0):
"""Serialize TicketType (tier) for partner portal."""
capacity = tt.ticket_meta.maximum_quantity if tt.ticket_meta_id else tt.ticket_type_quantity
return {
'id': str(tt.id),
'name': tt.ticket_type,
'description': tt.ticket_type_description or '',
'price': str(tt.price),
'capacity': tt.ticket_type_quantity,
'totalCapacity': capacity,
'sold': sold,
'isActive': tt.is_active,
'isOffer': tt.is_offer,
'offerPrice': str(tt.offer_price) if tt.is_offer else None,
}
class PartnerMeEventTiersView(APIView):
"""
GET /api/v1/partners/me/events/{event_pk}/tiers/ — list tiers
POST /api/v1/partners/me/events/{event_pk}/tiers/ — create tier
"""
permission_classes = [IsAuthenticated]
def _get_event_and_meta(self, request, event_pk):
from events.models import Event
from bookings.models import TicketMeta
from django.shortcuts import get_object_or_404
partner, err = _require_partner(request)
if err:
return None, None, None, err
event = get_object_or_404(Event, pk=event_pk)
if event.partner_id != partner.id:
return None, None, None, Response(
{'error': 'Event not found or access denied.'}, status=404
)
# Get or create the event's single TicketMeta
meta, _ = TicketMeta.objects.get_or_create(
event=event,
defaults={
'ticket_name': event.title or 'Tickets',
'maximum_quantity': 0,
'available_quantity': 0,
},
)
return partner, event, meta, None
def get(self, request, event_pk):
from bookings.models import TicketType, Booking
from django.db.models import Sum
_partner, _event, meta, err = self._get_event_and_meta(request, event_pk)
if err:
return err
tiers = TicketType.objects.filter(ticket_meta=meta).order_by('id')
# Aggregate sold count per tier
sold_map = dict(
Booking.objects.filter(ticket_meta=meta)
.values('ticket_type_id')
.annotate(total=Sum('quantity'))
.values_list('ticket_type_id', 'total')
)
return Response([_serialize_tier(t, sold_map.get(t.id, 0)) for t in tiers])
def post(self, request, event_pk):
from bookings.models import TicketType
_partner, _event, meta, err = self._get_event_and_meta(request, event_pk)
if err:
return err
data = request.data
name = (data.get('name') or '').strip()
if not name:
return Response({'error': 'name is required'}, status=400)
try:
price = float(data.get('price', 0))
except (ValueError, TypeError):
return Response({'error': 'price must be numeric'}, status=400)
try:
capacity = int(data.get('capacity', 0))
except (ValueError, TypeError):
return Response({'error': 'capacity must be integer'}, status=400)
tt = TicketType.objects.create(
ticket_meta=meta,
ticket_type=name,
ticket_type_description=data.get('description', ''),
ticket_type_quantity=capacity,
price=price,
is_active=True,
)
# Update meta total capacity
from django.db.models import Sum as _Sum
total = TicketType.objects.filter(ticket_meta=meta).aggregate(
total=_Sum('ticket_type_quantity')
)['total'] or 0
meta.maximum_quantity = total
meta.available_quantity = total
meta.save(update_fields=['maximum_quantity', 'available_quantity'])
return Response(_serialize_tier(tt), status=201)
class PartnerMeEventTierDetailView(APIView):
"""
PATCH /api/v1/partners/me/events/{event_pk}/tiers/{tier_pk}/
DELETE /api/v1/partners/me/events/{event_pk}/tiers/{tier_pk}/
"""
permission_classes = [IsAuthenticated]
def _get_tier(self, request, event_pk, tier_pk):
from events.models import Event
from bookings.models import TicketType
from django.shortcuts import get_object_or_404
partner, err = _require_partner(request)
if err:
return None, err
event = get_object_or_404(Event, pk=event_pk)
if event.partner_id != partner.id:
return None, Response({'error': 'Event not found or access denied.'}, status=404)
tt = get_object_or_404(TicketType, pk=tier_pk, ticket_meta__event=event)
return tt, None
def patch(self, request, event_pk, tier_pk):
tt, err = self._get_tier(request, event_pk, tier_pk)
if err:
return err
data = request.data
updated = []
if 'name' in data:
tt.ticket_type = (data['name'] or '').strip()
updated.append('ticket_type')
if 'description' in data:
tt.ticket_type_description = data['description'] or ''
updated.append('ticket_type_description')
if 'price' in data:
try:
tt.price = float(data['price'])
except (ValueError, TypeError):
return Response({'error': 'price must be numeric'}, status=400)
updated.append('price')
if 'capacity' in data:
try:
tt.ticket_type_quantity = int(data['capacity'])
except (ValueError, TypeError):
return Response({'error': 'capacity must be integer'}, status=400)
updated.append('ticket_type_quantity')
if 'isActive' in data:
tt.is_active = bool(data['isActive'])
updated.append('is_active')
if updated:
tt.save(update_fields=updated)
return Response(_serialize_tier(tt))
def delete(self, request, event_pk, tier_pk):
tt, err = self._get_tier(request, event_pk, tier_pk)
if err:
return err
tt.delete()
return Response({'status': 'deleted'}, status=204)

View File

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

View File

@@ -29,11 +29,20 @@ def _serialize_review(r, user_interactions=None):
uname = r.reviewer.username uname = r.reviewer.username
except Exception: except Exception:
uname = '' 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 { return {
'id': r.id, 'id': r.id,
'event_id': r.event_id, 'event_id': r.event_id,
'username': uname, 'username': uname,
'display_name': display, 'display_name': display,
'profile_photo': profile_photo,
'rating': r.rating, 'rating': r.rating,
'comment': r.review_text, 'comment': r.review_text,
'status': _STATUS_TO_JSON.get(r.status, r.status), '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_file = models.FileField(upload_to='kyc_documents/', blank=True, null=True)
kyc_compliance_document_number = models.CharField(max_length=250, blank=True, null=True) kyc_compliance_document_number = models.CharField(max_length=250, blank=True, null=True)
# Profile extras
bio = models.TextField(blank=True, null=True)
# Payout settings
PAYOUT_SCHEDULE_CHOICES = (
('weekly', 'Weekly'),
('biweekly', 'Bi-weekly'),
('monthly', 'Monthly'),
)
payout_account_holder_name = models.CharField(max_length=250, blank=True, null=True)
payout_account_number = models.CharField(max_length=50, blank=True, null=True)
payout_ifsc_code = models.CharField(max_length=20, blank=True, null=True)
payout_bank_name = models.CharField(max_length=250, blank=True, null=True)
payout_schedule = models.CharField(
max_length=20, choices=PAYOUT_SCHEDULE_CHOICES, default='monthly'
)
# Notification preferences
notif_new_booking = models.BooleanField(default=True)
notif_event_status = models.BooleanField(default=True)
notif_payout_update = models.BooleanField(default=True)
notif_weekly_report = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.name return self.name