feat(leads): add Lead Manager module with full admin and consumer endpoints

- Lead model in admin_api with status/priority/source/assigned_to fields
- Admin API: metrics, list, detail, update views at /api/v1/leads/
- Consumer API: public ScheduleCallView at /api/leads/schedule-call/
- RBAC: 'leads' module registered in ALL_MODULES and StaffProfile scopes
- Migration 0003_lead with indexes on status, priority, created_at, email

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 10:48:04 +05:30
parent d74698f0b8
commit 2434805738
8 changed files with 441 additions and 3 deletions

View File

@@ -5,6 +5,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), version
---
## [1.9.0] — 2026-04-07
### Added
- **Lead Manager** — new `Lead` model in `admin_api` for tracking Schedule-a-Call form submissions and sales inquiries
- Fields: name, email, phone, event_type, message, status (new/contacted/qualified/converted/closed), source (schedule_call/website/manual), priority (low/medium/high), assigned_to (FK User), notes
- Migration `admin_api/0003_lead` with indexes on status, priority, created_at, email
- **Consumer endpoint** `POST /api/leads/schedule-call/` — public (AllowAny, CSRF-exempt) endpoint for the Schedule a Call modal; creates Lead with status=new, source=schedule_call
- **Admin API endpoints** (all IsAuthenticated):
- `GET /api/v1/leads/metrics/` — total, new today, counts per status
- `GET /api/v1/leads/` — paginated list with filters (status, priority, source, search, date_from, date_to)
- `GET /api/v1/leads/<id>/` — single lead detail
- `PATCH /api/v1/leads/<id>/update/` — update status, priority, assigned_to, notes
- **RBAC**: `leads` added to `ALL_MODULES`, `get_allowed_modules()`, and `StaffProfile.SCOPE_TO_MODULE`
---
## [1.8.3] — 2026-04-06
### Fixed

View File

@@ -68,10 +68,10 @@ class User(AbstractUser):
help_text='Comma-separated module slugs this user can access',
)
ALL_MODULES = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "financials", "settings"]
ALL_MODULES = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "settings"]
def get_allowed_modules(self):
ALL = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "financials", "settings"]
ALL = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "settings"]
if self.is_superuser or self.role == "admin":
return ALL
if self.allowed_modules:

View File

@@ -0,0 +1,53 @@
# Generated by Django 4.2.21 on 2026-04-07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_api', '0002_rbac_models'),
]
operations = [
migrations.CreateModel(
name='Lead',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(max_length=20)),
('event_type', models.CharField(choices=[('private', 'Private Event'), ('ticketed', 'Ticketed Event'), ('corporate', 'Corporate Event'), ('wedding', 'Wedding'), ('other', 'Other')], default='private', max_length=20)),
('message', models.TextField(blank=True, default='')),
('status', models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('qualified', 'Qualified'), ('converted', 'Converted'), ('closed', 'Closed')], default='new', max_length=20)),
('source', models.CharField(choices=[('schedule_call', 'Schedule a Call'), ('website', 'Website'), ('manual', 'Manual')], default='schedule_call', max_length=20)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')], default='medium', max_length=10)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_leads', to=settings.AUTH_USER_MODEL)),
('notes', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='lead',
index=models.Index(fields=['status'], name='admin_api_lead_status_idx'),
),
migrations.AddIndex(
model_name='lead',
index=models.Index(fields=['priority'], name='admin_api_lead_priority_idx'),
),
migrations.AddIndex(
model_name='lead',
index=models.Index(fields=['created_at'], name='admin_api_lead_created_idx'),
),
migrations.AddIndex(
model_name='lead',
index=models.Index(fields=['email'], name='admin_api_lead_email_idx'),
),
]

View File

@@ -130,7 +130,7 @@ class StaffProfile(models.Model):
def get_allowed_modules(self):
scopes = self.get_effective_scopes()
if '*' in scopes:
return ['dashboard', 'partners', 'events', 'ad-control', 'users', 'reviews', 'contributions', 'financials', 'settings']
return ['dashboard', 'partners', 'events', 'ad-control', 'users', 'reviews', 'contributions', 'leads', 'financials', 'settings']
SCOPE_TO_MODULE = {
'users': 'users',
'events': 'events',
@@ -140,6 +140,7 @@ class StaffProfile(models.Model):
'settings': 'settings',
'ads': 'ad-control',
'contributions': 'contributions',
'leads': 'leads',
}
modules = {'dashboard'}
for scope in scopes:
@@ -181,3 +182,61 @@ class AuditLog(models.Model):
def __str__(self):
return f"{self.action} by {self.user} at {self.created_at}"
# ---------------------------------------------------------------------------
# Lead Manager
# ---------------------------------------------------------------------------
class Lead(models.Model):
EVENT_TYPE_CHOICES = [
('private', 'Private Event'),
('ticketed', 'Ticketed Event'),
('corporate', 'Corporate Event'),
('wedding', 'Wedding'),
('other', 'Other'),
]
STATUS_CHOICES = [
('new', 'New'),
('contacted', 'Contacted'),
('qualified', 'Qualified'),
('converted', 'Converted'),
('closed', 'Closed'),
]
SOURCE_CHOICES = [
('schedule_call', 'Schedule a Call'),
('website', 'Website'),
('manual', 'Manual'),
]
PRIORITY_CHOICES = [
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
]
name = models.CharField(max_length=200)
email = models.EmailField()
phone = models.CharField(max_length=20)
event_type = models.CharField(max_length=20, choices=EVENT_TYPE_CHOICES, default='private')
message = models.TextField(blank=True, default='')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='new')
source = models.CharField(max_length=20, choices=SOURCE_CHOICES, default='schedule_call')
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='medium')
assigned_to = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_leads'
)
notes = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['status']),
models.Index(fields=['priority']),
models.Index(fields=['created_at']),
models.Index(fields=['email']),
]
def __str__(self):
return f'Lead #{self.pk}{self.name} ({self.status})'

View File

@@ -44,6 +44,12 @@ urlpatterns = [
path('reviews/<int:pk>/moderate/', views.ReviewModerationView.as_view(), name='review-moderate'),
path('reviews/<int:pk>/', views.ReviewDeleteView.as_view(), name='review-delete'),
# Lead Manager
path('leads/metrics/', views.LeadMetricsView.as_view(), name='lead-metrics'),
path('leads/', views.LeadListView.as_view(), name='lead-list'),
path('leads/<int:pk>/', views.LeadDetailView.as_view(), name='lead-detail'),
path('leads/<int:pk>/update/', views.LeadUpdateView.as_view(), name='lead-update'),
path('gamification/submit-event/', views.GamificationSubmitEventView.as_view(), name='gamification-submit-event'),
path('gamification/submit-event', views.GamificationSubmitEventView.as_view()),
path('shop/items/', views.ShopItemsView.as_view(), name='shop-items'),

View File

@@ -684,6 +684,7 @@ def _serialize_event(e):
'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 '',
}
@@ -796,6 +797,7 @@ class EventUpdateView(APIView):
'pincode': 'pincode',
'importantInformation': 'important_information',
'source': 'source',
'contributedBy': 'contributed_by',
'cancelledReason': 'cancelled_reason',
'outsideEventUrl': 'outside_event_url',
}
@@ -2343,3 +2345,154 @@ class ShopRedeemView(APIView):
},
'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
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(),
}
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').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'), 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)
return Response(_serialize_lead(lead))

View File

@@ -1,5 +1,6 @@
from django.urls import path
from .views import *
from mobile_api.views.user import ScheduleCallView
from mobile_api.views.reviews import ReviewSubmitView, MobileReviewListView, ReviewHelpfulView, ReviewFlagView
from ad_control.views import ConsumerFeaturedEventsView, ConsumerTopEventsView
@@ -13,6 +14,7 @@ urlpatterns = [
path('user/update-profile/', UpdateProfileView.as_view(), name='update_profile'),
path('user/bulk-public-info/', BulkUserPublicInfoView.as_view(), name='bulk_public_info'),
path('user/google-login/', GoogleLoginView.as_view(), name='google_login'),
path('leads/schedule-call/', ScheduleCallView.as_view(), name='schedule_call'),
]
# Event URLS

View File

