From b60d03142c62d4386c8a9b840a279356c35320ea Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 24 Mar 2026 17:46:41 +0000 Subject: [PATCH] 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 --- Dockerfile | 19 +++ admin_api/urls.py | 5 + admin_api/views.py | 236 +++++++++++++++++++++++++++++++ banking_operations/models.py | 6 +- bookings/models.py | 2 +- eventify/settings.py | 3 +- events/models.py | 6 +- events/views.py | 14 +- ledger/models.py | 3 +- partner/models.py | 4 +- requirements.txt | 1 + templates/base.html | 84 ++++++----- templates/events/event_form.html | 79 ++++++----- templates/events/event_list.html | 48 ++++++- 14 files changed, 416 insertions(+), 94 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a2a561 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/admin_api/urls.py b/admin_api/urls.py index df64669..a571f69 100644 --- a/admin_api/urls.py +++ b/admin_api/urls.py @@ -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'), ] diff --git a/admin_api/views.py b/admin_api/views.py index f66fb68..45d9cd3 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -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) diff --git a/banking_operations/models.py b/banking_operations/models.py index d562da8..eda4eba 100644 --- a/banking_operations/models.py +++ b/banking_operations/models.py @@ -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) diff --git a/bookings/models.py b/bookings/models.py index 02b4f97..858c75b 100644 --- a/bookings/models.py +++ b/bookings/models.py @@ -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) diff --git a/eventify/settings.py b/eventify/settings.py index 6b9284b..7a098ed 100644 --- a/eventify/settings.py +++ b/eventify/settings.py @@ -36,7 +36,8 @@ INSTALLED_APPS = [ 'rest_framework.authtoken', 'rest_framework_simplejwt', 'admin_api', - 'django_summernote' + 'django_summernote', + 'ledger', ] INSTALLED_APPS += [ diff --git a/events/models.py b/events/models.py index 7210c28..5eaa5dc 100644 --- a/events/models.py +++ b/events/models.py @@ -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) diff --git a/events/views.py b/events/views.py index 890b10a..db39292 100644 --- a/events/views.py +++ b/events/views.py @@ -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) - - diff --git a/ledger/models.py b/ledger/models.py index 454d58b..2b6cefa 100644 --- a/ledger/models.py +++ b/ledger/models.py @@ -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}" diff --git a/partner/models.py b/partner/models.py index 4ba9eae..94524f4 100644 --- a/partner/models.py +++ b/partner/models.py @@ -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) diff --git a/requirements.txt b/requirements.txt index ca7c246..be31299 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Django>=4.2 Pillow +django-summernote diff --git a/templates/base.html b/templates/base.html index 66eb8c1..55ed91d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,75 +1,81 @@ + Eventify + + + - -
- {% if messages %} + +
+ {% if messages %} {% for message in messages %} -
{{ message }}
+
{{ message }}
{% endfor %} - {% endif %} - {% block content %}{% endblock %} -
- + {% endif %} + {% block content %}{% endblock %} +
+ - + + \ No newline at end of file diff --git a/templates/events/event_form.html b/templates/events/event_form.html index 57f65c7..a6fef3f 100644 --- a/templates/events/event_form.html +++ b/templates/events/event_form.html @@ -1,49 +1,50 @@ {% extends 'base.html' %} {% block content %} -
-

{% if object %}Edit{% else %}Add{% endif %} Event

+
+

{% if object %}Edit{% else %}Add{% endif %} Event

-
- {% csrf_token %} + + {% csrf_token %} + {{ form.media }} - {% for field in form %} -
- {{ field.label_tag }} - {{ field }} - {% for error in field.errors %} -
{{ error }}
- {% endfor %} -
+ {% for field in form %} +
+ {{ field.label_tag }} + {{ field }} + {% for error in field.errors %} +
{{ error }}
{% endfor %} +
+ {% endfor %} - - Cancel -
-
+ + Cancel + +
- -{% endblock %} + // Listen for checkbox changes + if (allYearEventCheckbox) { + allYearEventCheckbox.addEventListener('change', toggleDateTimeFields); + } + }); + +{% endblock %} \ No newline at end of file diff --git a/templates/events/event_list.html b/templates/events/event_list.html index 6eabd67..dc3e439 100644 --- a/templates/events/event_list.html +++ b/templates/events/event_list.html @@ -1,9 +1,20 @@ {% extends 'base.html' %} {% block content %} -
-

Events

- Add Event +
+
+

Events

+
+
+
+ + +
+
+
+ Add Event +
+ @@ -44,4 +55,35 @@ {% endfor %}
+ + + {% if is_paginated %} + + {% endif %} {% endblock %}