from django.contrib.auth import authenticate, get_user_model from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework import status from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.views import TokenRefreshView from django.db import connection, transaction from .serializers import UserSerializer User = get_user_model() class AdminLoginView(APIView): permission_classes = [AllowAny] def post(self, request): identifier = request.data.get('username') or request.data.get('email') password = request.data.get('password') if not identifier or not password: return Response({'error': 'username/email and password required'}, status=status.HTTP_400_BAD_REQUEST) # Try username first, then email user = authenticate(request, username=identifier, password=password) if not user: try: u = User.objects.get(email=identifier) user = authenticate(request, username=u.username, password=password) 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 # RBAC: prefer StaffProfile for allowed_modules and scopes try: sp = user.staff_profile allowed_modules = sp.get_allowed_modules() effective_scopes = sp.get_effective_scopes() user_data['staff_role'] = sp.staff_role user_data['department'] = sp.department.name if sp.department else None user_data['squad'] = sp.squad.name if sp.squad else None except Exception: allowed_modules = user.get_allowed_modules() 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), 'user': user_data, }) class MeView(APIView): permission_classes = [IsAuthenticated] def get(self, request): me_data = UserSerializer(request.user).data # RBAC: prefer StaffProfile for allowed_modules and scopes try: sp = request.user.staff_profile allowed_modules = sp.get_allowed_modules() effective_scopes = sp.get_effective_scopes() me_data['staff_role'] = sp.staff_role me_data['department'] = sp.department.name if sp.department else None me_data['squad'] = sp.squad.name if sp.squad else None except Exception: allowed_modules = request.user.get_allowed_modules() effective_scopes = [] me_data['allowed_modules'] = allowed_modules me_data['effective_scopes'] = effective_scopes return Response({'user': me_data}) class HealthView(APIView): permission_classes = [AllowAny] def get(self, request): try: connection.ensure_connection() db_status = 'ok' except Exception: db_status = 'error' return Response({'status': 'ok', 'db': db_status}) # --------------------------------------------------------------------------- # Phase 2: Dashboard Views # --------------------------------------------------------------------------- class DashboardMetricsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from ledger.models import RazorpayTransaction from partner.models import Partner from events.models import Event from bookings.models import Ticket, Booking from django.db.models import Sum from django.utils import timezone import datetime today = timezone.now().date() # --- Revenue --- total_paise = ( RazorpayTransaction.objects .filter(status='captured') .aggregate(total=Sum('amount'))['total'] or 0 ) total_revenue = total_paise / 100 # This-month / last-month revenue for growth first_of_this_month = today.replace(day=1) first_of_last_month = (first_of_this_month - datetime.timedelta(days=1)).replace(day=1) this_month_paise = ( RazorpayTransaction.objects .filter(status='captured', captured_at__date__gte=first_of_this_month) .aggregate(total=Sum('amount'))['total'] or 0 ) last_month_paise = ( RazorpayTransaction.objects .filter( status='captured', captured_at__date__gte=first_of_last_month, captured_at__date__lt=first_of_this_month, ) .aggregate(total=Sum('amount'))['total'] or 0 ) this_month_rev = this_month_paise / 100 last_month_rev = last_month_paise / 100 revenue_growth = ( round((this_month_rev - last_month_rev) / last_month_rev * 100, 2) if last_month_rev else 0 ) # --- Partners --- active_partners = Partner.objects.filter(status='active').count() pending_partners = Partner.objects.filter(kyc_compliance_status='pending').count() # --- Events --- live_events = Event.objects.filter(event_status='live').count() events_today = Event.objects.filter(start_date=today).count() # --- Tickets --- ticket_sales = Ticket.objects.count() # This-week / last-week ticket growth week_start = today - datetime.timedelta(days=today.weekday()) # Monday last_week_start = week_start - datetime.timedelta(days=7) this_week_tickets = Ticket.objects.filter( booking__created_date__gte=week_start ).count() last_week_tickets = Ticket.objects.filter( booking__created_date__gte=last_week_start, booking__created_date__lt=week_start, ).count() ticket_growth = ( round((this_week_tickets - last_week_tickets) / last_week_tickets * 100, 2) if last_week_tickets else 0 ) return Response({ 'totalRevenue': total_revenue, 'revenueGrowth': revenue_growth, 'activePartners': active_partners, 'pendingPartners': pending_partners, 'liveEvents': live_events, 'eventsToday': events_today, 'ticketSales': ticket_sales, 'ticketGrowth': ticket_growth, }) class DashboardRevenueView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from ledger.models import RazorpayTransaction from banking_operations.models import PaymentTransaction from django.db.models import Sum from django.utils import timezone import datetime today = timezone.now().date() result = [] for i in range(6, -1, -1): day = today - datetime.timedelta(days=i) rev_paise = ( RazorpayTransaction.objects .filter(status='captured', captured_at__date=day) .aggregate(total=Sum('amount'))['total'] or 0 ) revenue = rev_paise / 100 payouts = ( PaymentTransaction.objects .filter( payment_type='debit', payment_transaction_status='completed', payment_transaction_date=day, ) .aggregate(total=Sum('payment_transaction_amount'))['total'] or 0 ) result.append({ 'day': day.strftime('%a'), 'revenue': float(revenue), 'payouts': float(payouts), }) return Response(result) class DashboardActivityView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from partner.models import Partner from events.models import Event from bookings.models import Booking from django.utils import timezone items = [] # --- Partners (last 5 by id desc; no date field — timestamp=None, filtered out later) --- for p in Partner.objects.order_by('-id')[:5]: items.append({ 'id': f'partner-{p.id}', 'type': 'partner', 'title': f'{p.name} registered', 'description': f'New partner — {getattr(p, "partner_type", "individual")}', 'timestamp': None, 'status': p.kyc_compliance_status, }) # --- Events (last 5 by created_date desc) --- for e in Event.objects.order_by('-created_date')[:5]: display_name = e.title if getattr(e, 'title', None) else getattr(e, 'name', '') ts = e.created_date items.append({ 'id': f'event-{e.id}', 'type': 'event', 'title': f'{display_name} created', 'description': f'Event status: {e.event_status}', 'timestamp': ts.isoformat() if ts else None, 'status': e.event_status, }) # --- Bookings (last 5 by id desc) --- for b in Booking.objects.order_by('-id')[:5]: ts = b.created_date items.append({ 'id': f'booking-{b.booking_id}', 'type': 'booking', 'title': f'Booking {b.booking_id} placed', 'description': f'{b.quantity} ticket(s) at ₹{b.price}', 'timestamp': ts.isoformat() if ts else None, 'status': 'confirmed', }) # Filter out items with no timestamp, sort desc, return top 10 dated = [item for item in items if item['timestamp'] is not None] dated.sort(key=lambda x: x['timestamp'], reverse=True) return Response(dated[:10]) class DashboardActionsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from partner.models import Partner from events.models import Event from banking_operations.models import PaymentTransaction from django.db.models import Sum kyc_count = Partner.objects.filter(kyc_compliance_status='pending').count() flagged_count = Event.objects.filter(event_status='flagged').count() pending_payouts = float( PaymentTransaction.objects .filter(payment_type='debit', payment_transaction_status='pending') .aggregate(total=Sum('payment_transaction_amount'))['total'] or 0 ) actions = [ { 'id': 'kyc', 'type': 'kyc', 'count': kyc_count, 'title': 'Partner Approval Queue', 'description': f'{kyc_count} partners awaiting KYC review', 'href': '/partners?filter=pending', 'priority': 'high', }, { 'id': 'flagged', 'type': 'flagged', 'count': flagged_count, 'title': 'Flagged Events', 'description': f'{flagged_count} events reported for review', 'href': '/events?filter=flagged', 'priority': 'high', }, { 'id': 'payout', 'type': 'payout', 'count': pending_payouts, 'title': 'Pending Payouts', 'description': f'₹{pending_payouts:,.0f} ready for release', 'href': '/financials?tab=payouts', 'priority': 'medium', }, ] return Response(actions) # --------------------------------------------------------------------------- # Phase 3: Partner helpers # --------------------------------------------------------------------------- _PARTNER_STATUS_MAP = { 'active': 'Active', 'pending': 'Invited', 'suspended': 'Suspended', 'archived': 'Archived', 'deleted': 'Archived', 'inactive': 'Archived', } _PARTNER_KYC_MAP = {'approved': 'Verified', 'rejected': 'Rejected'} _PARTNER_TYPE_MAP = { 'venue': 'Venue', 'promoter': 'Promoter', 'sponsor': 'Sponsor', 'vendor': 'Vendor', 'affiliate': 'Affiliate', 'other': 'Other', } _RISK_MAP = { 'high_risk': 80, 'medium_risk': 45, 'low_risk': 15, 'rejected': 90, 'pending': 30, 'approved': 5, } def _partner_kyc_docs(p): if not p.kyc_compliance_document_type: return [] return [{ 'id': f'kyc-{p.id}', 'partnerId': str(p.id), 'type': p.kyc_compliance_document_type.upper(), 'name': p.kyc_compliance_document_other_type or p.kyc_compliance_document_type, 'url': p.kyc_compliance_document_file.url if p.kyc_compliance_document_file else '', 'status': {'approved': 'APPROVED', 'rejected': 'REJECTED'}.get(p.kyc_compliance_status, 'PENDING'), 'mandatory': True, 'adminNote': p.kyc_compliance_reason or '', 'uploadedBy': p.primary_contact_person_name, 'uploadedAt': '', }] def _serialize_partner(p, events_count=0): addr = ', '.join(filter(None, [p.address, p.city, p.state, p.country])) return { 'id': str(p.id), 'name': p.name, 'type': _PARTNER_TYPE_MAP.get(p.partner_type, 'Other'), 'status': _PARTNER_STATUS_MAP.get(p.status, 'Invited'), 'primaryContact': { 'name': p.primary_contact_person_name, 'email': p.primary_contact_person_email, 'phone': p.primary_contact_person_phone, }, 'companyDetails': { 'website': p.website_url or '', 'address': addr, }, 'metrics': { 'eventsCount': events_count, 'totalRevenue': 0, 'openBalance': 0, 'activeDeals': 0, 'lastActivity': None, }, 'verificationStatus': _PARTNER_KYC_MAP.get(p.kyc_compliance_status, 'Pending'), 'kycComplianceStatus': p.kyc_compliance_status, 'riskScore': _RISK_MAP.get(p.kyc_compliance_status, 0), 'joinedAt': None, 'tags': [], 'notes': p.kyc_compliance_reason or '', 'kycDocuments': _partner_kyc_docs(p), } # --------------------------------------------------------------------------- # Phase 3: Partner Views # --------------------------------------------------------------------------- class PartnerStatsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from partner.models import Partner return Response({ 'total': Partner.objects.count(), 'active': Partner.objects.filter(status='active').count(), 'pendingKyc': Partner.objects.filter(kyc_compliance_status='pending').count(), 'highRisk': Partner.objects.filter(kyc_compliance_status='high_risk').count(), }) class PartnerListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from partner.models import Partner from django.db.models import Count, Q qs = Partner.objects.annotate(events_count=Count('event')) if s := request.GET.get('status'): qs = qs.filter(status=s) if k := request.GET.get('kyc_status'): qs = qs.filter(kyc_compliance_status=k) if t := request.GET.get('partner_type'): qs = qs.filter(partner_type=t) if q := request.GET.get('search'): qs = qs.filter( Q(name__icontains=q) | Q(primary_contact_person_email__icontains=q) | Q(primary_contact_person_name__icontains=q) ) page = max(1, int(request.GET.get('page', 1))) page_size = min(100, int(request.GET.get('page_size', 20))) total = qs.count() partners = qs.order_by('-id')[(page - 1) * page_size: page * page_size] return Response({ 'count': total, 'results': [_serialize_partner(p, p.events_count) for p in partners], }) class PartnerDetailView(APIView): permission_classes = [IsAuthenticated] def get(self, request, pk): from partner.models import Partner from events.models import Event from django.shortcuts import get_object_or_404 p = get_object_or_404(Partner, pk=pk) events_count = Event.objects.filter(partner_id=pk).count() data = _serialize_partner(p, events_count) _EVENT_STATUS_MAP = { 'live': 'LIVE', 'published': 'LIVE', 'draft': 'DRAFT', 'cancelled': 'CANCELLED', 'flagged': 'PENDING_REVIEW', 'pending': 'PENDING_REVIEW', } events_qs = Event.objects.filter(partner_id=pk).order_by('-id')[:20] data['events'] = [{ 'id': str(e.id), 'partnerId': str(pk), 'title': e.title or e.name or '', 'date': e.start_date.isoformat() if e.start_date else '', 'venue': e.venue_name or '', 'category': '', 'ticketPrice': 0, 'totalTickets': 0, 'ticketsSold': 0, 'revenue': 0, 'status': _EVENT_STATUS_MAP.get(e.event_status, 'DRAFT'), 'submittedAt': e.created_date.isoformat() if e.created_date else '', 'createdAt': e.created_date.isoformat() if e.created_date else '', 'rejectionReason': '', } for e in events_qs] data['dealTerms'] = [] data['ledger'] = [] return Response(data) class PartnerStatusView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): from partner.models import Partner from django.shortcuts import get_object_or_404 p = get_object_or_404(Partner, pk=pk) new_status = request.data.get('status') 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 '') 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}) class PartnerKYCReviewView(APIView): permission_classes = [IsAuthenticated] _DECISION_SLUG = { 'approved': 'partner.kyc.approved', 'rejected': 'partner.kyc.rejected', 'requested_info': 'partner.kyc.requested_info', } def post(self, request, pk): from partner.models import Partner from django.shortcuts import get_object_or_404 p = get_object_or_404(Partner, pk=pk) decision = request.data.get('decision') audit_slug = self._DECISION_SLUG.get(decision) if audit_slug is None: return Response( {'error': 'decision must be approved, rejected, or requested_info'}, status=400, ) reason = request.data.get('reason', '') or '' docs_requested = request.data.get('docs_requested') or [] previous_status = p.kyc_compliance_status previous_is_compliant = bool(p.is_kyc_compliant) with transaction.atomic(): if decision in ('approved', 'rejected'): p.kyc_compliance_status = decision p.is_kyc_compliant = (decision == 'approved') # `requested_info` leaves the compliance state intact — it's a # reviewer signalling they need more documents from the partner. p.kyc_compliance_reason = reason p.save(update_fields=[ 'kyc_compliance_status', 'is_kyc_compliant', 'kyc_compliance_reason', ]) _audit_log( request, action=audit_slug, target_type='partner', target_id=p.id, details={ 'reason': reason, 'docs_requested': docs_requested, 'previous_status': previous_status, 'new_status': p.kyc_compliance_status, 'previous_is_compliant': previous_is_compliant, 'new_is_compliant': bool(p.is_kyc_compliant), }, ) # Preserve the existing response shape — frontend depends on it. if decision == 'approved': verification_status = 'Verified' elif decision == 'rejected': verification_status = 'Rejected' else: verification_status = 'Info Requested' return Response({ 'id': str(p.id), 'kyc_compliance_status': p.kyc_compliance_status, 'is_kyc_compliant': p.is_kyc_compliant, 'verificationStatus': verification_status, }) # --------------------------------------------------------------------------- # Phase 4: Users API # --------------------------------------------------------------------------- def _user_status(u): return 'Active' if u.is_active else 'Suspended' _USER_ROLE_MAP = { 'admin': 'Admin', 'manager': 'Admin', 'staff': 'Support Agent', 'customer': 'User', 'is_user': 'User', 'partner': 'Partner', 'partner_manager': 'Partner', 'partner_staff': 'Partner', 'partner_customer': 'Partner', } def _serialize_user(u): full_name = f'{u.first_name} {u.last_name}'.strip() or u.username role_key = u.role if u.role else ('customer' if getattr(u, 'is_customer', False) else 'staff') try: pic = u.profile_picture avatar = pic.url if pic and pic.name and pic.name != 'default.png' else '' except Exception: avatar = '' return { 'id': str(u.id), 'eventifyId': u.eventify_id or '', 'name': full_name, 'email': u.email, 'phone': getattr(u, 'phone_number', '') or '', 'countryCode': '+91', 'avatarUrl': avatar, 'role': _USER_ROLE_MAP.get(role_key, 'User'), 'status': _user_status(u), 'tier': 'Bronze', 'healthScore': 'warm', 'isVerified': u.is_active, 'is2FAEnabled': False, 'totalSpent': 0, 'bookingsCount': 0, 'refundRate': 0, 'averageOrderValue': 0, 'language': 'en', 'timezone': 'Asia/Kolkata', 'currency': 'INR', 'emailNotifications': True, 'pushNotifications': True, 'smsNotifications': True, 'tags': [], 'pinnedNote': '', 'createdAt': u.date_joined.isoformat() if u.date_joined else '', 'updatedAt': u.date_joined.isoformat() if u.date_joined else '', 'lastLoginAt': u.last_login.isoformat() if u.last_login else '', 'lastActivityAt': u.last_login.isoformat() if u.last_login else '', } class UserMetricsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from django.contrib.auth import get_user_model from django.db.models import Q from django.utils import timezone import datetime User = get_user_model() today = timezone.now().date() week_ago = today - datetime.timedelta(days=7) # Customers = all non-superuser accounts (end users registered via mobile/web) customer_qs = User.objects.filter(is_superuser=False) return Response({ 'total': customer_qs.count(), 'active': customer_qs.filter(is_active=True).count(), 'suspended': customer_qs.filter(is_active=False).count(), 'banned': 0, # Reserved for future explicit ban field 'newThisWeek': customer_qs.filter(date_joined__date__gte=week_ago).count(), }) class UserListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from django.contrib.auth import get_user_model from django.db.models import Q User = get_user_model() include_all = request.query_params.get('include_all', '0') == '1' qs = User.objects.all() if include_all else User.objects.filter(is_superuser=False) # Server-side search search = request.query_params.get('search', '').strip() if search: qs = qs.filter( Q(first_name__icontains=search) | Q(last_name__icontains=search) | Q(email__icontains=search) | Q(username__icontains=search) | Q(phone_number__icontains=search) | Q(eventify_id__icontains=search) ) # Status filter status_filter = request.query_params.get('status', '').strip().lower() if status_filter == 'active': qs = qs.filter(is_active=True) elif status_filter == 'suspended': qs = qs.filter(is_active=False) # Role filter role_filter = request.query_params.get('role', '').strip() if role_filter: qs = qs.filter(role__iexact=role_filter) try: page = max(1, int(request.query_params.get('page', 1))) page_size = min(100, int(request.query_params.get('page_size', 20))) except (ValueError, TypeError): page, page_size = 1, 20 total = qs.count() users = qs.order_by('-date_joined')[(page - 1) * page_size: page * page_size] return Response({'count': total, 'results': [_serialize_user(u) for u in users]}) class UserDetailView(APIView): permission_classes = [IsAuthenticated] def get(self, request, pk): from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 User = get_user_model() u = get_object_or_404(User, pk=pk) return Response(_serialize_user(u)) class UserStatusView(APIView): permission_classes = [IsAuthenticated] # Map the external `action` slug sent by the admin dashboard to the # audit-log slug we record + the `is_active` value that slug implies. # Keeping the mapping data-driven means a new moderation verb (e.g. # `flag`) only needs one new row, not scattered `if` branches. _ACTION_MAP = { 'suspend': ('user.suspended', False), 'ban': ('user.banned', False), 'reinstate': ('user.reinstated', True), 'flag': ('user.flagged', None), } def patch(self, request, pk): from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 User = get_user_model() u = get_object_or_404(User, pk=pk) action = request.data.get('action') mapping = self._ACTION_MAP.get(action) if mapping is None: return Response( {'error': 'action must be suspend, ban, reinstate, or flag'}, status=400, ) audit_slug, new_active = mapping reason = request.data.get('reason', '') or '' previous_status = _user_status(u) # Audit + state change must succeed or fail together — otherwise the # log says one thing and the database says another. with transaction.atomic(): if new_active is not None and u.is_active != new_active: u.is_active = new_active u.save(update_fields=['is_active']) _audit_log( request, action=audit_slug, target_type='user', target_id=u.id, details={ 'reason': reason, 'previous_status': previous_status, 'new_status': _user_status(u), }, ) return Response({'id': str(u.id), 'status': _user_status(u)}) # --------------------------------------------------------------------------- # Phase 5: Events API # --------------------------------------------------------------------------- _EVENT_STATUS_MAP = { 'live': 'live', 'published': 'published', 'pending': 'published', 'created': 'draft', 'flagged': 'flagged', 'cancelled': 'cancelled', 'postponed': 'cancelled', 'completed': 'completed', } def _serialize_event(e): partner_name = '' if e.partner_id: try: partner_name = e.partner.name except Exception: partner_name = '' return { 'id': str(e.id), 'title': e.title or getattr(e, 'name', '') or '', 'partnerId': str(e.partner_id) if e.partner_id else '', 'partnerName': partner_name, 'date': e.start_date.isoformat() if e.start_date else '', 'status': _EVENT_STATUS_MAP.get(e.event_status, 'draft'), 'ticketsSold': 0, 'revenue': 0, 'venueName': e.venue_name or '', 'createdAt': e.created_date.isoformat() if e.created_date else '', 'isFeatured': bool(e.is_featured), 'isTopEvent': bool(e.is_top_event), 'source': e.source or 'eventify', 'contributedBy': getattr(e, 'contributed_by', '') or '', 'eventTypeId': e.event_type_id, 'eventTypeName': e.event_type.event_type if e.event_type_id and e.event_type else '', } def _serialize_event_detail(e): """Full event serializer for detail view -- includes all fields + images.""" base = _serialize_event(e) base.update({ 'name': e.name or '', 'description': e.description or '', 'endDate': e.end_date.isoformat() if e.end_date else '', 'startTime': str(e.start_time or ''), 'endTime': str(e.end_time or ''), 'allYearEvent': bool(e.all_year_event), 'latitude': str(e.latitude) if e.latitude else '', 'longitude': str(e.longitude) if e.longitude else '', 'pincode': e.pincode or '', 'district': e.district or '', 'state': e.state or '', 'place': e.place or '', 'isBookable': bool(e.is_bookable), 'eventType': e.event_type_id, 'importantInformation': e.important_information or '', 'source': e.source or '', 'cancelledReason': e.cancelled_reason or '', 'outsideEventUrl': e.outside_event_url or '', 'images': [ { 'id': img.id, 'url': f'/media/{img.event_image}', 'isPrimary': bool(img.is_primary), } for img in e.eventimages_set.all() ], }) return base class EventStatsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from events.models import Event return Response({ 'total': Event.objects.count(), 'live': Event.objects.filter(event_status='live').count(), 'pending': Event.objects.filter(event_status__in=['pending', 'created']).count(), 'flagged': Event.objects.filter(event_status='flagged').count(), 'published': Event.objects.filter(event_status='published').count(), }) class EventListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from events.models import Event from django.db.models import Q qs = Event.objects.select_related('partner', 'event_type').all() if s := request.GET.get('status'): reverse_map = {v: k for k, v in _EVENT_STATUS_MAP.items()} backend_status = reverse_map.get(s, s) qs = qs.filter(event_status=backend_status) if etid := request.GET.get('event_type'): qs = qs.filter(event_type_id=etid) if pid := request.GET.get('partner_id'): qs = qs.filter(partner_id=pid) if q := request.GET.get('search'): qs = qs.filter( Q(title__icontains=q) | Q(name__icontains=q) | Q(venue_name__icontains=q) ) if date_from := request.GET.get('date_from'): qs = qs.filter(start_date__gte=date_from) if date_to := request.GET.get('date_to'): qs = qs.filter(start_date__lte=date_to) 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]}) class EventDetailView(APIView): permission_classes = [IsAuthenticated] def get(self, request, pk): from events.models import Event from django.shortcuts import get_object_or_404 e = get_object_or_404(Event.objects.select_related('partner').prefetch_related('eventimages_set'), pk=pk) return Response(_serialize_event_detail(e)) class EventUpdateView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): from events.models import Event from django.shortcuts import get_object_or_404 e = get_object_or_404(Event, pk=pk) field_map = { 'title': 'title', 'name': 'name', 'description': 'description', 'venueName': 'venue_name', 'place': 'place', 'district': 'district', 'state': 'state', 'pincode': 'pincode', 'importantInformation': 'important_information', 'source': 'source', 'contributedBy': 'contributed_by', 'cancelledReason': 'cancelled_reason', 'outsideEventUrl': 'outside_event_url', } bool_fields = { 'isBookable': 'is_bookable', 'isFeatured': 'is_featured', 'isTopEvent': 'is_top_event', 'allYearEvent': 'all_year_event', } updated_fields = [] for api_key, model_field in field_map.items(): if api_key in request.data: setattr(e, model_field, request.data[api_key] or '') updated_fields.append(model_field) for api_key, model_field in bool_fields.items(): if api_key in request.data: setattr(e, model_field, bool(request.data[api_key])) updated_fields.append(model_field) # Handle status if 'status' in request.data: reverse_map = {v: k for k, v in _EVENT_STATUS_MAP.items()} backend_status = reverse_map.get(request.data['status'], request.data['status']) e.event_status = backend_status updated_fields.append('event_status') if 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)) class EventModerationView(APIView): permission_classes = [IsAuthenticated] _ACTION_SLUG = { 'approve': 'event.approved', 'reject': 'event.rejected', 'flag': 'event.flagged', 'feature': 'event.featured', 'unfeature': 'event.unfeatured', } def patch(self, request, pk): from events.models import Event from django.shortcuts import get_object_or_404 e = get_object_or_404(Event, pk=pk) action = request.data.get('action') audit_slug = self._ACTION_SLUG.get(action) if audit_slug is None: return Response({'error': 'Invalid action'}, status=400) reason = request.data.get('reason', '') or '' previous_status = e.event_status previous_is_featured = bool(e.is_featured) with transaction.atomic(): if action == 'approve': e.event_status = 'published' e.save(update_fields=['event_status']) elif action == 'reject': e.event_status = 'cancelled' e.cancelled_reason = reason e.save(update_fields=['event_status', 'cancelled_reason']) elif action == 'flag': e.event_status = 'flagged' e.save(update_fields=['event_status']) elif action == 'feature': e.is_featured = True e.save(update_fields=['is_featured']) elif action == 'unfeature': e.is_featured = False e.save(update_fields=['is_featured']) _audit_log( request, action=audit_slug, target_type='event', target_id=e.id, details={ 'reason': reason, 'partner_id': str(e.partner_id) if e.partner_id else None, 'previous_status': previous_status, 'new_status': e.event_status, 'previous_is_featured': previous_is_featured, 'new_is_featured': bool(e.is_featured), }, ) e.refresh_from_db() return Response(_serialize_event(e)) class EventDeleteView(APIView): permission_classes = [IsAuthenticated] def delete(self, request, pk): from events.models import Event from django.shortcuts import get_object_or_404 e = get_object_or_404(Event, pk=pk) 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) # --------------------------------------------------------------------------- # Phase 6: Financials & Payouts # --------------------------------------------------------------------------- _TX_STATUS_MAP = { 'captured': 'Completed', 'created': 'Pending', 'failed': 'Failed', 'refunded': 'Failed', } def _serialize_transaction(t): ts = t.captured_at or t.created_at order_ref = getattr(t, 'razorpay_order_id', None) or getattr(t, 'transaction_id', None) return { 'id': str(t.id), 'title': f'Payment {order_ref}' if order_ref else f'Transaction #{t.id}', 'partner': '', 'amount': t.amount / 100, 'date': ts.isoformat() if ts else '', 'type': 'in', 'method': 'Razorpay', 'fees': 0, 'net': t.amount / 100, 'status': _TX_STATUS_MAP.get(t.status, 'Pending'), } _SETTLEMENT_STATUS_MAP = { 'pending': 'Ready', 'failed': 'Overdue', 'cancelled': 'Overdue', 'completed': 'On Hold', 'refunded': 'On Hold', } def _serialize_settlement(p): return { 'id': str(p.id), 'partnerName': '', 'eventName': '', 'amount': float(p.payment_transaction_amount), 'dueDate': p.payment_transaction_date.isoformat() if p.payment_transaction_date else '', 'status': _SETTLEMENT_STATUS_MAP.get(p.payment_transaction_status, 'On Hold'), } class FinancialMetricsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from ledger.models import RazorpayTransaction from banking_operations.models import PaymentTransaction from django.db.models import Sum total_paise = ( RazorpayTransaction.objects.filter(status='captured') .aggregate(t=Sum('amount'))['t'] or 0 ) total_revenue = total_paise / 100 total_payouts = float( PaymentTransaction.objects .filter(payment_type='debit', payment_transaction_status='completed') .aggregate(t=Sum('payment_transaction_amount'))['t'] or 0 ) platform_earnings = round(total_revenue * 0.12, 2) pending_count = PaymentTransaction.objects.filter( payment_type='debit', payment_transaction_status='pending' ).count() pending_amount = float( PaymentTransaction.objects .filter(payment_type='debit', payment_transaction_status='pending') .aggregate(t=Sum('payment_transaction_amount'))['t'] or 0 ) return Response({ 'totalRevenue': total_revenue, 'totalPayouts': total_payouts, 'platformEarnings': platform_earnings, 'pendingPayouts': pending_count, 'pendingPayoutAmount': pending_amount, }) class TransactionListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from ledger.models import RazorpayTransaction 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 qs = RazorpayTransaction.objects.order_by('-id') total = qs.count() txs = qs[(page - 1) * page_size: page * page_size] return Response({'count': total, 'results': [_serialize_transaction(t) for t in txs]}) class SettlementListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from banking_operations.models import PaymentTransaction qs = PaymentTransaction.objects.filter( payment_type='debit' ).order_by('-id')[:50] return Response([_serialize_settlement(p) for p in qs]) class SettlementReleaseView(APIView): permission_classes = [IsAuthenticated] def post(self, request, pk): 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' 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)) # --------------------------------------------------------------------------- # Phase 7: Reviews Moderation # --------------------------------------------------------------------------- def _reviewer_rank(total_reviews): if total_reviews >= 41: return 'Legend' if total_reviews >= 21: return 'Champion' if total_reviews >= 11: return 'Enthusiast' if total_reviews >= 4: return 'Contributor' return 'Explorer' def _serialize_review(r): from admin_api.models import Review try: name = r.reviewer.get_full_name() or r.reviewer.username email = r.reviewer.email total = Review.objects.filter(reviewer=r.reviewer, status='live').count() except Exception: name, email, total = '', '', 0 try: event_name = getattr(r.event, 'title', None) or getattr(r.event, 'name', '') or '' event_id = str(r.event.id) start = getattr(r.event, 'start_date', None) event_date = start.isoformat() if start else '' except Exception: event_name, event_id, event_date = '', '', '' return { 'id': str(r.id), 'reviewerName': name, 'reviewerEmail': email, 'reviewerAvatar': '', 'eventName': event_name, 'eventId': event_id, 'eventDate': event_date, 'rating': r.rating, 'reviewText': r.review_text, 'submissionDate': r.submission_date.isoformat(), 'status': r.status, 'reviewerRank': _reviewer_rank(total), 'reviewerTotalReviews': total, 'rejectReason': r.reject_reason or '', } class ReviewMetricsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from admin_api.models import Review return Response({ 'totalPending': Review.objects.filter(status='pending').count(), 'liveReviews': Review.objects.filter(status='live').count(), 'rejected': Review.objects.filter(status='rejected').count(), }) class ReviewListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from admin_api.models import Review qs = Review.objects.select_related('reviewer', 'event').order_by('-submission_date') status = request.GET.get('status') if status in ('pending', 'live', 'rejected'): qs = qs.filter(status=status) 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() reviews = qs[(page - 1) * page_size: page * page_size] return Response({'count': total, 'results': [_serialize_review(r) for r in reviews]}) class ReviewModerationView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): from admin_api.models import Review from django.shortcuts import get_object_or_404 review = get_object_or_404(Review, pk=pk) action = request.data.get('action') previous_status = review.status previous_text = review.review_text audit_slug = None details = {} if action == 'approve': review.status = 'live' audit_slug = 'review.approved' elif action == 'reject': rr = request.data.get('reject_reason', 'spam') valid_reasons = [c[0] for c in Review.REJECT_CHOICES] if rr not in valid_reasons: return Response({'error': 'Invalid reject_reason'}, status=400) review.status = 'rejected' review.reject_reason = rr audit_slug = 'review.rejected' details['reject_reason'] = rr elif action == 'save_and_approve': review.review_text = request.data.get('review_text', review.review_text) review.status = 'live' audit_slug = 'review.edited' details['edited_text'] = True elif action == 'save_live': review.review_text = request.data.get('review_text', review.review_text) audit_slug = 'review.edited' details['edited_text'] = True else: return Response({'error': 'Invalid action'}, status=400) details.update({ 'previous_status': previous_status, 'new_status': review.status, }) if previous_text != review.review_text: details['original_text'] = previous_text with transaction.atomic(): review.save() _audit_log( request, action=audit_slug, target_type='review', target_id=review.id, details=details, ) return Response(_serialize_review(review)) class ReviewDeleteView(APIView): permission_classes = [IsAuthenticated] def delete(self, request, pk): from admin_api.models import Review from django.shortcuts import get_object_or_404 review = get_object_or_404(Review, pk=pk) 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) # --- Payment Gateway Settings --- def _serialize_gateway(gw, include_secret=False): data = { 'id': gw.pk, 'payment_gateway_id': gw.payment_gateway_id, 'name': gw.payment_gateway_name, 'description': gw.payment_gateway_description, 'url': gw.payment_gateway_url, 'api_key': gw.payment_gateway_api_key, 'api_url': gw.payment_gateway_api_url, 'api_version': gw.payment_gateway_api_version, 'api_method': gw.payment_gateway_api_method, 'is_active': gw.is_active, 'gateway_priority': gw.gateway_priority, 'created_date': gw.created_date.isoformat() if gw.created_date else None, 'updated_date': gw.updated_date.isoformat() if gw.updated_date else None, 'logo': gw.payment_gateway_logo.url if gw.payment_gateway_logo else None, } if include_secret: data['api_secret'] = gw.payment_gateway_api_secret return data class ActivePaymentGatewayView(APIView): permission_classes = [AllowAny] def get(self, request): from banking_operations.models import PaymentGateway gateway = PaymentGateway.objects.filter(is_active=True).order_by('-gateway_priority', '-id').first() if not gateway: return Response({'status': 'error', 'message': 'No active payment gateway configured.'}, status=404) return Response({ 'status': 'success', 'gateway': { 'name': gateway.payment_gateway_name, 'key_id': gateway.payment_gateway_api_key, 'currency': 'INR', } }) class PaymentGatewaySettingsView(APIView): permission_classes = [IsAuthenticated] def get(self, request, pk=None): from banking_operations.models import PaymentGateway gateways = PaymentGateway.objects.all().order_by('-gateway_priority', '-id') return Response({ 'status': 'success', 'payment_gateways': [_serialize_gateway(g, include_secret=True) for g in gateways] }) def post(self, request, pk=None): from banking_operations.models import PaymentGateway import uuid d = request.data required = ['name', 'api_key', 'api_secret'] missing = [f for f in required if not d.get(f)] if missing: return Response({'status': 'error', 'message': 'Missing fields: {}'.format(missing)}, status=400) gw = PaymentGateway.objects.create( payment_gateway_id=str(uuid.uuid4().hex[:10]).upper(), payment_gateway_name=d['name'], payment_gateway_description=d.get('description', ''), payment_gateway_url=d.get('url', ''), payment_gateway_api_key=d['api_key'], payment_gateway_api_secret=d['api_secret'], payment_gateway_api_url=d.get('api_url', ''), payment_gateway_api_version=d.get('api_version', 'v1'), payment_gateway_api_method=d.get('api_method', 'POST'), 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): from banking_operations.models import PaymentGateway from django.shortcuts import get_object_or_404 gw = get_object_or_404(PaymentGateway, pk=pk) d = request.data field_map = { 'name': 'payment_gateway_name', 'description': 'payment_gateway_description', 'url': 'payment_gateway_url', 'api_key': 'payment_gateway_api_key', 'api_secret': 'payment_gateway_api_secret', 'api_url': 'payment_gateway_api_url', 'api_version': 'payment_gateway_api_version', 'api_method': 'payment_gateway_api_method', '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) class EventCreateView(APIView): permission_classes = [IsAuthenticated] def post(self, request): from events.models import Event, EventType data = request.data # Required fields title = (data.get('title') or '').strip() name = (data.get('name') or title).strip() if not title: return Response({'error': 'Title is required'}, status=400) # Get event_type (required FK) event_type_id = data.get('eventType') if not event_type_id: return Response({'error': 'Event type is required'}, status=400) try: event_type = EventType.objects.get(id=event_type_id) except EventType.DoesNotExist: return Response({'error': 'Invalid event type'}, status=400) # Build the event event = Event( title=title, name=name, description=data.get('description', ''), event_type=event_type, event_status=data.get('eventStatus', 'pending'), venue_name=data.get('venueName', ''), place=data.get('place', ''), district=data.get('district', ''), state=data.get('state', ''), pincode=data.get('pincode', ''), latitude=data.get('latitude', 0), longitude=data.get('longitude', 0), is_bookable=data.get('isBookable', False), is_featured=data.get('isFeatured', False), is_top_event=data.get('isTopEvent', False), all_year_event=data.get('allYearEvent', False), source=data.get('source', 'official'), important_information=data.get('importantInformation', ''), cancelled_reason=data.get('cancelledReason', 'NA'), outside_event_url=data.get('outsideEventUrl', 'NA'), is_eventify_event=data.get('isEventifyEvent', True), ) # Optional dates/times 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'] # Optional partner partner_id = data.get('partnerId') if partner_id: try: from partners.models import Partner event.partner = Partner.objects.get(id=partner_id) event.is_partner_event = True except Exception: 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) class EventTypesView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from events.models import EventType types = EventType.objects.all().order_by('id') return Response([ {'id': t.id, 'name': t.event_type} for t in types ]) class EventPrimaryImageView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): from events.models import Event, EventImages try: event = Event.objects.get(pk=pk) except Event.DoesNotExist: return Response({"error": "Event not found"}, status=404) image_id = request.data.get("image_id") if not image_id: return Response({"error": "image_id is required"}, status=400) try: img = EventImages.objects.get(pk=image_id, event=event) except EventImages.DoesNotExist: 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}) # --------------------------------------------------------------------------- # RBAC Views # --------------------------------------------------------------------------- from admin_api.models import Department, Squad, StaffProfile, CustomRole, AuditLog from django.utils.text import slugify from django.db.models import Q import json SCOPE_DEFINITIONS = { 'users.read': {'label': 'View Users', 'category': 'Users'}, 'users.write': {'label': 'Edit Users', 'category': 'Users'}, 'users.delete': {'label': 'Delete Users', 'category': 'Users'}, 'users.ban': {'label': 'Ban/Suspend Users', 'category': 'Users'}, 'events.read': {'label': 'View Events', 'category': 'Events'}, 'events.write': {'label': 'Create/Edit Events', 'category': 'Events'}, 'events.approve': {'label': 'Approve Events', 'category': 'Events'}, 'events.delete': {'label': 'Delete Events', 'category': 'Events'}, 'finance.read': {'label': 'View Finance Dashboard', 'category': 'Finance'}, 'finance.refunds.read': {'label': 'View Refund Requests', 'category': 'Finance'}, 'finance.refunds.execute': {'label': 'Process Refunds', 'category': 'Finance'}, 'finance.payouts.read': {'label': 'View Payouts', 'category': 'Finance'}, 'finance.payouts.execute': {'label': 'Execute Payouts', 'category': 'Finance'}, 'partners.read': {'label': 'View Partners', 'category': 'Partners'}, 'partners.write': {'label': 'Edit Partners', 'category': 'Partners'}, 'partners.kyc': {'label': 'Verify Partner KYC', 'category': 'Partners'}, 'partners.impersonate': {'label': 'Login as Partner', 'category': 'Partners'}, 'partners.events.review': {'label': 'Review Partner Events', 'category': 'Partners'}, 'partners.suspend': {'label': 'Suspend/Unsuspend Partners', 'category': 'Partners'}, 'tickets.read': {'label': 'View Tickets', 'category': 'Support'}, 'tickets.write': {'label': 'Respond to Tickets', 'category': 'Support'}, 'tickets.assign': {'label': 'Assign Tickets', 'category': 'Support'}, 'tickets.escalate': {'label': 'Escalate Tickets', 'category': 'Support'}, 'settings.read': {'label': 'View Settings', 'category': 'Settings'}, 'settings.write': {'label': 'Modify Settings', 'category': 'Settings'}, 'settings.staff': {'label': 'Manage Staff', 'category': 'Settings'}, 'ads.read': {'label': 'View Ad Campaigns', 'category': 'Ad Control'}, '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, user=None): AuditLog.objects.create( 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), details=details or {}, ip_address=request.META.get('REMOTE_ADDR'), ) def _serialize_department(dept): return { 'id': dept.id, 'name': dept.name, 'slug': dept.slug, 'description': dept.description, 'base_scopes': dept.base_scopes, 'color': dept.color, 'squad_count': dept.squads.count(), 'member_count': dept.staff_members.count(), 'created_at': dept.created_at.isoformat() if dept.created_at else None, 'updated_at': dept.updated_at.isoformat() if dept.updated_at else None, } def _serialize_squad(squad): return { 'id': squad.id, 'name': squad.name, 'department_id': squad.department_id, 'department_name': squad.department.name, 'manager': { 'id': squad.manager.id, 'username': squad.manager.username, 'first_name': squad.manager.first_name, 'last_name': squad.manager.last_name, } if squad.manager else None, 'extra_scopes': squad.extra_scopes, 'member_count': squad.members.count(), 'created_at': squad.created_at.isoformat() if squad.created_at else None, } def _serialize_staff(sp): u = sp.user return { 'id': sp.id, 'user_id': u.id, 'username': u.username, 'email': u.email, 'name': (u.first_name + ' ' + u.last_name).strip() or u.username, 'first_name': u.first_name, 'last_name': u.last_name, 'phone_number': u.phone_number, 'profile_picture': u.profile_picture.url if u.profile_picture else None, 'staff_role': sp.staff_role, 'status': sp.status, 'department': { 'id': sp.department.id, 'name': sp.department.name, 'slug': sp.department.slug, 'color': sp.department.color, } if sp.department else None, 'squad': { 'id': sp.squad.id, 'name': sp.squad.name, } if sp.squad else None, 'effective_scopes': sp.get_effective_scopes(), 'allowed_modules': sp.get_allowed_modules(), 'joined_at': sp.joined_at.isoformat() if sp.joined_at else None, } # 1. DepartmentListCreateView class DepartmentListCreateView(APIView): permission_classes = [IsAuthenticated] def get(self, request): depts = Department.objects.all() return Response([_serialize_department(d) for d in depts]) def post(self, request): data = request.data name = data.get('name', '').strip() if not name: return Response({'error': 'name is required'}, status=400) slug = data.get('slug') or slugify(name) if Department.objects.filter(slug=slug).exists(): return Response({'error': 'Department with this slug already exists'}, status=400) dept = Department.objects.create( name=name, slug=slug, description=data.get('description', ''), base_scopes=data.get('base_scopes', []), color=data.get('color', '#3B82F6'), ) _audit_log(request, 'department.created', 'Department', dept.id, {'name': name}) return Response(_serialize_department(dept), status=201) # 2. DepartmentDetailView class DepartmentDetailView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): try: dept = Department.objects.get(pk=pk) except Department.DoesNotExist: return Response({'error': 'Department not found'}, status=404) data = request.data if 'name' in data: dept.name = data['name'] if 'description' in data: dept.description = data['description'] if 'base_scopes' in data: dept.base_scopes = data['base_scopes'] if 'color' in data: dept.color = data['color'] dept.save() _audit_log(request, 'department.updated', 'Department', dept.id, data) return Response(_serialize_department(dept)) def delete(self, request, pk): try: dept = Department.objects.get(pk=pk) except Department.DoesNotExist: return Response({'error': 'Department not found'}, status=404) if dept.squads.exists(): return Response({'error': 'Cannot delete department that has squads. Remove squads first.'}, status=400) dept_name = dept.name dept.delete() _audit_log(request, 'department.deleted', 'Department', pk, {'name': dept_name}) return Response({'success': True}, status=200) # 3. SquadListCreateView class SquadListCreateView(APIView): permission_classes = [IsAuthenticated] def get(self, request): qs = Squad.objects.select_related('department', 'manager').all() dept_id = request.query_params.get('department') if dept_id: qs = qs.filter(department_id=dept_id) return Response([_serialize_squad(s) for s in qs]) def post(self, request): data = request.data name = data.get('name', '').strip() department_id = data.get('department_id') if not name or not department_id: return Response({'error': 'name and department_id are required'}, status=400) try: dept = Department.objects.get(pk=department_id) except Department.DoesNotExist: return Response({'error': 'Department not found'}, status=404) manager_id = data.get('manager_id') manager = None if manager_id: try: manager = User.objects.get(pk=manager_id) except User.DoesNotExist: pass squad = Squad.objects.create( name=name, department=dept, manager=manager, extra_scopes=data.get('extra_scopes', []), ) _audit_log(request, 'squad.created', 'Squad', squad.id, {'name': name, 'department': dept.name}) return Response(_serialize_squad(squad), status=201) # 4. SquadDetailView class SquadDetailView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): try: squad = Squad.objects.select_related('department', 'manager').get(pk=pk) except Squad.DoesNotExist: return Response({'error': 'Squad not found'}, status=404) data = request.data if 'name' in data: squad.name = data['name'] if 'extra_scopes' in data: squad.extra_scopes = data['extra_scopes'] if 'manager_id' in data: if data['manager_id']: try: squad.manager = User.objects.get(pk=data['manager_id']) except User.DoesNotExist: return Response({'error': 'Manager user not found'}, status=404) else: squad.manager = None squad.save() _audit_log(request, 'squad.updated', 'Squad', squad.id, data) return Response(_serialize_squad(squad)) def delete(self, request, pk): try: squad = Squad.objects.get(pk=pk) except Squad.DoesNotExist: return Response({'error': 'Squad not found'}, status=404) if squad.members.exists(): return Response({'error': 'Cannot delete squad that has members. Move members first.'}, status=400) squad_name = squad.name squad.delete() _audit_log(request, 'squad.deleted', 'Squad', pk, {'name': squad_name}) return Response({'success': True}, status=200) # 5. StaffListView class StaffListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): qs = StaffProfile.objects.select_related('user', 'department', 'squad').all() # Filters dept = request.query_params.get('department') if dept: qs = qs.filter(department_id=dept) squad_id = request.query_params.get('squad') if squad_id: qs = qs.filter(squad_id=squad_id) role = request.query_params.get('role') if role: qs = qs.filter(staff_role=role) status_filter = request.query_params.get('status') if status_filter: qs = qs.filter(status=status_filter) search = request.query_params.get('search') if search: qs = qs.filter( Q(user__username__icontains=search) | Q(user__email__icontains=search) | Q(user__first_name__icontains=search) | Q(user__last_name__icontains=search) ) return Response([_serialize_staff(sp) for sp in qs]) # 6. StaffInviteView class StaffInviteView(APIView): permission_classes = [IsAuthenticated] def post(self, request): data = request.data email = data.get('email', '').strip() if not email: return Response({'error': 'email is required'}, status=400) if User.objects.filter(email=email).exists(): return Response({'error': 'User with this email already exists'}, status=400) username = data.get('username') or email.split('@')[0] first_name = data.get('first_name', '') last_name = data.get('last_name', '') user = User.objects.create_user( username=username, email=email, first_name=first_name, last_name=last_name, password=data.get('password', 'TempPass123!'), is_customer=False, role='staff', ) department = None if data.get('department_id'): try: department = Department.objects.get(pk=data['department_id']) except Department.DoesNotExist: pass squad = None if data.get('squad_id'): try: squad = Squad.objects.get(pk=data['squad_id']) except Squad.DoesNotExist: pass sp = StaffProfile.objects.create( user=user, staff_role=data.get('staff_role', 'MEMBER'), department=department, squad=squad, status='invited', ) _audit_log(request, 'staff.invited', 'StaffProfile', sp.id, { 'email': email, 'staff_role': sp.staff_role, }) return Response(_serialize_staff(sp), status=201) # 7. StaffUpdateView class StaffUpdateView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): try: sp = StaffProfile.objects.select_related('user', 'department', 'squad').get(pk=pk) except StaffProfile.DoesNotExist: return Response({'error': 'Staff profile not found'}, status=404) data = request.data if 'staff_role' in data: sp.staff_role = data['staff_role'] if 'department_id' in data: if data['department_id']: try: sp.department = Department.objects.get(pk=data['department_id']) except Department.DoesNotExist: return Response({'error': 'Department not found'}, status=404) else: sp.department = None if 'squad_id' in data: if data['squad_id']: try: sp.squad = Squad.objects.get(pk=data['squad_id']) except Squad.DoesNotExist: return Response({'error': 'Squad not found'}, status=404) else: sp.squad = None sp.save() _audit_log(request, 'staff.updated', 'StaffProfile', sp.id, data) return Response(_serialize_staff(sp)) # 8. StaffDeactivateView class StaffDeactivateView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): try: sp = StaffProfile.objects.select_related('user', 'department', 'squad').get(pk=pk) except StaffProfile.DoesNotExist: return Response({'error': 'Staff profile not found'}, status=404) sp.status = 'deactivated' sp.save() _audit_log(request, 'staff.deactivated', 'StaffProfile', sp.id, { 'username': sp.user.username, }) return Response(_serialize_staff(sp)) # 9. StaffMoveView class StaffMoveView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): try: sp = StaffProfile.objects.select_related('user', 'department', 'squad').get(pk=pk) except StaffProfile.DoesNotExist: return Response({'error': 'Staff profile not found'}, status=404) data = request.data old_dept = sp.department.name if sp.department else None old_squad = sp.squad.name if sp.squad else None if 'department_id' in data: if data['department_id']: try: sp.department = Department.objects.get(pk=data['department_id']) except Department.DoesNotExist: return Response({'error': 'Department not found'}, status=404) else: sp.department = None if 'squad_id' in data: if data['squad_id']: try: sp.squad = Squad.objects.get(pk=data['squad_id']) except Squad.DoesNotExist: return Response({'error': 'Squad not found'}, status=404) else: sp.squad = None sp.save() _audit_log(request, 'staff.moved', 'StaffProfile', sp.id, { 'from_department': old_dept, 'to_department': sp.department.name if sp.department else None, 'from_squad': old_squad, 'to_squad': sp.squad.name if sp.squad else None, }) return Response(_serialize_staff(sp)) # 10. RoleListCreateView class RoleListCreateView(APIView): permission_classes = [IsAuthenticated] def get(self, request): roles = CustomRole.objects.all() return Response([{ 'id': r.id, 'name': r.name, 'slug': r.slug, 'description': r.description, 'scopes': r.scopes, 'is_system': r.is_system, 'created_at': r.created_at.isoformat() if r.created_at else None, } for r in roles]) def post(self, request): data = request.data name = data.get('name', '').strip() if not name: return Response({'error': 'name is required'}, status=400) slug = data.get('slug') or slugify(name) if CustomRole.objects.filter(slug=slug).exists(): return Response({'error': 'Role with this slug already exists'}, status=400) role = CustomRole.objects.create( name=name, slug=slug, description=data.get('description', ''), scopes=data.get('scopes', []), is_system=data.get('is_system', False), ) _audit_log(request, 'role.created', 'CustomRole', role.id, {'name': name}) return Response({ 'id': role.id, 'name': role.name, 'slug': role.slug, 'description': role.description, 'scopes': role.scopes, 'is_system': role.is_system, 'created_at': role.created_at.isoformat() if role.created_at else None, }, status=201) # 11. RoleDetailView class RoleDetailView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): try: role = CustomRole.objects.get(pk=pk) except CustomRole.DoesNotExist: return Response({'error': 'Role not found'}, status=404) data = request.data if 'name' in data: role.name = data['name'] if 'description' in data: role.description = data['description'] if 'scopes' in data: role.scopes = data['scopes'] role.save() _audit_log(request, 'role.updated', 'CustomRole', role.id, data) return Response({ 'id': role.id, 'name': role.name, 'slug': role.slug, 'description': role.description, 'scopes': role.scopes, 'is_system': role.is_system, 'created_at': role.created_at.isoformat() if role.created_at else None, }) def delete(self, request, pk): try: role = CustomRole.objects.get(pk=pk) except CustomRole.DoesNotExist: return Response({'error': 'Role not found'}, status=404) if role.is_system: return Response({'error': 'Cannot delete system roles'}, status=400) role_name = role.name role.delete() _audit_log(request, 'role.deleted', 'CustomRole', pk, {'name': role_name}) return Response({'success': True}, status=200) # 12. ScopeListView class ScopeListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): # Group by category grouped = {} for key, val in SCOPE_DEFINITIONS.items(): cat = val['category'] if cat not in grouped: grouped[cat] = [] grouped[cat].append({ 'key': key, 'label': val['label'], }) return Response({ 'scopes': SCOPE_DEFINITIONS, 'grouped': grouped, }) # 13. OrgTreeView class OrgTreeView(APIView): permission_classes = [IsAuthenticated] def get(self, request): departments = Department.objects.prefetch_related( 'squads__members__user', 'squads__manager', 'staff_members__user', ).all() tree = [] for dept in departments: squads_data = [] for squad in dept.squads.all(): members_data = [] for member in squad.members.filter(status='active'): members_data.append({ 'id': member.id, 'user_id': member.user.id, 'username': member.user.username, 'first_name': member.user.first_name, 'last_name': member.user.last_name, 'email': member.user.email, 'staff_role': member.staff_role, 'profile_picture': member.user.profile_picture.url if member.user.profile_picture else None, }) squads_data.append({ 'id': squad.id, 'name': squad.name, 'manager': { 'id': squad.manager.id, 'username': squad.manager.username, 'first_name': squad.manager.first_name, 'last_name': squad.manager.last_name, } if squad.manager else None, 'extra_scopes': squad.extra_scopes, 'members': members_data, 'member_count': len(members_data), }) # Unassigned staff (in department but no squad) unassigned = dept.staff_members.filter(squad__isnull=True, status='active') unassigned_data = [] for member in unassigned: unassigned_data.append({ 'id': member.id, 'user_id': member.user.id, 'username': member.user.username, 'first_name': member.user.first_name, 'last_name': member.user.last_name, 'email': member.user.email, 'staff_role': member.staff_role, }) tree.append({ 'id': dept.id, 'name': dept.name, 'slug': dept.slug, 'color': dept.color, 'description': dept.description, 'base_scopes': dept.base_scopes, 'squads': squads_data, 'unassigned_members': unassigned_data, 'total_members': dept.staff_members.filter(status='active').count(), }) # Also include staff with no department orphans = StaffProfile.objects.filter( department__isnull=True, status='active' ).select_related('user') orphan_data = [] for sp in orphans: orphan_data.append({ 'id': sp.id, 'user_id': sp.user.id, 'username': sp.user.username, 'first_name': sp.user.first_name, 'last_name': sp.user.last_name, 'email': sp.user.email, 'staff_role': sp.staff_role, }) return Response({ 'departments': tree, 'unassigned_staff': orphan_data, }) # 14. AuditLogListView def _serialize_audit_log(log): return { 'id': log.id, 'user': { 'id': log.user.id, 'username': log.user.username, 'email': log.user.email, } if log.user else None, 'action': log.action, 'target_type': log.target_type, 'target_id': log.target_id, 'details': log.details, 'ip_address': log.ip_address, 'created_at': log.created_at.isoformat(), } class AuditLogListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from django.db.models import Q qs = AuditLog.objects.select_related('user').all() # Filters user_id = request.query_params.get('user') if user_id: qs = qs.filter(user_id=user_id) action = request.query_params.get('action') if action: qs = qs.filter(action__icontains=action) target_type = request.query_params.get('target_type') if target_type: qs = qs.filter(target_type=target_type) date_from = request.query_params.get('date_from') if date_from: qs = qs.filter(created_at__date__gte=date_from) date_to = request.query_params.get('date_to') if date_to: qs = qs.filter(created_at__date__lte=date_to) # Free-text search across action, target type/id, and actor identity. # ORed so one box can locate entries regardless of which axis the # admin remembers (slug vs. target vs. user). search = request.query_params.get('search') if search: qs = qs.filter( Q(action__icontains=search) | Q(target_type__icontains=search) | Q(target_id__icontains=search) | Q(user__username__icontains=search) | Q(user__email__icontains=search) ) # Pagination try: page = max(1, int(request.query_params.get('page', 1))) page_size = max(1, min(200, int(request.query_params.get('page_size', 50)))) except (ValueError, TypeError): page, page_size = 1, 50 total = qs.count() start = (page - 1) * page_size end = start + page_size logs = qs[start:end] return Response({ 'results': [_serialize_audit_log(log) for log in logs], 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size if total else 0, }) # 15. AuditLogMetricsView — aggregated counts for the admin header bar. def _compute_action_groups(qs): """Bucket action slugs into UI-visible groups without a DB roundtrip per slug. Mirrors `actionGroup()` on the frontend so the tooltip numbers match the badge colours. """ groups = { 'create': 0, 'update': 0, 'delete': 0, 'moderate': 0, 'auth': 0, 'other': 0, } for action in qs.values_list('action', flat=True): slug = (action or '').lower() if '.deactivated' in slug or '.deleted' in slug or '.removed' in slug: groups['delete'] += 1 elif '.created' in slug or '.invited' in slug or '.added' in slug: groups['create'] += 1 elif ( '.updated' in slug or '.edited' in slug or '.moved' in slug or '.changed' in slug ): groups['update'] += 1 elif ( slug.startswith('user.') or slug.startswith('event.') or slug.startswith('review.') or slug.startswith('partner.kyc.') ): groups['moderate'] += 1 elif slug.startswith('auth.') or 'login' in slug or 'logout' in slug: groups['auth'] += 1 else: groups['other'] += 1 return groups class AuditLogMetricsView(APIView): """GET /api/v1/rbac/audit-log/metrics/ — aggregated counts. Cached for 60 s because the header bar is fetched on every page load and the numbers are approximate by nature. If you need fresh numbers, bypass cache with `?nocache=1` (useful from the Django shell during incident response). """ permission_classes = [IsAuthenticated] CACHE_KEY = 'admin_api:audit_log:metrics:v1' CACHE_TTL_SECONDS = 60 def get(self, request): from django.core.cache import cache from django.utils import timezone from datetime import timedelta if request.query_params.get('nocache') == '1': payload = self._compute() cache.set(self.CACHE_KEY, payload, self.CACHE_TTL_SECONDS) return Response(payload) payload = cache.get(self.CACHE_KEY) if payload is None: payload = self._compute() cache.set(self.CACHE_KEY, payload, self.CACHE_TTL_SECONDS) return Response(payload) def _compute(self): from django.utils import timezone from datetime import timedelta now = timezone.now() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) week_start = today_start - timedelta(days=6) qs = AuditLog.objects.all() return { 'total': qs.count(), 'today': qs.filter(created_at__gte=today_start).count(), 'week': qs.filter(created_at__gte=week_start).count(), 'distinct_users': qs.exclude(user__isnull=True) .values('user_id').distinct().count(), 'by_action_group': _compute_action_groups(qs), } # --------------------------------------------------------------------------- # Partner Onboarding API # --------------------------------------------------------------------------- class PartnerOnboardView(APIView): """ POST /api/v1/partners/onboard/ Creates a Partner + its first partner_manager User atomically. Requires admin or manager JWT auth. """ permission_classes = [IsAuthenticated] def post(self, request): from partner.models import Partner data = request.data # --- Validate required fields --- required = { "partner_name": data.get("partner_name"), "contact_email": data.get("contact_email"), "manager_username": data.get("manager_username"), "manager_password": data.get("manager_password"), } missing = [k for k, v in required.items() if not v] if missing: return Response( {"success": False, "error": "Missing required fields: " + ", ".join(missing)}, status=status.HTTP_400_BAD_REQUEST, ) manager_username = data["manager_username"].strip() contact_email = data["contact_email"].strip().lower() manager_password = data["manager_password"] # --- Check uniqueness --- if User.objects.filter(username=manager_username).exists(): return Response( {"success": False, "error": "Username '" + manager_username + "' already exists"}, status=status.HTTP_409_CONFLICT, ) if User.objects.filter(email=contact_email).exists(): return Response( {"success": False, "error": "Email '" + contact_email + "' already exists"}, status=status.HTTP_409_CONFLICT, ) # --- Split contact_name into first/last --- contact_name = (data.get("contact_name") or "").strip() name_parts = contact_name.split(None, 1) first_name = name_parts[0] if name_parts else "" last_name = name_parts[1] if len(name_parts) > 1 else "" # --- Validate partner_type --- partner_type = (data.get("partner_type") or "other").strip().lower() valid_types = ("venue", "promoter", "sponsor", "vendor", "affiliate", "other") if partner_type not in valid_types: partner_type = "other" try: with transaction.atomic(): # 1) Create Partner partner = Partner.objects.create( name=data["partner_name"].strip(), partner_type=partner_type, primary_contact_person_name=contact_name, primary_contact_person_email=contact_email, primary_contact_person_phone=(data.get("contact_phone") or ""), status="pending", is_kyc_compliant=False, kyc_compliance_status="pending", address=(data.get("address") or ""), city=(data.get("city") or ""), state=(data.get("state") or ""), pincode=(data.get("pincode") or ""), website_url=(data.get("website_url") or None), ) # 2) Create partner_manager User manager_user = User( username=manager_username, email=contact_email, first_name=first_name, last_name=last_name, role="partner_manager", is_customer=False, partner=partner, phone_number=(data.get("contact_phone") or ""), ) 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( {"success": False, "error": "Failed to create partner: " + str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) return Response( { "success": True, "partner": { "id": partner.id, "name": partner.name, "partner_type": partner.partner_type, "status": partner.status, "contact_name": partner.primary_contact_person_name, "contact_email": partner.primary_contact_person_email, "contact_phone": partner.primary_contact_person_phone, "address": partner.address, "city": partner.city, "state": partner.state, "pincode": partner.pincode, "website_url": partner.website_url or "", "is_kyc_compliant": partner.is_kyc_compliant, "kyc_compliance_status": partner.kyc_compliance_status, }, "manager": { "id": manager_user.id, "username": manager_user.username, "email": manager_user.email, "first_name": manager_user.first_name, "last_name": manager_user.last_name, "role": manager_user.role, }, "login_url": "https://partner.eventifyplus.com", "message": "Partner onboarded successfully. Manager can login at partner.eventifyplus.com", }, status=status.HTTP_201_CREATED, ) class PartnerStaffCreateView(APIView): """ POST /api/v1/partners//staff/ Allows a partner_manager (or admin) to add staff to their partner org. """ permission_classes = [IsAuthenticated] def post(self, request, partner_id): from partner.models import Partner from django.shortcuts import get_object_or_404 # --- Authorization: must be admin or partner_manager of this partner --- user = request.user is_admin = user.role in ("admin", "manager") or user.is_superuser is_partner_manager = ( user.role == "partner_manager" and user.partner_id is not None and user.partner_id == partner_id ) if not is_admin and not is_partner_manager: return Response( {"success": False, "error": "You do not have permission to add staff to this partner"}, status=status.HTTP_403_FORBIDDEN, ) # --- Validate partner exists --- partner = get_object_or_404(Partner, pk=partner_id) data = request.data # --- Validate required fields --- required = { "username": data.get("username"), "email": data.get("email"), "password": data.get("password"), } missing = [k for k, v in required.items() if not v] if missing: return Response( {"success": False, "error": "Missing required fields: " + ", ".join(missing)}, status=status.HTTP_400_BAD_REQUEST, ) username = data["username"].strip() email = data["email"].strip().lower() password = data["password"] # --- Check uniqueness --- if User.objects.filter(username=username).exists(): return Response( {"success": False, "error": "Username '" + username + "' already exists"}, status=status.HTTP_409_CONFLICT, ) if User.objects.filter(email=email).exists(): return Response( {"success": False, "error": "Email '" + email + "' already exists"}, status=status.HTTP_409_CONFLICT, ) # --- Determine role (default partner_staff, allow partner_manager) --- requested_role = (data.get("role") or "partner_staff").strip().lower() allowed_roles = ("partner_staff", "partner_manager") if requested_role not in allowed_roles: return Response( {"success": False, "error": "Role must be one of: " + ", ".join(allowed_roles)}, status=status.HTTP_400_BAD_REQUEST, ) # Non-admin users cannot create partner_manager if requested_role == "partner_manager" and not is_admin: return Response( {"success": False, "error": "Only admins can create additional partner managers"}, status=status.HTTP_403_FORBIDDEN, ) try: staff_user = User( username=username, email=email, first_name=(data.get("first_name") or "").strip(), last_name=(data.get("last_name") or "").strip(), role=requested_role, is_customer=False, partner=partner, phone_number=(data.get("phone_number") or ""), ) 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)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) return Response( { "success": True, "user": { "id": staff_user.id, "username": staff_user.username, "email": staff_user.email, "first_name": staff_user.first_name, "last_name": staff_user.last_name, "role": staff_user.role, "phone_number": staff_user.phone_number or "", "partner_id": partner.id, "partner_name": partner.name, }, "message": f"Staff user '{username}' created successfully for partner '{partner.name}'", }, status=status.HTTP_201_CREATED, ) class PartnerImpersonateView(APIView): """ POST /api/v1/partners//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 def get(self, request): user_id = request.GET.get('user_id', '') return Response({ 'status': 'success', 'profile': { 'user_id': user_id, 'current_tier': 'BRONZE', 'current_ep': 0, 'current_rp': 0, 'lifetime_ep': 0, }, 'submissions': [], }) # ─── Gamification: Event Submission (stub) ──────────────────────────────────── class GamificationSubmitEventView(APIView): permission_classes = [] def post(self, request): data = request.data return Response({ 'status': 'success', 'submission': { 'id': 1, 'event_name': data.get('event_name', ''), 'status': 'PENDING', 'total_ep_awarded': 0, 'created_at': __import__('datetime').datetime.now().isoformat(), }, 'message': 'Event submitted for review. You will earn EP once approved!', }) # ─── Reward Shop: List Items (stub) ────────────────────────────────────────── class ShopItemsView(APIView): permission_classes = [] def get(self, request): return Response({ 'status': 'success', 'items': [ { 'id': 1, 'name': 'BookMyShow Voucher', 'description': 'Get a Rs.100 BookMyShow gift card', 'rp_cost': 50, 'stock_quantity': 10, }, { 'id': 2, 'name': 'Event Priority Listing', 'description': 'Feature your next event at the top for 7 days', 'rp_cost': 30, 'stock_quantity': 5, }, { 'id': 3, 'name': 'Eventify Merch Pack', 'description': 'Exclusive stickers, badge & notebook', 'rp_cost': 100, 'stock_quantity': 3, }, ], }) # ─── Reward Shop: Redeem (stub) ────────────────────────────────────────────── class ShopRedeemView(APIView): permission_classes = [] def post(self, request): import uuid item_id = request.data.get('item_id') return Response({ 'status': 'success', 'voucher': { 'item_id': item_id, 'voucher_code_issued': 'EVF-' + uuid.uuid4().hex[:8].upper(), }, 'message': 'Reward redeemed successfully!', }) # --------------------------------------------------------------------------- # Lead Manager # --------------------------------------------------------------------------- def _serialize_lead(lead): assigned_name = '' assigned_id = None if lead.assigned_to: assigned_name = lead.assigned_to.get_full_name() or lead.assigned_to.username assigned_id = lead.assigned_to.pk user_account = None if lead.user_account: u = lead.user_account profile_pic = None try: if u.profile_picture: profile_pic = u.profile_picture.url except Exception: pass user_account = { 'id': u.pk, 'name': u.get_full_name() or u.username, 'email': u.email, 'phone': getattr(u, 'phone_number', None) or '', 'eventifyId': getattr(u, 'eventify_id', None), 'profilePicture': profile_pic, } return { 'id': lead.pk, 'name': lead.name, 'email': lead.email, 'phone': lead.phone, 'eventType': lead.event_type, 'message': lead.message, 'status': lead.status, 'source': lead.source, 'priority': lead.priority, 'assignedTo': assigned_id, 'assignedToName': assigned_name, 'notes': lead.notes, 'createdAt': lead.created_at.isoformat(), 'updatedAt': lead.updated_at.isoformat(), 'userAccount': user_account, } class LeadMetricsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from admin_api.models import Lead from django.utils import timezone today = timezone.now().date() return Response({ 'total': Lead.objects.count(), 'newToday': Lead.objects.filter(created_at__date=today).count(), 'new': Lead.objects.filter(status='new').count(), 'contacted': Lead.objects.filter(status='contacted').count(), 'qualified': Lead.objects.filter(status='qualified').count(), 'converted': Lead.objects.filter(status='converted').count(), 'closed': Lead.objects.filter(status='closed').count(), }) class LeadListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from admin_api.models import Lead from django.db.models import Q qs = Lead.objects.select_related('assigned_to', 'user_account').order_by('-created_at') # Filters status_f = request.query_params.get('status', '').strip() if status_f and status_f in dict(Lead.STATUS_CHOICES): qs = qs.filter(status=status_f) priority_f = request.query_params.get('priority', '').strip() if priority_f and priority_f in dict(Lead.PRIORITY_CHOICES): qs = qs.filter(priority=priority_f) source_f = request.query_params.get('source', '').strip() if source_f and source_f in dict(Lead.SOURCE_CHOICES): qs = qs.filter(source=source_f) search = request.query_params.get('search', '').strip() if search: qs = qs.filter( Q(name__icontains=search) | Q(email__icontains=search) | Q(phone__icontains=search) ) date_from = request.query_params.get('date_from', '').strip() if date_from: qs = qs.filter(created_at__date__gte=date_from) date_to = request.query_params.get('date_to', '').strip() if date_to: qs = qs.filter(created_at__date__lte=date_to) # Pagination try: page = max(1, int(request.query_params.get('page', 1))) page_size = min(100, int(request.query_params.get('page_size', 20))) except (ValueError, TypeError): page, page_size = 1, 20 total = qs.count() leads = qs[(page - 1) * page_size: page * page_size] return Response({'count': total, 'results': [_serialize_lead(l) for l in leads]}) class LeadDetailView(APIView): permission_classes = [IsAuthenticated] def get(self, request, pk): from admin_api.models import Lead from django.shortcuts import get_object_or_404 lead = get_object_or_404(Lead.objects.select_related('assigned_to', 'user_account'), pk=pk) return Response(_serialize_lead(lead)) class LeadUpdateView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): from admin_api.models import Lead from django.shortcuts import get_object_or_404 from eventify_logger.services import log lead = get_object_or_404(Lead, pk=pk) changed = [] new_status = request.data.get('status') if new_status: if new_status not in dict(Lead.STATUS_CHOICES): return Response({'error': f'Invalid status: {new_status}'}, status=400) lead.status = new_status changed.append('status') new_priority = request.data.get('priority') if new_priority: if new_priority not in dict(Lead.PRIORITY_CHOICES): return Response({'error': f'Invalid priority: {new_priority}'}, status=400) lead.priority = new_priority changed.append('priority') assigned_to_id = request.data.get('assignedTo') if assigned_to_id is not None: if assigned_to_id == '' or assigned_to_id is False: lead.assigned_to = None changed.append('assigned_to') else: try: lead.assigned_to = User.objects.get(pk=int(assigned_to_id)) changed.append('assigned_to') except (User.DoesNotExist, ValueError, TypeError): return Response({'error': 'Invalid assignedTo user'}, status=400) notes = request.data.get('notes') if notes is not None: lead.notes = notes changed.append('notes') 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)) # --------------------------------------------------------------------------- # Notification schedules (admin-side recurring email jobs) # --------------------------------------------------------------------------- def _serialize_recipient(r): return { 'id': r.pk, 'email': r.email, 'displayName': r.display_name, 'isActive': r.is_active, 'createdAt': r.created_at.isoformat(), } def _serialize_schedule(s, include_recipients=True): payload = { 'id': s.pk, 'name': s.name, 'notificationType': s.notification_type, 'cronExpression': s.cron_expression, 'isActive': s.is_active, 'lastRunAt': s.last_run_at.isoformat() if s.last_run_at else None, 'lastStatus': s.last_status, 'lastError': s.last_error, 'createdAt': s.created_at.isoformat(), 'updatedAt': s.updated_at.isoformat(), } if include_recipients: payload['recipients'] = [ _serialize_recipient(r) for r in s.recipients.all() ] payload['recipientCount'] = s.recipients.filter(is_active=True).count() return payload def _validate_cron(expr): from croniter import croniter if not expr or not isinstance(expr, str): return False, 'cronExpression is required' if not croniter.is_valid(expr.strip()): return False, f'Invalid cron expression: {expr!r}' return True, None class NotificationTypesView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from notifications.models import NotificationSchedule return Response({ 'types': [ {'value': v, 'label': l} for v, l in NotificationSchedule.TYPE_CHOICES ], }) class NotificationScheduleListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): from notifications.models import NotificationSchedule qs = NotificationSchedule.objects.prefetch_related('recipients').order_by('-created_at') return Response({ 'count': qs.count(), 'results': [_serialize_schedule(s) for s in qs], }) def post(self, request): from notifications.models import NotificationSchedule, NotificationRecipient from django.db import transaction as db_tx from eventify_logger.services import log name = (request.data.get('name') or '').strip() ntype = (request.data.get('notificationType') or '').strip() cron = (request.data.get('cronExpression') or '').strip() is_active = bool(request.data.get('isActive', True)) recipients_in = request.data.get('recipients') or [] if not name: return Response({'error': 'name is required'}, status=400) if ntype not in dict(NotificationSchedule.TYPE_CHOICES): return Response({'error': f'Invalid notificationType: {ntype!r}'}, status=400) ok, err = _validate_cron(cron) if not ok: return Response({'error': err}, status=400) with db_tx.atomic(): schedule = NotificationSchedule.objects.create( name=name, notification_type=ntype, cron_expression=cron, is_active=is_active, ) seen_emails = set() for r in recipients_in: email = (r.get('email') or '').strip().lower() if not email or email in seen_emails: continue seen_emails.add(email) NotificationRecipient.objects.create( schedule=schedule, email=email, 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) return Response(_serialize_schedule(schedule), status=201) class NotificationScheduleDetailView(APIView): permission_classes = [IsAuthenticated] def get(self, request, pk): from notifications.models import NotificationSchedule from django.shortcuts import get_object_or_404 s = get_object_or_404( NotificationSchedule.objects.prefetch_related('recipients'), pk=pk, ) return Response(_serialize_schedule(s)) def patch(self, request, pk): from notifications.models import NotificationSchedule from django.shortcuts import get_object_or_404 from eventify_logger.services import log s = get_object_or_404(NotificationSchedule, pk=pk) changed = [] if (name := request.data.get('name')) is not None: name = str(name).strip() if not name: return Response({'error': 'name cannot be empty'}, status=400) s.name = name changed.append('name') if (ntype := request.data.get('notificationType')) is not None: if ntype not in dict(NotificationSchedule.TYPE_CHOICES): return Response({'error': f'Invalid notificationType: {ntype!r}'}, status=400) s.notification_type = ntype changed.append('notification_type') if (cron := request.data.get('cronExpression')) is not None: ok, err = _validate_cron(cron) if not ok: return Response({'error': err}, status=400) s.cron_expression = cron.strip() changed.append('cron_expression') if (is_active := request.data.get('isActive')) is not None: s.is_active = bool(is_active) 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)) def delete(self, request, pk): from notifications.models import NotificationSchedule 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) class NotificationRecipientView(APIView): permission_classes = [IsAuthenticated] def post(self, request, pk): from notifications.models import NotificationSchedule, NotificationRecipient from django.shortcuts import get_object_or_404 schedule = get_object_or_404(NotificationSchedule, pk=pk) email = (request.data.get('email') or '').strip().lower() if not email: return Response({'error': 'email is required'}, status=400) if NotificationRecipient.objects.filter(schedule=schedule, email=email).exists(): return Response({'error': f'{email} is already a recipient'}, status=409) r = NotificationRecipient.objects.create( schedule=schedule, email=email, 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) class NotificationRecipientDetailView(APIView): permission_classes = [IsAuthenticated] def patch(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) changed = [] if (email := request.data.get('email')) is not None: email = str(email).strip().lower() if not email: return Response({'error': 'email cannot be empty'}, status=400) clash = NotificationRecipient.objects.filter( schedule_id=pk, email=email, ).exclude(pk=rid).exists() if clash: return Response({'error': f'{email} is already a recipient'}, status=409) if r.email != email: r.email = email changed.append('email') if (display_name := request.data.get('displayName')) is not None: 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: 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) class NotificationScheduleSendNowView(APIView): """Trigger an immediate dispatch of one schedule, bypassing cron check.""" permission_classes = [IsAuthenticated] def post(self, request, pk): from notifications.models import NotificationSchedule from notifications.emails import render_and_send from django.shortcuts import get_object_or_404 from django.utils import timezone as dj_tz from eventify_logger.services import log schedule = get_object_or_404(NotificationSchedule, pk=pk) try: recipient_count = render_and_send(schedule) except Exception as exc: # noqa: BLE001 schedule.last_status = NotificationSchedule.STATUS_ERROR schedule.last_error = str(exc)[:2000] schedule.save(update_fields=['last_status', 'last_error', 'updated_at']) log('error', f'Send-now failed for schedule #{pk}: {exc}', request=request, user=request.user) return Response({'error': str(exc)}, status=500) schedule.last_run_at = dj_tz.now() schedule.last_status = NotificationSchedule.STATUS_SUCCESS schedule.last_error = '' 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({ 'ok': True, 'recipientCount': recipient_count, 'schedule': _serialize_schedule(schedule), }) class NotificationScheduleTestSendView(APIView): """Send a preview of this schedule's email to a single address. Does NOT update last_run_at / last_status — purely for previewing content. """ permission_classes = [IsAuthenticated] def post(self, request, pk): from notifications.models import NotificationSchedule from notifications.emails import BUILDERS from django.conf import settings from django.core.mail import EmailMessage from django.shortcuts import get_object_or_404 from eventify_logger.services import log schedule = get_object_or_404(NotificationSchedule, pk=pk) email = (request.data.get('email') or '').strip().lower() if not email: return Response({'error': 'email is required'}, status=400) builder = BUILDERS.get(schedule.notification_type) if builder is None: return Response( {'error': f'No builder for type: {schedule.notification_type}'}, status=400, ) try: subject, html = builder(schedule) subject = f'[TEST] {subject}' msg = EmailMessage( subject=subject, body=html, from_email=settings.DEFAULT_FROM_EMAIL, to=[email], ) msg.content_subtype = 'html' msg.send(fail_silently=False) except Exception as exc: # noqa: BLE001 log('error', f'Test-send failed for schedule #{pk}: {exc}', request=request, user=request.user) return Response({'error': str(exc)}, status=500) 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) # =========================================================================== # 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)