feat: Add user leaderboard API, Cloudflare Turnstile integration, and support ticket categorization.

This commit is contained in:
pacnpal
2025-12-27 15:41:10 -05:00
parent 137b9b8cb9
commit aa56c46c27
11 changed files with 656 additions and 428 deletions

View File

@@ -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)

View File

@@ -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/",

View 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,
})

View 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')

View File

@@ -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,
),
),
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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"]

View File

@@ -88,6 +88,7 @@ THIRD_PARTY_APPS = [
"allauth.socialaccount",
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.discord",
"django_turnstile", # Cloudflare Turnstile CAPTCHA
"django_cleanup",
"django_filters",
"django_htmx",

View File

@@ -183,3 +183,18 @@ AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = config(
# =============================================================================
FRONTEND_DOMAIN = config("FRONTEND_DOMAIN", default="https://thrillwiki.com")
# =============================================================================
# Cloudflare Turnstile Configuration
# =============================================================================
# https://developers.cloudflare.com/turnstile/
TURNSTILE_SITEKEY = config("TURNSTILE_SITEKEY", default="")
TURNSTILE_SECRET = config("TURNSTILE_SECRET", default="")
# Skip Turnstile validation in development if keys not set
TURNSTILE_SKIP_VALIDATION = config(
"TURNSTILE_SKIP_VALIDATION",
default=not TURNSTILE_SECRET, # Skip if no secret
cast=bool
)