diff --git a/backend/apps/challenges/serializers.py b/backend/apps/challenges/serializers.py index 7c5076e..e9f0e24 100644 --- a/backend/apps/challenges/serializers.py +++ b/backend/apps/challenges/serializers.py @@ -31,6 +31,7 @@ class ChallengeMapSerializer(GeoFeatureModelSerializer): GeoJSON FeatureCollection using region_centroid as geometry. Used for the map pin list — does not include the full region polygon. """ + coaster_count = serializers.SerializerMethodField() class Meta: model = Challenge @@ -38,9 +39,13 @@ class ChallengeMapSerializer(GeoFeatureModelSerializer): fields = [ "id", "title", "status", "submission_count", "max_submissions", + "coaster_count", "expires_at", "created_at", ] + def get_coaster_count(self, obj): + return obj.coasters.count() + class ChallengeSplatPreviewSerializer(serializers.Serializer): id = serializers.UUIDField() @@ -58,6 +63,7 @@ class ChallengeDetailSerializer(serializers.ModelSerializer): participant_count = serializers.SerializerMethodField() is_participating = serializers.SerializerMethodField() preview_splats = serializers.SerializerMethodField() + coaster_count = serializers.SerializerMethodField() class Meta: model = Challenge @@ -67,6 +73,7 @@ class ChallengeDetailSerializer(serializers.ModelSerializer): "region", "region_centroid", "max_submissions", "submission_count", "participant_count", "is_participating", + "coaster_count", "preview_splats", "expires_at", "created_at", "updated_at", ] @@ -88,6 +95,9 @@ class ChallengeDetailSerializer(serializers.ModelSerializer): return False return obj.participants.filter(user=request.user).exists() + def get_coaster_count(self, obj): + return obj.coasters.count() + def get_preview_splats(self, obj): from apps.splats.models import Splat qs = ( diff --git a/backend/apps/coaster/migrations/0002_coasterrating.py b/backend/apps/coaster/migrations/0002_coasterrating.py new file mode 100644 index 0000000..790c37a --- /dev/null +++ b/backend/apps/coaster/migrations/0002_coasterrating.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.4 on 2026-04-22 15:23 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('coaster', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CoasterRating', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rating', models.PositiveSmallIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('coaster', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='coaster.coaster')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coaster_ratings', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('coaster', 'user')}, + }, + ), + ] diff --git a/backend/apps/coaster/models.py b/backend/apps/coaster/models.py index ec7f1d5..68f930f 100644 --- a/backend/apps/coaster/models.py +++ b/backend/apps/coaster/models.py @@ -29,3 +29,25 @@ class Coaster(models.Model): def __str__(self): return f'{self.creator.username} / {self.challenge_id}' + + +class CoasterRating(models.Model): + coaster = models.ForeignKey( + Coaster, + on_delete=models.CASCADE, + related_name='ratings', + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='coaster_ratings', + ) + rating = models.PositiveSmallIntegerField() # 1–5 + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [('coaster', 'user')] + + def __str__(self): + return f'{self.user.username} → {self.coaster_id} ({self.rating}★)' diff --git a/backend/apps/coaster/physics.py b/backend/apps/coaster/physics.py index 4ca1197..3d039a2 100644 --- a/backend/apps/coaster/physics.py +++ b/backend/apps/coaster/physics.py @@ -349,7 +349,7 @@ def build_profile_arrays(s, velocities, k, downsample_factor, B, T) -> dict: def generate_rails( points_m: np.ndarray, - rail_spacing: float = 1.5, + rail_spacing: float = 0.6, mass: float = 1000.0, initial_velocity: float = 1.0, friction_coeff: float = 0.02, @@ -368,7 +368,7 @@ def generate_rails( points_m : np.ndarray shape (n, 3) Centreline waypoints in **metres**, local coordinate frame. rail_spacing : float - Distance between the two rails in metres (default 1.5 — standard gauge). + Half-distance from centreline to each rail in metres (total width = 2×, default 0.6 → 1.2 m gauge). mass : float Mass of the coaster car in kg, used for friction losses. initial_velocity : float diff --git a/backend/apps/coaster/serializers.py b/backend/apps/coaster/serializers.py index f1b0851..557b6dd 100644 --- a/backend/apps/coaster/serializers.py +++ b/backend/apps/coaster/serializers.py @@ -4,11 +4,32 @@ from .models import Coaster class CoasterSerializer(serializers.ModelSerializer): creator_username = serializers.ReadOnlyField(source='creator.username') + rating_avg = serializers.SerializerMethodField() + rating_count = serializers.SerializerMethodField() + user_rating = serializers.SerializerMethodField() class Meta: model = Coaster fields = [ 'id', 'creator_username', 'challenge', 'name', - 'anchors', 'acceleration_strips', 'created_at', 'updated_at', + 'anchors', 'acceleration_strips', + 'rating_avg', 'rating_count', 'user_rating', + 'created_at', 'updated_at', ] read_only_fields = ['id', 'creator_username', 'created_at', 'updated_at'] + + def get_rating_avg(self, obj): + ratings = list(obj.ratings.values_list('rating', flat=True)) + if not ratings: + return None + return round(sum(ratings) / len(ratings), 2) + + def get_rating_count(self, obj): + return obj.ratings.count() + + def get_user_rating(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return None + rating = obj.ratings.filter(user=request.user).first() + return rating.rating if rating else None diff --git a/backend/apps/coaster/urls.py b/backend/apps/coaster/urls.py index 80a41e0..793982d 100644 --- a/backend/apps/coaster/urls.py +++ b/backend/apps/coaster/urls.py @@ -1,8 +1,16 @@ from django.urls import path -from .views import CoasterSimulateView, CoasterListCreateView, CoasterDeleteView +from .views import ( + CoasterSimulateView, + CoasterListCreateView, + CoasterDeleteView, + CoasterGlobalListView, + CoasterRateView, +) urlpatterns = [ path("simulate/", CoasterSimulateView.as_view()), + path("coasters/", CoasterGlobalListView.as_view()), path("challenges//coasters/", CoasterListCreateView.as_view()), path("/", CoasterDeleteView.as_view()), + path("/rate/", CoasterRateView.as_view()), ] diff --git a/backend/apps/coaster/views.py b/backend/apps/coaster/views.py index fbf790c..37315e0 100644 --- a/backend/apps/coaster/views.py +++ b/backend/apps/coaster/views.py @@ -93,7 +93,7 @@ def _upload_glb(glb_bytes: bytes) -> str | None: # ── Allowed simulation params ────────────────────────────────────────────────── _PARAM_SCHEMA = { - "rail_spacing": (float, 0.5, 10.0, 1.5), + "rail_spacing": (float, 0.5, 10.0, 0.6), "mass": (float, 1.0, 50000, 1000.0), "initial_velocity": (float, 0.0, 100.0, 1.0), "friction_coeff": (float, 0.0, 1.0, 0.02), @@ -242,9 +242,10 @@ class CoasterListCreateView(APIView): Coaster.objects .filter(challenge_id=challenge_id) .select_related('creator') + .prefetch_related('ratings') .order_by('-updated_at') ) - return Response(CoasterSerializer(coasters, many=True).data) + return Response(CoasterSerializer(coasters, many=True, context={'request': request}).data) def post(self, request, challenge_id): from .models import Coaster @@ -269,7 +270,7 @@ class CoasterListCreateView(APIView): }, ) return Response( - CoasterSerializer(coaster).data, + CoasterSerializer(coaster, context={'request': request}).data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, ) @@ -288,3 +289,62 @@ class CoasterDeleteView(APIView): return Response(status=status.HTTP_403_FORBIDDEN) coaster.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class CoasterGlobalListView(APIView): + """GET /api/v1/coaster/coasters/ — list all coasters across all challenges.""" + permission_classes = [IsAuthenticated] + + def get(self, request): + from .models import Coaster + from .serializers import CoasterSerializer + coasters = ( + Coaster.objects + .select_related('creator') + .prefetch_related('ratings') + .order_by('-updated_at') + ) + return Response( + CoasterSerializer(coasters, many=True, context={'request': request}).data + ) + + +class CoasterRateView(APIView): + """POST /api/v1/coaster/{pk}/rate/ — submit or update a 1–5 star rating.""" + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + from .models import Coaster, CoasterRating + from .serializers import CoasterSerializer + + try: + coaster = Coaster.objects.prefetch_related('ratings').get(id=pk) + except Coaster.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + try: + rating_value = int(request.data.get('rating', 0)) + except (TypeError, ValueError): + return Response( + {'error': 'rating must be an integer 1–5'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not (1 <= rating_value <= 5): + return Response( + {'error': 'rating must be between 1 and 5'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + CoasterRating.objects.update_or_create( + coaster=coaster, + user=request.user, + defaults={'rating': rating_value}, + ) + + # Re-fetch to get updated rating aggregates + coaster.refresh_from_db() + coaster_fresh = Coaster.objects.prefetch_related('ratings').get(id=pk) + return Response( + CoasterSerializer(coaster_fresh, context={'request': request}).data + ) diff --git a/web/index.html b/web/index.html index ec0a738..61a3062 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,10 @@ - SplatMap + RCNN + + +