Files
eventify_backend/admin_api/views.py
Sicherhaven 8bc176b2f6 feat(sprint8): add PartnerDashboardView + URL for partner me dashboard
Returns KPIs (revenue/tickets/events) with 30d vs prev-30d % change,
last 5 bookings, and next 5 upcoming events with capacity progress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:50:07 +05:30

4417 lines
164 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<partner_id>/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/<pk>/impersonate/
Admin-only: generate a short-lived JWT for the partner's primary manager user.
Returns access/refresh tokens + user info so the partner portal can create a session.
"""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
from partner.models import Partner as PartnerModel
from django.shortcuts import get_object_or_404
partner = get_object_or_404(PartnerModel, pk=pk)
partner_user = User.objects.filter(partner=partner, role='partner_manager').first()
if not partner_user:
return Response(
{'error': 'No partner_manager user found for this partner.'},
status=status.HTTP_404_NOT_FOUND,
)
refresh = RefreshToken.for_user(partner_user)
_audit_log(request, 'partner.impersonated', 'partner', str(pk), {
'partner_name': partner.name,
'impersonated_user': partner_user.username,
'admin': request.user.username,
})
return Response({
'access': str(refresh.access_token),
'refresh': str(refresh),
'user': {
'id': partner_user.id,
'email': partner_user.email,
'username': partner_user.username,
'role': partner_user.role,
'partnerId': str(pk),
},
})
# ─── Gamification Dashboard (stub) ───────────────────────────────────────────
class GamificationDashboardView(APIView):
permission_classes = [] # public for now; restrict when auth is wired up
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)
# ============================================================
# Sprint 4 — Partner Bookings
# ============================================================
class PartnerBookingListView(APIView):
"""
GET /api/v1/partners/me/bookings/
Query params: search, status (payment_status), event_id, page, page_size
"""
permission_classes = [IsAuthenticated]
def get(self, request):
from bookings.models import Booking
from django.db.models import Q
partner, err = _require_partner(request)
if err:
return err
qs = Booking.objects.filter(
ticket_meta__event__partner=partner
).select_related(
'user', 'ticket_meta__event', 'ticket_type'
).order_by('-created_date', '-id')
# Search: booking_id, user email, user first/last name
search = request.query_params.get('search', '').strip()
if search:
qs = qs.filter(
Q(booking_id__icontains=search) |
Q(user__email__icontains=search) |
Q(user__first_name__icontains=search) |
Q(user__last_name__icontains=search)
)
# Filter by payment_status
status = request.query_params.get('status', '').strip()
if status:
qs = qs.filter(payment_status=status)
# Filter by event_id
event_id = request.query_params.get('event_id', '').strip()
if event_id:
qs = qs.filter(ticket_meta__event_id=event_id)
# Pagination
try:
page_size = max(1, min(int(request.query_params.get('page_size', 20)), 200))
except (ValueError, TypeError):
page_size = 20
try:
page = max(1, int(request.query_params.get('page', 1)))
except (ValueError, TypeError):
page = 1
total = qs.count()
start = (page - 1) * page_size
bookings = qs[start:start + page_size]
results = []
for b in bookings:
user = b.user
customer_name = f"{user.first_name} {user.last_name}".strip() or user.username
event = b.ticket_meta.event if b.ticket_meta_id else None
results.append({
'id': str(b.id),
'bookingId': b.booking_id or f'BKG-{b.id}',
'customerName': customer_name,
'customerEmail': user.email,
'eventTitle': event.title if event else '',
'eventId': str(event.id) if event else None,
'ticketType': b.ticket_type.ticket_type if b.ticket_type_id else '',
'quantity': b.quantity,
'amount': str(b.price),
'totalAmount': str(b.price * b.quantity),
'paymentStatus': b.payment_status,
'transactionId': b.transaction_id or '',
'createdDate': b.created_date.isoformat() if b.created_date else None,
})
return Response({
'count': total,
'page': page,
'pageSize': page_size,
'results': results,
})
# ============================================================
# Sprint 5 — Partner Customers (Users who booked partner events)
# ============================================================
class PartnerCustomerListView(APIView):
"""
GET /api/v1/partners/me/customers/
Returns distinct users who have made bookings for this partner's events.
Query params: search, page, page_size
"""
permission_classes = [IsAuthenticated]
def get(self, request):
from bookings.models import Booking
from accounts.models import User
from django.db.models import Count, Sum, F, Q, DecimalField, ExpressionWrapper
partner, err = _require_partner(request)
if err:
return err
# Annotate users with per-partner booking stats
user_qs = User.objects.filter(
booking__ticket_meta__event__partner=partner
).annotate(
bookings_count=Count(
'booking',
filter=Q(booking__ticket_meta__event__partner=partner),
),
total_spent=Sum(
ExpressionWrapper(
F('booking__price') * F('booking__quantity'),
output_field=DecimalField(max_digits=12, decimal_places=2),
),
filter=Q(booking__ticket_meta__event__partner=partner),
),
).distinct().order_by('-bookings_count', 'id')
# Search by email / name
search = request.query_params.get('search', '').strip()
if search:
user_qs = user_qs.filter(
Q(email__icontains=search) |
Q(first_name__icontains=search) |
Q(last_name__icontains=search) |
Q(username__icontains=search)
)
# Pagination
try:
page_size = max(1, min(int(request.query_params.get('page_size', 20)), 200))
except (ValueError, TypeError):
page_size = 20
try:
page = max(1, int(request.query_params.get('page', 1)))
except (ValueError, TypeError):
page = 1
total = user_qs.count()
start = (page - 1) * page_size
users = user_qs[start:start + page_size]
results = []
for u in users:
display_name = f"{u.first_name} {u.last_name}".strip() or u.username
results.append({
'id': str(u.id),
'name': display_name,
'email': u.email,
'phone': u.phone if hasattr(u, 'phone') else '',
'bookingsCount': u.bookings_count or 0,
'totalSpent': str(u.total_spent or 0),
'joinedAt': u.date_joined.isoformat() if u.date_joined else None,
'lastLogin': u.last_login.isoformat() if u.last_login else None,
'isActive': u.is_active,
})
return Response({
'count': total,
'page': page,
'pageSize': page_size,
'results': results,
})
# ============================================================
# Sprint 6 — Partner Staff CRUD
# ============================================================
def _serialize_staff_member(u):
"""Serialize a partner staff user for the partner portal."""
display_name = f"{u.first_name} {u.last_name}".strip() or u.username
# Map backend role to frontend label
frontend_role = 'manager' if u.role == 'partner_manager' else 'scanner'
return {
'id': str(u.id),
'name': display_name,
'email': u.email,
'role': frontend_role,
'backendRole': u.role,
'status': 'active' if u.is_active else 'suspended',
'lastActive': u.last_login.isoformat() if u.last_login else None,
'joinedAt': u.date_joined.isoformat() if u.date_joined else None,
}
class PartnerMeStaffListView(APIView):
"""
GET /api/v1/partners/me/staff/ — list partner staff
POST /api/v1/partners/me/staff/ — invite new staff member
"""
permission_classes = [IsAuthenticated]
def get(self, request):
partner, err = _require_partner(request)
if err:
return err
staff = User.objects.filter(
partner=partner,
role__in=['partner_manager', 'partner_staff'],
).order_by('id')
search = request.query_params.get('search', '').strip()
if search:
from django.db.models import Q
staff = staff.filter(
Q(email__icontains=search) |
Q(first_name__icontains=search) |
Q(last_name__icontains=search)
)
return Response([_serialize_staff_member(u) for u in staff])
def post(self, request):
import uuid as _uuid_mod
partner, err = _require_partner(request)
if err:
return err
data = request.data
name = (data.get('name') or '').strip()
email = (data.get('email') or '').strip().lower()
frontend_role = (data.get('role') or 'scanner').strip().lower()
if not name:
return Response({'error': 'name is required'}, status=400)
if not email:
return Response({'error': 'email is required'}, status=400)
# Map frontend role to backend role
# admin/manager → partner_manager; analyst/scanner → partner_staff
backend_role = 'partner_manager' if frontend_role in ('admin', 'manager') else 'partner_staff'
# Check email uniqueness
if User.objects.filter(email=email).exists():
return Response({'error': f"Email '{email}' already exists"}, status=409)
# Generate username from email
base_username = email.split('@')[0]
username = base_username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
# Generate temporary password
temp_password = _uuid_mod.uuid4().hex[:12]
# Split name
parts = name.split(' ', 1)
first_name = parts[0]
last_name = parts[1] if len(parts) > 1 else ''
try:
staff_user = User(
username=username,
email=email,
first_name=first_name,
last_name=last_name,
role=backend_role,
is_customer=False,
partner=partner,
)
staff_user.set_password(temp_password)
staff_user.save()
except Exception as e:
return Response({'error': f'Failed to create staff: {str(e)}'}, status=500)
result = _serialize_staff_member(staff_user)
result['tempPassword'] = temp_password # Exposed once — partner shares with invitee
return Response(result, status=201)
class PartnerMeStaffDetailView(APIView):
"""
PATCH /api/v1/partners/me/staff/{pk}/ — update role
DELETE /api/v1/partners/me/staff/{pk}/ — revoke (deactivate)
"""
permission_classes = [IsAuthenticated]
def _get_staff(self, request, pk):
from django.shortcuts import get_object_or_404
partner, err = _require_partner(request)
if err:
return None, err
staff_user = get_object_or_404(
User, pk=pk, partner=partner,
role__in=['partner_manager', 'partner_staff'],
)
return staff_user, None
def patch(self, request, pk):
staff_user, err = self._get_staff(request, pk)
if err:
return err
data = request.data
updated = []
if 'role' in data:
frontend_role = (data['role'] or '').strip().lower()
backend_role = 'partner_manager' if frontend_role in ('admin', 'manager') else 'partner_staff'
staff_user.role = backend_role
updated.append('role')
if updated:
staff_user.save(update_fields=updated)
return Response(_serialize_staff_member(staff_user))
def delete(self, request, pk):
staff_user, err = self._get_staff(request, pk)
if err:
return err
# Deactivate rather than hard delete to preserve booking records
staff_user.is_active = False
staff_user.save(update_fields=['is_active'])
return Response({'status': 'revoked'}, status=204)
# ============================================================
# Sprint 7 — Partner Check-in (JWT-authenticated)
# ============================================================
class PartnerMeCheckInView(APIView):
"""
POST /api/v1/partners/me/check-in/
Body: { "ticket_id": "<ticket_id>" }
Validates the ticket belongs to a partner-owned event, marks checked-in.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
from bookings.models import Ticket
partner, err = _require_partner(request)
if err:
return err
ticket_id = (request.data.get('ticket_id') or '').strip()
if not ticket_id:
return Response({'valid': False, 'error': 'ticket_id is required'}, status=400)
try:
ticket = Ticket.objects.select_related(
'booking__user',
'booking__ticket_meta__event',
'booking__ticket_type',
).get(ticket_id=ticket_id)
except Ticket.DoesNotExist:
return Response({'valid': False, 'error': 'Ticket not found'}, status=404)
# Verify the ticket's event belongs to this partner
event = ticket.booking.ticket_meta.event if ticket.booking.ticket_meta_id else None
if not event or event.partner_id != partner.id:
return Response({'valid': False, 'error': 'Ticket not found'}, status=404)
user = ticket.booking.user
customer_name = f"{user.first_name} {user.last_name}".strip() or user.username
ticket_type = ticket.booking.ticket_type.ticket_type if ticket.booking.ticket_type_id else ''
already_checked_in = ticket.is_checked_in
if not already_checked_in:
from datetime import datetime, timezone as _tz
ticket.is_checked_in = True
ticket.checked_in_date_time = datetime.now(_tz.utc)
ticket.save(update_fields=['is_checked_in', 'checked_in_date_time'])
return Response({
'valid': True,
'alreadyCheckedIn': already_checked_in,
'name': customer_name,
'ticket': ticket_type,
'event': event.title if event else '',
'ticketId': ticket.ticket_id,
})
# ============================================================
# Sprint 8 — Partner Dashboard Aggregate
# ============================================================
class PartnerDashboardView(APIView):
"""
GET /api/v1/partners/me/dashboard/
Returns partner KPIs, recent bookings, and upcoming events in one call.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
from bookings.models import Booking
from django.db.models import Sum, Count, F, Q, DecimalField, ExpressionWrapper
from datetime import date, timedelta
partner, err = _require_partner(request)
if err:
return err
today = date.today()
# Current period: last 30 days
period_start = today - timedelta(days=30)
# Previous period: 3160 days ago (for change %)
prev_start = today - timedelta(days=60)
prev_end = today - timedelta(days=31)
def booking_revenue_qs(start, end):
return Booking.objects.filter(
ticket_meta__event__partner=partner,
payment_status='paid',
created_date__gte=start,
created_date__lte=end,
).aggregate(
total=Sum(
ExpressionWrapper(
F('price') * F('quantity'),
output_field=DecimalField(max_digits=14, decimal_places=2),
)
),
tickets=Sum('quantity'),
)
curr = booking_revenue_qs(period_start, today)
prev = booking_revenue_qs(prev_start, prev_end)
curr_rev = float(curr['total'] or 0)
prev_rev = float(prev['total'] or 0)
curr_tickets = int(curr['tickets'] or 0)
prev_tickets = int(prev['tickets'] or 0)
def pct_change(curr_v, prev_v):
if prev_v == 0:
return '+0%' if curr_v == 0 else '+100%'
delta = ((curr_v - prev_v) / prev_v) * 100
sign = '+' if delta >= 0 else ''
return f'{sign}{delta:.1f}%'
from events.models import Event
active_events = Event.objects.filter(
partner=partner, event_status__in=['published', 'live']
).count()
total_events = Event.objects.filter(partner=partner).count()
# KPIs
kpis = {
'totalRevenue': f'\u20b9{curr_rev:,.0f}',
'revenueChange': pct_change(curr_rev, prev_rev),
'ticketsSold': f'{curr_tickets:,}',
'ticketsChange': pct_change(curr_tickets, prev_tickets),
'activeEvents': str(active_events),
'totalEvents': str(total_events),
}
# Recent bookings (last 5 paid + pending, newest first)
recent_qs = Booking.objects.filter(
ticket_meta__event__partner=partner,
).select_related('user', 'ticket_meta__event', 'ticket_type').order_by(
'-created_date', '-id'
)[:5]
recent_bookings = []
for b in recent_qs:
user = b.user
name = f"{user.first_name} {user.last_name}".strip() or user.username
event = b.ticket_meta.event if b.ticket_meta_id else None
recent_bookings.append({
'id': b.booking_id or f'BKG-{b.id}',
'event': event.title if event else '',
'customer': name,
'date': b.created_date.isoformat() if b.created_date else None,
'amount': f'\u20b9{float(b.price * b.quantity):,.0f}',
'status': b.payment_status,
})
# Upcoming events (next 3 by date, have a future start date or are published)
upcoming_qs = Event.objects.filter(
partner=partner,
event_status__in=['published', 'live', 'draft'],
).order_by('event_date', 'id')[:5]
upcoming_events = []
for ev in upcoming_qs:
# Count tickets sold for this event
sold = Booking.objects.filter(
ticket_meta__event=ev, payment_status='paid'
).aggregate(s=Sum('quantity'))['s'] or 0
# Get total capacity from TicketMeta
from bookings.models import TicketMeta
try:
meta = TicketMeta.objects.get(event=ev)
total_capacity = meta.maximum_quantity or 0
except TicketMeta.DoesNotExist:
total_capacity = 0
upcoming_events.append({
'id': str(ev.id),
'name': ev.title,
'date': ev.event_date.isoformat() if ev.event_date else None,
'sold': sold,
'total': total_capacity,
'status': ev.event_status,
})
return Response({
'kpis': kpis,
'recentBookings': recent_bookings,
'upcomingEvents': upcoming_events,
})