From 54315408eb0e1ee7b553de5938af3f58d8a75a9c Mon Sep 17 00:00:00 2001 From: Eventify Deploy Date: Wed, 25 Mar 2026 02:46:50 +0000 Subject: [PATCH] =?UTF-8?q?Phase=207:=20Reviews=20Moderation=20=E2=80=94?= =?UTF-8?q?=20Review=20model=20+=20migration=20+=204=20admin=20endpoints?= =?UTF-8?q?=20(metrics,=20list,=20moderate,=20delete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_api/migrations/0001_initial.py | 36 ++++++++ admin_api/migrations/__init__.py | 0 admin_api/models.py | 46 +++++++++++ admin_api/urls.py | 7 +- admin_api/views.py | 119 +++++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 admin_api/migrations/0001_initial.py create mode 100644 admin_api/migrations/__init__.py create mode 100644 admin_api/models.py diff --git a/admin_api/migrations/0001_initial.py b/admin_api/migrations/0001_initial.py new file mode 100644 index 0000000..d7f8e83 --- /dev/null +++ b/admin_api/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.21 on 2026-03-25 02:17 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('events', '0011_dashboard_indexes'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rating', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])), + ('review_text', models.TextField()), + ('submission_date', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('live', 'Live'), ('rejected', 'Rejected')], default='pending', max_length=10)), + ('reject_reason', models.CharField(blank=True, choices=[('spam', 'Spam'), ('inappropriate', 'Inappropriate'), ('fake', 'Fake')], max_length=15, null=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admin_reviews', to='events.event')), + ('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admin_reviews', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-submission_date'], + 'indexes': [models.Index(fields=['status'], name='admin_api_r_status_2f6c07_idx'), models.Index(fields=['submission_date'], name='admin_api_r_submiss_02d0c1_idx')], + }, + ), + ] diff --git a/admin_api/migrations/__init__.py b/admin_api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin_api/models.py b/admin_api/models.py new file mode 100644 index 0000000..7f941e8 --- /dev/null +++ b/admin_api/models.py @@ -0,0 +1,46 @@ +from django.db import models +from django.core.validators import MinValueValidator, MaxValueValidator + + +class Review(models.Model): + STATUS_PENDING = 'pending' + STATUS_LIVE = 'live' + STATUS_REJECTED = 'rejected' + STATUS_CHOICES = [ + (STATUS_PENDING, 'Pending'), + (STATUS_LIVE, 'Live'), + (STATUS_REJECTED, 'Rejected'), + ] + REJECT_CHOICES = [ + ('spam', 'Spam'), + ('inappropriate', 'Inappropriate'), + ('fake', 'Fake'), + ] + + reviewer = models.ForeignKey( + 'accounts.User', on_delete=models.CASCADE, related_name='admin_reviews' + ) + event = models.ForeignKey( + 'events.Event', on_delete=models.CASCADE, related_name='admin_reviews' + ) + rating = models.IntegerField( + validators=[MinValueValidator(1), MaxValueValidator(5)] + ) + review_text = models.TextField() + submission_date = models.DateTimeField(auto_now_add=True) + status = models.CharField( + max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDING + ) + reject_reason = models.CharField( + max_length=15, choices=REJECT_CHOICES, null=True, blank=True + ) + + class Meta: + ordering = ['-submission_date'] + indexes = [ + models.Index(fields=['status']), + models.Index(fields=['submission_date']), + ] + + def __str__(self): + return f'Review #{self.pk} by {self.reviewer_id} — {self.status}' diff --git a/admin_api/urls.py b/admin_api/urls.py index b367366..edfc3ab 100644 --- a/admin_api/urls.py +++ b/admin_api/urls.py @@ -31,4 +31,9 @@ urlpatterns = [ path('financials/transactions/', views.TransactionListView.as_view(), name='transaction-list'), path('financials/settlements/', views.SettlementListView.as_view(), name='settlement-list'), path('financials/settlements//release/', views.SettlementReleaseView.as_view(), name='settlement-release'), -] + + path('reviews/metrics/', views.ReviewMetricsView.as_view(), name='review-metrics'), + path('reviews/', views.ReviewListView.as_view(), name='review-list'), + path('reviews//moderate/', views.ReviewModerationView.as_view(), name='review-moderate'), + path('reviews//', views.ReviewDeleteView.as_view(), name='review-delete'), +] \ No newline at end of file diff --git a/admin_api/views.py b/admin_api/views.py index c8338a5..8f8cf7c 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -841,3 +841,122 @@ class SettlementReleaseView(APIView): p.payment_transaction_status = 'completed' p.save(update_fields=['payment_transaction_status']) return Response(_serialize_settlement(p)) + + +# --------------------------------------------------------------------------- +# Phase 7: Reviews Moderation +# --------------------------------------------------------------------------- + +def _reviewer_rank(total_reviews): + if total_reviews >= 41: + return 'Legend' + if total_reviews >= 21: + return 'Champion' + if total_reviews >= 11: + return 'Enthusiast' + if total_reviews >= 4: + return 'Contributor' + return 'Explorer' + + +def _serialize_review(r): + from admin_api.models import Review + try: + name = r.reviewer.get_full_name() or r.reviewer.username + email = r.reviewer.email + total = Review.objects.filter(reviewer=r.reviewer, status='live').count() + except Exception: + name, email, total = '', '', 0 + try: + event_name = getattr(r.event, 'title', None) or getattr(r.event, 'name', '') or '' + event_id = str(r.event.id) + start = getattr(r.event, 'start_date', None) + event_date = start.isoformat() if start else '' + except Exception: + event_name, event_id, event_date = '', '', '' + return { + 'id': str(r.id), + 'reviewerName': name, + 'reviewerEmail': email, + 'reviewerAvatar': '', + 'eventName': event_name, + 'eventId': event_id, + 'eventDate': event_date, + 'rating': r.rating, + 'reviewText': r.review_text, + 'submissionDate': r.submission_date.isoformat(), + 'status': r.status, + 'reviewerRank': _reviewer_rank(total), + 'reviewerTotalReviews': total, + 'rejectReason': r.reject_reason or '', + } + + +class ReviewMetricsView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + from admin_api.models import Review + return Response({ + 'totalPending': Review.objects.filter(status='pending').count(), + 'liveReviews': Review.objects.filter(status='live').count(), + 'rejected': Review.objects.filter(status='rejected').count(), + }) + + +class ReviewListView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + from admin_api.models import Review + qs = Review.objects.select_related('reviewer', 'event').order_by('-submission_date') + status = request.GET.get('status') + if status in ('pending', 'live', 'rejected'): + qs = qs.filter(status=status) + try: + page = max(1, int(request.GET.get('page', 1))) + page_size = min(100, int(request.GET.get('page_size', 20))) + except (ValueError, TypeError): + page, page_size = 1, 20 + total = qs.count() + reviews = qs[(page - 1) * page_size: page * page_size] + return Response({'count': total, 'results': [_serialize_review(r) for r in reviews]}) + + +class ReviewModerationView(APIView): + permission_classes = [IsAuthenticated] + + def patch(self, request, pk): + from admin_api.models import Review + from django.shortcuts import get_object_or_404 + review = get_object_or_404(Review, pk=pk) + action = request.data.get('action') + if action == 'approve': + review.status = 'live' + elif action == 'reject': + rr = request.data.get('reject_reason', 'spam') + valid_reasons = [c[0] for c in Review.REJECT_CHOICES] + if rr not in valid_reasons: + return Response({'error': 'Invalid reject_reason'}, status=400) + review.status = 'rejected' + review.reject_reason = rr + elif action == 'save_and_approve': + review.review_text = request.data.get('review_text', review.review_text) + review.status = 'live' + elif action == 'save_live': + review.review_text = request.data.get('review_text', review.review_text) + else: + return Response({'error': 'Invalid action'}, status=400) + review.save() + return Response(_serialize_review(review)) + + +class ReviewDeleteView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request, pk): + from admin_api.models import Review + from django.shortcuts import get_object_or_404 + review = get_object_or_404(Review, pk=pk) + review.delete() + return Response(status=204)