@@ -1,9 +1,11 @@
# accounts/views.py
import json
import secrets
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from rest_framework.views import APIView
from rest_framework.authtoken.models import Token
from mobile_api.forms import RegisterForm, LoginForm, WebRegisterForm
from rest_framework.authentication import TokenAuthentication
@@ -363,3 +365,150 @@ class UpdateProfileView(View):
'success': False,
'error': 'An unexpected server error occurred. Please try again.'
}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
class BulkUserPublicInfoView(APIView):
"""Internal endpoint for Node.js gamification server to resolve user details.
Accepts POST with { emails: [...] } (max 500).
Returns { users: { email: { district, display_name, eventify_id } } }
"""
authentication_classes = []
permission_classes = []
def post(self, request):
try:
json_data = json.loads(request.body)
emails = json_data.get('emails', [])
if not emails or not isinstance(emails, list) or len(emails) > 500:
return JsonResponse({'error': 'Provide 1-500 emails'}, status=400)
users_qs = User.objects.filter(email__in=emails).values_list(
'email', 'first_name', 'last_name', 'district', 'eventify_id'
)
result = {}
for email, first, last, district, eid in users_qs:
name = f"{first} {last}".strip() or email.split('@')[0]
result[email] = {
'display_name': name,
'district': district or '',
'eventify_id': eid or '',
}
return JsonResponse({'users': result})
except Exception as e:
log("error", "BulkUserPublicInfoView error", logger_data={"error": str(e)})
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
class GoogleLoginView(View):
"""Verify a Google ID token, find or create the user, return the same response shape as LoginView."""
def post(self, request):
try:
from google.oauth2 import id_token as google_id_token
from google.auth.transport import requests as google_requests
data = json.loads(request.body)
token = data.get('id_token')
if not token:
return JsonResponse({'error': 'id_token is required'}, status=400)
idinfo = google_id_token.verify_oauth2_token(token, google_requests.Request())
email = idinfo.get('email')
if not email:
return JsonResponse({'error': 'Email not found in Google token'}, status=400)
user, created = User.objects.get_or_create(
email=email,
defaults={
'username': email,
'first_name': idinfo.get('given_name', ''),
'last_name': idinfo.get('family_name', ''),
'role': 'customer',
},
)
if created:
user.set_password(secrets.token_urlsafe(32))
user.save()
log("info", "Google OAuth new user created", request=request, user=user)
auth_token, _ = Token.objects.get_or_create(user=user)
log("info", "Google OAuth login", request=request, user=user)
return JsonResponse({
'message': 'Login successful',
'token': auth_token.key,
'eventify_id': user.eventify_id or '',
'username': user.username,
'email': user.email,
'phone_number': user.phone_number or '',
'first_name': user.first_name,
'last_name': user.last_name,
'role': user.role,
'pincode': user.pincode or '',
'district': user.district or '',
'district_changed_at': user.district_changed_at.isoformat() if user.district_changed_at else None,
'state': user.state or '',
'country': user.country or '',
'place': user.place or '',
'latitude': user.latitude or '',
'longitude': user.longitude or '',
'profile_photo': user.profile_picture.url if user.profile_picture else '',
}, status=200)
except ValueError as e:
log("warning", "Google OAuth invalid token", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': 'Invalid Google token'}, status=401)
except Exception as e:
log("error", "Google OAuth exception", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
class ScheduleCallView(View):
"""Public endpoint for the 'Schedule a Call' form on the consumer app."""
def post(self, request):
from admin_api.models import Lead
try:
data = json.loads(request.body)
name = (data.get('name') or '').strip()
email = (data.get('email') or '').strip()
phone = (data.get('phone') or '').strip()
event_type = (data.get('eventType') or '').strip()
message = (data.get('message') or '').strip()
errors = {}
if not name:
errors['name'] = ['This field is required.']
if not email:
errors['email'] = ['This field is required.']
if not phone:
errors['phone'] = ['This field is required.']
valid_event_types = [c[0] for c in Lead.EVENT_TYPE_CHOICES]
if not event_type or event_type not in valid_event_types:
errors['eventType'] = [f'Must be one of: {", ".join(valid_event_types)}']
if errors:
return JsonResponse({'errors': errors}, status=400)
lead = Lead.objects.create(
name=name,
email=email,
phone=phone,
event_type=event_type,
message=message,
status='new',
source='schedule_call',
priority='medium',
)
log("info", f"New schedule-call lead #{lead.pk} from {email}", request=request)
return JsonResponse({
'status': 'success',
'message': 'Your request has been submitted. Our team will get back to you soon.',
'lead_id': lead.pk,
}, status=201)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON body.'}, status=400)
except Exception as e:
log("error", "Schedule call exception", request=request, logger_data={"error": str(e)})
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)