Add partner-scoped event endpoints under /api/v1/partners/me/events/:
- GET/POST /partners/me/events/ → list + create
- GET/PATCH/DELETE /partners/me/events/{pk}/ → detail + update + delete
- POST /partners/me/events/{pk}/duplicate/ → clone as draft
All endpoints enforce partner ownership via _require_owned_event().
Create auto-sets partner FK + source='partner'. Duplicate always
resets status to 'created' (draft).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3722 lines
139 KiB
Python
3722 lines
139 KiB
Python
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)
|