From 25cace427f5b447090cadf8d40df8c11396eb0c4 Mon Sep 17 00:00:00 2001 From: Ultramas Date: Fri, 1 May 2026 23:37:10 -0700 Subject: [PATCH 1/6] set up basic models, try using it to formulate the frontend --- backend/api/models.py | 60 ++++++++++++++++++++++++++++++++++++++++--- backend/api/views.py | 19 +++++++++++++- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/backend/api/models.py b/backend/api/models.py index 4b3c308..d29cf1b 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -1,11 +1,65 @@ from django.db import models -# Create your models here. +from colorfield.fields import ColorField + from django.contrib.auth.models import User -class UserProgress(models.Model): +class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) + + +class UserProgress(models.Model): + user = models.OneToOneField(UserProfile, on_delete=models.CASCADE) data = models.JSONField(default=dict) def __str__(self): - return f"{self.user.username}'s level progress" \ No newline at end of file + return f"{self.user.username}'s level progress" + + + +class Level(models.Model): + COLOR_PALETTE = [ + ("#FFFFFF", "white",), + ("#000000", "black",), + ] + level = models.IntegerField(default=1, blank=True, null=True) + level_name = models.CharField(max_length=200) + experience = models.IntegerField(default=0, blank=True, null=True) + icon = models.ImageField(blank=True, null=True) + color_wheel = ColorField(samples=COLOR_PALETTE, blank=True, null=True) + color = models.CharField(max_length=500, blank=True, null=True, help_text="Comma-separated hex colors for gradient") + display_name = models.CharField( + max_length=300, + blank=True, + editable=False, + verbose_name="Display Name (with Roman if needed)" + ) + + is_active = models.IntegerField( + default=1, + blank=True, + null=True, + help_text='1->Active, 0->Inactive', + choices=((1, 'Active'), (0, 'Inactive')), + verbose_name="Set active?" + ) + + def __str__(self): + return f"{self.level_name} (Level {self.level})" + +#essentially treated like a through model branching together users & levels +class User_Level(models.Model): + COLOR_PALETTE = [ + ("#FFFFFF", "white",), + ("#000000", "black",), + ] + level = models.ForeignKey(Level, on_delete=models.CASCADE) + stars = models.IntegerField(minvalidator=1, maxvalidator=3) #create a function to determine accuracy & time, pulled from frontend data + is_active = models.IntegerField( + default=1, + blank=True, + null=True, + help_text='1->Active, 0->Inactive', + choices=((1, 'Active'), (0, 'Inactive')), + verbose_name="Set active?" + ) \ No newline at end of file diff --git a/backend/api/views.py b/backend/api/views.py index 81078d5..69cd0c1 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -63,4 +63,21 @@ def save_level(request): progress, _ = UserProgress.objects.get_or_create(user=request.user) progress.data = request.data progress.save() - return Response({'status': 'saved'}) \ No newline at end of file + return Response({'status': 'saved'}) + +class level_user_view(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated] + def get(self, request): + progress, _ = UserProgress.objects.get_or_create(user=request.user) + return Response(progress.data) + def put(self, request): + progress, _ = UserProgress.objects.get_or_create(user=request.user) + progress.data = request.data + progress.save() + return Response({'status': 'updated'}) + def delete(self, request): + progress, _ = UserProgress.objects.get_or_create(user=request.user) + progress.delete() + return Response({'status': 'deleted'}) + def patch(self, request): + progress, _ = UserProgress.objects.get_or_create(user=request.user) From 754f84dab8d2da2386c889781644eb15c3ba4ad4 Mon Sep 17 00:00:00 2001 From: Ultramas Date: Fri, 1 May 2026 23:38:49 -0700 Subject: [PATCH 2/6] pushed backend basics, try using it to formulate the frontend --- .idea/.gitignore | 0 .idea/arch-vim.iml | 14 +++++ .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/misc.xml | 6 ++ .idea/modules.xml | 8 +++ .idea/vcs.xml | 6 ++ .idea/workspace.xml | 57 +++++++++++++++++++ backend/api/serializers.py | 17 ++++++ frontend/src/components/loadprogress.js | 11 ++++ views.py | 0 10 files changed, 125 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/arch-vim.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 backend/api/serializers.py create mode 100644 frontend/src/components/loadprogress.js create mode 100644 views.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.idea/arch-vim.iml b/.idea/arch-vim.iml new file mode 100644 index 0000000..204ac0e --- /dev/null +++ b/.idea/arch-vim.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..879b4cf --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a6411eb --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..b1518e2 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 1777701251187 + + + + + + \ No newline at end of file diff --git a/backend/api/serializers.py b/backend/api/serializers.py new file mode 100644 index 0000000..39bd267 --- /dev/null +++ b/backend/api/serializers.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from .models import Level + +class LevelSerializer(serializers.ModelSerializer): + class Meta: + model = Level + fields = [ + 'id', + 'level', + 'level_name', + 'display_name', + 'experience', + 'color', + 'color_wheel', + 'icon', + 'is_active', + ] \ No newline at end of file diff --git a/frontend/src/components/loadprogress.js b/frontend/src/components/loadprogress.js new file mode 100644 index 0000000..31b254a --- /dev/null +++ b/frontend/src/components/loadprogress.js @@ -0,0 +1,11 @@ +// In loadProgress.js or a new levels.js +export async function fetchLevels() { + const res = await fetch('/api/levels/'); + return res.json(); +} + +export async function fetchLevel(pk) { + const res = await fetch(`/api/levels/${pk}/`); + if (!res.ok) return null; + return res.json(); +} \ No newline at end of file diff --git a/views.py b/views.py new file mode 100644 index 0000000..e69de29 From b4194dcfb1d5c7525deca9a985ed207c920951b4 Mon Sep 17 00:00:00 2001 From: Ultramas Date: Sat, 2 May 2026 12:20:16 -0700 Subject: [PATCH 3/6] set up basic models, try using it to formulate the frontend --- .idea/workspace.xml | 71 +++++-- backend/api/models.py | 12 +- backend/api/views.py | 234 +++++++++++++++++++++--- backend/config/urls.py | 5 + frontend/src/components/loadprogress.js | 52 +++++- 5 files changed, 327 insertions(+), 47 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index b1518e2..7cdfbd2 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -1,9 +1,12 @@ - + + + + \ No newline at end of file diff --git a/backend/api/models.py b/backend/api/models.py index d29cf1b..8862c4b 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -54,7 +54,9 @@ class User_Level(models.Model): ("#000000", "black",), ] level = models.ForeignKey(Level, on_delete=models.CASCADE) - stars = models.IntegerField(minvalidator=1, maxvalidator=3) #create a function to determine accuracy & time, pulled from frontend data + min_accuracy = models.FloatField(validators=[MaxValueValidator(100)]) + max_keystrokes = models.IntegerField(blank=True, null=True) + stars = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(1)]) #create a function to determine accuracy & time, pulled from frontend data is_active = models.IntegerField( default=1, blank=True, @@ -62,4 +64,10 @@ class User_Level(models.Model): help_text='1->Active, 0->Inactive', choices=((1, 'Active'), (0, 'Inactive')), verbose_name="Set active?" - ) \ No newline at end of file + ) + + def __str__(self): + return f"{self.level_name} (Level {self.level})" + + class Meta(): + verbose_name_plural = "User's Levels" \ No newline at end of file diff --git a/backend/api/views.py b/backend/api/views.py index 69cd0c1..86085d5 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -50,12 +50,6 @@ def get(self, request): }) -@api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) -def get_progress(request): - progress, _ = UserProgress.objects.get_or_create(user=request.user) - return Response(progress.data) - @api_view(['POST']) @permission_classes([permissions.IsAuthenticated]) @@ -65,19 +59,217 @@ def save_level(request): progress.save() return Response({'status': 'saved'}) -class level_user_view(generics.RetrieveAPIView): - permission_classes = [permissions.IsAuthenticated] +from django.db import models +from django.contrib.auth.models import User +from django.core.validators import MaxValueValidator, MinValueValidator + + +class Level(models.Model): + title = models.CharField(max_length=200) + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="created_levels") + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.title + + +class User_Level(models.Model): + level = models.ForeignKey(Level, on_delete=models.CASCADE, related_name="configurations") + min_accuracy = models.FloatField(validators=[MaxValueValidator(100)]) + max_keystrokes = models.IntegerField(blank=True, null=True) + max_time = models.FloatField( + blank=True, null=True, + help_text="Max time in seconds to earn the timing star" + ) + stars = models.IntegerField( + validators=[MinValueValidator(1), MaxValueValidator(3)] + ) + is_active = models.IntegerField( + default=1, + blank=True, + null=True, + help_text="1->Active, 0->Inactive", + choices=((1, "Active"), (0, "Inactive")), + verbose_name="Set active?" + ) + + def __str__(self): + return f"Config for Level {self.level_id} (min_accuracy={self.min_accuracy})" + + +class UserLevelInstance(models.Model): + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="level_attempts" + ) + level = models.ForeignKey( + Level, + on_delete=models.CASCADE, + related_name="user_instances" + ) + max_time = models.FloatField( + blank=True, null=True, + help_text="Time taken (seconds) to complete the level" + ) + stroke_count = models.IntegerField( + blank=True, null=True, + help_text="Number of keystrokes used during the attempt" + ) + accuracy = models.FloatField( + blank=True, null=True, + help_text="Accuracy percentage (0–100) submitted by the frontend" + ) + completed = models.BooleanField(default=False) + stars_earned = models.IntegerField( + default=0, + validators=[MinValueValidator(0), MaxValueValidator(3)], + help_text="0=not completed, 1=completed, 2=+accuracy, 3=+accuracy & time" + ) + attempted_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-stars_earned", "-attempted_at"] + + def __str__(self): + return ( + f"{self.user.username} → Level {self.level_id} | " + f"{'✓' if self.completed else '✗'} | ⭐{self.stars_earned}" + ) + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +import json + +from .models import Level, User_Level, UserLevelInstance + + +@method_decorator(csrf_exempt, name="dispatch") +class StarView(LoginRequiredMixin, View): + """ + GET /api/stars// → return the user's best attempt for that level + POST /api/stars// → receive frontend performance data, compute stars, + save UserLevelInstance, return result + """ + + def _compute_stars(self, config: User_Level, completed: bool, accuracy: float, time_taken: float) -> int: + """ + Star logic: + 0 → not completed + 1 → completed + 2 → completed + accuracy met + 3 → completed + accuracy met + time met + """ + if not completed: + return 0 + + accuracy_met = (accuracy is not None) and (accuracy >= config.min_accuracy) + time_met = ( + config.max_time is not None + and time_taken is not None + and time_taken <= config.max_time + ) + + if accuracy_met and time_met: + return 3 + if accuracy_met: + return 2 + return 1 + + def get(self, request, level_id): + """Return the user's best existing attempt for a level. + + + Expects JSON body: + { + "completed": true, + "accuracy": 92.5, + "time_taken": 45.3, + "stroke_count": 134 + } + """ + try: + body = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + completed = bool(body.get("completed", False)) + accuracy = body.get("accuracy") + time_taken = body.get("time_taken") + stroke_count = body.get("stroke_count") + + + config = ( + User_Level.objects + .filter(level_id=level_id, is_active=1) + .first() + ) + if not config: + return JsonResponse({"error": "No active config found for this level"}, status=404) + + stars = self._compute_stars(config, completed, accuracy, time_taken) + + + existing = ( + UserLevelInstance.objects + .filter(user=request.user, level_id=level_id) + .order_by("-stars_earned") + .first() + ) + if not existing or stars >= existing.stars_earned: + UserLevelInstance.objects.update_or_create( + user=request.user, + level_id=level_id, + defaults={ + "max_time": time_taken, + "stroke_count": stroke_count, + "accuracy": accuracy, + "completed": completed, + "stars_earned": stars, + }, + ) + + return JsonResponse({ + "stars_earned": stars, + "completed": completed, + "accuracy": accuracy, + "time_taken": time_taken, + }) + + +@method_decorator(csrf_exempt, name="dispatch") +class ProgressView(LoginRequiredMixin, View): + """ + GET /api/progress/ → all level completions for the current user + POST /api/progress/save/ → thin wrapper kept for backwards-compat with progress.js + """ + def get(self, request): - progress, _ = UserProgress.objects.get_or_create(user=request.user) - return Response(progress.data) - def put(self, request): - progress, _ = UserProgress.objects.get_or_create(user=request.user) - progress.data = request.data - progress.save() - return Response({'status': 'updated'}) - def delete(self, request): - progress, _ = UserProgress.objects.get_or_create(user=request.user) - progress.delete() - return Response({'status': 'deleted'}) - def patch(self, request): - progress, _ = UserProgress.objects.get_or_create(user=request.user) + instances = UserLevelInstance.objects.filter(user=request.user) + data = { + str(inst.level_id): { + "stars_earned": inst.stars_earned, + "completed": inst.completed, + } + for inst in instances + } + return JsonResponse(data) + + def post(self, request): + """ + Accepts the same shape as the existing saveProgress() call in progress.js. + Delegates to StarView logic if performance data is present, + otherwise just records completion. + """ + try: + body = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + level_id = body.get("level_id") + if not level_id: + return JsonResponse({"error": "level_id required"}, status=400) + + request._body = request.body + return StarView.as_view()(request, level_id=level_id) \ No newline at end of file diff --git a/backend/config/urls.py b/backend/config/urls.py index 1cdb2f2..0c29412 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -2,9 +2,14 @@ from django.urls import path, include from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from backend.api.views import ProgressView, StarView + urlpatterns = [ path('admin/', admin.site.urls), path('api/auth/login/', TokenObtainPairView.as_view()), path('api/auth/refresh/', TokenRefreshView.as_view()), path('api/', include('api.urls')), + path("api/stars//", StarView.as_view(), name="star-view"), + path("api/progress/", ProgressView.as_view(), name="progress"), + path("api/progress/save/", ProgressView.as_view(), name="progress-save"), ] \ No newline at end of file diff --git a/frontend/src/components/loadprogress.js b/frontend/src/components/loadprogress.js index 31b254a..0d381fc 100644 --- a/frontend/src/components/loadprogress.js +++ b/frontend/src/components/loadprogress.js @@ -1,11 +1,47 @@ -// In loadProgress.js or a new levels.js -export async function fetchLevels() { - const res = await fetch('/api/levels/'); - return res.json(); +import api from './api.js'; + +/** + * Load all level progress for the current user. + * Returns: { "1": { stars_earned: 3, completed: true }, ... } + */ +export async function loadProgress() { + try { + const res = await api.get('/api/progress/'); + return res.data; + } catch { + return {}; + } +} + +/** + * Save a level attempt and get back the stars awarded. + * + * @param {Object} data + * @param {number} data.level_id - e.g. 1 + * @param {boolean} data.completed - did the user finish? + * @param {number} data.accuracy - 0–100 float + * @param {number} data.time_taken - seconds (float) + * @param {number} data.stroke_count - keystroke count (int) + * @returns {{ stars_earned, completed, accuracy, time_taken }} + */ +export async function saveProgress(data) { + try { + const res = await api.post(`/api/stars/${data.level_id}/`, data); + return res.data; // ← includes stars_earned so the UI can react + } catch (err) { + console.error('Failed to save progress', err); + return null; + } } -export async function fetchLevel(pk) { - const res = await fetch(`/api/levels/${pk}/`); - if (!res.ok) return null; - return res.json(); +/** + * Fetch the best stars earned for a single level (for the level select screen). + */ +export async function getLevelStars(levelId) { + try { + const res = await api.get(`/api/stars/${levelId}/`); + return res.data.stars_earned ?? 0; + } catch { + return 0; + } } \ No newline at end of file From 1ce257748726c6755df7bf12c1536827d33f60b9 Mon Sep 17 00:00:00 2001 From: Ultramas Date: Wed, 6 May 2026 11:30:41 -0700 Subject: [PATCH 4/6] fixed requirements, clear model seperation --- .idea/workspace.xml | 8 ++- backend/api/models.py | 45 +++++++++++- backend/api/urls.py | 6 +- backend/api/views.py | 145 +++++++++++++++++---------------------- backend/config/models.py | 64 +++++++++++++++++ backend/config/urls.py | 4 -- backend/config/views.py | 62 +++++++++++++++++ backend/requirements.txt | Bin 328 -> 352 bytes views.py | 0 9 files changed, 244 insertions(+), 90 deletions(-) delete mode 100644 views.py diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 7cdfbd2..99fa75d 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,9 +4,13 @@ + + - + + +