Compare commits
9 Commits
4a9f754fda
...
sprint/3-t
| Author | SHA1 | Date | |
|---|---|---|---|
| 611d653938 | |||
| 16c21c17d2 | |||
| 761b702e57 | |||
| 46b391bd51 | |||
| d9a2af7168 | |||
| f75d4f2915 | |||
| 05de552820 | |||
| f85188ca6b | |||
| 64ff08b2b2 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user