diff --git a/admin_api/views.py b/admin_api/views.py index d119821..d2bd5cf 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -5,7 +5,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework import status from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.views import TokenRefreshView -from django.db import connection +from django.db import connection, transaction from .serializers import UserSerializer User = get_user_model() @@ -30,16 +30,44 @@ class AdminLoginView(APIView): if not user.is_active: return Response({'error': 'Account is disabled'}, status=status.HTTP_403_FORBIDDEN) refresh = RefreshToken.for_user(user) + user_data = UserSerializer(user).data + # RBAC: prefer StaffProfile for allowed_modules and scopes + try: + sp = user.staff_profile + allowed_modules = sp.get_allowed_modules() + effective_scopes = sp.get_effective_scopes() + user_data['staff_role'] = sp.staff_role + user_data['department'] = sp.department.name if sp.department else None + user_data['squad'] = sp.squad.name if sp.squad else None + except Exception: + allowed_modules = user.get_allowed_modules() + effective_scopes = [] + user_data['allowed_modules'] = allowed_modules + user_data['effective_scopes'] = effective_scopes return Response({ 'access': str(refresh.access_token), 'refresh': str(refresh), - 'user': UserSerializer(user).data, + 'user': user_data, }) class MeView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - return Response({'user': UserSerializer(request.user).data}) + me_data = UserSerializer(request.user).data + # RBAC: prefer StaffProfile for allowed_modules and scopes + try: + sp = request.user.staff_profile + allowed_modules = sp.get_allowed_modules() + effective_scopes = sp.get_effective_scopes() + me_data['staff_role'] = sp.staff_role + me_data['department'] = sp.department.name if sp.department else None + me_data['squad'] = sp.squad.name if sp.squad else None + except Exception: + allowed_modules = request.user.get_allowed_modules() + effective_scopes = [] + me_data['allowed_modules'] = allowed_modules + me_data['effective_scopes'] = effective_scopes + return Response({'user': me_data}) class HealthView(APIView): permission_classes = [AllowAny] @@ -653,6 +681,7 @@ def _serialize_event(e): 'createdAt': e.created_date.isoformat() if e.created_date else '', 'isFeatured': bool(e.is_featured), 'isTopEvent': bool(e.is_top_event), + 'source': e.source or 'eventify', } @@ -1289,3 +1318,923 @@ class EventPrimaryImageView(APIView): img.save() return Response({"success": True, "primaryImageId": image_id}) + + +# --------------------------------------------------------------------------- +# RBAC Views +# --------------------------------------------------------------------------- +from admin_api.models import Department, Squad, StaffProfile, CustomRole, AuditLog +from django.utils.text import slugify +from django.db.models import Q +import json + +SCOPE_DEFINITIONS = { + 'users.read': {'label': 'View Users', 'category': 'Users'}, + 'users.write': {'label': 'Edit Users', 'category': 'Users'}, + 'users.delete': {'label': 'Delete Users', 'category': 'Users'}, + 'users.ban': {'label': 'Ban/Suspend Users', 'category': 'Users'}, + 'events.read': {'label': 'View Events', 'category': 'Events'}, + 'events.write': {'label': 'Create/Edit Events', 'category': 'Events'}, + 'events.approve': {'label': 'Approve Events', 'category': 'Events'}, + 'events.delete': {'label': 'Delete Events', 'category': 'Events'}, + 'finance.read': {'label': 'View Finance Dashboard', 'category': 'Finance'}, + 'finance.refunds.read': {'label': 'View Refund Requests', 'category': 'Finance'}, + 'finance.refunds.execute': {'label': 'Process Refunds', 'category': 'Finance'}, + 'finance.payouts.read': {'label': 'View Payouts', 'category': 'Finance'}, + 'finance.payouts.execute': {'label': 'Execute Payouts', 'category': 'Finance'}, + 'partners.read': {'label': 'View Partners', 'category': 'Partners'}, + 'partners.write': {'label': 'Edit Partners', 'category': 'Partners'}, + 'partners.kyc': {'label': 'Verify Partner KYC', 'category': 'Partners'}, + 'partners.impersonate': {'label': 'Login as Partner', 'category': 'Partners'}, + 'partners.events.review': {'label': 'Review Partner Events', 'category': 'Partners'}, + 'partners.suspend': {'label': 'Suspend/Unsuspend Partners', 'category': 'Partners'}, + 'tickets.read': {'label': 'View Tickets', 'category': 'Support'}, + 'tickets.write': {'label': 'Respond to Tickets', 'category': 'Support'}, + 'tickets.assign': {'label': 'Assign Tickets', 'category': 'Support'}, + 'tickets.escalate': {'label': 'Escalate Tickets', 'category': 'Support'}, + 'settings.read': {'label': 'View Settings', 'category': 'Settings'}, + 'settings.write': {'label': 'Modify Settings', 'category': 'Settings'}, + 'settings.staff': {'label': 'Manage Staff', 'category': 'Settings'}, + 'ads.read': {'label': 'View Ad Campaigns', 'category': 'Ad Control'}, + 'ads.write': {'label': 'Create/Edit Campaigns', 'category': 'Ad Control'}, + 'ads.approve': {'label': 'Approve Campaigns', 'category': 'Ad Control'}, + 'ads.report': {'label': 'View Ad Reports', 'category': 'Ad Control'}, +} + + +def _audit_log(request, action, target_type, target_id, details=None): + AuditLog.objects.create( + user=request.user if request.user.is_authenticated else None, + action=action, + target_type=target_type, + target_id=str(target_id), + details=details or {}, + ip_address=request.META.get('REMOTE_ADDR'), + ) + + +def _serialize_department(dept): + return { + 'id': dept.id, + 'name': dept.name, + 'slug': dept.slug, + 'description': dept.description, + 'base_scopes': dept.base_scopes, + 'color': dept.color, + 'squad_count': dept.squads.count(), + 'member_count': dept.staff_members.count(), + 'created_at': dept.created_at.isoformat() if dept.created_at else None, + 'updated_at': dept.updated_at.isoformat() if dept.updated_at else None, + } + + +def _serialize_squad(squad): + return { + 'id': squad.id, + 'name': squad.name, + 'department_id': squad.department_id, + 'department_name': squad.department.name, + 'manager': { + 'id': squad.manager.id, + 'username': squad.manager.username, + 'first_name': squad.manager.first_name, + 'last_name': squad.manager.last_name, + } if squad.manager else None, + 'extra_scopes': squad.extra_scopes, + 'member_count': squad.members.count(), + 'created_at': squad.created_at.isoformat() if squad.created_at else None, + } + + +def _serialize_staff(sp): + u = sp.user + return { + 'id': sp.id, + 'user_id': u.id, + 'username': u.username, + 'email': u.email, + 'name': (u.first_name + ' ' + u.last_name).strip() or u.username, + 'first_name': u.first_name, + 'last_name': u.last_name, + 'phone_number': u.phone_number, + 'profile_picture': u.profile_picture.url if u.profile_picture else None, + 'staff_role': sp.staff_role, + 'status': sp.status, + 'department': { + 'id': sp.department.id, + 'name': sp.department.name, + 'slug': sp.department.slug, + 'color': sp.department.color, + } if sp.department else None, + 'squad': { + 'id': sp.squad.id, + 'name': sp.squad.name, + } if sp.squad else None, + 'effective_scopes': sp.get_effective_scopes(), + 'allowed_modules': sp.get_allowed_modules(), + 'joined_at': sp.joined_at.isoformat() if sp.joined_at else None, + } + + +# 1. DepartmentListCreateView +class DepartmentListCreateView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + depts = Department.objects.all() + return Response([_serialize_department(d) for d in depts]) + + def post(self, request): + data = request.data + name = data.get('name', '').strip() + if not name: + return Response({'error': 'name is required'}, status=400) + slug = data.get('slug') or slugify(name) + if Department.objects.filter(slug=slug).exists(): + return Response({'error': 'Department with this slug already exists'}, status=400) + dept = Department.objects.create( + name=name, + slug=slug, + description=data.get('description', ''), + base_scopes=data.get('base_scopes', []), + color=data.get('color', '#3B82F6'), + ) + _audit_log(request, 'department.created', 'Department', dept.id, {'name': name}) + return Response(_serialize_department(dept), status=201) + + +# 2. DepartmentDetailView +class DepartmentDetailView(APIView): + permission_classes = [IsAuthenticated] + + def patch(self, request, pk): + try: + dept = Department.objects.get(pk=pk) + except Department.DoesNotExist: + return Response({'error': 'Department not found'}, status=404) + data = request.data + if 'name' in data: + dept.name = data['name'] + if 'description' in data: + dept.description = data['description'] + if 'base_scopes' in data: + dept.base_scopes = data['base_scopes'] + if 'color' in data: + dept.color = data['color'] + dept.save() + _audit_log(request, 'department.updated', 'Department', dept.id, data) + return Response(_serialize_department(dept)) + + def delete(self, request, pk): + try: + dept = Department.objects.get(pk=pk) + except Department.DoesNotExist: + return Response({'error': 'Department not found'}, status=404) + if dept.squads.exists(): + return Response({'error': 'Cannot delete department that has squads. Remove squads first.'}, status=400) + dept_name = dept.name + dept.delete() + _audit_log(request, 'department.deleted', 'Department', pk, {'name': dept_name}) + return Response({'success': True}, status=200) + + +# 3. SquadListCreateView +class SquadListCreateView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + qs = Squad.objects.select_related('department', 'manager').all() + dept_id = request.query_params.get('department') + if dept_id: + qs = qs.filter(department_id=dept_id) + return Response([_serialize_squad(s) for s in qs]) + + def post(self, request): + data = request.data + name = data.get('name', '').strip() + department_id = data.get('department_id') + if not name or not department_id: + return Response({'error': 'name and department_id are required'}, status=400) + try: + dept = Department.objects.get(pk=department_id) + except Department.DoesNotExist: + return Response({'error': 'Department not found'}, status=404) + manager_id = data.get('manager_id') + manager = None + if manager_id: + try: + manager = User.objects.get(pk=manager_id) + except User.DoesNotExist: + pass + squad = Squad.objects.create( + name=name, + department=dept, + manager=manager, + extra_scopes=data.get('extra_scopes', []), + ) + _audit_log(request, 'squad.created', 'Squad', squad.id, {'name': name, 'department': dept.name}) + return Response(_serialize_squad(squad), status=201) + + +# 4. SquadDetailView +class SquadDetailView(APIView): + permission_classes = [IsAuthenticated] + + def patch(self, request, pk): + try: + squad = Squad.objects.select_related('department', 'manager').get(pk=pk) + except Squad.DoesNotExist: + return Response({'error': 'Squad not found'}, status=404) + data = request.data + if 'name' in data: + squad.name = data['name'] + if 'extra_scopes' in data: + squad.extra_scopes = data['extra_scopes'] + if 'manager_id' in data: + if data['manager_id']: + try: + squad.manager = User.objects.get(pk=data['manager_id']) + except User.DoesNotExist: + return Response({'error': 'Manager user not found'}, status=404) + else: + squad.manager = None + squad.save() + _audit_log(request, 'squad.updated', 'Squad', squad.id, data) + return Response(_serialize_squad(squad)) + + def delete(self, request, pk): + try: + squad = Squad.objects.get(pk=pk) + except Squad.DoesNotExist: + return Response({'error': 'Squad not found'}, status=404) + if squad.members.exists(): + return Response({'error': 'Cannot delete squad that has members. Move members first.'}, status=400) + squad_name = squad.name + squad.delete() + _audit_log(request, 'squad.deleted', 'Squad', pk, {'name': squad_name}) + return Response({'success': True}, status=200) + + +# 5. StaffListView +class StaffListView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + qs = StaffProfile.objects.select_related('user', 'department', 'squad').all() + # Filters + dept = request.query_params.get('department') + if dept: + qs = qs.filter(department_id=dept) + squad_id = request.query_params.get('squad') + if squad_id: + qs = qs.filter(squad_id=squad_id) + role = request.query_params.get('role') + if role: + qs = qs.filter(staff_role=role) + status_filter = request.query_params.get('status') + if status_filter: + qs = qs.filter(status=status_filter) + search = request.query_params.get('search') + if search: + qs = qs.filter( + Q(user__username__icontains=search) | + Q(user__email__icontains=search) | + Q(user__first_name__icontains=search) | + Q(user__last_name__icontains=search) + ) + return Response([_serialize_staff(sp) for sp in qs]) + + +# 6. StaffInviteView +class StaffInviteView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + data = request.data + email = data.get('email', '').strip() + if not email: + return Response({'error': 'email is required'}, status=400) + if User.objects.filter(email=email).exists(): + return Response({'error': 'User with this email already exists'}, status=400) + + username = data.get('username') or email.split('@')[0] + first_name = data.get('first_name', '') + last_name = data.get('last_name', '') + + user = User.objects.create_user( + username=username, + email=email, + first_name=first_name, + last_name=last_name, + password=data.get('password', 'TempPass123!'), + is_customer=False, + role='staff', + ) + + department = None + if data.get('department_id'): + try: + department = Department.objects.get(pk=data['department_id']) + except Department.DoesNotExist: + pass + + squad = None + if data.get('squad_id'): + try: + squad = Squad.objects.get(pk=data['squad_id']) + except Squad.DoesNotExist: + pass + + sp = StaffProfile.objects.create( + user=user, + staff_role=data.get('staff_role', 'MEMBER'), + department=department, + squad=squad, + status='invited', + ) + _audit_log(request, 'staff.invited', 'StaffProfile', sp.id, { + 'email': email, 'staff_role': sp.staff_role, + }) + return Response(_serialize_staff(sp), status=201) + + +# 7. StaffUpdateView +class StaffUpdateView(APIView): + permission_classes = [IsAuthenticated] + + def patch(self, request, pk): + try: + sp = StaffProfile.objects.select_related('user', 'department', 'squad').get(pk=pk) + except StaffProfile.DoesNotExist: + return Response({'error': 'Staff profile not found'}, status=404) + data = request.data + if 'staff_role' in data: + sp.staff_role = data['staff_role'] + if 'department_id' in data: + if data['department_id']: + try: + sp.department = Department.objects.get(pk=data['department_id']) + except Department.DoesNotExist: + return Response({'error': 'Department not found'}, status=404) + else: + sp.department = None + if 'squad_id' in data: + if data['squad_id']: + try: + sp.squad = Squad.objects.get(pk=data['squad_id']) + except Squad.DoesNotExist: + return Response({'error': 'Squad not found'}, status=404) + else: + sp.squad = None + sp.save() + _audit_log(request, 'staff.updated', 'StaffProfile', sp.id, data) + return Response(_serialize_staff(sp)) + + +# 8. StaffDeactivateView +class StaffDeactivateView(APIView): + permission_classes = [IsAuthenticated] + + def patch(self, request, pk): + try: + sp = StaffProfile.objects.select_related('user', 'department', 'squad').get(pk=pk) + except StaffProfile.DoesNotExist: + return Response({'error': 'Staff profile not found'}, status=404) + sp.status = 'deactivated' + sp.save() + _audit_log(request, 'staff.deactivated', 'StaffProfile', sp.id, { + 'username': sp.user.username, + }) + return Response(_serialize_staff(sp)) + + +# 9. StaffMoveView +class StaffMoveView(APIView): + permission_classes = [IsAuthenticated] + + def patch(self, request, pk): + try: + sp = StaffProfile.objects.select_related('user', 'department', 'squad').get(pk=pk) + except StaffProfile.DoesNotExist: + return Response({'error': 'Staff profile not found'}, status=404) + data = request.data + old_dept = sp.department.name if sp.department else None + old_squad = sp.squad.name if sp.squad else None + + if 'department_id' in data: + if data['department_id']: + try: + sp.department = Department.objects.get(pk=data['department_id']) + except Department.DoesNotExist: + return Response({'error': 'Department not found'}, status=404) + else: + sp.department = None + if 'squad_id' in data: + if data['squad_id']: + try: + sp.squad = Squad.objects.get(pk=data['squad_id']) + except Squad.DoesNotExist: + return Response({'error': 'Squad not found'}, status=404) + else: + sp.squad = None + sp.save() + _audit_log(request, 'staff.moved', 'StaffProfile', sp.id, { + 'from_department': old_dept, + 'to_department': sp.department.name if sp.department else None, + 'from_squad': old_squad, + 'to_squad': sp.squad.name if sp.squad else None, + }) + return Response(_serialize_staff(sp)) + + +# 10. RoleListCreateView +class RoleListCreateView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + roles = CustomRole.objects.all() + return Response([{ + 'id': r.id, + 'name': r.name, + 'slug': r.slug, + 'description': r.description, + 'scopes': r.scopes, + 'is_system': r.is_system, + 'created_at': r.created_at.isoformat() if r.created_at else None, + } for r in roles]) + + def post(self, request): + data = request.data + name = data.get('name', '').strip() + if not name: + return Response({'error': 'name is required'}, status=400) + slug = data.get('slug') or slugify(name) + if CustomRole.objects.filter(slug=slug).exists(): + return Response({'error': 'Role with this slug already exists'}, status=400) + role = CustomRole.objects.create( + name=name, + slug=slug, + description=data.get('description', ''), + scopes=data.get('scopes', []), + is_system=data.get('is_system', False), + ) + _audit_log(request, 'role.created', 'CustomRole', role.id, {'name': name}) + return Response({ + 'id': role.id, + 'name': role.name, + 'slug': role.slug, + 'description': role.description, + 'scopes': role.scopes, + 'is_system': role.is_system, + 'created_at': role.created_at.isoformat() if role.created_at else None, + }, status=201) + + +# 11. RoleDetailView +class RoleDetailView(APIView): + permission_classes = [IsAuthenticated] + + def patch(self, request, pk): + try: + role = CustomRole.objects.get(pk=pk) + except CustomRole.DoesNotExist: + return Response({'error': 'Role not found'}, status=404) + data = request.data + if 'name' in data: + role.name = data['name'] + if 'description' in data: + role.description = data['description'] + if 'scopes' in data: + role.scopes = data['scopes'] + role.save() + _audit_log(request, 'role.updated', 'CustomRole', role.id, data) + return Response({ + 'id': role.id, + 'name': role.name, + 'slug': role.slug, + 'description': role.description, + 'scopes': role.scopes, + 'is_system': role.is_system, + 'created_at': role.created_at.isoformat() if role.created_at else None, + }) + + def delete(self, request, pk): + try: + role = CustomRole.objects.get(pk=pk) + except CustomRole.DoesNotExist: + return Response({'error': 'Role not found'}, status=404) + if role.is_system: + return Response({'error': 'Cannot delete system roles'}, status=400) + role_name = role.name + role.delete() + _audit_log(request, 'role.deleted', 'CustomRole', pk, {'name': role_name}) + return Response({'success': True}, status=200) + + +# 12. ScopeListView +class ScopeListView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + # Group by category + grouped = {} + for key, val in SCOPE_DEFINITIONS.items(): + cat = val['category'] + if cat not in grouped: + grouped[cat] = [] + grouped[cat].append({ + 'key': key, + 'label': val['label'], + }) + return Response({ + 'scopes': SCOPE_DEFINITIONS, + 'grouped': grouped, + }) + + +# 13. OrgTreeView +class OrgTreeView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + departments = Department.objects.prefetch_related( + 'squads__members__user', + 'squads__manager', + 'staff_members__user', + ).all() + + tree = [] + for dept in departments: + squads_data = [] + for squad in dept.squads.all(): + members_data = [] + for member in squad.members.filter(status='active'): + members_data.append({ + 'id': member.id, + 'user_id': member.user.id, + 'username': member.user.username, + 'first_name': member.user.first_name, + 'last_name': member.user.last_name, + 'email': member.user.email, + 'staff_role': member.staff_role, + 'profile_picture': member.user.profile_picture.url if member.user.profile_picture else None, + }) + squads_data.append({ + 'id': squad.id, + 'name': squad.name, + 'manager': { + 'id': squad.manager.id, + 'username': squad.manager.username, + 'first_name': squad.manager.first_name, + 'last_name': squad.manager.last_name, + } if squad.manager else None, + 'extra_scopes': squad.extra_scopes, + 'members': members_data, + 'member_count': len(members_data), + }) + + # Unassigned staff (in department but no squad) + unassigned = dept.staff_members.filter(squad__isnull=True, status='active') + unassigned_data = [] + for member in unassigned: + unassigned_data.append({ + 'id': member.id, + 'user_id': member.user.id, + 'username': member.user.username, + 'first_name': member.user.first_name, + 'last_name': member.user.last_name, + 'email': member.user.email, + 'staff_role': member.staff_role, + }) + + tree.append({ + 'id': dept.id, + 'name': dept.name, + 'slug': dept.slug, + 'color': dept.color, + 'description': dept.description, + 'base_scopes': dept.base_scopes, + 'squads': squads_data, + 'unassigned_members': unassigned_data, + 'total_members': dept.staff_members.filter(status='active').count(), + }) + + # Also include staff with no department + orphans = StaffProfile.objects.filter( + department__isnull=True, status='active' + ).select_related('user') + orphan_data = [] + for sp in orphans: + orphan_data.append({ + 'id': sp.id, + 'user_id': sp.user.id, + 'username': sp.user.username, + 'first_name': sp.user.first_name, + 'last_name': sp.user.last_name, + 'email': sp.user.email, + 'staff_role': sp.staff_role, + }) + + return Response({ + 'departments': tree, + 'unassigned_staff': orphan_data, + }) + + +# 14. AuditLogListView +class AuditLogListView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + qs = AuditLog.objects.select_related('user').all() + # Filters + user_id = request.query_params.get('user') + if user_id: + qs = qs.filter(user_id=user_id) + action = request.query_params.get('action') + if action: + qs = qs.filter(action__icontains=action) + target_type = request.query_params.get('target_type') + if target_type: + qs = qs.filter(target_type=target_type) + date_from = request.query_params.get('date_from') + if date_from: + qs = qs.filter(created_at__date__gte=date_from) + date_to = request.query_params.get('date_to') + if date_to: + qs = qs.filter(created_at__date__lte=date_to) + + # Pagination + page = int(request.query_params.get('page', 1)) + page_size = int(request.query_params.get('page_size', 50)) + total = qs.count() + start = (page - 1) * page_size + end = start + page_size + logs = qs[start:end] + + return Response({ + 'results': [{ + 'id': log.id, + 'user': { + 'id': log.user.id, + 'username': log.user.username, + 'email': log.user.email, + } if log.user else None, + 'action': log.action, + 'target_type': log.target_type, + 'target_id': log.target_id, + 'details': log.details, + 'ip_address': log.ip_address, + 'created_at': log.created_at.isoformat(), + } for log in logs], + 'total': total, + 'page': page, + 'page_size': page_size, + 'total_pages': (total + page_size - 1) // page_size if total else 0, + }) + + +# --------------------------------------------------------------------------- +# Partner Onboarding API +# --------------------------------------------------------------------------- + +class PartnerOnboardView(APIView): + """ + POST /api/v1/partners/onboard/ + Creates a Partner + its first partner_manager User atomically. + Requires admin or manager JWT auth. + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + from partner.models import Partner + + data = request.data + + # --- Validate required fields --- + required = { + "partner_name": data.get("partner_name"), + "contact_email": data.get("contact_email"), + "manager_username": data.get("manager_username"), + "manager_password": data.get("manager_password"), + } + missing = [k for k, v in required.items() if not v] + if missing: + return Response( + {"success": False, "error": "Missing required fields: " + ", ".join(missing)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + manager_username = data["manager_username"].strip() + contact_email = data["contact_email"].strip().lower() + manager_password = data["manager_password"] + + # --- Check uniqueness --- + if User.objects.filter(username=manager_username).exists(): + return Response( + {"success": False, "error": "Username '" + manager_username + "' already exists"}, + status=status.HTTP_409_CONFLICT, + ) + if User.objects.filter(email=contact_email).exists(): + return Response( + {"success": False, "error": "Email '" + contact_email + "' already exists"}, + status=status.HTTP_409_CONFLICT, + ) + + # --- Split contact_name into first/last --- + contact_name = (data.get("contact_name") or "").strip() + name_parts = contact_name.split(None, 1) + first_name = name_parts[0] if name_parts else "" + last_name = name_parts[1] if len(name_parts) > 1 else "" + + # --- Validate partner_type --- + partner_type = (data.get("partner_type") or "other").strip().lower() + valid_types = ("venue", "promoter", "sponsor", "vendor", "affiliate", "other") + if partner_type not in valid_types: + partner_type = "other" + + try: + with transaction.atomic(): + # 1) Create Partner + partner = Partner.objects.create( + name=data["partner_name"].strip(), + partner_type=partner_type, + primary_contact_person_name=contact_name, + primary_contact_person_email=contact_email, + primary_contact_person_phone=(data.get("contact_phone") or ""), + status="pending", + is_kyc_compliant=False, + kyc_compliance_status="pending", + address=(data.get("address") or ""), + city=(data.get("city") or ""), + state=(data.get("state") or ""), + pincode=(data.get("pincode") or ""), + website_url=(data.get("website_url") or None), + ) + + # 2) Create partner_manager User + manager_user = User( + username=manager_username, + email=contact_email, + first_name=first_name, + last_name=last_name, + role="partner_manager", + is_customer=False, + partner=partner, + phone_number=(data.get("contact_phone") or ""), + ) + manager_user.set_password(manager_password) + manager_user.save() + + except Exception as e: + return Response( + {"success": False, "error": "Failed to create partner: " + str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + { + "success": True, + "partner": { + "id": partner.id, + "name": partner.name, + "partner_type": partner.partner_type, + "status": partner.status, + "contact_name": partner.primary_contact_person_name, + "contact_email": partner.primary_contact_person_email, + "contact_phone": partner.primary_contact_person_phone, + "address": partner.address, + "city": partner.city, + "state": partner.state, + "pincode": partner.pincode, + "website_url": partner.website_url or "", + "is_kyc_compliant": partner.is_kyc_compliant, + "kyc_compliance_status": partner.kyc_compliance_status, + }, + "manager": { + "id": manager_user.id, + "username": manager_user.username, + "email": manager_user.email, + "first_name": manager_user.first_name, + "last_name": manager_user.last_name, + "role": manager_user.role, + }, + "login_url": "https://partner.eventifyplus.com", + "message": "Partner onboarded successfully. Manager can login at partner.eventifyplus.com", + }, + status=status.HTTP_201_CREATED, + ) + + +class PartnerStaffCreateView(APIView): + """ + POST /api/v1/partners//staff/ + Allows a partner_manager (or admin) to add staff to their partner org. + """ + permission_classes = [IsAuthenticated] + + def post(self, request, partner_id): + from partner.models import Partner + from django.shortcuts import get_object_or_404 + + # --- Authorization: must be admin or partner_manager of this partner --- + user = request.user + is_admin = user.role in ("admin", "manager") or user.is_superuser + is_partner_manager = ( + user.role == "partner_manager" + and user.partner_id is not None + and user.partner_id == partner_id + ) + if not is_admin and not is_partner_manager: + return Response( + {"success": False, "error": "You do not have permission to add staff to this partner"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # --- Validate partner exists --- + partner = get_object_or_404(Partner, pk=partner_id) + + data = request.data + + # --- Validate required fields --- + required = { + "username": data.get("username"), + "email": data.get("email"), + "password": data.get("password"), + } + missing = [k for k, v in required.items() if not v] + if missing: + return Response( + {"success": False, "error": "Missing required fields: " + ", ".join(missing)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + username = data["username"].strip() + email = data["email"].strip().lower() + password = data["password"] + + # --- Check uniqueness --- + if User.objects.filter(username=username).exists(): + return Response( + {"success": False, "error": "Username '" + username + "' already exists"}, + status=status.HTTP_409_CONFLICT, + ) + if User.objects.filter(email=email).exists(): + return Response( + {"success": False, "error": "Email '" + email + "' already exists"}, + status=status.HTTP_409_CONFLICT, + ) + + # --- Determine role (default partner_staff, allow partner_manager) --- + requested_role = (data.get("role") or "partner_staff").strip().lower() + allowed_roles = ("partner_staff", "partner_manager") + if requested_role not in allowed_roles: + return Response( + {"success": False, "error": "Role must be one of: " + ", ".join(allowed_roles)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Non-admin users cannot create partner_manager + if requested_role == "partner_manager" and not is_admin: + return Response( + {"success": False, "error": "Only admins can create additional partner managers"}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + staff_user = User( + username=username, + email=email, + first_name=(data.get("first_name") or "").strip(), + last_name=(data.get("last_name") or "").strip(), + role=requested_role, + is_customer=False, + partner=partner, + phone_number=(data.get("phone_number") or ""), + ) + staff_user.set_password(password) + staff_user.save() + except Exception as e: + return Response( + {"success": False, "error": "Failed to create staff user: " + str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + { + "success": True, + "user": { + "id": staff_user.id, + "username": staff_user.username, + "email": staff_user.email, + "first_name": staff_user.first_name, + "last_name": staff_user.last_name, + "role": staff_user.role, + "phone_number": staff_user.phone_number or "", + "partner_id": partner.id, + "partner_name": partner.name, + }, + "message": f"Staff user '{username}' created successfully for partner '{partner.name}'", + }, + status=status.HTTP_201_CREATED, + ) diff --git a/events/admin.py b/events/admin.py index b935de6..a91daa8 100644 --- a/events/admin.py +++ b/events/admin.py @@ -3,9 +3,9 @@ from .models import Event, EventImages @admin.register(Event) class EventAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'start_date', 'end_date', 'event_type', 'event_status', 'is_featured', 'is_top_event') - list_filter = ('event_status', 'event_type', 'is_featured', 'is_top_event') - list_editable = ('is_featured', 'is_top_event') + list_display = ('id', 'name', 'start_date', 'end_date', 'event_type', 'event_status', 'source', 'is_featured', 'is_top_event') + list_filter = ('event_status', 'event_type', 'source', 'is_featured', 'is_top_event') + list_editable = ('is_featured', 'is_top_event', 'source') search_fields = ('name', 'place', 'district') @admin.register(EventImages) diff --git a/events/forms.py b/events/forms.py index 6346e7c..23a4125 100644 --- a/events/forms.py +++ b/events/forms.py @@ -36,9 +36,13 @@ class EventForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Set source to 'official' only and hide the field - self.fields['source'].initial = 'official' - self.fields['source'].widget = forms.HiddenInput() + # Show source as visible radio buttons with Bootstrap styling + self.fields['source'].widget = forms.RadioSelect( + choices=self.fields['source'].choices, + attrs={'class': 'form-check-input'} + ) + if not self.instance.pk: + self.fields['source'].initial = 'eventify' # Check if all_year_event is True (from instance or initial data) all_year_event = False @@ -60,8 +64,7 @@ class EventForm(forms.ModelForm): cleaned_data = super().clean() all_year_event = cleaned_data.get('all_year_event', False) - # Force source to be 'official' only - cleaned_data['source'] = 'official' + # Source is now user-selectable (eventify/community/partner) # If all_year_event is True, clear date/time fields if all_year_event: diff --git a/events/migrations/migrations/0001_initial.py b/events/migrations/migrations/0001_initial.py new file mode 100644 index 0000000..de46e14 --- /dev/null +++ b/events/migrations/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.21 on 2025-11-26 22:07 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('master_data', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateField(auto_now_add=True)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField()), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('latitude', models.DecimalField(decimal_places=6, max_digits=9)), + ('longitude', models.DecimalField(decimal_places=6, max_digits=9)), + ('pincode', models.CharField(max_length=10)), + ('district', models.CharField(max_length=100)), + ('state', models.CharField(max_length=100)), + ('place', models.CharField(max_length=200)), + ('is_bookable', models.BooleanField(default=False)), + ('is_eventify_event', models.BooleanField(default=True)), + ('outside_event_url', models.URLField(default='NA')), + ('event_status', models.CharField(choices=[('created', 'Created'), ('cancelled', 'Cancelled'), ('pending', 'Pending'), ('completed', 'Completed'), ('postponed', 'Postponed')], default='pending', max_length=250)), + ('cancelled_reason', models.TextField(default='NA')), + ('event_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='master_data.eventtype')), + ], + ), + migrations.CreateModel( + name='EventImages', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_primary', models.BooleanField(default=False)), + ('event_image', models.ImageField(upload_to='event_images')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')), + ], + ), + ] diff --git a/events/migrations/migrations/0002_event_important_information_event_title_and_more.py b/events/migrations/migrations/0002_event_important_information_event_title_and_more.py new file mode 100644 index 0000000..33f8ca5 --- /dev/null +++ b/events/migrations/migrations/0002_event_important_information_event_title_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.21 on 2025-11-28 20:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='important_information', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='event', + name='title', + field=models.CharField(blank=True, max_length=250), + ), + migrations.AddField( + model_name='event', + name='venue_name', + field=models.CharField(blank=True, max_length=250), + ), + ] diff --git a/events/migrations/migrations/0003_event_end_time_event_start_time.py b/events/migrations/migrations/0003_event_end_time_event_start_time.py new file mode 100644 index 0000000..f5e30c9 --- /dev/null +++ b/events/migrations/migrations/0003_event_end_time_event_start_time.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.21 on 2025-11-28 21:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0002_event_important_information_event_title_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='end_time', + field=models.TimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='event', + name='start_time', + field=models.TimeField(blank=True, null=True), + ), + ] diff --git a/events/migrations/migrations/0004_event_all_year_event_alter_event_end_date_and_more.py b/events/migrations/migrations/0004_event_all_year_event_alter_event_end_date_and_more.py new file mode 100644 index 0000000..957178b --- /dev/null +++ b/events/migrations/migrations/0004_event_all_year_event_alter_event_end_date_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0 on 2025-12-19 22:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0003_event_end_time_event_start_time'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='all_year_event', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='event', + name='end_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AlterField( + model_name='event', + name='start_date', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/events/migrations/migrations/0005_event_source.py b/events/migrations/migrations/0005_event_source.py new file mode 100644 index 0000000..f719437 --- /dev/null +++ b/events/migrations/migrations/0005_event_source.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0 on 2025-12-19 22:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0004_event_all_year_event_alter_event_end_date_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='source', + field=models.CharField(blank=True, choices=[('eventify', 'Eventify'), ('community', 'Community')], max_length=250), + ), + ] diff --git a/events/migrations/migrations/0006_alter_event_source.py b/events/migrations/migrations/0006_alter_event_source.py new file mode 100644 index 0000000..79e005d --- /dev/null +++ b/events/migrations/migrations/0006_alter_event_source.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0 on 2025-12-19 22:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0005_event_source'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='source', + field=models.CharField(blank=True, choices=[('official', 'Official'), ('community', 'Community')], max_length=250), + ), + ] diff --git a/events/migrations/migrations/0007_add_is_featured_is_top_event.py b/events/migrations/migrations/0007_add_is_featured_is_top_event.py new file mode 100644 index 0000000..9bfc20d --- /dev/null +++ b/events/migrations/migrations/0007_add_is_featured_is_top_event.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0006_alter_event_source'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='is_featured', + field=models.BooleanField(default=False, help_text='Show this event in the featured section'), + ), + migrations.AddField( + model_name='event', + name='is_top_event', + field=models.BooleanField(default=False, help_text='Show this event in the Top Events section'), + ), + ] diff --git a/events/migrations/migrations/0007_event_gst_percentage_1_event_gst_percentage_2_and_more.py b/events/migrations/migrations/0007_event_gst_percentage_1_event_gst_percentage_2_and_more.py new file mode 100644 index 0000000..0bc59b8 --- /dev/null +++ b/events/migrations/migrations/0007_event_gst_percentage_1_event_gst_percentage_2_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.27 on 2026-03-13 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0006_alter_event_source'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='gst_percentage_1', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='event', + name='gst_percentage_2', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='event', + name='include_gst', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='event', + name='event_status', + field=models.CharField(choices=[('created', 'Created'), ('cancelled', 'Cancelled'), ('pending', 'Pending'), ('completed', 'Completed'), ('postponed', 'Postponed'), ('published', 'Published'), ('live', 'Live'), ('flagged', 'Flagged')], default='pending', max_length=250), + ), + ] diff --git a/events/migrations/migrations/0008_event_is_partner_event_event_partner.py b/events/migrations/migrations/0008_event_is_partner_event_event_partner.py new file mode 100644 index 0000000..67c52e2 --- /dev/null +++ b/events/migrations/migrations/0008_event_is_partner_event_event_partner.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.27 on 2026-03-14 15:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('partner', '0001_initial'), + ('events', '0007_event_gst_percentage_1_event_gst_percentage_2_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='is_partner_event', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='event', + name='partner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='partner.partner'), + ), + ] diff --git a/events/migrations/migrations/0009_alter_event_id_alter_eventimages_id.py b/events/migrations/migrations/0009_alter_event_id_alter_eventimages_id.py new file mode 100644 index 0000000..c1db954 --- /dev/null +++ b/events/migrations/migrations/0009_alter_event_id_alter_eventimages_id.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-03-14 19:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0008_event_is_partner_event_event_partner'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='eventimages', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/events/migrations/migrations/0010_merge_20260324_1443.py b/events/migrations/migrations/0010_merge_20260324_1443.py new file mode 100644 index 0000000..1582e74 --- /dev/null +++ b/events/migrations/migrations/0010_merge_20260324_1443.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.21 on 2026-03-24 14:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0007_add_is_featured_is_top_event'), + ('events', '0009_alter_event_id_alter_eventimages_id'), + ] + + operations = [ + ] diff --git a/events/migrations/migrations/0011_alter_event_created_date_alter_event_event_status_and_more.py b/events/migrations/migrations/0011_alter_event_created_date_alter_event_event_status_and_more.py new file mode 100644 index 0000000..8928c6c --- /dev/null +++ b/events/migrations/migrations/0011_alter_event_created_date_alter_event_event_status_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.21 on 2026-03-30 10:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0010_merge_20260324_1443'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='created_date', + field=models.DateField(auto_now_add=True, db_index=True), + ), + migrations.AlterField( + model_name='event', + name='event_status', + field=models.CharField(choices=[('created', 'Created'), ('cancelled', 'Cancelled'), ('pending', 'Pending'), ('completed', 'Completed'), ('postponed', 'Postponed'), ('published', 'Published'), ('live', 'Live'), ('flagged', 'Flagged')], db_index=True, default='pending', max_length=250), + ), + migrations.AlterField( + model_name='event', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='event', + name='source', + field=models.CharField(choices=[('eventify', 'Added by Eventify'), ('community', 'Community Contribution'), ('partner', 'Partner Event')], default='eventify', max_length=50), + ), + migrations.AlterField( + model_name='event', + name='start_date', + field=models.DateField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='eventimages', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/events/migrations/migrations/__init__.py b/events/migrations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/models.py b/events/models.py index 5eaa5dc..21c5ed5 100644 --- a/events/models.py +++ b/events/models.py @@ -49,9 +49,10 @@ class Event(models.Model): important_information = models.TextField(blank=True) venue_name = models.CharField(max_length=250, blank=True) - source = models.CharField(max_length=250, blank=True, choices=[ - ('official', 'Official'), - ('community', 'Community'), + source = models.CharField(max_length=50, default='eventify', choices=[ + ('eventify', 'Added by Eventify'), + ('community', 'Community Contribution'), + ('partner', 'Partner Event'), ]) is_featured = models.BooleanField(default=False, help_text='Show this event in the featured section') diff --git a/mobile_api/views/events.py b/mobile_api/views/events.py index 09ab0c2..ea19217 100644 --- a/mobile_api/views/events.py +++ b/mobile_api/views/events.py @@ -37,6 +37,36 @@ class EventTypeListAPIView(APIView): class EventListAPI(APIView): permission_classes = [AllowAny] + @staticmethod + def _serialize_event(e, thumb_map): + """Slim serialization for list views — only fields the Flutter app uses.""" + img = thumb_map.get(e.id) + lat = e.latitude + lng = e.longitude + desc = e.description or '' + return { + 'id': e.id, + 'name': e.name or '', + 'title': e.title or '', + 'description': desc[:200] if len(desc) > 200 else desc, + 'start_date': str(e.start_date) if e.start_date else '', + 'end_date': str(e.end_date) if e.end_date else '', + 'start_time': str(e.start_time) if e.start_time else '', + 'end_time': str(e.end_time) if e.end_time else '', + 'pincode': e.pincode or '', + 'place': e.place or '', + 'is_bookable': bool(e.is_bookable), + 'event_type': e.event_type_id, + 'event_status': e.event_status or '', + 'venue_name': getattr(e, 'venue_name', '') or '', + 'latitude': float(lat) if lat is not None else None, + 'longitude': float(lng) if lng is not None else None, + 'location_name': getattr(e, 'location_name', '') or '', + 'thumb_img': img.event_image.url if img and img.event_image else '', + 'is_eventify_event': bool(e.is_eventify_event), + 'source': e.source or 'eventify', + } + def post(self, request): try: try: @@ -44,40 +74,53 @@ class EventListAPI(APIView): except Exception: data = {} - paginate = "page" in data - page = int(data.get("page", 1)) - page_size = int(data.get("page_size", 15)) + pincode = data.get("pincode", "all") + page = int(data.get("page", 1)) + page_size = int(data.get("page_size", 50)) + per_type = int(data.get("per_type", 0)) - events = Event.objects.select_related('event_type').order_by('-created_date') + # Build base queryset (lazy - no DB hit yet) + MIN_EVENTS_THRESHOLD = 6 + qs = Event.objects.all() + if pincode and pincode != 'all': + pincode_qs = qs.filter(pincode=pincode) + # Fallback to all events if pincode has too few + if pincode_qs.count() >= MIN_EVENTS_THRESHOLD: + qs = pincode_qs + # else: keep qs as Event.objects.all() - total = events.count() - - if paginate: - start = (page - 1) * page_size - end = start + page_size - page_qs = list(events[start:end]) + if per_type > 0 and page == 1: + # Diverse mode: one bounded query per event type + type_ids = list(qs.values_list('event_type_id', flat=True).distinct()) + events_page = [] + for tid in sorted(type_ids): + chunk = list(qs.filter(event_type_id=tid).order_by('-created_date')[:per_type]) + events_page.extend(chunk) + total_count = qs.count() + end = len(events_page) else: - page_qs = list(events) - start, end = 0, total + # Standard pagination at DB level + total_count = qs.count() + qs = qs.order_by('-created_date') + start = (page - 1) * page_size + end = start + page_size + events_page = list(qs[start:end]) - event_ids = [e.id for e in page_qs] - primary_images = EventImages.objects.filter(event_id__in=event_ids, is_primary=True) + # Fetch images ONLY for the events we will return + page_ids = [e.id for e in events_page] + primary_images = EventImages.objects.filter(event_id__in=page_ids, is_primary=True) thumb_map = {img.event_id: img for img in primary_images} - event_list = [] - for e in page_qs: - d = model_to_dict(e) - img = thumb_map.get(e.id) - d['thumb_img'] = img.event_image.url if img else '' - event_list.append(d) + # Serialize with direct attribute access (fast) + event_list = [self._serialize_event(e, thumb_map) for e in events_page] return JsonResponse({ "status": "success", "events": event_list, - "total": total, + "total_count": total_count, "page": page, "page_size": page_size, - "has_next": end < total, + "has_next": end < total_count, }) except Exception as e: return JsonResponse({"status": "error", "message": str(e)}) @@ -110,6 +153,8 @@ class EventDetailAPI(APIView): class EventImagesListAPI(APIView): + authentication_classes = [] + permission_classes = [AllowAny] def post(self, request): try: user, token, data, error_response = validate_token_and_get_user(request) @@ -139,6 +184,8 @@ class EventImagesListAPI(APIView): @method_decorator(csrf_exempt, name='dispatch') class EventsByCategoryAPI(APIView): + authentication_classes = [] + permission_classes = [AllowAny] def post(self, request): try: user, token, data, error_response = validate_token_and_get_user(request) @@ -176,6 +223,8 @@ class EventsByCategoryAPI(APIView): @method_decorator(csrf_exempt, name='dispatch') class EventsByMonthYearAPI(APIView): + authentication_classes = [] + permission_classes = [AllowAny] """ API to get events by month and year. Returns dates that have events, total count, and date-wise breakdown. @@ -298,6 +347,8 @@ class EventsByMonthYearAPI(APIView): @method_decorator(csrf_exempt, name='dispatch') class EventsByDateAPI(APIView): + authentication_classes = [] + permission_classes = [AllowAny] """ API to get events occurring on a specific date. Returns complete event information with primary images. @@ -354,6 +405,8 @@ class EventsByDateAPI(APIView): @method_decorator(csrf_exempt, name='dispatch') class FeaturedEventsAPI(APIView): + authentication_classes = [] + permission_classes = [AllowAny] """Returns events where is_featured=True — used for the homepage hero carousel.""" def post(self, request): @@ -380,6 +433,8 @@ class FeaturedEventsAPI(APIView): @method_decorator(csrf_exempt, name='dispatch') class TopEventsAPI(APIView): + authentication_classes = [] + permission_classes = [AllowAny] """Returns events where is_top_event=True — used for the Top Events section.""" def post(self, request): diff --git a/templates/events/event_form.html b/templates/events/event_form.html index a6fef3f..f1bb519 100644 --- a/templates/events/event_form.html +++ b/templates/events/event_form.html @@ -10,15 +10,28 @@ {% for field in form %}
{{ field.label_tag }} + {% if field.name == 'source' %} +
+ {% for radio in field %} +
+ {{ radio.tag }} + +
+ {% endfor %} +
+ {% else %} {{ field }} + {% endif %} {% for error in field.errors %}
{{ error }}
{% endfor %}
{% endfor %} - - Cancel +
+ + Cancel +