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
+
+
+ 1777701251187
+
+
+
+ 1777703832844
+
+
+
+ 1777703832844
+
+
+
+ 1778092244462
+
+
+
+ 1778092244463
+
+
+
+ 1778533419100
+
+
+
+ 1778533419100
+
+
+
+ 1778641753609
+
+
+
+ 1778641753609
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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