Compare commits
37 Commits
9aa7c01efe
...
d74698f0b8
| Author | SHA1 | Date | |
|---|---|---|---|
| d74698f0b8 | |||
| fb1abc0b99 | |||
| c7cb1fef62 | |||
| 4de955ba62 | |||
| 9c17c6e4fc | |||
| d34caeccae | |||
| e6ffe8efe3 | |||
| 5e14511a12 | |||
| 38b45e8c79 | |||
| d6ca058864 | |||
| 8c9ad49387 | |||
| 0b2050443b | |||
| 7913f9f8e9 | |||
| cb63ceab92 | |||
| 1a82a3a8fc | |||
| d182cfe5ee | |||
| a6e080bf6c | |||
| d1a0c95dfd | |||
| a8f12a9d34 | |||
| 35a99c08fd | |||
| 291fa40283 | |||
| 068d700059 | |||
| 6ea51e1463 | |||
| 4cf70b6330 | |||
| f8c17e2c8d | |||
| f2134c5529 | |||
| 239a84cd76 | |||
| 1a668c078a | |||
| 4274672d25 | |||
| 19b4482057 | |||
| 81a261f726 | |||
| e26f463bbb | |||
| 8e081c8c24 | |||
| c2175fa58e | |||
|
|
0ae42f5757 | ||
|
|
422002e9ef | ||
|
|
91751ac127 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -5,22 +5,6 @@ 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
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Merge migration to resolve conflicting 0013 migrations."""
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0013_merge_eventify_id'),
|
||||
('accounts', '0013_user_district_changed_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@@ -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", "leads", "financials", "settings"]
|
||||
ALL_MODULES = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "financials", "settings"]
|
||||
|
||||
def get_allowed_modules(self):
|
||||
ALL = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "leads", "financials", "settings"]
|
||||
ALL = ["dashboard", "partners", "events", "ad-control", "users", "reviews", "contributions", "financials", "settings"]
|
||||
if self.is_superuser or self.role == "admin":
|
||||
return ALL
|
||||
if self.allowed_modules:
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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', '0003_lead'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='lead',
|
||||
name='user_account',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='submitted_leads',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
help_text='Consumer platform account that submitted this lead (auto-matched by email)',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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', 'leads', 'financials', 'settings']
|
||||
return ['dashboard', 'partners', 'events', 'ad-control', 'users', 'reviews', 'contributions', 'financials', 'settings']
|
||||
SCOPE_TO_MODULE = {
|
||||
'users': 'users',
|
||||
'events': 'events',
|
||||
@@ -140,7 +140,6 @@ class StaffProfile(models.Model):
|
||||
'settings': 'settings',
|
||||
'ads': 'ad-control',
|
||||
'contributions': 'contributions',
|
||||
'leads': 'leads',
|
||||
}
|
||||
modules = {'dashboard'}
|
||||
for scope in scopes:
|
||||
@@ -182,65 +181,3 @@ 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'
|
||||
)
|
||||
user_account = models.ForeignKey(
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True, related_name='submitted_leads',
|
||||
help_text='Consumer platform account that submitted this lead (auto-matched by email)'
|
||||
)
|
||||
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,12 +44,6 @@ 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,7 +684,6 @@ 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 '',
|
||||
}
|
||||
@@ -797,7 +796,6 @@ class EventUpdateView(APIView):
|
||||
'pincode': 'pincode',
|
||||
'importantInformation': 'important_information',
|
||||
'source': 'source',
|
||||
'contributedBy': 'contributed_by',
|
||||
'cancelledReason': 'cancelled_reason',
|
||||
'outsideEventUrl': 'outside_event_url',
|
||||
}
|
||||
@@ -2345,174 +2343,3 @@ 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
|
||||
|
||||
user_account = None
|
||||
if lead.user_account:
|
||||
u = lead.user_account
|
||||
profile_pic = None
|
||||
try:
|
||||
if u.profile_picture:
|
||||
profile_pic = u.profile_picture.url
|
||||
except Exception:
|
||||
pass
|
||||
user_account = {
|
||||
'id': u.pk,
|
||||
'name': u.get_full_name() or u.username,
|
||||
'email': u.email,
|
||||
'phone': getattr(u, 'phone_number', None) or '',
|
||||
'eventifyId': getattr(u, 'eventify_id', None),
|
||||
'profilePicture': profile_pic,
|
||||
}
|
||||
|
||||
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(),
|
||||
'userAccount': user_account,
|
||||
}
|
||||
|
||||
|
||||
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', 'user_account').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', 'user_account'), 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))
|
||||
|
||||
@@ -36,7 +36,6 @@ urlpatterns = [
|
||||
path('banking/', include('banking_operations.urls')),
|
||||
path('api/', include('mobile_api.urls')),
|
||||
path('api/v1/', include('admin_api.urls')),
|
||||
path('api/notifications/', include('notifications.urls')),
|
||||
# path('web-api/', include('web_api.urls')),
|
||||
|
||||
path('summernote/', include('django_summernote.urls')),
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
Add contributed_by field to Event and backfill from overloaded source field.
|
||||
|
||||
The admin dashboard stores community contributor identifiers (EVT-XXXXXXXX or email)
|
||||
in the source field. This migration:
|
||||
1. Adds a dedicated contributed_by CharField
|
||||
2. Copies user identifiers from source → contributed_by
|
||||
3. Normalizes source back to its intended choices ('eventify', 'community', 'partner')
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_contributed_by(apps, schema_editor):
|
||||
"""Move user identifiers from source to contributed_by."""
|
||||
Event = apps.get_model('events', 'Event')
|
||||
|
||||
STANDARD_SOURCES = {'eventify', 'community', 'partner', 'eventify_team', 'official', ''}
|
||||
|
||||
for event in Event.objects.all().iterator():
|
||||
source_val = (event.source or '').strip()
|
||||
changed = False
|
||||
|
||||
# User identifier: contains @ (email) or starts with EVT- (eventifyId)
|
||||
if source_val and source_val not in STANDARD_SOURCES and not source_val.startswith('partner:'):
|
||||
event.contributed_by = source_val
|
||||
event.source = 'community'
|
||||
changed = True
|
||||
|
||||
# Normalize eventify_team → eventify
|
||||
elif source_val == 'eventify_team':
|
||||
event.source = 'eventify'
|
||||
changed = True
|
||||
|
||||
# Normalize official → eventify
|
||||
elif source_val == 'official':
|
||||
event.source = 'eventify'
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
event.save(update_fields=['source', 'contributed_by'])
|
||||
|
||||
|
||||
def reverse_backfill(apps, schema_editor):
|
||||
"""Reverse: move contributed_by back to source."""
|
||||
Event = apps.get_model('events', 'Event')
|
||||
for event in Event.objects.exclude(contributed_by__isnull=True).exclude(contributed_by='').iterator():
|
||||
event.source = event.contributed_by
|
||||
event.contributed_by = None
|
||||
event.save(update_fields=['source', 'contributed_by'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0010_merge_20260324_1443'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Step 1: Add the field
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='contributed_by',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text='Eventify ID (EVT-XXXXXXXX) or email of the community contributor',
|
||||
max_length=100,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
# Step 2: Backfill data
|
||||
migrations.RunPython(backfill_contributed_by, reverse_backfill),
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0011_event_contributed_by'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EventLike',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('event', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='likes',
|
||||
to='events.event',
|
||||
)),
|
||||
('user', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='event_likes',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'event')},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='eventlike',
|
||||
index=models.Index(fields=['user', '-created_at'], name='events_even_user_id_created_idx'),
|
||||
),
|
||||
]
|
||||
@@ -58,11 +58,6 @@ class Event(models.Model):
|
||||
is_featured = models.BooleanField(default=False, help_text='Show this event in the featured section')
|
||||
is_top_event = models.BooleanField(default=False, help_text='Show this event in the Top Events section')
|
||||
|
||||
contributed_by = models.CharField(
|
||||
max_length=100, blank=True, null=True,
|
||||
help_text='Eventify ID (EVT-XXXXXXXX) or email of the community contributor',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.start_date})"
|
||||
|
||||
@@ -76,26 +71,3 @@ class EventImages(models.Model):
|
||||
return f"{self.event_image}"
|
||||
|
||||
|
||||
class EventLike(models.Model):
|
||||
user = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='event_likes'
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='likes'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'event')
|
||||
indexes = [
|
||||
models.Index(fields=['user', '-created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} likes {self.event.name}"
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +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 mobile_api.views.favorites import ToggleLikeView, MyLikedIdsView, MyLikedEventsView
|
||||
from ad_control.views import ConsumerFeaturedEventsView, ConsumerTopEventsView
|
||||
|
||||
|
||||
@@ -15,7 +13,6 @@ 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
|
||||
@@ -40,10 +37,3 @@ urlpatterns += [
|
||||
path('reviews/helpful', ReviewHelpfulView.as_view()),
|
||||
path('reviews/flag', ReviewFlagView.as_view()),
|
||||
]
|
||||
|
||||
# Favorites URLs
|
||||
urlpatterns += [
|
||||
path('events/like/', ToggleLikeView.as_view()),
|
||||
path('events/my-likes/', MyLikedIdsView.as_view()),
|
||||
path('events/my-liked-events/', MyLikedEventsView.as_view()),
|
||||
]
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
from django.views import View
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.http import JsonResponse
|
||||
from django.core.paginator import Paginator
|
||||
|
||||
from events.models import Event, EventLike, EventImages
|
||||
from mobile_api.utils import validate_token_and_get_user
|
||||
from eventify_logger.services import log
|
||||
|
||||
|
||||
def _serialize_liked_event(event):
|
||||
"""Serialize an Event for the liked-events list."""
|
||||
primary_img = EventImages.objects.filter(
|
||||
event=event, is_primary=True
|
||||
).first()
|
||||
if not primary_img:
|
||||
primary_img = EventImages.objects.filter(event=event).first()
|
||||
|
||||
return {
|
||||
'id': event.id,
|
||||
'title': event.title or event.name,
|
||||
'image': primary_img.event_image.url if primary_img else '',
|
||||
'date': str(event.start_date) if event.start_date else None,
|
||||
'location': event.place or '',
|
||||
'venue': event.venue_name or '',
|
||||
'event_type': event.event_type.event_type if event.event_type else '',
|
||||
'event_status': event.event_status,
|
||||
}
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class ToggleLikeView(View):
|
||||
"""POST /api/events/like/ — toggle like on/off for an event."""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
event_id = data.get('event_id')
|
||||
if not event_id:
|
||||
return JsonResponse(
|
||||
{'status': 'error', 'message': 'event_id is required'},
|
||||
status=400
|
||||
)
|
||||
|
||||
try:
|
||||
event = Event.objects.get(pk=event_id)
|
||||
except Event.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{'status': 'error', 'message': 'Event not found'},
|
||||
status=404
|
||||
)
|
||||
|
||||
like, created = EventLike.objects.get_or_create(user=user, event=event)
|
||||
if not created:
|
||||
like.delete()
|
||||
return JsonResponse({'status': 'success', 'liked': False})
|
||||
|
||||
return JsonResponse({'status': 'success', 'liked': True})
|
||||
|
||||
except Exception as e:
|
||||
log("error", "ToggleLikeView exception", request=request,
|
||||
logger_data={"error": str(e)})
|
||||
return JsonResponse(
|
||||
{'status': 'error', 'message': 'An unexpected server error occurred.'},
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class MyLikedIdsView(View):
|
||||
"""POST /api/events/my-likes/ — return all liked event IDs for the user."""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
liked_ids = list(
|
||||
EventLike.objects.filter(user=user)
|
||||
.values_list('event_id', flat=True)
|
||||
)
|
||||
return JsonResponse({'status': 'success', 'liked_event_ids': liked_ids})
|
||||
|
||||
except Exception as e:
|
||||
log("error", "MyLikedIdsView exception", request=request,
|
||||
logger_data={"error": str(e)})
|
||||
return JsonResponse(
|
||||
{'status': 'error', 'message': 'An unexpected server error occurred.'},
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class MyLikedEventsView(View):
|
||||
"""POST /api/events/my-liked-events/ — paginated liked events with full data."""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
page = int(data.get('page', 1))
|
||||
page_size = min(int(data.get('page_size', 20)), 50)
|
||||
|
||||
# Event IDs liked by this user, newest first
|
||||
liked_event_ids = list(
|
||||
EventLike.objects.filter(user=user)
|
||||
.order_by('-created_at')
|
||||
.values_list('event_id', flat=True)
|
||||
)
|
||||
|
||||
# Preserve ordering from liked_event_ids
|
||||
from django.db.models import Case, When, IntegerField
|
||||
ordering = Case(
|
||||
*[When(pk=pk, then=pos) for pos, pk in enumerate(liked_event_ids)],
|
||||
output_field=IntegerField()
|
||||
)
|
||||
events_qs = Event.objects.filter(id__in=liked_event_ids).order_by(ordering)
|
||||
|
||||
paginator = Paginator(events_qs, page_size)
|
||||
page_obj = paginator.get_page(page)
|
||||
|
||||
events_data = [_serialize_liked_event(e) for e in page_obj]
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'events': events_data,
|
||||
'total': paginator.count,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'has_next': page_obj.has_next(),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log("error", "MyLikedEventsView exception", request=request,
|
||||
logger_data={"error": str(e)})
|
||||
return JsonResponse(
|
||||
{'status': 'error', 'message': 'An unexpected server error occurred.'},
|
||||
status=500
|
||||
)
|
||||
@@ -1,11 +1,9 @@
|
||||
# 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
|
||||
@@ -365,159 +363,3 @@ 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)
|
||||
|
||||
# Auto-link to a consumer account if one exists with this email
|
||||
from django.contrib.auth import get_user_model
|
||||
_User = get_user_model()
|
||||
try:
|
||||
consumer_account = _User.objects.get(email=email)
|
||||
except _User.DoesNotExist:
|
||||
consumer_account = None
|
||||
|
||||
lead = Lead.objects.create(
|
||||
name=name,
|
||||
email=email,
|
||||
phone=phone,
|
||||
event_type=event_type,
|
||||
message=message,
|
||||
status='new',
|
||||
source='schedule_call',
|
||||
priority='medium',
|
||||
user_account=consumer_account,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
from django.contrib import admin
|
||||
from .models import Notification
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'user', 'notification_type', 'is_read', 'created_at')
|
||||
list_filter = ('notification_type', 'is_read', 'created_at')
|
||||
search_fields = ('title', 'message', 'user__email')
|
||||
readonly_fields = ('created_at',)
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'notifications'
|
||||
@@ -1,25 +0,0 @@
|
||||
from django.db import models
|
||||
from accounts.models import User
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
NOTIFICATION_TYPES = [
|
||||
('event', 'Event'),
|
||||
('promo', 'Promotion'),
|
||||
('system', 'System'),
|
||||
('booking', 'Booking'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications')
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
notification_type = models.CharField(max_length=20, choices=NOTIFICATION_TYPES, default='system')
|
||||
is_read = models.BooleanField(default=False)
|
||||
action_url = models.URLField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.notification_type}: {self.title} → {self.user.email}"
|
||||
@@ -1,8 +0,0 @@
|
||||
from django.urls import path
|
||||
from .views import NotificationListView, NotificationMarkReadView, NotificationCountView
|
||||
|
||||
urlpatterns = [
|
||||
path('list/', NotificationListView.as_view(), name='notification_list'),
|
||||
path('mark-read/', NotificationMarkReadView.as_view(), name='notification_mark_read'),
|
||||
path('count/', NotificationCountView.as_view(), name='notification_count'),
|
||||
]
|
||||
@@ -1,85 +0,0 @@
|
||||
import json
|
||||
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 mobile_api.utils import validate_token_and_get_user
|
||||
from eventify_logger.services import log
|
||||
from .models import Notification
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class NotificationListView(View):
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
page = int(data.get('page', 1))
|
||||
page_size = int(data.get('page_size', 20))
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
notifications = Notification.objects.filter(user=user)[offset:offset + page_size]
|
||||
total = Notification.objects.filter(user=user).count()
|
||||
|
||||
items = [{
|
||||
'id': n.id,
|
||||
'title': n.title,
|
||||
'message': n.message,
|
||||
'notification_type': n.notification_type,
|
||||
'is_read': n.is_read,
|
||||
'action_url': n.action_url or '',
|
||||
'created_at': n.created_at.isoformat(),
|
||||
} for n in notifications]
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'notifications': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
})
|
||||
except Exception as e:
|
||||
log("error", "NotificationListView error", request=request, logger_data={"error": str(e)})
|
||||
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class NotificationMarkReadView(View):
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
mark_all = data.get('mark_all', False)
|
||||
notification_id = data.get('notification_id')
|
||||
|
||||
if mark_all:
|
||||
Notification.objects.filter(user=user, is_read=False).update(is_read=True)
|
||||
return JsonResponse({'status': 'success', 'message': 'All notifications marked as read'})
|
||||
|
||||
if notification_id:
|
||||
Notification.objects.filter(id=notification_id, user=user).update(is_read=True)
|
||||
return JsonResponse({'status': 'success', 'message': 'Notification marked as read'})
|
||||
|
||||
return JsonResponse({'error': 'Provide notification_id or mark_all=true'}, status=400)
|
||||
except Exception as e:
|
||||
log("error", "NotificationMarkReadView error", request=request, logger_data={"error": str(e)})
|
||||
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class NotificationCountView(View):
|
||||
def post(self, request):
|
||||
try:
|
||||
user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
count = Notification.objects.filter(user=user, is_read=False).count()
|
||||
return JsonResponse({'status': 'success', 'unread_count': count})
|
||||
except Exception as e:
|
||||
log("error", "NotificationCountView error", request=request, logger_data={"error": str(e)})
|
||||
return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500)
|
||||
@@ -1,4 +1,3 @@
|
||||
Django>=4.2
|
||||
Pillow
|
||||
django-summernote
|
||||
google-auth>=2.0.0
|
||||
|
||||
Reference in New Issue
Block a user