From 797a38e938cd7a80df4a545a93d6999959a4aecb Mon Sep 17 00:00:00 2001 From: Abhishek Date: Mon, 17 Mar 2025 21:28:19 +0530 Subject: [PATCH 1/2] Added initial setup for user badges --- web/admin.py | 17 +++ web/migrations/0029_badge_userbadge.py | 138 +++++++++++++++++++++++++ web/models.py | 133 ++++++++++++++++++++++++ web/templates/profile.html | 19 ++++ web/views.py | 4 + 5 files changed, 311 insertions(+) create mode 100644 web/migrations/0029_badge_userbadge.py diff --git a/web/admin.py b/web/admin.py index dbf2e4fee..5b0ddb45d 100644 --- a/web/admin.py +++ b/web/admin.py @@ -10,6 +10,7 @@ from .models import ( Achievement, + Badge, BlogComment, BlogPost, Cart, @@ -39,6 +40,7 @@ Storefront, Subject, SuccessStory, + UserBadge, WebRequest, ) @@ -564,3 +566,18 @@ def display_name(self, obj): return obj.display_name display_name.short_description = "Name" + + +@admin.register(Badge) +class BadgeAdmin(admin.ModelAdmin): + list_display = ("name", "badge_type", "is_active", "created_by", "created_at") + list_filter = ("badge_type", "is_active") + search_fields = ("name", "description") + + +@admin.register(UserBadge) +class UserBadgeAdmin(admin.ModelAdmin): + list_display = ("user", "badge", "award_method", "awarded_at") + list_filter = ("award_method", "badge__badge_type") + search_fields = ("user__username", "badge__name") + date_hierarchy = "awarded_at" diff --git a/web/migrations/0029_badge_userbadge.py b/web/migrations/0029_badge_userbadge.py new file mode 100644 index 000000000..dbfb57241 --- /dev/null +++ b/web/migrations/0029_badge_userbadge.py @@ -0,0 +1,138 @@ +# Generated by Django 5.1.6 on 2025-03-19 07:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("web", "0028_certificate"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Badge", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=100)), + ("description", models.TextField()), + ("image", models.ImageField(upload_to="badges/")), + ( + "badge_type", + models.CharField( + choices=[ + ("challenge", "Challenge Completion"), + ("course", "Course Completion"), + ("achievement", "Special Achievement"), + ("teacher_awarded", "Teacher Awarded"), + ], + max_length=20, + ), + ), + ("is_active", models.BooleanField(default=True)), + ("criteria", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "challenge", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="badges", + to="web.challenge", + ), + ), + ( + "course", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="badges", + to="web.course", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="created_badges", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["badge_type", "name"], + }, + ), + migrations.CreateModel( + name="UserBadge", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "award_method", + models.CharField( + choices=[ + ("challenge_completion", "Challenge Completion"), + ("course_completion", "Course Completion"), + ("teacher_awarded", "Teacher Awarded"), + ("system_awarded", "System Awarded"), + ], + max_length=20, + ), + ), + ("awarded_at", models.DateTimeField(auto_now_add=True)), + ("award_message", models.TextField(blank=True)), + ( + "awarded_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="awarded_badges", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "badge", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="awarded_to", to="web.badge" + ), + ), + ( + "challenge_submission", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="badges", + to="web.challengesubmission", + ), + ), + ( + "course_enrollment", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="badges", + to="web.enrollment", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="badges", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "ordering": ["-awarded_at"], + "unique_together": {("user", "badge")}, + }, + ), + ] diff --git a/web/models.py b/web/models.py index dc7fa5eb8..f5e9598ce 100644 --- a/web/models.py +++ b/web/models.py @@ -1308,6 +1308,139 @@ def display_name(self): return self.email.split("@")[0] # Use part before @ in email +class Badge(models.Model): + BADGE_TYPES = [ + ("challenge", "Challenge Completion"), + ("course", "Course Completion"), + ("achievement", "Special Achievement"), + ("teacher_awarded", "Teacher Awarded"), + ] + name = models.CharField(max_length=100) + description = models.TextField() + image = models.ImageField(upload_to="badges/") + badge_type = models.CharField(max_length=20, choices=BADGE_TYPES) + course = models.ForeignKey(Course, on_delete=models.CASCADE, null=True, blank=True, related_name="badges") + challenge = models.ForeignKey(Challenge, on_delete=models.CASCADE, null=True, blank=True, related_name="badges") + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name="created_badges") + is_active = models.BooleanField(default=True) + criteria = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if self.image: + img = Image.open(self.image) + if img.mode != "RGB": + img = img.convert("RGB") + img = img.resize((200, 200), Image.Resampling.LANCZOS) + buffer = BytesIO() + img.save(buffer, format="PNG", quality=90) + file_name = self.image.name + self.image.delete(save=False) + self.image.save(file_name, ContentFile(buffer.getvalue()), save=False) + super().save(*args, **kwargs) + + class Meta: + ordering = ["badge_type", "name"] + + +class UserBadge(models.Model): + AWARD_METHODS = [ + ("challenge_completion", "Challenge Completion"), + ("course_completion", "Course Completion"), + ("teacher_awarded", "Teacher Awarded"), + ("system_awarded", "System Awarded"), + ] + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="badges") + badge = models.ForeignKey(Badge, on_delete=models.CASCADE, related_name="awarded_to") + award_method = models.CharField(max_length=20, choices=AWARD_METHODS) + awarded_at = models.DateTimeField(auto_now_add=True) + challenge_submission = models.ForeignKey( + ChallengeSubmission, on_delete=models.SET_NULL, null=True, blank=True, related_name="badges" + ) + course_enrollment = models.ForeignKey( + Enrollment, on_delete=models.SET_NULL, null=True, blank=True, related_name="badges" + ) + awarded_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, related_name="awarded_badges" + ) + award_message = models.TextField(blank=True) + + def __str__(self): + return f"{self.user.username} - {self.badge.name}" + + class Meta: + unique_together = ["user", "badge"] + ordering = ["-awarded_at"] + + +@receiver(post_save, sender=ChallengeSubmission) +def award_challenge_badge(sender, instance, created, **kwargs): + if created: + challenge_badges = Badge.objects.filter(challenge=instance.challenge, badge_type="challenge", is_active=True) + for badge in challenge_badges: + if not UserBadge.objects.filter(user=instance.user, badge=badge).exists(): + UserBadge.objects.create( + user=instance.user, badge=badge, award_method="challenge_completion", challenge_submission=instance + ) + Notification.objects.create( + user=instance.user, + title=f"New Badge: {badge.name}", + message=f"Congrats! You've earned {badge.name} for completing {instance.challenge.title}", + notification_type="success", + ) + + +@receiver(post_save, sender=Enrollment) +def award_course_completion_badge(sender, instance, **kwargs): + if instance.status == "completed": + course_badges = Badge.objects.filter(course=instance.course, badge_type="course", is_active=True) + for badge in course_badges: + if not UserBadge.objects.filter(user=instance.student, badge=badge).exists(): + UserBadge.objects.create( + user=instance.student, badge=badge, award_method="course_completion", course_enrollment=instance + ) + Notification.objects.create( + user=instance.student, + title=f"New Badge: {badge.name}", + message=f"Congrats! You've earned {badge.name} for completing {instance.course.title}", + notification_type="success", + ) + + +def award_badge_to_student(badge_id, student_id, teacher_id, message=""): + try: + badge = Badge.objects.get(id=badge_id) + student = User.objects.get(id=student_id) + teacher = User.objects.get(id=teacher_id) + if not teacher.profile.is_teacher: + return None + if UserBadge.objects.filter(user=student, badge=badge).exists(): + return None + user_badge = UserBadge.objects.create( + user=student, badge=badge, award_method="teacher_awarded", awarded_by=teacher, award_message=message + ) + Notification.objects.create( + user=student, + title=f"New Badge: {badge.name}", + message=f"You were awarded {badge.name} by {teacher.username}. {message}", + notification_type="success", + ) + return user_badge + except (Badge.DoesNotExist, User.DoesNotExist): + return None + + +def get_user_badges(self): + return UserBadge.objects.filter(user=self.user) + + +Profile.get_user_badges = get_user_badges + + class Certificate(models.Model): # Certificate Model certificate_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) diff --git a/web/templates/profile.html b/web/templates/profile.html index fadfe0789..0179d5e98 100644 --- a/web/templates/profile.html +++ b/web/templates/profile.html @@ -351,6 +351,25 @@

