feat: Phase 1+2 - JWT auth, dashboard metrics API, DB indexes
Phase 1 - JWT Auth Foundation: - Replace token auth with djangorestframework-simplejwt - POST /api/v1/admin/auth/login/ - returns access + refresh JWT - POST /api/v1/auth/refresh/ - JWT refresh - GET /api/v1/auth/me/ - current admin profile - GET /api/v1/health/ - DB health check - Add ledger app to INSTALLED_APPS Phase 2 - Dashboard Metrics API: - GET /api/v1/dashboard/metrics/ - revenue, partners, events, tickets - GET /api/v1/dashboard/revenue/ - 7-day revenue vs payouts chart data - GET /api/v1/dashboard/activity/ - last 10 platform events feed - GET /api/v1/dashboard/actions/ - KYC queue, flagged events, pending payouts DB Indexes (dashboard query optimisation): - RazorpayTransaction: status, captured_at - Partner: status, kyc_compliance_status - Event: event_status, start_date, created_date - Booking: created_date - PaymentTransaction: payment_type, payment_transaction_status, payment_transaction_date Infra: - Add Dockerfile for eventify-backend container - Add simplejwt to requirements.txt - All 4 dashboard views use IsAuthenticated permission class
This commit is contained in:
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements-docker.txt .
|
||||
RUN pip install --no-cache-dir -r requirements-docker.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN python manage.py collectstatic --noinput || true
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120", "eventify.wsgi:application"]
|
||||
@@ -7,4 +7,9 @@ urlpatterns = [
|
||||
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('auth/me/', views.MeView.as_view(), name='auth_me'),
|
||||
path('health/', views.HealthView.as_view(), name='health'),
|
||||
# Phase 2: Dashboard endpoints
|
||||
path('dashboard/metrics/', views.DashboardMetricsView.as_view(), name='dashboard-metrics'),
|
||||
path('dashboard/revenue/', views.DashboardRevenueView.as_view(), name='dashboard-revenue'),
|
||||
path('dashboard/activity/', views.DashboardActivityView.as_view(), name='dashboard-activity'),
|
||||
path('dashboard/actions/', views.DashboardActionsView.as_view(), name='dashboard-actions'),
|
||||
]
|
||||
|
||||
@@ -50,3 +50,239 @@ class HealthView(APIView):
|
||||
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)
|
||||
|
||||
@@ -44,7 +44,7 @@ class PaymentGatewayCredentials(models.Model):
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
payment_transaction_id = models.CharField(max_length=250)
|
||||
payment_type = models.CharField(max_length=250, choices=[
|
||||
payment_type = models.CharField(max_length=250, db_index=True, choices=[
|
||||
('credit', 'Credit'),
|
||||
('debit', 'Debit'),
|
||||
('transfer', 'Transfer'),
|
||||
@@ -58,14 +58,14 @@ class PaymentTransaction(models.Model):
|
||||
payment_gateway = models.ForeignKey(PaymentGateway, on_delete=models.CASCADE)
|
||||
payment_transaction_amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
payment_transaction_currency = models.CharField(max_length=10)
|
||||
payment_transaction_status = models.CharField(max_length=250, choices=[
|
||||
payment_transaction_status = models.CharField(max_length=250, db_index=True, choices=[
|
||||
('pending', 'Pending'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
('refunded', 'Refunded'),
|
||||
('cancelled', 'Cancelled'),
|
||||
])
|
||||
payment_transaction_date = models.DateField(auto_now_add=True)
|
||||
payment_transaction_date = models.DateField(auto_now_add=True, db_index=True)
|
||||
payment_transaction_time = models.TimeField(auto_now_add=True)
|
||||
payment_transaction_notes = models.TextField(blank=True, null=True)
|
||||
payment_transaction_raw_data = models.JSONField(blank=True, null=True)
|
||||
|
||||
@@ -61,7 +61,7 @@ class Booking(models.Model):
|
||||
ticket_type = models.ForeignKey(TicketType, on_delete=models.CASCADE)
|
||||
quantity = models.IntegerField()
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
created_date = models.DateField(auto_now_add=True)
|
||||
created_date = models.DateField(auto_now_add=True, db_index=True)
|
||||
updated_date = models.DateField(auto_now=True)
|
||||
|
||||
transaction_id = models.CharField(max_length=250, blank=True, null=True)
|
||||
|
||||
@@ -36,7 +36,8 @@ INSTALLED_APPS = [
|
||||
'rest_framework.authtoken',
|
||||
'rest_framework_simplejwt',
|
||||
'admin_api',
|
||||
'django_summernote'
|
||||
'django_summernote',
|
||||
'ledger',
|
||||
]
|
||||
|
||||
INSTALLED_APPS += [
|
||||
|
||||
@@ -5,10 +5,10 @@ from partner.models import Partner
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
created_date = models.DateField(auto_now_add=True)
|
||||
created_date = models.DateField(auto_now_add=True, db_index=True)
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField()
|
||||
start_date = models.DateField(blank=True, null=True)
|
||||
start_date = models.DateField(blank=True, null=True, db_index=True)
|
||||
end_date = models.DateField(blank=True, null=True)
|
||||
start_time = models.TimeField(blank=True, null=True)
|
||||
end_time = models.TimeField(blank=True, null=True)
|
||||
@@ -42,7 +42,7 @@ class Event(models.Model):
|
||||
('published', 'Published'),
|
||||
('live', 'Live'),
|
||||
('flagged', 'Flagged'),
|
||||
], default='pending')
|
||||
], default='pending', db_index=True)
|
||||
cancelled_reason = models.TextField(default='NA')
|
||||
|
||||
title = models.CharField(max_length=250, blank=True)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.views import generic
|
||||
from django.urls import reverse_lazy
|
||||
from django.db.models import Q
|
||||
from .models import Event
|
||||
from .models import EventImages
|
||||
from .forms import EventForm
|
||||
@@ -18,6 +19,17 @@ class EventListView(LoginRequiredMixin, generic.ListView):
|
||||
template_name = 'events/event_list.html'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
query = self.request.GET.get('q')
|
||||
if query:
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(district__icontains=query) |
|
||||
Q(state__icontains=query)
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
class EventCreateView(LoginRequiredMixin, generic.CreateView):
|
||||
model = Event
|
||||
@@ -91,5 +103,3 @@ def delete_event_image(request, pk, img_id):
|
||||
image.delete()
|
||||
messages.success(request, "Image deleted!")
|
||||
return redirect("events:event_images", pk=pk)
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class RazorpayTransaction(models.Model):
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
help_text="created/authorized/captured/failed/refunded",
|
||||
db_index=True,
|
||||
)
|
||||
method = models.CharField(
|
||||
max_length=50,
|
||||
@@ -59,7 +60,7 @@ class RazorpayTransaction(models.Model):
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
captured_at = models.DateTimeField(blank=True, null=True)
|
||||
captured_at = models.DateTimeField(blank=True, null=True, db_index=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.razorpay_payment_id or self.razorpay_order_id} - {self.status}"
|
||||
|
||||
@@ -45,7 +45,7 @@ class Partner(models.Model):
|
||||
primary_contact_person_email = models.EmailField()
|
||||
primary_contact_person_phone = models.CharField(max_length=15)
|
||||
|
||||
status = models.CharField(max_length=250, choices=STATUS_CHOICES, default='active')
|
||||
status = models.CharField(max_length=250, choices=STATUS_CHOICES, default='active', db_index=True)
|
||||
|
||||
address = models.TextField(blank=True, null=True)
|
||||
city = models.CharField(max_length=250, blank=True, null=True)
|
||||
@@ -58,7 +58,7 @@ class Partner(models.Model):
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
|
||||
|
||||
is_kyc_compliant = models.BooleanField(default=False)
|
||||
kyc_compliance_status = models.CharField(max_length=250, choices=KYC_COMPLIANCE_STATUS_CHOICES, default='pending')
|
||||
kyc_compliance_status = models.CharField(max_length=250, choices=KYC_COMPLIANCE_STATUS_CHOICES, default='pending', db_index=True)
|
||||
kyc_compliance_reason = models.TextField(blank=True, null=True)
|
||||
kyc_compliance_document_type = models.CharField(max_length=250, choices=KYC_DOCUMENT_TYPE_CHOICES, blank=True, null=True)
|
||||
kyc_compliance_document_other_type = models.CharField(max_length=250, blank=True, null=True)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
Django>=4.2
|
||||
Pillow
|
||||
django-summernote
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||
<title>Eventify</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- jQuery required for Summernote -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
@@ -54,7 +58,8 @@
|
||||
{{ user.email }}
|
||||
{% endif %}
|
||||
</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-danger" href="{% url 'accounts:logout' %}">Logout</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-danger" href="{% url 'accounts:logout' %}">Logout</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'accounts:login' %}">Login</a></li>
|
||||
{% endif %}
|
||||
@@ -72,4 +77,5 @@
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{{ form.media }}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<h3>Events</h3>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<form method="get" action="." class="d-flex">
|
||||
<input class="form-control me-2" type="search" name="q" placeholder="Search events..." aria-label="Search" value="{{ request.GET.q }}">
|
||||
<button class="btn btn-outline-success" type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-2 text-end">
|
||||
<a class="btn btn-success" href="{% url 'events:event_add' %}">Add Event</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -44,4 +55,35 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}">« First</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}">Next</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}">Last »</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user