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:
Ubuntu
2026-03-24 17:46:41 +00:00
parent 37001f8e70
commit b60d03142c
14 changed files with 416 additions and 94 deletions

19
Dockerfile Normal file
View 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"]

View File

@@ -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'),
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -36,7 +36,8 @@ INSTALLED_APPS = [
'rest_framework.authtoken',
'rest_framework_simplejwt',
'admin_api',
'django_summernote'
'django_summernote',
'ledger',
]
INSTALLED_APPS += [

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}"

View File

@@ -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)

View File

@@ -1,2 +1,3 @@
Django>=4.2
Pillow
django-summernote

View File

@@ -1,13 +1,17 @@
<!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">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'accounts:dashboard' %}">Eventify</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navmenu">
@@ -54,22 +58,24 @@
{{ 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 %}
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
</nav>
<div class="container mt-4">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -1,10 +1,11 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mt-4">
<div class="container mt-4">
<h3>{% if object %}Edit{% else %}Add{% endif %} Event</h3>
<form method="post" novalidate>
{% csrf_token %}
{{ form.media }}
{% for field in form %}
<div class="mb-3">
@@ -19,10 +20,10 @@
<button class="btn btn-primary">Save</button>
<a class="btn btn-secondary" href="{% url 'events:event_list' %}">Cancel</a>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
<script>
document.addEventListener('DOMContentLoaded', function () {
const allYearEventCheckbox = document.getElementById('id_all_year_event');
const startDateField = document.getElementById('id_start_date');
const endDateField = document.getElementById('id_end_date');
@@ -45,5 +46,5 @@
allYearEventCheckbox.addEventListener('change', toggleDateTimeFields);
}
});
</script>
</script>
{% endblock %}

View File

@@ -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 %}">&laquo; 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 &raquo;</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}