diff --git a/CHANGELOG.md b/CHANGELOG.md index e01b24d..124f74d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//` — single lead detail + - `PATCH /api/v1/leads//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 diff --git a/accounts/models.py b/accounts/models.py index 4a573f0..e8de0c5 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -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: diff --git a/admin_api/migrations/0003_lead.py b/admin_api/migrations/0003_lead.py new file mode 100644 index 0000000..0469b06 --- /dev/null +++ b/admin_api/migrations/0003_lead.py @@ -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'), + ), + ] diff --git a/admin_api/models.py b/admin_api/models.py index 25bb444..b4e3ff2 100644 --- a/admin_api/models.py +++ b/admin_api/models.py @@ -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})' diff --git a/admin_api/urls.py b/admin_api/urls.py index f847948..c2b815e 100644 --- a/admin_api/urls.py +++ b/admin_api/urls.py @@ -44,6 +44,12 @@ urlpatterns = [ path('reviews//moderate/', views.ReviewModerationView.as_view(), name='review-moderate'), path('reviews//', 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//', views.LeadDetailView.as_view(), name='lead-detail'), + path('leads//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'), diff --git a/admin_api/views.py b/admin_api/views.py index b13e7e0..b8c1393 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -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)) diff --git a/mobile_api/urls.py b/mobile_api/urls.py index ef764fa..a852982 100644 --- a/mobile_api/urls.py +++ b/mobile_api/urls.py @@ -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 diff --git a/mobile_api/views/user.py b/mobile_api/views/user.py index 129cd24..79856ac 100644 --- a/mobile_api/views/user.py +++ b/mobile_api/views/user.py @@ -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)