Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
20 changes: 20 additions & 0 deletions backend/apps/achievements/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from django.contrib import admin

# Register your models here.
from .models import Achievement, StudentAchievement

# ai-gen start (ChatGPT-4, 0)
@admin.register(Achievement)
class AchievementAdmin(admin.ModelAdmin):
list_display = ('name', 'difficulty', 'created_at')
list_filter = ('difficulty', 'created_at')
search_fields = ('name', 'description')
ordering = ('-created_at',)

@admin.register(StudentAchievement)
class StudentAchievementAdmin(admin.ModelAdmin):
list_display = ('student', 'achievement', 'progress', 'unlocked', 'date_unlocked')
list_filter = ('unlocked', 'achievement__difficulty', 'date_unlocked')
search_fields = ('student__user__username', 'achievement__name')
ordering = ('-date_unlocked', '-created_at')
# ai-gen end
35 changes: 35 additions & 0 deletions backend/apps/achievements/managers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from django.db import models
from django.utils import timezone

class StudentAchievementQuerySet(models.QuerySet):
def unlocked(self):
return self.filter(unlocked=True)

def in_progress(self):
return self.filter(unlocked=False)

def for_student(self, student):
return self.filter(student=student)

class StudentAchievementManager(models.Manager):
def get_queryset(self):
return StudentAchievementQuerySet(self.model, using=self._db)

def award_achievement(self, student, achievement, progress=0):
obj, created = self.get_or_create(
student=student,
achievement=achievement,
defaults={'progress': progress}
)
return obj, created

def update_progress(self, student, achievement, amount=1, threshold=100):
"""Increments progress and unlocks if threshold is met."""
obj, _ = self.get_or_create(student=student, achievement=achievement)
if not obj.unlocked:
obj.progress += amount
if obj.progress >= threshold:
obj.unlocked = True
obj.date_unlocked = timezone.now()
obj.save()
return obj
12 changes: 12 additions & 0 deletions backend/apps/achievements/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Generated by Django 5.1.6 on 2025-04-26 13:10

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
]

operations = [
]
49 changes: 49 additions & 0 deletions backend/apps/achievements/migrations/0002_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated by Django 5.1.6 on 2025-04-26 13:10

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
('achievements', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='Achievement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('description', models.TextField()),
('difficulty', models.CharField(choices=[('Easy', 'Easy'), ('Medium', 'Medium'), ('Hard', 'Hard')], default='Easy', max_length=50)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Achievement',
'verbose_name_plural': 'Achievements',
},
),
migrations.CreateModel(
name='StudentAchievement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('progress', models.PositiveIntegerField(default=0)),
('unlocked', models.BooleanField(default=False)),
('date_unlocked', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('achievement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_achievements', to='achievements.achievement')),
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Student Achievement',
'verbose_name_plural': 'Student Achievements',
},
),
]
Empty file.
71 changes: 71 additions & 0 deletions backend/apps/achievements/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from django.db import models
from django.conf import settings
from django.utils import timezone

class Achievement(models.Model):
"""Defines an achievement that students can unlock."""

class DifficultyLevel(models.TextChoices):
EASY = 'Easy', 'Easy'
MEDIUM = 'Medium', 'Medium'
HARD = 'Hard', 'Hard'

name = models.CharField(max_length=255, unique=True)
description = models.TextField()
difficulty = models.CharField(
max_length=50,
choices=DifficultyLevel.choices,
default=DifficultyLevel.EASY,
)

created_at = models.DateTimeField(auto_now_add=True)

def __str__(self):
return f"{self.name} ({self.get_difficulty_display()})"

class Meta:
verbose_name = "Achievement"
verbose_name_plural = "Achievements"


class StudentAchievement(models.Model):
"""Tracks a student's progress and unlocked achievements."""

student = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)

achievement = models.ForeignKey(
Achievement,
on_delete=models.CASCADE,
related_name='student_achievements'
)
progress = models.PositiveIntegerField(default=0)
unlocked = models.BooleanField(default=False)
date_unlocked = models.DateTimeField(null=True, blank=True)

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
unique_together = ('student', 'achievement')

def __str__(self):
status = "Unlocked" if self.unlocked else "In Progress"
return f"{self.student} - {self.achievement.name} ({status})"

