diff --git a/.gitignore b/.gitignore index b96c073..7d5a1a1 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ frontend/.env # vim *.swp *.*~ +/*~ # OS files .DS_Store 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..176f0b3 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + { + "lastFilter": { + "state": "OPEN", + "assignee": "Ultramas" + } +} + { + "selectedUrlAndAccountId": { + "url": "https://github.com/ChicoState/arch-vim.git", + "accountId": "130df1b8-71e2-4f25-9651-adb1142a8a39" + } +} + { + "associatedIndex": 4, + "fromUser": false +} + + + + + + + + + + + + + + + + + + + + + + + + 1777701251187 + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/api/admin.py b/backend/api/admin.py index 8c38f3f..56d1b8b 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -1,3 +1,80 @@ from django.contrib import admin +from django.utils.html import mark_safe +from .models import UserProfile, UserProgress, Level, User_Level, UserLevelInstance -# Register your models here. + +class UserProgressInline(admin.StackedInline): + model = UserProgress + extra = 0 + + +class UserProfileInline(admin.StackedInline): + model = UserProfile + extra = 0 + + +@admin.register(Level) +class LevelAdmin(admin.ModelAdmin): + list_display = ('level', 'level_name', 'get_color_preview', 'get_icon_preview', 'is_active') + list_editable = ('is_active',) + search_fields = ('level_name',) + list_filter = ('is_active',) + readonly_fields = ('get_icon_preview',) + + def get_icon_preview(self, obj): + if obj.icon: + return mark_safe('', obj.icon.url) + return "No Icon" + + get_icon_preview.short_description = "Icon Preview" + + def get_color_preview(self, obj): + hex_color = obj.color_wheel or "#ffffff" + gradient = f"linear-gradient(to right, {obj.color})" if obj.color else hex_color + + return mark_safe( + '
', + gradient + ) + + get_color_preview.short_description = "Color/Gradient" + + +@admin.register(User_Level) +class UserLevelAdmin(admin.ModelAdmin): + list_display = ('level', 'min_accuracy', 'max_keystrokes', 'stars', 'is_active') + list_filter = ('level', 'is_active') + + +@admin.register(UserLevelInstance) +class UserLevelInstanceAdmin(admin.ModelAdmin): + list_display = ('user', 'level', 'stars_earned', 'accuracy', 'max_time', 'completed', 'attempted_at') + list_filter = ('completed', 'stars_earned', 'level') + search_fields = ('user__username', 'level__level_name') + readonly_fields = ('attempted_at',) + + def stars_earned_display(self, obj): + return mark_safe( + '{}', + "★" * obj.stars_earned + "☆" * (3 - obj.stars_earned) + ) + + stars_earned_display.short_description = "Stars" + + list_display = ('user', 'level', 'stars_earned_display', 'accuracy', 'completed', 'attempted_at') + + +@admin.register(UserProgress) +class UserProgressAdmin(admin.ModelAdmin): + list_display = ('user', 'get_data_summary') + + def get_data_summary(self, obj): + return str(obj.data)[:50] + "..." if obj.data else "{}" + + get_data_summary.short_description = "Data Preview" + + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ('user',) + inlines = [UserProgressInline] \ No newline at end of file diff --git a/backend/api/factory.py b/backend/api/factory.py new file mode 100644 index 0000000..57bab07 --- /dev/null +++ b/backend/api/factory.py @@ -0,0 +1,32 @@ + +import factory +from django.contrib.auth.models import User +from .models import Level, User_Level, UserLevelInstance, UserProfile + + +class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = User + + username = factory.Sequence(lambda n: f"user{n}") + password = factory.PostGenerationMethodCall('set_password', 'password123') + + +class LevelFactory(factory.django.DjangoModelFactory): + class Meta: + model = Level + + level = factory.Sequence(lambda n: n) + level_name = factory.Sequence(lambda n: f"Level {n}") + display_name = factory.LazyAttribute(lambda o: o.level_name) + + +class UserLevelConfigFactory(factory.django.DjangoModelFactory): + class Meta: + model = User_Level + + level = factory.SubFactory(LevelFactory) + min_accuracy = 80.0 + max_keystrokes = 150 + stars = 1 # not really used in your logic + is_active = 1 \ No newline at end of file diff --git a/backend/api/migrations/0002_level_userprofile_userlevelinstance_user_level_and_more.py b/backend/api/migrations/0002_level_userprofile_userlevelinstance_user_level_and_more.py new file mode 100644 index 0000000..c566445 --- /dev/null +++ b/backend/api/migrations/0002_level_userprofile_userlevelinstance_user_level_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.28 on 2026-05-06 18:29 + +import colorfield.fields +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Level', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.IntegerField(blank=True, default=1, null=True)), + ('level_name', models.CharField(max_length=200)), + ('experience', models.IntegerField(blank=True, default=0, null=True)), + ('icon', models.ImageField(blank=True, null=True, upload_to='')), + ('color_wheel', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#000000', 'black')])), + ('color', models.CharField(blank=True, help_text='Comma-separated hex colors for gradient', max_length=500, null=True)), + ('display_name', models.CharField(blank=True, editable=False, max_length=300, verbose_name='Display Name (with Roman if needed)')), + ('is_active', models.IntegerField(blank=True, choices=[(1, 'Active'), (0, 'Inactive')], default=1, help_text='1->Active, 0->Inactive', null=True, verbose_name='Set active?')), + ], + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserLevelInstance', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('max_time', models.FloatField(blank=True, help_text='Time taken (seconds) to complete the level', null=True)), + ('stroke_count', models.IntegerField(blank=True, help_text='Number of keystrokes used during the attempt', null=True)), + ('accuracy', models.FloatField(blank=True, help_text='Accuracy percentage (0–100) submitted by the frontend', null=True)), + ('completed', models.BooleanField(default=False)), + ('stars_earned', models.IntegerField(default=0, help_text='0=not completed, 1=completed, 2=+accuracy, 3=+accuracy & time', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(3)])), + ('attempted_at', models.DateTimeField(auto_now_add=True)), + ('level', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_instances', to='api.level')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='level_attempts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-stars_earned', '-attempted_at'], + }, + ), + migrations.CreateModel( + name='User_Level', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('min_accuracy', models.FloatField(validators=[django.core.validators.MaxValueValidator(100)])), + ('max_keystrokes', models.IntegerField(blank=True, null=True)), + ('stars', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1)])), + ('is_active', models.IntegerField(blank=True, choices=[(1, 'Active'), (0, 'Inactive')], default=1, help_text='1->Active, 0->Inactive', null=True, verbose_name='Set active?')), + ('level', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.level')), + ], + options={ + 'verbose_name_plural': "User's Levels", + }, + ), + migrations.AlterField( + model_name='userprogress', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='api.userprofile'), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 4b3c308..d227235 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -1,11 +1,143 @@ +from django.core.validators import MaxValueValidator, MinValueValidator 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" + +def to_roman(n): + """Simple helper to convert numbers 1-5 to Roman numerals""" + return {1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V'}.get(n, str(n)) + +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): + # Changed from the 'Rules' version to the 'Instance' version + return f"{self.level_name} (Level {self.level})" + + def save(self, *args, **kwargs): + # IMPLEMENTATION: This satisfies test_level_display_name_generation + roman = to_roman(self.level) + self.display_name = f"{self.level_name} {roman}" + super().save(*args, **kwargs) + +#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) + min_accuracy = models.FloatField(validators=[MaxValueValidator(100)]) + max_keystrokes = models.IntegerField(blank=True, null=True) + stars = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(3)], blank=True, null=True) #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?" + ) + + def __str__(self): + return f"{self.level.level_name} Rules - {self.stars} Stars" + + class Meta(): + verbose_name_plural = "User's Levels" + + + +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.level.level_name} Rule: {self.stars} Stars @ {self.min_accuracy}%" + + def save(self, *args, **kwargs): + # Find the rules for this level + rules = User_Level.objects.filter(level=self.level, is_active=1).order_by('-stars') + + if self.completed and self.accuracy: + for rule in rules: + if self.accuracy >= rule.min_accuracy: + self.stars_earned = rule.stars + break + super().save(*args, **kwargs) + +# models.py +from django.db.models.signals import post_save +from django.dispatch import receiver + +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + if created: + profile = UserProfile.objects.create(user=instance) + UserProgress.objects.create(user=profile) \ 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/backend/api/tests.py b/backend/api/tests.py index 7ce503c..e775346 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -1,3 +1,141 @@ from django.test import TestCase +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from .models import Level, User_Level, UserLevelInstance, UserProfile, UserProgress +from .factory import UserFactory, LevelFactory -# Create your tests here. +class LevelModelTests(TestCase): + def test_str_representation(self): + level = LevelFactory(level=5, level_name="Advanced Typing") + self.assertEqual(str(level), "Advanced Typing (Level 5)") + +class UserLevelInstanceTests(TestCase): + def setUp(self): + self.user = UserFactory() + self.level = LevelFactory() + + def test_compute_stars_logic(self): + # We add stars=1 because it's a required field in your model + config = User_Level.objects.create( + level=self.level, + min_accuracy=85.0, + stars=1, + is_active=1 + ) + instance = UserLevelInstance( + user=self.user, + level=self.level, + completed=True, + accuracy=92.0, + max_time=45.0 + ) + self.assertEqual(instance.stars_earned, 0) + +class UserProgressTests(TestCase): + def setUp(self): + self.user = UserFactory() + self.level = LevelFactory() + + def test_default_jsonfield(self): + # Use get_or_create to avoid IntegrityError from signals + profile, _ = UserProfile.objects.get_or_create(user=self.user) + progress, _ = UserProgress.objects.get_or_create(user=profile) + self.assertEqual(progress.data, {}) + + def test_star_calculation_thresholds(self): + User_Level.objects.create( + level=self.level, + min_accuracy=90.0, + stars=3, + is_active=1 + ) + attempt = UserLevelInstance.objects.create( + user=self.user, + level=self.level, + accuracy=80.0, + completed=True + ) + self.assertTrue(attempt.accuracy < 90.0) + +class ValidationTests(TestCase): + def test_stars_out_of_range(self): + level = LevelFactory() + invalid_user_level = User_Level( + level=level, + min_accuracy=95.0, + stars=5 + ) + with self.assertRaises(ValidationError): + invalid_user_level.full_clean() + +class RelationshipTests(TestCase): + def setUp(self): + self.user = UserFactory() + self.level = LevelFactory() + + + def test_profile_deletion_cascades(self): + # Safely handle potential existing profile from signals + profile, _ = UserProfile.objects.get_or_create(user=self.user) + UserProgress.objects.get_or_create(user=profile) + + user_id = self.user.id + self.user.delete() + + self.assertFalse(UserProfile.objects.filter(user_id=user_id).exists()) + self.assertFalse(UserProgress.objects.filter(user__user_id=user_id).exists()) + + def test_attempt_ordering(self): + level = LevelFactory() + # Create attempts with specific star counts + UserLevelInstance.objects.create(user=self.user, level=level, stars_earned=1) + UserLevelInstance.objects.create(user=self.user, level=level, stars_earned=3) + + attempts = UserLevelInstance.objects.filter(user=self.user) + # -stars_earned in Meta means 3 should come before 1 + self.assertEqual(attempts[0].stars_earned, 3) + + def test_level_display_name_generation(self): + """Test that Level 2 'Basic' becomes 'Basic II'""" + level = Level.objects.create( + level=2, + level_name="Basic", + is_active=1 + ) + # This forces you to write a Roman numeral helper in your Level.save() + self.assertEqual(level.display_name, "Basic II") + + def test_user_progress_data_persistence(self): + profile, _ = UserProfile.objects.get_or_create(user=self.user) + progress, _ = UserProgress.objects.get_or_create(user=profile) + + # Simulating updating progress from a view + new_data = {"last_played": "2024-01-01", "unlocked_items": ["hat_01"]} + progress.data = new_data + progress.save() + + # Refresh from DB + progress.refresh_from_db() + self.assertEqual(progress.data["unlocked_items"][0], "hat_01") + + def test_automatic_star_assignment_on_save(self): + """ + Test that stars_earned is calculated automatically + based on User_Level thresholds when saved. + """ + # Define the 'Gold' standard for this level + User_Level.objects.create( + level=self.level, + min_accuracy=95.0, + stars=3 + ) + + # Create an attempt that meets the criteria + attempt = UserLevelInstance.objects.create( + user=self.user, + level=self.level, + accuracy=98.0, + completed=True + ) + + # This will fail now, but it tells you to implement logic in models.py diff --git a/backend/api/urls.py b/backend/api/urls.py index f919a45..0ad8eac 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,9 +1,14 @@ -from django.urls import path +from django.urls import path, include from . import views +from .views import StarView, ProgressView urlpatterns = [ path('auth/register/', views.RegisterView.as_view()), path('auth/me/', views.UserDetailView.as_view()), - path('progress/', views.get_progress), + path('progress/', views.UserProgress), path('progress/save/', views.save_level), + 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/backend/api/views.py b/backend/api/views.py index 81078d5..268dd8d 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -1,3 +1,5 @@ +from django.http import JsonResponse +from django.views.generic import ListView from rest_framework import generics, permissions, status from rest_framework.response import Response from rest_framework.decorators import api_view, permission_classes @@ -50,12 +52,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]) @@ -63,4 +59,198 @@ 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'}) + +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 User_Level, UserLevelInstance + + +@method_decorator(csrf_exempt, name="dispatch") +class StarView(LoginRequiredMixin, ListView): + """ + 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, ListView): + """ + 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): + 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) + + + +from django.http import JsonResponse +from django.views.generic import ListView +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.decorators import api_view, permission_classes +from django.contrib.auth.models import User +from rest_framework_simplejwt.tokens import RefreshToken + + +class RegisterView(generics.CreateAPIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + username = request.data.get('username') + password = request.data.get('password') + email = request.data.get('email', '') + + if not username or not password: + return Response( + {'error': 'Username and password are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if User.objects.filter(username=username).exists(): + return Response( + {'error': 'Username already taken'}, + status=status.HTTP_400_BAD_REQUEST + ) + + user = User.objects.create_user( + username=username, + password=password, + email=email + ) + refresh = RefreshToken.for_user(user) + + return Response({ + 'access': str(refresh.access_token), + 'refresh': str(refresh), + }, status=status.HTTP_201_CREATED) + + +class UserDetailView(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + return Response({ + 'id': request.user.id, + 'username': request.user.username, + 'email': request.user.email, + }) + + + diff --git a/backend/config/models.py b/backend/config/models.py index e69de29..0842ea4 100644 --- a/backend/config/models.py +++ b/backend/config/models.py @@ -0,0 +1,64 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + +from colorfield.fields import ColorField + +from django.contrib.auth.models import User + + + + +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) + 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, + 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})" + + class Meta(): + verbose_name_plural = "User's Levels" diff --git a/backend/config/urls.py b/backend/config/urls.py index 1cdb2f2..8d8090c 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -2,6 +2,8 @@ from django.urls import path, include from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +app_name = 'api' + urlpatterns = [ path('admin/', admin.site.urls), path('api/auth/login/', TokenObtainPairView.as_view()), diff --git a/backend/config/views.py b/backend/config/views.py index e69de29..ac024b5 100644 --- a/backend/config/views.py +++ b/backend/config/views.py @@ -0,0 +1,62 @@ +from django.http import JsonResponse +from django.views.generic import ListView +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.decorators import api_view, permission_classes +from django.contrib.auth.models import User +from rest_framework_simplejwt.tokens import RefreshToken + + +class RegisterView(generics.CreateAPIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + username = request.data.get('username') + password = request.data.get('password') + email = request.data.get('email', '') + + if not username or not password: + return Response( + {'error': 'Username and password are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if User.objects.filter(username=username).exists(): + return Response( + {'error': 'Username already taken'}, + status=status.HTTP_400_BAD_REQUEST + ) + + user = User.objects.create_user( + username=username, + password=password, + email=email + ) + refresh = RefreshToken.for_user(user) + + return Response({ + 'access': str(refresh.access_token), + 'refresh': str(refresh), + }, status=status.HTTP_201_CREATED) + + +class UserDetailView(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + return Response({ + 'id': request.user.id, + 'username': request.user.username, + 'email': request.user.email, + }) + + + + +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 + diff --git a/backend/requirements.txt b/backend/requirements.txt index e77af21..aaf459a 100644 Binary files a/backend/requirements.txt and b/backend/requirements.txt differ diff --git a/frontend/src/components/loadprogress.js b/frontend/src/components/loadprogress.js new file mode 100644 index 0000000..0d381fc --- /dev/null +++ b/frontend/src/components/loadprogress.js @@ -0,0 +1,47 @@ +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; + } +} + +/** + * 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5e13513 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "backend.config.settings" +python_files = ["tests.py", "test_*.py", "*_tests.py"] \ No newline at end of file