mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-28 10:27:04 -05:00
feat: Add user leaderboard API, Cloudflare Turnstile integration, and support ticket categorization.
This commit is contained in:
@@ -1,35 +1,44 @@
|
||||
import requests
|
||||
from django.conf import settings
|
||||
"""
|
||||
Mixins for authentication views.
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.core.utils.turnstile import validate_turnstile_token, get_client_ip
|
||||
|
||||
|
||||
class TurnstileMixin:
|
||||
"""
|
||||
Mixin to handle Cloudflare Turnstile validation.
|
||||
Bypasses validation when DEBUG is True.
|
||||
Works with both form POST data and JSON request bodies.
|
||||
"""
|
||||
|
||||
def validate_turnstile(self, request):
|
||||
"""
|
||||
Validate the Turnstile response token.
|
||||
Skips validation when DEBUG is True.
|
||||
|
||||
The token can be provided as:
|
||||
- 'cf-turnstile-response' in POST data (form submission)
|
||||
- 'turnstile_token' in JSON body (API request)
|
||||
"""
|
||||
if settings.DEBUG:
|
||||
return
|
||||
|
||||
token = request.POST.get("cf-turnstile-response")
|
||||
if not token:
|
||||
raise ValidationError("Please complete the Turnstile challenge.")
|
||||
|
||||
# Verify the token with Cloudflare
|
||||
data = {
|
||||
"secret": settings.TURNSTILE_SECRET_KEY,
|
||||
"response": token,
|
||||
"remoteip": request.META.get("REMOTE_ADDR"),
|
||||
}
|
||||
|
||||
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
||||
result = response.json()
|
||||
|
||||
if not result.get("success"):
|
||||
raise ValidationError("Turnstile validation failed. Please try again.")
|
||||
# Try to get token from various sources
|
||||
token = None
|
||||
|
||||
# Check POST data (form submissions)
|
||||
if hasattr(request, 'POST'):
|
||||
token = request.POST.get("cf-turnstile-response")
|
||||
|
||||
# Check JSON body (API requests)
|
||||
if not token and hasattr(request, 'data'):
|
||||
data = getattr(request, 'data', {})
|
||||
if hasattr(data, 'get'):
|
||||
token = data.get('turnstile_token') or data.get('cf-turnstile-response')
|
||||
|
||||
# Get client IP
|
||||
ip = get_client_ip(request)
|
||||
|
||||
# Validate the token
|
||||
result = validate_turnstile_token(token, ip)
|
||||
|
||||
if not result.get('success'):
|
||||
error_msg = result.get('error', 'Captcha verification failed. Please try again.')
|
||||
raise ValidationError(error_msg)
|
||||
|
||||
@@ -19,6 +19,7 @@ from .views import (
|
||||
from .views.discovery import DiscoveryAPIView
|
||||
from .views.stats import StatsAPIView, StatsRecalculateAPIView
|
||||
from .views.reviews import LatestReviewsAPIView
|
||||
from .views.leaderboard import leaderboard
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
@@ -61,6 +62,8 @@ urlpatterns = [
|
||||
),
|
||||
# Reviews endpoints
|
||||
path("reviews/latest/", LatestReviewsAPIView.as_view(), name="latest-reviews"),
|
||||
# Leaderboard endpoint
|
||||
path("leaderboard/", leaderboard, name="leaderboard"),
|
||||
# Ranking system endpoints
|
||||
path(
|
||||
"rankings/calculate/",
|
||||
|
||||
133
backend/apps/api/v1/views/leaderboard.py
Normal file
133
backend/apps/api/v1/views/leaderboard.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Leaderboard views for user rankings
|
||||
"""
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Count, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.rides.models import RideCredit
|
||||
from apps.reviews.models import Review
|
||||
from apps.moderation.models import EditSubmission
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
def leaderboard(request):
|
||||
"""
|
||||
Get user leaderboard data.
|
||||
|
||||
Query params:
|
||||
- category: 'credits' | 'reviews' | 'contributions' (default: credits)
|
||||
- period: 'all' | 'monthly' | 'weekly' (default: all)
|
||||
- limit: int (default: 25, max: 100)
|
||||
"""
|
||||
category = request.query_params.get('category', 'credits')
|
||||
period = request.query_params.get('period', 'all')
|
||||
limit = min(int(request.query_params.get('limit', 25)), 100)
|
||||
|
||||
# Calculate date filter based on period
|
||||
date_filter = None
|
||||
if period == 'weekly':
|
||||
date_filter = timezone.now() - timedelta(days=7)
|
||||
elif period == 'monthly':
|
||||
date_filter = timezone.now() - timedelta(days=30)
|
||||
|
||||
if category == 'credits':
|
||||
return _get_credits_leaderboard(date_filter, limit)
|
||||
elif category == 'reviews':
|
||||
return _get_reviews_leaderboard(date_filter, limit)
|
||||
elif category == 'contributions':
|
||||
return _get_contributions_leaderboard(date_filter, limit)
|
||||
else:
|
||||
return Response({'error': 'Invalid category'}, status=400)
|
||||
|
||||
|
||||
def _get_credits_leaderboard(date_filter, limit):
|
||||
"""Top users by total ride credits."""
|
||||
queryset = RideCredit.objects.all()
|
||||
|
||||
if date_filter:
|
||||
queryset = queryset.filter(created_at__gte=date_filter)
|
||||
|
||||
# Aggregate credits per user
|
||||
users_data = queryset.values('user_id', 'user__username', 'user__display_name').annotate(
|
||||
total_credits=Coalesce(Sum('count'), 0),
|
||||
unique_rides=Count('ride', distinct=True),
|
||||
).order_by('-total_credits')[:limit]
|
||||
|
||||
results = []
|
||||
for rank, entry in enumerate(users_data, 1):
|
||||
results.append({
|
||||
'rank': rank,
|
||||
'user_id': entry['user_id'],
|
||||
'username': entry['user__username'],
|
||||
'display_name': entry['user__display_name'] or entry['user__username'],
|
||||
'total_credits': entry['total_credits'],
|
||||
'unique_rides': entry['unique_rides'],
|
||||
})
|
||||
|
||||
return Response({
|
||||
'category': 'credits',
|
||||
'results': results,
|
||||
})
|
||||
|
||||
|
||||
def _get_reviews_leaderboard(date_filter, limit):
|
||||
"""Top users by review count."""
|
||||
queryset = Review.objects.all()
|
||||
|
||||
if date_filter:
|
||||
queryset = queryset.filter(created_at__gte=date_filter)
|
||||
|
||||
# Count reviews per user
|
||||
users_data = queryset.values('user_id', 'user__username', 'user__display_name').annotate(
|
||||
review_count=Count('id'),
|
||||
).order_by('-review_count')[:limit]
|
||||
|
||||
results = []
|
||||
for rank, entry in enumerate(users_data, 1):
|
||||
results.append({
|
||||
'rank': rank,
|
||||
'user_id': entry['user_id'],
|
||||
'username': entry['user__username'],
|
||||
'display_name': entry['user__display_name'] or entry['user__username'],
|
||||
'review_count': entry['review_count'],
|
||||
})
|
||||
|
||||
return Response({
|
||||
'category': 'reviews',
|
||||
'results': results,
|
||||
})
|
||||
|
||||
|
||||
def _get_contributions_leaderboard(date_filter, limit):
|
||||
"""Top users by approved contributions."""
|
||||
queryset = EditSubmission.objects.filter(status='approved')
|
||||
|
||||
if date_filter:
|
||||
queryset = queryset.filter(created_at__gte=date_filter)
|
||||
|
||||
# Count contributions per user
|
||||
users_data = queryset.values('submitted_by_id', 'submitted_by__username', 'submitted_by__display_name').annotate(
|
||||
contribution_count=Count('id'),
|
||||
).order_by('-contribution_count')[:limit]
|
||||
|
||||
results = []
|
||||
for rank, entry in enumerate(users_data, 1):
|
||||
results.append({
|
||||
'rank': rank,
|
||||
'user_id': entry['submitted_by_id'],
|
||||
'username': entry['submitted_by__username'],
|
||||
'display_name': entry['submitted_by__display_name'] or entry['submitted_by__username'],
|
||||
'contribution_count': entry['contribution_count'],
|
||||
})
|
||||
|
||||
return Response({
|
||||
'category': 'contributions',
|
||||
'results': results,
|
||||
})
|
||||
64
backend/apps/core/utils/turnstile.py
Normal file
64
backend/apps/core/utils/turnstile.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Cloudflare Turnstile validation utilities.
|
||||
|
||||
This module provides a function to validate Turnstile tokens
|
||||
on the server side before processing form submissions.
|
||||
"""
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def validate_turnstile_token(token: str, ip: str = None) -> dict:
|
||||
"""
|
||||
Validate a Cloudflare Turnstile token.
|
||||
|
||||
Args:
|
||||
token: The Turnstile response token from the client
|
||||
ip: Optional client IP address for additional verification
|
||||
|
||||
Returns:
|
||||
dict with 'success' boolean and optional 'error' message
|
||||
"""
|
||||
# Skip validation if configured (dev mode)
|
||||
if getattr(settings, 'TURNSTILE_SKIP_VALIDATION', False):
|
||||
return {'success': True}
|
||||
|
||||
secret = getattr(settings, 'TURNSTILE_SECRET', '')
|
||||
if not secret:
|
||||
return {'success': True} # Skip if no secret configured
|
||||
|
||||
if not token:
|
||||
return {'success': False, 'error': 'Captcha verification required'}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
data={
|
||||
'secret': secret,
|
||||
'response': token,
|
||||
'remoteip': ip,
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
if result.get('success'):
|
||||
return {'success': True}
|
||||
else:
|
||||
error_codes = result.get('error-codes', [])
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Captcha verification failed',
|
||||
'error_codes': error_codes
|
||||
}
|
||||
except requests.RequestException as e:
|
||||
# Log error but don't block user on network issues
|
||||
return {'success': True} # Fail open to avoid blocking legitimate users
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
"""Extract client IP from request, handling proxies."""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
return x_forwarded_for.split(',')[0].strip()
|
||||
return request.META.get('REMOTE_ADDR')
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.6 on 2025-12-27 18:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("support", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="category",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("general", "General Inquiry"),
|
||||
("bug", "Bug Report"),
|
||||
("partnership", "Partnership"),
|
||||
("press", "Press/Media"),
|
||||
("data", "Data Correction"),
|
||||
("account", "Account Issue"),
|
||||
],
|
||||
db_index=True,
|
||||
default="general",
|
||||
help_text="Category of the ticket",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -13,6 +13,22 @@ class Ticket(TrackedModel):
|
||||
(STATUS_CLOSED, 'Closed'),
|
||||
]
|
||||
|
||||
CATEGORY_GENERAL = 'general'
|
||||
CATEGORY_BUG = 'bug'
|
||||
CATEGORY_PARTNERSHIP = 'partnership'
|
||||
CATEGORY_PRESS = 'press'
|
||||
CATEGORY_DATA = 'data'
|
||||
CATEGORY_ACCOUNT = 'account'
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
(CATEGORY_GENERAL, 'General Inquiry'),
|
||||
(CATEGORY_BUG, 'Bug Report'),
|
||||
(CATEGORY_PARTNERSHIP, 'Partnership'),
|
||||
(CATEGORY_PRESS, 'Press/Media'),
|
||||
(CATEGORY_DATA, 'Data Correction'),
|
||||
(CATEGORY_ACCOUNT, 'Account Issue'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -22,6 +38,13 @@ class Ticket(TrackedModel):
|
||||
help_text="User who submitted the ticket (optional)"
|
||||
)
|
||||
|
||||
category = models.CharField(
|
||||
max_length=20,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default=CATEGORY_GENERAL,
|
||||
db_index=True,
|
||||
help_text="Category of the ticket"
|
||||
)
|
||||
subject = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
email = models.EmailField(help_text="Contact email", blank=True)
|
||||
@@ -39,10 +62,11 @@ class Ticket(TrackedModel):
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"[{self.get_status_display()}] {self.subject}"
|
||||
return f"[{self.get_category_display()}] {self.subject}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# If user is set but email is empty, autofill from user
|
||||
if self.user and not self.email:
|
||||
self.email = self.user.email
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -4,16 +4,21 @@ from apps.accounts.serializers import UserSerializer
|
||||
|
||||
class TicketSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
category_display = serializers.CharField(source='get_category_display', read_only=True)
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"category",
|
||||
"category_display",
|
||||
"subject",
|
||||
"message",
|
||||
"email",
|
||||
"status",
|
||||
"status_display",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -25,3 +30,4 @@ class TicketSerializer(serializers.ModelSerializer):
|
||||
if request and not request.user.is_authenticated and not data.get('email'):
|
||||
raise serializers.ValidationError({"email": "Email is required for guests."})
|
||||
return data
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class TicketViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TicketSerializer
|
||||
permission_classes = [permissions.AllowAny] # We handle granular perms in get_queryset/perform_create
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_fields = ["status"]
|
||||
filterset_fields = ["status", "category"]
|
||||
ordering_fields = ["created_at", "status"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user