def update_progress(self, amount=1):
"""Increase progress and check if the achievement is unlocked."""
if not self.unlocked:
self.progress += amount
# You could add logic here: if self.progress >= required_threshold: self.unlock()
self.save()

def unlock(self):
"""Unlock the achievement."""
if not self.unlocked:
self.unlocked = True
self.date_unlocked = timezone.now()
self.save()

class Meta:
verbose_name = "Student Achievement"
verbose_name_plural = "Student Achievements"
36 changes: 36 additions & 0 deletions backend/apps/achievements/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from rest_framework import serializers
from .models import Achievement, StudentAchievement

class AchievementSerializer(serializers.ModelSerializer):
class Meta:
model = Achievement
fields = ['id', 'name', 'description', 'difficulty', 'created_at']

class StudentAchievementSerializer(serializers.ModelSerializer):
achievement = AchievementSerializer(read_only=True)
achievement_id = serializers.PrimaryKeyRelatedField(
queryset=Achievement.objects.all(), source='achievement', write_only=True
)

class Meta:
model = StudentAchievement
fields = [
'id', 'student', 'achievement', 'achievement_id',
'progress', 'unlocked', 'date_unlocked', 'created_at'
]
read_only_fields = ['student', 'unlocked', 'date_unlocked', 'created_at']

class StudentAchievementUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = StudentAchievement
fields = ['progress', 'unlocked', 'date_unlocked']
read_only_fields = ['date_unlocked']

def update(self, instance, validated_data):
progress = validated_data.get('progress', instance.progress)
if not instance.unlocked and progress >= 100: # Example unlock threshold
instance.unlock()
else:
instance.progress = progress
instance.save()
return instance
9 changes: 9 additions & 0 deletions backend/apps/achievements/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.urls import path
from . import views

urlpatterns = [
path("achievements/", views.AchievementListAPIView.as_view(), name="achievement-list"),
path("student_achievements/", views.StudentAchievementCreateAPIView.as_view(), name="student-achievement-create"),
path("student_achievements/<int:student_id>/", views.StudentAchievementsByStudentAPIView.as_view(), name="student-achievements-by-student"),
path("student_achievements/update/<int:pk>/", views.StudentAchievementUpdateAPIView.as_view(), name="student-achievement-update"),
]
44 changes: 44 additions & 0 deletions backend/apps/achievements/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from rest_framework import generics, status
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .models import Achievement, StudentAchievement
from .serializers import (
AchievementSerializer,
StudentAchievementSerializer,
StudentAchievementUpdateSerializer,
)

class AchievementListAPIView(generics.ListAPIView):
queryset = Achievement.objects.all().order_by('created_at')
serializer_class = AchievementSerializer
permission_classes = [IsAuthenticated]

class StudentAchievementCreateAPIView(generics.CreateAPIView):
queryset = StudentAchievement.objects.all()
serializer_class = StudentAchievementSerializer
permission_classes = [IsAuthenticated]

def perform_create(self, serializer):
serializer.save(student=self.request.user)

class StudentAchievementsByStudentAPIView(generics.ListAPIView):
serializer_class = StudentAchievementSerializer
permission_classes = [IsAuthenticated]

def get_queryset(self):
student_id = self.kwargs["student_id"]
return StudentAchievement.objects.filter(student_id=student_id).select_related('achievement')

class StudentAchievementUpdateAPIView(generics.UpdateAPIView):
queryset = StudentAchievement.objects.all()
serializer_class = StudentAchievementUpdateSerializer
permission_classes = [IsAuthenticated]

def patch(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=True)

if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
1 change: 1 addition & 0 deletions backend/backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"apps.whiteboard", # our whiteboard app
"apps.sessions.apps.SessionsConfig", # our sessions app
"apps.planner", # our calendar app
"apps.achievements", # our achievements app
"rest_framework", # rest framework
'rest_framework_simplejwt.token_blacklist', # for logout functionality
"channels", # Django channels
Expand Down
1 change: 1 addition & 0 deletions backend/backend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@
path('submissions/', include('apps.submissions.urls')),
path('pomodoro/', include('apps.pomodoro.urls')),
path('planner/', include('apps.planner.urls')),
path('achievements/', include('apps.achievements.urls')),
]