{{ enrollment.course.title }}

{% endif %} +
+

My Badges

+ {% if badges %} + {% for user_badge in badges %} +
+ {{ user_badge.badge.name }} +

{{ user_badge.badge.name }}

+
+ {% endfor %} + {% else %} +
+
+ +
+

You haven't earned any badges yet.

+
+ {% endif %} +
{% endif %} diff --git a/web/views.py b/web/views.py index ac0456f49..49b44fc54 100644 --- a/web/views.py +++ b/web/views.py @@ -113,6 +113,7 @@ Subject, SuccessStory, TimeSlot, + UserBadge, WebRequest, ) from .notifications import notify_session_reminder, notify_teacher_new_enrollment, send_enrollment_confirmation @@ -246,8 +247,11 @@ def profile(request): } ) + badges = UserBadge.objects.filter(user=request.user).select_related("badge") + context = { "form": form, + "badges": badges, } # Add teacher-specific stats From 7787493600126fd178672d1cd1df6257a7c04396 Mon Sep 17 00:00:00 2001 From: A1L13N <193832434+A1L13N@users.noreply.github.com> Date: Sat, 22 Mar 2025 23:23:02 -0400 Subject: [PATCH 2/2] Update admin.py --- web/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/web/admin.py b/web/admin.py index fe57b1385..3a6d389b4 100644 --- a/web/admin.py +++ b/web/admin.py @@ -664,3 +664,4 @@ class QuizOptionAdmin(admin.ModelAdmin): search_fields = ("text", "question__text") autocomplete_fields = ["question"] +