From 388057b64184d1cbf440c3b8ec3697d5dadebd95 Mon Sep 17 00:00:00 2001 From: Eventify Deploy Date: Thu, 26 Mar 2026 09:50:03 +0000 Subject: [PATCH] feat: add user search/filter, banned metric, mobile review API, event detail improvements - admin_api/views.py: Add banned count to UserMetrics, fix server-side search/filter in UserListView - admin_api/models.py: Add ReviewInteraction model, display_name/is_verified/helpful_count/flag_count to Review - mobile_api/views/reviews.py: Customer-facing review submit/list/helpful/flag endpoints - mobile_api/urls.py: Wire review API routes - mobile_api/views/events.py: Event detail and listing improvements - Security hardening across API modules --- accounts/api.py | 2 +- admin_api/models.py | 19 +++ admin_api/views.py | 20 +-- banking_operations/api.py | 2 +- events/api.py | 2 +- mobile_api/urls.py | 9 ++ mobile_api/views/__init__.py | 1 + mobile_api/views/events.py | 125 +++++++--------- mobile_api/views/reviews.py | 274 +++++++++++++++++++++++++++++++++++ mobile_api/views/user.py | 2 +- partner/api.py | 6 +- 11 files changed, 371 insertions(+), 91 deletions(-) create mode 100644 mobile_api/views/reviews.py diff --git a/accounts/api.py b/accounts/api.py index 0edc10d..2e91ce0 100644 --- a/accounts/api.py +++ b/accounts/api.py @@ -38,7 +38,7 @@ def _partner_user_to_dict(user, request=None): # Add profile picture URL if exists if getattr(user, "profile_picture", None): if request: - data["profile_picture"] = request.build_absolute_uri(user.profile_picture.url) + data["profile_picture"] = user.profile_picture.url else: data["profile_picture"] = user.profile_picture.url else: diff --git a/admin_api/models.py b/admin_api/models.py index 7f941e8..72711b0 100644 --- a/admin_api/models.py +++ b/admin_api/models.py @@ -34,6 +34,10 @@ class Review(models.Model): reject_reason = models.CharField( max_length=15, choices=REJECT_CHOICES, null=True, blank=True ) + display_name = models.CharField(max_length=100, blank=True, default='') + is_verified = models.BooleanField(default=False) + helpful_count = models.IntegerField(default=0) + flag_count = models.IntegerField(default=0) class Meta: ordering = ['-submission_date'] @@ -44,3 +48,18 @@ class Review(models.Model): def __str__(self): return f'Review #{self.pk} by {self.reviewer_id} — {self.status}' + + +class ReviewInteraction(models.Model): + INTERACTION_CHOICES = [('HELPFUL', 'Helpful'), ('FLAG', 'Flag')] + + review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name='interactions') + username = models.CharField(max_length=255) + interaction_type = models.CharField(max_length=20, choices=INTERACTION_CHOICES) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('review', 'username', 'interaction_type') + + def __str__(self): + return f'{self.username} {self.interaction_type} on Review #{self.review_id}' diff --git a/admin_api/views.py b/admin_api/views.py index f45d500..d119821 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -545,7 +545,7 @@ class UserMetricsView(APIView): 'total': customer_qs.count(), 'active': customer_qs.filter(is_active=True).count(), 'suspended': customer_qs.filter(is_active=False).count(), - + 'banned': 0, # Reserved for future explicit ban field 'newThisWeek': customer_qs.filter(date_joined__date__gte=week_ago).count(), }) @@ -557,19 +557,11 @@ class UserListView(APIView): from django.contrib.auth import get_user_model from django.db.models import Q User = get_user_model() - # Customers = all non-superuser accounts (end users registered via mobile/web) qs = User.objects.filter(is_superuser=False) - if s := request.GET.get('status'): - if s == 'Active': - qs = qs.filter(is_active=True) - elif s in ('Suspended', 'Banned'): - qs = qs.filter(is_active=False) - if r := request.GET.get('role'): - role_reverse = {v: k for k, v in _USER_ROLE_MAP.items()} - backend_role = role_reverse.get(r) - if backend_role: - qs = qs.filter(role=backend_role) - if q := request.GET.get('search'): + + # Server-side search + search = request.query_params.get('search', '').strip() + if search: qs = qs.filter( Q(first_name__icontains=search) | Q(last_name__icontains=search) | @@ -578,12 +570,14 @@ class UserListView(APIView): Q(phone_number__icontains=search) ) + # Status filter status_filter = request.query_params.get('status', '').strip().lower() if status_filter == 'active': qs = qs.filter(is_active=True) elif status_filter == 'suspended': qs = qs.filter(is_active=False) + # Role filter role_filter = request.query_params.get('role', '').strip() if role_filter: qs = qs.filter(role__iexact=role_filter) diff --git a/banking_operations/api.py b/banking_operations/api.py index 67d70c6..c525a23 100644 --- a/banking_operations/api.py +++ b/banking_operations/api.py @@ -34,7 +34,7 @@ def _payment_gateway_to_dict(gateway, request=None): # Add logo URL if exists if gateway.payment_gateway_logo: if request: - data["payment_gateway_logo"] = request.build_absolute_uri(gateway.payment_gateway_logo.url) + data["payment_gateway_logo"] = gateway.payment_gateway_logo.url else: data["payment_gateway_logo"] = gateway.payment_gateway_logo.url else: diff --git a/events/api.py b/events/api.py index ca23678..1201d64 100644 --- a/events/api.py +++ b/events/api.py @@ -45,7 +45,7 @@ def _event_to_dict(event, request=None): } if event.event_type.event_type_icon: if request: - data["event_type"]["event_type_icon"] = request.build_absolute_uri(event.event_type.event_type_icon.url) + data["event_type"]["event_type_icon"] = event.event_type.event_type_icon.url else: data["event_type"]["event_type_icon"] = event.event_type.event_type_icon.url else: diff --git a/mobile_api/urls.py b/mobile_api/urls.py index a9a4cbc..98e30cf 100644 --- a/mobile_api/urls.py +++ b/mobile_api/urls.py @@ -1,5 +1,6 @@ from django.urls import path from .views import * +from mobile_api.views.reviews import ReviewSubmitView, MobileReviewListView, ReviewHelpfulView, ReviewFlagView # Customer URLS @@ -24,3 +25,11 @@ urlpatterns += [ path('events/featured-events/', FeaturedEventsAPI.as_view(), name='featured_events'), path('events/top-events/', TopEventsAPI.as_view(), name='top_events'), ] + +# Review URLs +urlpatterns += [ + path('reviews/submit', ReviewSubmitView.as_view()), + path('reviews/list', MobileReviewListView.as_view()), + path('reviews/helpful', ReviewHelpfulView.as_view()), + path('reviews/flag', ReviewFlagView.as_view()), +] diff --git a/mobile_api/views/__init__.py b/mobile_api/views/__init__.py index 15b3254..d72baad 100644 --- a/mobile_api/views/__init__.py +++ b/mobile_api/views/__init__.py @@ -1,2 +1,3 @@ from .user import * from .events import * +from .reviews import * diff --git a/mobile_api/views/events.py b/mobile_api/views/events.py index d795e7f..09ab0c2 100644 --- a/mobile_api/views/events.py +++ b/mobile_api/views/events.py @@ -1,7 +1,8 @@ +import json from django.http import JsonResponse from rest_framework.views import APIView from rest_framework.authentication import TokenAuthentication -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, AllowAny from events.models import Event, EventImages from master_data.models import EventType from django.forms.models import model_to_dict @@ -15,93 +16,83 @@ from mobile_api.utils import validate_token_and_get_user @method_decorator(csrf_exempt, name='dispatch') class EventTypeListAPIView(APIView): + permission_classes = [AllowAny] def post(self, request): try: - user, token, data, error_response = validate_token_and_get_user(request) - if error_response: - return error_response - - # Fetch event types manually without serializer event_types_queryset = EventType.objects.all() event_types = [] - for event_type in event_types_queryset: event_type_data = { "id": event_type.id, "event_type": event_type.event_type, - "event_type_icon": request.build_absolute_uri(event_type.event_type_icon.url) if event_type.event_type_icon else None + "event_type_icon": event_type.event_type_icon.url if event_type.event_type_icon else None } event_types.append(event_type_data) - - print(event_types) - - return JsonResponse({ - "status": "success", - "event_types": event_types - }) - + return JsonResponse({"status": "success", "event_types": event_types}) except Exception as e: - return JsonResponse( - {"status": "error", "message": str(e)}, - ) + return JsonResponse({"status": "error", "message": str(e)}) class EventListAPI(APIView): + permission_classes = [AllowAny] def post(self, request): try: - print('*' * 100) - print(request.body) - print('*' * 100) - user, token, data, error_response = validate_token_and_get_user(request) - if error_response: - return error_response + try: + data = json.loads(request.body) if request.body else {} + except Exception: + data = {} - pincode = data.get("pincode") - print('*' * 100) - print(pincode) - print('*' * 100) - # pincode is optional - if not provided or 'all', return all events + paginate = "page" in data + page = int(data.get("page", 1)) + page_size = int(data.get("page_size", 15)) + + events = Event.objects.select_related('event_type').order_by('-created_date') + + total = events.count() + + if paginate: + start = (page - 1) * page_size + end = start + page_size + page_qs = list(events[start:end]) + else: + page_qs = list(events) + start, end = 0, total + + event_ids = [e.id for e in page_qs] + primary_images = EventImages.objects.filter(event_id__in=event_ids, is_primary=True) + thumb_map = {img.event_id: img for img in primary_images} - events = Event.objects.all().order_by('-created_date') - event_list = [] - - for e in events: - data_dict = model_to_dict(e) - try: - thumb_img = EventImages.objects.get(event=e.id, is_primary=True) - data_dict['thumb_img'] = request.build_absolute_uri(thumb_img.event_image.url) - except EventImages.DoesNotExist: - data_dict['thumb_img'] = '' - - event_list.append(data_dict) - - print('*' * 100) - print(event_list) - print('*' * 100) + 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) return JsonResponse({ "status": "success", - "events": event_list + "events": event_list, + "total": total, + "page": page, + "page_size": page_size, + "has_next": end < total, }) - except Exception as e: - return JsonResponse( - {"status": "error", "message": str(e)}, - ) + return JsonResponse({"status": "error", "message": str(e)}) class EventDetailAPI(APIView): + permission_classes = [AllowAny] + def post(self, request): try: - user, token, data, error_response = validate_token_and_get_user(request) - if error_response: - return error_response - + try: + data = json.loads(request.body) if request.body else {} + except Exception: + data = {} event_id = data.get("event_id") - events = Event.objects.get(id=event_id) event_images = EventImages.objects.filter(event=event_id) event_data = model_to_dict(events) @@ -110,18 +101,12 @@ class EventDetailAPI(APIView): for ei in event_images: event_img = {} event_img['is_primary'] = ei.is_primary - event_img['image'] = request.build_absolute_uri(ei.event_image.url) + event_img['image'] = ei.event_image.url event_images_list.append(event_img) event_data["images"] = event_images_list - - print(event_data) - return JsonResponse(event_data) - except Exception as e: - return JsonResponse( - {"status": "error", "message": str(e)}, - ) + return JsonResponse({"status": "error", "message": str(e)}) class EventImagesListAPI(APIView): @@ -138,7 +123,7 @@ class EventImagesListAPI(APIView): res_data["status"] = "success" event_images_list = [] for ei in event_images: - event_images_list.append(request.build_absolute_uri(ei.event_image.url)) + event_images_list.append(ei.event_image.url) res_data["images"] = event_images_list @@ -172,9 +157,7 @@ class EventsByCategoryAPI(APIView): for event in events_dict: try: - event['event_image'] = request.build_absolute_uri( - EventImages.objects.get(event=event['id'], is_primary=True).event_image.url - ) + event['event_image'] = EventImages.objects.get(event=event['id'], is_primary=True).event_image.url except EventImages.DoesNotExist: event['event_image'] = '' # event['start_date'] = convert_date_to_dd_mm_yyyy(event['start_date']) @@ -352,7 +335,7 @@ class EventsByDateAPI(APIView): data_dict = model_to_dict(e) try: thumb_img = EventImages.objects.get(event=e.id, is_primary=True) - data_dict['thumb_img'] = request.build_absolute_uri(thumb_img.event_image.url) + data_dict['thumb_img'] = thumb_img.event_image.url except EventImages.DoesNotExist: data_dict['thumb_img'] = '' @@ -385,7 +368,7 @@ class FeaturedEventsAPI(APIView): data_dict = model_to_dict(e) try: thumb = EventImages.objects.get(event=e.id, is_primary=True) - data_dict['thumb_img'] = request.build_absolute_uri(thumb.event_image.url) + data_dict['thumb_img'] = thumb.event_image.url except EventImages.DoesNotExist: data_dict['thumb_img'] = '' event_list.append(data_dict) @@ -411,7 +394,7 @@ class TopEventsAPI(APIView): data_dict = model_to_dict(e) try: thumb = EventImages.objects.get(event=e.id, is_primary=True) - data_dict['thumb_img'] = request.build_absolute_uri(thumb.event_image.url) + data_dict['thumb_img'] = thumb.event_image.url except EventImages.DoesNotExist: data_dict['thumb_img'] = '' event_list.append(data_dict) diff --git a/mobile_api/views/reviews.py b/mobile_api/views/reviews.py new file mode 100644 index 0000000..02200a8 --- /dev/null +++ b/mobile_api/views/reviews.py @@ -0,0 +1,274 @@ +""" +Customer-facing review API views. +Writes to admin_api.Review so admin panel sees reviews immediately. +""" +import json +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from rest_framework.views import APIView +from rest_framework.permissions import AllowAny +from django.db.models import Avg, Count, Q +from admin_api.models import Review, ReviewInteraction +from events.models import Event +from mobile_api.utils import validate_token_and_get_user + + +_STATUS_TO_JSON = {'live': 'PUBLISHED', 'pending': 'PENDING', 'rejected': 'FLAGGED'} +_JSON_TO_STATUS = {'PUBLISHED': 'live', 'PENDING': 'pending', 'FLAGGED': 'rejected'} + + +def _serialize_review(r, user_interactions=None): + """Serialize a Review to match the customer app's expected shape.""" + interactions = user_interactions or {} + try: + display = r.display_name or r.reviewer.get_full_name() or r.reviewer.username + except Exception: + display = r.display_name or '' + try: + uname = r.reviewer.username + except Exception: + uname = '' + return { + 'id': r.id, + 'event_id': r.event_id, + 'username': uname, + 'display_name': display, + 'rating': r.rating, + 'comment': r.review_text, + 'status': _STATUS_TO_JSON.get(r.status, r.status), + 'is_verified': r.is_verified, + 'helpful_count': r.helpful_count, + 'flag_count': r.flag_count, + 'has_helpful': interactions.get('HELPFUL', False), + 'has_flagged': interactions.get('FLAG', False), + 'created_at': r.submission_date.isoformat() if r.submission_date else '', + } + + +def _rating_distribution(event_id): + """Return {1:count, 2:count, ..., 5:count} for live reviews.""" + dist = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0} + qs = Review.objects.filter(event_id=event_id, status='live').values('rating').annotate(c=Count('id')) + for row in qs: + dist[row['rating']] = row['c'] + return dist + + +def _aggregates(event_id): + """Return (average_rating, review_count) for live reviews.""" + agg = Review.objects.filter(event_id=event_id, status='live').aggregate( + avg=Avg('rating'), cnt=Count('id') + ) + return round(float(agg['avg'] or 0), 1), agg['cnt'] or 0 + + +@method_decorator(csrf_exempt, name='dispatch') +class ReviewSubmitView(APIView): + """POST /api/reviews/submit -- Submit or update a review.""" + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request): + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + event_id = data.get('event_id') + rating = data.get('rating') + comment = data.get('comment', '') or '' + is_verified = bool(data.get('is_verified', False)) + + if not event_id or not rating: + return JsonResponse({'status': 'error', 'message': 'event_id and rating are required'}, status=400) + + try: + rating = int(rating) + if rating < 1 or rating > 5: + raise ValueError + except (ValueError, TypeError): + return JsonResponse({'status': 'error', 'message': 'Rating must be 1-5'}, status=400) + + try: + event = Event.objects.get(pk=event_id) + except Event.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Event not found'}, status=404) + + # Upsert: one review per user per event + review, created = Review.objects.update_or_create( + reviewer=user, + event=event, + defaults={ + 'rating': rating, + 'review_text': comment, + 'is_verified': is_verified, + 'status': 'live', # auto-approve for customer submissions + 'display_name': user.get_full_name() or user.username, + } + ) + + avg_rating, review_count = _aggregates(event_id) + + return JsonResponse({ + 'status': 'success', + 'review': _serialize_review(review), + 'average_rating': avg_rating, + 'review_count': review_count, + }) + + +@method_decorator(csrf_exempt, name='dispatch') +class MobileReviewListView(APIView): + """POST /api/reviews/list -- Get published reviews for an event.""" + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request): + try: + body = json.loads(request.body) if request.body else {} + except json.JSONDecodeError: + body = {} + + event_id = body.get('event_id') + username = body.get('username', '') + page = int(body.get('page', 1)) + page_size = min(int(body.get('page_size', 10)), 50) + + if not event_id: + return JsonResponse({'status': 'error', 'message': 'event_id is required'}, status=400) + + qs = Review.objects.filter(event_id=event_id, status='live').select_related('reviewer') + qs = qs.order_by('-is_verified', '-helpful_count', '-submission_date') + + total = qs.count() + reviews = list(qs[(page - 1) * page_size: page * page_size]) + + # Bulk lookup interactions for current user + user_interactions = {} + if username and reviews: + review_ids = [r.id for r in reviews] + interactions = ReviewInteraction.objects.filter( + username=username, review_id__in=review_ids + ) + for inter in interactions: + if inter.review_id not in user_interactions: + user_interactions[inter.review_id] = {} + user_interactions[inter.review_id][inter.interaction_type] = True + + formatted = [ + _serialize_review(r, user_interactions.get(r.id, {})) + for r in reviews + ] + + avg_rating, review_count = _aggregates(event_id) + distribution = _rating_distribution(event_id) + + # Get user's own review (any status) + user_review = None + if username: + try: + from accounts.models import User + u = User.objects.get(username=username) + ur = Review.objects.filter(event_id=event_id, reviewer=u).first() + if ur: + user_review = _serialize_review(ur) + except Exception: + pass + + return JsonResponse({ + 'status': 'success', + 'reviews': formatted, + 'total': total, + 'page': page, + 'page_size': page_size, + 'average_rating': avg_rating, + 'review_count': review_count, + 'distribution': distribution, + 'user_review': user_review, + }) + + +@method_decorator(csrf_exempt, name='dispatch') +class ReviewHelpfulView(APIView): + """POST /api/reviews/helpful -- Mark a review as helpful.""" + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request): + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + review_id = data.get('review_id') + if not review_id: + return JsonResponse({'status': 'error', 'message': 'review_id is required'}, status=400) + + try: + review = Review.objects.get(pk=review_id) + except Review.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Review not found'}, status=404) + + if review.reviewer == user: + return JsonResponse({'status': 'error', 'message': 'Cannot mark your own review as helpful'}, status=400) + + _, created = ReviewInteraction.objects.get_or_create( + review=review, + username=user.username, + interaction_type='HELPFUL' + ) + + if created: + new_count = ReviewInteraction.objects.filter(review=review, interaction_type='HELPFUL').count() + review.helpful_count = new_count + review.save(update_fields=['helpful_count']) + + return JsonResponse({ + 'status': 'success', + 'helpful_count': review.helpful_count, + }) + + +@method_decorator(csrf_exempt, name='dispatch') +class ReviewFlagView(APIView): + """POST /api/reviews/flag -- Flag/report a review.""" + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request): + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + review_id = data.get('review_id') + if not review_id: + return JsonResponse({'status': 'error', 'message': 'review_id is required'}, status=400) + + try: + review = Review.objects.get(pk=review_id) + except Review.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Review not found'}, status=404) + + if review.reviewer == user: + return JsonResponse({'status': 'error', 'message': 'Cannot flag your own review'}, status=400) + + _, created = ReviewInteraction.objects.get_or_create( + review=review, + username=user.username, + interaction_type='FLAG' + ) + + new_count = ReviewInteraction.objects.filter(review=review, interaction_type='FLAG').count() + new_status = review.status + if new_count >= 3 and review.status == 'live': + new_status = 'rejected' + review.status = 'rejected' + review.reject_reason = 'inappropriate' + + review.flag_count = new_count + review.save(update_fields=['flag_count', 'status', 'reject_reason']) + + return JsonResponse({ + 'status': 'success', + 'flag_count': new_count, + 'review_status': _STATUS_TO_JSON.get(new_status, new_status), + }) diff --git a/mobile_api/views/user.py b/mobile_api/views/user.py index 229d81d..59a0980 100644 --- a/mobile_api/views/user.py +++ b/mobile_api/views/user.py @@ -97,7 +97,7 @@ class LoginView(View): 'place': user.place, 'latitude': user.latitude, 'longitude': user.longitude, - 'profile_photo': request.build_absolute_uri(user.profile_picture.url) if user.profile_picture else '' + 'profile_photo': user.profile_picture.url if user.profile_picture else '' } print('4') print(response) diff --git a/partner/api.py b/partner/api.py index 5355aa6..71ee149 100644 --- a/partner/api.py +++ b/partner/api.py @@ -58,7 +58,7 @@ def _partner_to_dict(partner, request=None): # Add document file URL if exists if partner.kyc_compliance_document_file: if request: - data["kyc_compliance_document_file"] = request.build_absolute_uri(partner.kyc_compliance_document_file.url) + data["kyc_compliance_document_file"] = partner.kyc_compliance_document_file.url else: data["kyc_compliance_document_file"] = partner.kyc_compliance_document_file.url else: @@ -168,7 +168,7 @@ def _build_kyc_documents(partner, request): name = f"{type_label} - {partner.name}" if partner.kyc_compliance_document_file: if request: - url = request.build_absolute_uri(partner.kyc_compliance_document_file.url) + url = partner.kyc_compliance_document_file.url else: url = partner.kyc_compliance_document_file.url else: @@ -854,7 +854,7 @@ def _user_to_dict(user, request=None): # Add profile picture URL if exists if user.profile_picture: if request: - data["profile_picture"] = request.build_absolute_uri(user.profile_picture.url) + data["profile_picture"] = user.profile_picture.url else: data["profile_picture"] = user.profile_picture.url else: