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
This commit is contained in:
53
admin_api/migrations/0003_lead.py
Normal file
53
admin_api/migrations/0003_lead.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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})'
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user