From b00d51fe42004b3db6d311ebaa2adb0ce5412b7f Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 2 Dec 2025 15:23:06 +0100 Subject: [PATCH 001/139] Updated CHANGELOG for 8.3.3 release --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3ff5582..16a4b75cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [8.3.3] - 2025-12-02 + +#### Full GitHub changelogs: + +Backend: https://github.com/edubadges/edubadges-server/compare/v8.3.2...v8.3.3
+ +- Update to Django 4.2.26 +- Updating swagger annotations +- Remove referer header requirement from auth provider views +- Merge pull request #215 from edubadges/feature/reduce_error_logs +- Only allow for super-users to perform impersonation +- Added extra logging to MobileAPIAuthentication +- Slug fields were removed in 2020 from all models +- Catch TypeError when trying to load JSON from imported badge +- Adding DIRS var to TEMPLATES object +- Return 404 in case badgr app is none +- Added is_authenticated checks +- Increase MAX_URL_LENGTH even more, to 16384 +- Increased MAX_URL_LENGTH times 4 to be able to exceed 2048 chars which is to low for our use-cases +- Quick fix for Unsafe redirect exceeding 2048 characters +- Do not use SIS authentication for mobile flow + ## [8.3.2] - 2025-11-14 #### Full GitHub changelogs: From 503f94d1940ffebbd5418b07b171633bd9d1fa99 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Fri, 19 Dec 2025 16:19:22 +0100 Subject: [PATCH 002/139] Fix for MA7QDbnn Added expiration date based on the badgeclass when a user claims a DA See https://trello.com/c/MA7QDbnn/1143-vervallen-edubadge-werkt-niet --- apps/directaward/models.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/directaward/models.py b/apps/directaward/models.py index afb9da117..907a9a1d6 100644 --- a/apps/directaward/models.py +++ b/apps/directaward/models.py @@ -1,6 +1,6 @@ import urllib import uuid - +import datetime from django.conf import settings from django.db import models, IntegrityError from django.utils.html import strip_tags @@ -109,11 +109,18 @@ def award(self, recipient): 'name': self.name, } ] + expires_at = None + if self.badgeclass.expiration_period: + expires_at = ( + datetime.datetime.now().replace(microsecond=0, second=0, minute=0, hour=0) + + self.badgeclass.expiration_period + ) assertion = self.badgeclass.issue( recipient=recipient, created_by=self.created_by, acceptance=BadgeInstance.ACCEPTANCE_ACCEPTED, recipient_type=BadgeInstance.RECIPIENT_TYPE_EDUID, + expires_at=expires_at, send_email=False, issued_on=self.created_at, award_type=BadgeInstance.AWARD_TYPE_DIRECT_AWARD, From 983442829ffe380097a4e3b7f24460e747cf86da Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 8 Jan 2026 10:29:37 +0100 Subject: [PATCH 003/139] Remove setlocale usage and localize email dates in templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed locale.setlocale() from email utility functions and moved date localization into the email templates using Django’s {% language %} tag and date filter. This avoids reliance on system locales (which are missing in Docker) and eliminates unsafe global locale switching in threaded email sending. Localization is now explicit, thread-safe, and handled entirely by Django’s i18n system. --- .../email/earned_direct_award_new.html | 29 ++++++++++----- .../email/reminder_direct_award_new.html | 36 ++++++++++++------- apps/mainsite/utils.py | 20 ++--------- 3 files changed, 47 insertions(+), 38 deletions(-) diff --git a/apps/mainsite/templates/email/earned_direct_award_new.html b/apps/mainsite/templates/email/earned_direct_award_new.html index d7366b515..e97611657 100644 --- a/apps/mainsite/templates/email/earned_direct_award_new.html +++ b/apps/mainsite/templates/email/earned_direct_award_new.html @@ -1,4 +1,6 @@ -{% extends 'email/base.html' %} {% block style %} +{% extends 'email/base.html' %} +{% load i18n %} +{% block style %} -{% endblock %} {% block title %} +{% endblock %} + +{% block title %} +{% language "en" %} Je hebt een edubadge ontvangen. You received an edubadge. Claim before - {{nl_da_enddate}}. + {{ da_enddate|date:"d F Y" }}. -{% endblock %} {% block content %} +{% endlanguage %} +{% endblock %} +{% block content %}

For the English version, see below.

- +{% language "nl" %}

{{institution_name}} heeft je de volgende edubadge gestuurd.
- Claim deze voor {{nl_da_enddate}}! + Claim deze voor {{ da_enddate|date:"d F Y" }}!

Hoe krijg ik de edubadge in mijn bezit?

@@ -124,7 +131,7 @@ href="https://servicedesk.surf.nl/wiki/spaces/WIKI/pages/142573610/Ontvanger+student+lerende" >Handleiding edubadges -

Let op! Je kunt tot {{nl_da_enddate}} je edubadge claimen +

Let op! Je kunt tot {{ da_enddate|date:"d F Y" }} je edubadge claimen Claim deze edubadge in je backpack
+{% endlanguage %} + +{% language "en" %}

{{institution_name}} has sent you the following edubadge.
- Claim it before {{en_da_enddate}}! + Claim it before {{ da_enddate|date:"d F Y" }}!

How do I obtain the edubadge?

@@ -159,7 +169,7 @@ >Manual edubadges

Please note! You can claim your edubadge until {{en_da_enddate}}Please note! You can claim your edubadge until {{ da_enddate|date:"d F Y" }} @@ -180,4 +190,5 @@
+{% endlanguage %} {% endblock %} diff --git a/apps/mainsite/templates/email/reminder_direct_award_new.html b/apps/mainsite/templates/email/reminder_direct_award_new.html index cb3c76f92..4ddc6cc25 100644 --- a/apps/mainsite/templates/email/reminder_direct_award_new.html +++ b/apps/mainsite/templates/email/reminder_direct_award_new.html @@ -1,4 +1,6 @@ -{% extends 'email/base.html' %} {% block style %} +{% extends 'email/base.html' %} +{% load i18n %} +{% block style %} -{% endblock %} {% block title %} +{% endblock %} + +{% block title %} +{% language "en" %} REMINDER: Je hebt een edubadge ontvangen. You received an edubadge. Claim - before {{da_enddate}} + before {{ da_enddate|date:"d F Y" }} -{% endblock %} {% block content %} +{% endlanguage %} +{% endblock %} +{% block content %}

For the English version, see below.

+{% language "nl" %}

- {{institution_name}} heeft je op {{da_creationdate_nl}} een edubadge + {{institution_name}} heeft je op {{ da_creationdate|date:"d F Y" }} een edubadge gestuurd.
- Claim deze voor {{da_enddate_nl}}! + Claim deze voor {{ da_enddate|date:"d F Y" }}!

Hoe krijg ik de edubadge in mijn bezit?

@@ -124,7 +132,7 @@ href="https://servicedesk.surf.nl/wiki/spaces/WIKI/pages/142573610/Ontvanger+student+lerende" >Handleiding edubadges
-

Let op! Je kunt tot {{da_enddate_nl}} je edubadge claimen +

Let op! Je kunt tot {{ da_enddate|date:"d F Y" }} je edubadge claimen Claim deze edubadge in je backpack

{{badgeclass_name}}

-

Uitgegeven op {{da_creationdate_nl}}

+

Uitgegeven op {{ da_creationdate|date:"d F Y" }}

Uitgegeven door

@@ -145,9 +153,12 @@
+{% endlanguage %} + +{% language "en" %}

- {{institution_name}} has sent you an edubadge on {{da_creationdate_en}}.
- Claim it before {{da_enddate_en}}! + {{institution_name}} has sent you an edubadge on {{ da_creationdate|date:"d F Y" }}.
+ Claim it before {{ da_enddate|date:"d F Y" }}!

How do I obtain this edubadge?

@@ -160,7 +171,7 @@ >Manual edubadges

Please note! You can claim your edubadge until {{da_enddate_en}}Please note! You can claim your edubadge until {{ da_enddate|date:"d F Y" }} @@ -172,7 +183,7 @@

{{badgeclass_name}}

-

Issued on {{da_creationdate_en}}

+

Issued on {{ da_creationdate|date:"d F Y" }}

Issued by

{{issuer_name}}

@@ -182,4 +193,5 @@
+{% endlanguage %} {% endblock %} diff --git a/apps/mainsite/utils.py b/apps/mainsite/utils.py index 4a7f28ff1..60bdb2256 100644 --- a/apps/mainsite/utils.py +++ b/apps/mainsite/utils.py @@ -6,7 +6,6 @@ import datetime import hashlib import io -import locale import math import os import pathlib @@ -296,10 +295,6 @@ def create_direct_award_student_mail(direct_award): badgeclass = direct_award.badgeclass template = 'email/earned_direct_award_new.html' badgeclass_image = EmailMessageMaker._create_example_image(badgeclass) - en_end_date = direct_award.expiration_date.strftime('%d %B %Y') - locale.setlocale(locale.LC_ALL, 'nl_NL.UTF-8') - nl_end_date = direct_award.expiration_date.strftime('%d %B %Y') - locale.setlocale(locale.LC_ALL, os.environ.get('LC_ALL', 'en_US.UTF-8')) faculty = badgeclass.issuer.faculty institution_name = faculty.name if faculty.on_behalf_of else faculty.institution.name email_vars = { @@ -311,8 +306,7 @@ def create_direct_award_student_mail(direct_award): 'badgeclass_description': badgeclass.description, 'badgeclass_name': badgeclass.name, 'institution_name': institution_name, - 'en_da_enddate': en_end_date, - 'nl_da_enddate': nl_end_date, + 'da_enddate': direct_award.expiration_date, } return render_to_string(template, email_vars) @@ -335,12 +329,6 @@ def direct_award_reminder_student_mail(direct_award): template = 'email/reminder_direct_award_new.html' badgeclass_image = EmailMessageMaker._create_example_image(badgeclass) - en_create_date = direct_award.created_at.strftime('%d %B %Y') - en_end_date = direct_award.expiration_date.strftime('%d %B %Y') - locale.setlocale(locale.LC_ALL, 'nl_NL.UTF-8') - nl_create_date = direct_award.created_at.strftime('%d %B %Y') - nl_end_date = direct_award.expiration_date.strftime('%d %B %Y') - locale.setlocale(locale.LC_ALL, os.environ.get('LC_ALL', 'en_US.UTF-8')) faculty = badgeclass.issuer.faculty institution_name = faculty.name if faculty.on_behalf_of else faculty.institution.name email_vars = { @@ -352,10 +340,8 @@ def direct_award_reminder_student_mail(direct_award): 'badgeclass_description': badgeclass.description, 'badgeclass_name': badgeclass.name, 'institution_name': institution_name, - 'da_enddate_nl': nl_end_date, - 'da_enddate_en': en_end_date, - 'da_creationdate_nl': nl_create_date, - 'da_creationdate_en': en_create_date, + 'da_creationdate': direct_award.created_at, + 'da_enddate': direct_award.expiration_date, } return render_to_string(template, email_vars) From e357bea5d4505ec592a14ed80584549984b5363b Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 8 Jan 2026 11:52:28 +0100 Subject: [PATCH 004/139] Fix naive datetime defaults in legacy migrations The previous defaults produced naive datetimes during test database creation while USE_TZ was enabled, causing runtime warnings before tests ran. This change fixes the issue at the migration level and eliminates test startup noise. --- apps/badgeuser/migrations/0068_auto_20200820_1138.py | 5 +++-- apps/issuer/migrations/0027_auto_20170801_1636.py | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/badgeuser/migrations/0068_auto_20200820_1138.py b/apps/badgeuser/migrations/0068_auto_20200820_1138.py index cefaf1f31..5b8fdc896 100644 --- a/apps/badgeuser/migrations/0068_auto_20200820_1138.py +++ b/apps/badgeuser/migrations/0068_auto_20200820_1138.py @@ -4,6 +4,7 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion +from django.utils import timezone class Migration(migrations.Migration): @@ -16,7 +17,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='termsagreement', name='created_at', - field=models.DateTimeField(default=datetime.datetime.now), + field=models.DateTimeField(default=timezone.now), ), migrations.AddField( model_name='termsagreement', @@ -26,7 +27,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='termsagreement', name='updated_at', - field=models.DateTimeField(default=datetime.datetime.now), + field=models.DateTimeField(default=timezone.now), ), migrations.AddField( model_name='termsagreement', diff --git a/apps/issuer/migrations/0027_auto_20170801_1636.py b/apps/issuer/migrations/0027_auto_20170801_1636.py index 3750f7354..d7a0131fc 100644 --- a/apps/issuer/migrations/0027_auto_20170801_1636.py +++ b/apps/issuer/migrations/0027_auto_20170801_1636.py @@ -2,9 +2,8 @@ # Generated by Django 1.10.7 on 2017-08-01 23:36 -import datetime - from django.db import migrations, models +from django.utils import timezone class Migration(migrations.Migration): @@ -17,6 +16,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='badgeinstance', name='issued_on', - field=models.DateTimeField(default=datetime.datetime.now), + field=models.DateTimeField(default=timezone.now), ), ] From 5acea261f115b5754de0147ebef95e46be4e170f Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 8 Jan 2026 12:23:06 +0100 Subject: [PATCH 005/139] Suppress cssutils CSS validation errors in test environment cssutils logs ERROR-level messages for valid modern CSS due to CSS 2.1 validation limitations. Since these warnings do not indicate functional issues, we silence cssutils logging in BadgrRunner to reduce noise during tests. --- apps/mainsite/testrunner.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/mainsite/testrunner.py b/apps/mainsite/testrunner.py index c9f37a389..cadf921da 100644 --- a/apps/mainsite/testrunner.py +++ b/apps/mainsite/testrunner.py @@ -11,11 +11,21 @@ class BadgrRunner(DiscoverRunner): # super(BadgrRunner, self).__init__(*args, **kwargs) # self.keepdb = True + def setup_test_environment(self, **kwargs): + super().setup_test_environment(**kwargs) + + import logging + import cssutils + + # Silence cssutils completely + cssutils.log.setLevel(logging.CRITICAL) + cssutils.log.raiseExceptions = False + + # Also prevent propagation just in case + logging.getLogger("cssutils").propagate = False + def run_tests(self, test_labels, extra_tests=None, **kwargs): if not test_labels and extra_tests is None and 'badgebook' in getattr(settings, 'INSTALLED_APPS', []): badgebook_suite = self.build_suite(('badgebook',)) extra_tests = badgebook_suite._tests return super(BadgrRunner, self).run_tests(test_labels, extra_tests=extra_tests, **kwargs) - - - From 4a405354b0fdce6739336c88fdd0f7c82da83fc5 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 8 Jan 2026 13:09:12 +0100 Subject: [PATCH 006/139] Add dedicated settings for testing --- apps/mainsite/settings_tests.py | 17 ----------------- manage.py | 5 ++++- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/apps/mainsite/settings_tests.py b/apps/mainsite/settings_tests.py index a77a6cd66..8841ec59c 100644 --- a/apps/mainsite/settings_tests.py +++ b/apps/mainsite/settings_tests.py @@ -5,20 +5,3 @@ # disable logging for tests LOGGING = {} - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'badgr_server', - 'OPTIONS': { - "init_command": "SET default_storage_engine=InnoDB", - }, - } -} - -CELERY_ALWAYS_EAGER = True -SECRET_KEY = 'aninsecurekeyusedfortesting' -UNSUBSCRIBE_SECRET_KEY = str(SECRET_KEY) -PAGINATION_SECRET_KEY = Fernet.generate_key() -AUTHCODE_SECRET_KEY = Fernet.generate_key() -DEFAULT_DOMAIN = 'https://badgr-pilot2.edubadges.nl' diff --git a/manage.py b/manage.py index 47e65110d..fab2b389b 100755 --- a/manage.py +++ b/manage.py @@ -8,6 +8,9 @@ sys.path.insert(0, APPS_DIR) if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mainsite.settings") + if "test" in sys.argv: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mainsite.settings_tests") + else: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mainsite.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) From a53679be738d63bcea3d53f57a7565736ae258b6 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 8 Jan 2026 13:11:09 +0100 Subject: [PATCH 007/139] Disable auth signals and logging in tests Authentication login/logout signals produced noisy stdout output during tests. These are now disabled via a test-only settings override --- apps/badgrsocialauth/providers/eduid/views.py | 6 +++--- apps/mainsite/settings_tests.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/badgrsocialauth/providers/eduid/views.py b/apps/badgrsocialauth/providers/eduid/views.py index e40dd8f6d..138f9d7f7 100644 --- a/apps/badgrsocialauth/providers/eduid/views.py +++ b/apps/badgrsocialauth/providers/eduid/views.py @@ -301,6 +301,6 @@ def print_logout_message(sender, user, request, **kwargs): def print_login_message(sender, user, request, **kwargs): print('user logged in') - -user_logged_out.connect(print_logout_message) -user_logged_in.connect(print_login_message) +if not getattr(settings, 'DISABLE_AUTH_SIGNALS', False): + user_logged_out.connect(print_logout_message) + user_logged_in.connect(print_login_message) diff --git a/apps/mainsite/settings_tests.py b/apps/mainsite/settings_tests.py index 8841ec59c..008bd5cfe 100644 --- a/apps/mainsite/settings_tests.py +++ b/apps/mainsite/settings_tests.py @@ -5,3 +5,4 @@ # disable logging for tests LOGGING = {} +DISABLE_AUTH_SIGNALS = True From f3c1f421c8242f9bb94fec7b7d9e5adc03d73100 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 8 Jan 2026 15:21:56 +0100 Subject: [PATCH 008/139] Fix broken test helpers for enrollment setup --- apps/directaward/tests/test_direct_award.py | 3 +-- apps/issuer/tests/test_issuer.py | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/directaward/tests/test_direct_award.py b/apps/directaward/tests/test_direct_award.py index 8ed61ed36..f70ed50cb 100644 --- a/apps/directaward/tests/test_direct_award.py +++ b/apps/directaward/tests/test_direct_award.py @@ -7,7 +7,6 @@ class DirectAwardTest(BadgrTestCase): - def test_create_direct_award_bundle(self): teacher1 = self.setup_teacher(authenticate=True, ) self.setup_staff_membership(teacher1, teacher1.institution, may_award=True) @@ -56,7 +55,7 @@ def test_accept_direct_award_from_bundle(self): student = self.setup_student(authenticate=True, affiliated_institutions=[teacher1.institution]) student.add_affiliations([{'eppn': 'some_eppn', 'schac_home': 'some_home'}]) - enrollment = self.enroll_user(student, badgeclass) # add enrollment, this one should be removed after accepting direct award + enrollment = StudentsEnrolled.objects.create(user=student, badge_class=badgeclass) # add enrollment, this one should be removed after accepting direct award direct_award_bundle = DirectAwardBundle.objects.get(entity_id=response.data['entity_id']) response = self.client.post('/directaward/accept/{}'.format(direct_award_bundle.directaward_set.all()[0].entity_id), json.dumps({'accept': True}), diff --git a/apps/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py index f90f69d02..09e104234 100644 --- a/apps/issuer/tests/test_issuer.py +++ b/apps/issuer/tests/test_issuer.py @@ -9,6 +9,7 @@ from institution.models import Institution from issuer.models import Issuer from issuer.testfiles.helper import badgeclass_json, issuer_json +from lti_edu.models import StudentsEnrolled from mainsite.exceptions import BadgrValidationFieldError, BadgrValidationMultipleFieldError from mainsite.tests import BadgrTestCase @@ -282,8 +283,12 @@ def test_enrollment_denial(self): self.setup_staff_membership( teacher1, teacher1.institution, may_award=True, may_read=True, may_create=True, may_update=True ) - enrollment = self.enroll_user(student, badgeclass) - response = self.client.put(reverse('api_lti_edu_update_enrollment', kwargs={'entity_id': enrollment.entity_id})) + enrollment = StudentsEnrolled.objects.create(user=student, badge_class=badgeclass) + response = self.client.put( + reverse('api_lti_edu_update_enrollment', kwargs={'entity_id': enrollment.entity_id}), + data={'denyReason': 'Not eligible'}, + content_type='application/json', + ) self.assertEqual(response.status_code, 200) def test_award_badge_expiration_date(self): From f56e713e2d9c308dd354ae2013483eff23140638 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 8 Jan 2026 16:59:03 +0100 Subject: [PATCH 009/139] Fix staff permission in test to show issuers Apparently for institution staff the may_update permission is required to view issuers. --- apps/badgeuser/tests/test_badgeuser.py | 2 +- apps/issuer/tests/test_issuer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/badgeuser/tests/test_badgeuser.py b/apps/badgeuser/tests/test_badgeuser.py index 2bd9be148..9fd128f9d 100644 --- a/apps/badgeuser/tests/test_badgeuser.py +++ b/apps/badgeuser/tests/test_badgeuser.py @@ -477,7 +477,7 @@ def test_current_student(self): def test_userprovisionment_exposed_in_entities(self): teacher1 = self.setup_teacher(authenticate=True) - self.setup_staff_membership(teacher1, teacher1.institution, may_read=True, may_administrate_users=True) + self.setup_staff_membership(teacher1, teacher1.institution, may_read=True, may_update=True, may_administrate_users=True) new_teacher = self.setup_teacher(institution=teacher1.institution) institution = teacher1.institution faculty = self.setup_faculty(institution=teacher1.institution) diff --git a/apps/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py index 09e104234..256eee489 100644 --- a/apps/issuer/tests/test_issuer.py +++ b/apps/issuer/tests/test_issuer.py @@ -470,7 +470,7 @@ def test_badgeinstance_get_json(self): class IssuerSchemaTest(BadgrTestCase): def test_issuer_schema(self): teacher1 = self.setup_teacher(authenticate=True) - self.setup_staff_membership(teacher1, teacher1.institution, may_read=True) + self.setup_staff_membership(teacher1, teacher1.institution, may_read=True, may_update=True) faculty = self.setup_faculty(institution=teacher1.institution) self.setup_issuer(teacher1, faculty=faculty) query = 'query foo {issuers {entityId contentTypeId}}' From 3a007339cd219547463dfb4dd62e1771680abb84 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 8 Jan 2026 17:35:13 +0100 Subject: [PATCH 010/139] Assert correct type --- apps/mainsite/test_utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/mainsite/test_utils.py b/apps/mainsite/test_utils.py index be14d6941..6ccf37813 100644 --- a/apps/mainsite/test_utils.py +++ b/apps/mainsite/test_utils.py @@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError from django.test import TestCase, RequestFactory +from django.core.files.uploadedfile import InMemoryUploadedFile from PIL import Image from apps.mainsite.utils import ( @@ -361,8 +362,8 @@ def test_removes_script_tags(self): mock_file.name = 'test.svg' result = scrub_svg_image(mock_file) - - self.assertIsInstance(result, type(mock_file)) + + self.assertIsInstance(result, InMemoryUploadedFile) self.assertEqual(result.name, 'test.svg') def test_removes_onload_attributes(self): @@ -376,9 +377,10 @@ def test_removes_onload_attributes(self): mock_file.name = 'test.svg' result = scrub_svg_image(mock_file) - - self.assertIsInstance(result, type(mock_file)) + + self.assertIsInstance(result, InMemoryUploadedFile) + self.assertEqual(result.name, 'test.svg') if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From 72c1e0e822ef9fb619f28d2c3154e82e51666b35 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 12 Jan 2026 09:56:27 +0100 Subject: [PATCH 011/139] Remove edit directaward functionality from tests Functionality itself was removed in 72d6783 --- apps/directaward/tests/test_direct_award.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/directaward/tests/test_direct_award.py b/apps/directaward/tests/test_direct_award.py index f70ed50cb..492ee27f1 100644 --- a/apps/directaward/tests/test_direct_award.py +++ b/apps/directaward/tests/test_direct_award.py @@ -95,19 +95,13 @@ def test_accept_direct_award_failures(self): content_type='application/json') self.assertEqual(response.status_code, 400) - def test_update_and_revoke_direct_award(self): + def test_revoke_direct_award(self): teacher1 = self.setup_teacher(authenticate=True) self.setup_staff_membership(teacher1, teacher1.institution, may_award=True) faculty = self.setup_faculty(institution=teacher1.institution) issuer = self.setup_issuer(created_by=teacher1, faculty=faculty) badgeclass = self.setup_badgeclass(issuer=issuer) direct_award = self.setup_direct_award(created_by=teacher1, badgeclass=badgeclass) - post_data = {'recipient_email': 'other@email.com'} - response = self.client.put('/directaward/edit/{}'.format(direct_award.entity_id), json.dumps(post_data), - content_type='application/json') - self.assertEqual(response.status_code, 200) - self.assertEqual(direct_award.__class__.objects.get(pk=direct_award.pk).recipient_email, - 'other@email.com') response = self.client.post('/directaward/revoke-direct-awards', json.dumps({'revocation_reason': 'revocation_reason', 'direct_awards': [{'entity_id': direct_award.entity_id}]}), From 94790adf44b548bbf0f7517d9877dae5001d4e4b Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 12 Jan 2026 11:16:12 +0100 Subject: [PATCH 012/139] Fix urls and expected response code in institution test --- apps/institution/tests/test_institution.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/institution/tests/test_institution.py b/apps/institution/tests/test_institution.py index 2d12f7c87..b431e1f3c 100644 --- a/apps/institution/tests/test_institution.py +++ b/apps/institution/tests/test_institution.py @@ -27,9 +27,9 @@ def test_edit_institution(self): self.assertEqual(response.status_code, 200) institution = Institution.objects.get(pk=teacher1.institution.pk) self.assertEqual(institution.description_english, description) - response = self.client.delete("/institution/edit/".format(teacher1.institution.entity_id), + response = self.client.delete("/institution/edit/{}".format(teacher1.institution.entity_id), content_type='application/json') - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 405) def test_check_institutions_validity(self): teacher1 = self.setup_teacher() @@ -52,7 +52,7 @@ def test_faculty_delete(self): assertion = self.setup_assertion(recipient=student, badgeclass=badgeclass, created_by=teacher1) - response_fail = self.client.delete("/issuer/faculty/delete/{}".format(faculty.entity_id), + response_fail = self.client.delete("/institution/faculties/delete/{}".format(faculty.entity_id), content_type='application/json') self.assertEqual(response_fail.status_code, 404) assertion.delete() @@ -103,4 +103,4 @@ def test_faculty_schema(self): self.setup_faculty(institution=teacher1.institution) response = self.graphene_post(teacher1, query) self.assertTrue(bool(response['data']['faculties'][0]['contentTypeId'])) - self.assertTrue(bool(response['data']['faculties'][0]['entityId'])) \ No newline at end of file + self.assertTrue(bool(response['data']['faculties'][0]['entityId'])) From abf0655b6dbcbaedf97a037efd93609aafbdf4c3 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 12 Jan 2026 11:51:33 +0100 Subject: [PATCH 013/139] Fix assertion for showing archived badges in issuer response Archived badges should show now in the resolve_issuers. Change done in 49834567217f74bad9da418160f5e13f78891a2b by Okke, test was not updated --- apps/issuer/tests/test_issuer.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py index 256eee489..4b32993e9 100644 --- a/apps/issuer/tests/test_issuer.py +++ b/apps/issuer/tests/test_issuer.py @@ -171,10 +171,15 @@ def test_archive_entity(self): '/issuer/delete/{}'.format(issuer.entity_id), content_type='application/json' ) self.assertEqual(issuer_response.status_code, 204) - # and its child badgeclass is not gettable, as it has been archived + # and its child badgeclass is still gettable, even though it has been archived query = 'query foo{badgeClass(id: "' + badgeclass.entity_id + '") { entityId name } }' response = self.graphene_post(teacher1, query) - self.assertEqual(response['data']['badgeClass'], None) + badgeclass_data = response['data']['badgeClass'] + + self.assertIsNotNone(badgeclass_data) + self.assertEqual(badgeclass_data['entityId'], badgeclass.entity_id) + self.assertEqual(badgeclass_data['name'], badgeclass.name) + self.assertTrue(self.reload_from_db(issuer).archived) self.assertTrue(self.reload_from_db(badgeclass).archived) From 3e6c9cdcc57a3e5dbd321d87a98e5483d65337be Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 12 Jan 2026 14:42:29 +0100 Subject: [PATCH 014/139] Disable extension validation in tests This doesn't work because the @context is not available --- apps/issuer/serializers.py | 16 ++++++++++++---- apps/mainsite/settings_tests.py | 1 + 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/issuer/serializers.py b/apps/issuer/serializers.py index 6642807f6..fadeb8c93 100644 --- a/apps/issuer/serializers.py +++ b/apps/issuer/serializers.py @@ -241,7 +241,10 @@ class BadgeClassSerializer( stackable = serializers.BooleanField(required=False, default=False) alignments = AlignmentItemSerializer(many=True, source='alignment_items', required=False) - extensions = serializers.DictField(source='extension_items', required=False, validators=[BadgeExtensionValidator()]) + extensions = serializers.DictField( + source='extension_items', + required=False, + validators=[BadgeExtensionValidator()] if getattr(settings, 'ENABLE_EXTENSION_VALIDATION', True) else []) expiration_period = PeriodField(required=False) award_allowed_institutions = PrimaryKeyRelatedField(many=True, queryset=Institution.objects.all(), required=False) tags = PrimaryKeyRelatedField(many=True, queryset=BadgeClassTag.objects.all(), required=False) @@ -279,9 +282,11 @@ def validate(self, data): if data.get(field_name) is None: errors[field_name] = ErrorDetail('This field may not be blank.', code='blank') extension_items = data.get('extension_items', []) - for extension in extensions: - if not extension_items.get(f'extensions:{extension}'): - errors[f'extensions.{extension}'] = ErrorDetail('This field may not be blank.', code='blank') + if getattr(settings, 'ENABLE_EXTENSION_VALIDATION', True): + # Skip JSON-LD validation entirely in tests + for extension in extensions: + if not extension_items.get(f'extensions:{extension}'): + errors[f'extensions.{extension}'] = ErrorDetail('This field may not be blank.', code='blank') if errors: raise ValidationError(errors) @@ -329,6 +334,9 @@ def validate_description(self, description): return strip_tags(description) def validate_extensions(self, extensions): + if getattr(settings, 'ENABLE_EXTENSION_VALIDATION', True): + # Skip JSON-LD validation entirely in tests + return extensions if extensions: for ext_name, ext in extensions.items(): if '@context' in ext and not ext['@context'].startswith(settings.EXTENSIONS_ROOT_URL): diff --git a/apps/mainsite/settings_tests.py b/apps/mainsite/settings_tests.py index 008bd5cfe..aef801b49 100644 --- a/apps/mainsite/settings_tests.py +++ b/apps/mainsite/settings_tests.py @@ -6,3 +6,4 @@ # disable logging for tests LOGGING = {} DISABLE_AUTH_SIGNALS = True +ENABLE_EXTENSION_VALIDATION = False From aa7b04b4e191fa0c1bc89cd2eb7ae35a24c2bdb4 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 12 Jan 2026 14:48:29 +0100 Subject: [PATCH 015/139] Add required badgeclass type to request data --- apps/issuer/tests/test_issuer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py index 4b32993e9..bc137eca5 100644 --- a/apps/issuer/tests/test_issuer.py +++ b/apps/issuer/tests/test_issuer.py @@ -7,7 +7,7 @@ from django.db.models import ProtectedError from django.urls import reverse from institution.models import Institution -from issuer.models import Issuer +from issuer.models import Issuer, BadgeClass from issuer.testfiles.helper import badgeclass_json, issuer_json from lti_edu.models import StudentsEnrolled from mainsite.exceptions import BadgrValidationFieldError, BadgrValidationMultipleFieldError @@ -58,6 +58,7 @@ def test_create_badgeclass(self): self.setup_staff_membership(teacher1, issuer, may_create=True) badgeclass_json_copy = copy.deepcopy(badgeclass_json) badgeclass_json_copy['issuer'] = issuer.entity_id + badgeclass_json_copy['badge_class_type'] = BadgeClass.BADGE_CLASS_TYPE_REGULAR response = self.client.post( '/issuer/badgeclasses/create', json.dumps(badgeclass_json_copy), content_type='application/json' ) @@ -70,6 +71,7 @@ def test_create_badgeclass_alignments(self): self.setup_staff_membership(teacher1, issuer, may_create=True) badgeclass_json_copy = copy.deepcopy(badgeclass_json) badgeclass_json_copy['issuer'] = issuer.entity_id + badgeclass_json_copy['badge_class_type'] = BadgeClass.BADGE_CLASS_TYPE_REGULAR alignment_json = [ { 'target_name': 'name', @@ -95,6 +97,7 @@ def test_create_badgeclass_grondslag_failure(self): badgeclass_json_copy = copy.deepcopy(badgeclass_json) badgeclass_json_copy['formal'] = True badgeclass_json_copy['issuer'] = issuer.entity_id + badgeclass_json_copy['badge_class_type'] = BadgeClass.BADGE_CLASS_TYPE_REGULAR response = self.client.post( '/issuer/badgeclasses/create', json.dumps(badgeclass_json_copy), content_type='application/json' ) @@ -141,6 +144,7 @@ def test_may_not_create_badgeclass(self): self.setup_staff_membership(teacher1, issuer, may_read=True) badgeclass_json_copy = copy.deepcopy(badgeclass_json) badgeclass_json_copy['issuer'] = issuer.entity_id + badgeclass_json_copy['badge_class_type'] = BadgeClass.BADGE_CLASS_TYPE_REGULAR response = self.client.post( '/issuer/badgeclasses/create', json.dumps(badgeclass_json_copy), content_type='application/json' ) From 5138a0d3c7ba46ef857ad52e0e41b30be77d880b Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 12 Jan 2026 14:48:45 +0100 Subject: [PATCH 016/139] Fix request data that was no valid json --- apps/issuer/tests/test_issuer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py index bc137eca5..1832e8fef 100644 --- a/apps/issuer/tests/test_issuer.py +++ b/apps/issuer/tests/test_issuer.py @@ -295,7 +295,7 @@ def test_enrollment_denial(self): enrollment = StudentsEnrolled.objects.create(user=student, badge_class=badgeclass) response = self.client.put( reverse('api_lti_edu_update_enrollment', kwargs={'entity_id': enrollment.entity_id}), - data={'denyReason': 'Not eligible'}, + data=json.dumps({'denyReason': 'Not eligible'}), content_type='application/json', ) self.assertEqual(response.status_code, 200) From 4e46cf32bbf8e091e639b50814dddcf580d9c44a Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 12 Jan 2026 15:19:10 +0100 Subject: [PATCH 017/139] Fix tests for removed constraint for badgeclass Badge names do not have to be unique anymore since dd765f3 --- apps/issuer/tests/test_issuer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py index 1832e8fef..a31e3964a 100644 --- a/apps/issuer/tests/test_issuer.py +++ b/apps/issuer/tests/test_issuer.py @@ -417,7 +417,9 @@ def test_badgeclass_uniqueness_constraints_when_archiving(self): issuer = self.setup_issuer(created_by=teacher1, faculty=faculty) setup_badgeclass_kwargs = {'created_by': teacher1, 'issuer': issuer, 'name': 'The same'} badgeclass = self.setup_badgeclass(**setup_badgeclass_kwargs) - self.assertRaises(IntegrityError, self.setup_badgeclass, **setup_badgeclass_kwargs) + # setting up another badgeclass with same name and other kwargs should be possible + self.setup_badgeclass(**setup_badgeclass_kwargs) + # setting up another badgeclass with same kwargs but archived should also be possible setup_badgeclass_kwargs['archived'] = True self.setup_badgeclass(**setup_badgeclass_kwargs) badgeclass.archive() @@ -457,9 +459,9 @@ def test_recursive_archiving(self): self.assertTrue(self.reload_from_db(badgeclass).archived) self.assertTrue(self.instance_is_removed(staff)) self.assertEqual(teacher1.cached_badgeclass_staffs().__len__(), 0) - self.assertEqual(faculty.cached_issuers().__len__(), 0) - self.assertEqual(issuer.cached_badgeclasses().__len__(), 0) - self.assertEqual(teacher1.institution.cached_faculties().__len__(), 0) + self.assertEqual(faculty.cached_issuers().__len__(), 1) + self.assertEqual(issuer.cached_badgeclasses().__len__(), 1) + self.assertEqual(teacher1.institution.cached_faculties().__len__(), 1) def test_badgeinstance_get_json(self): teacher1 = self.setup_teacher() From 9e9e4a55fc3ce373d2f7646ae71afc1bce5f59c1 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 12 Jan 2026 15:34:48 +0100 Subject: [PATCH 018/139] Add workflow to run django tests --- .github/workflows/tests.yml | 87 +++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..9038fc4b1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,87 @@ +name: Django Tests + +on: + pull_request: + branches: [ develop ] + push: + branches: [ develop ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: badgr + MYSQL_USER: badgr + MYSQL_PASSWORD: badgr + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + memcached: + image: memcached:1.6 + ports: + - 11211:11211 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Wait for MySQL + run: | + until mysqladmin ping -h "127.0.0.1" --silent; do + echo "Waiting for MySQL..." + sleep 2 + done + + - name: Run Django tests + env: + DJANGO_SETTINGS_MODULE: apps.mainsite.settings_tests + DOMAIN: 0.0.0.0:8000 + DEFAULT_DOMAIN: http://0.0.0.0:8000 + SITE_ID: "1" + ACCOUNT_SALT: test + ROOT_INFO_SECRET_KEY: test + UNSUBSCRIBE_SECRET_KEY: test + EXTENSIONS_ROOT_URL: http://localhost/static + TIME_STAMPED_OPEN_BADGES_BASE_URL: http://localhost/ + UI_URL: http://localhost:8080 + DEFAULT_FROM_EMAIL: test@example.com + EMAIL_BACKEND: django.core.mail.backends.locmem.EmailBackend + EMAIL_HOST: localhost + EMAIL_PORT: "1025" + EMAIL_USE_TLS: "0" + BADGR_DB_HOST: 127.0.0.1 + BADGR_DB_PORT: "3306" + BADGR_DB_NAME: badgr + BADGR_DB_USER: badgr + BADGR_DB_PASSWORD: badgr + DISABLE_EXTENSION_VALIDATION: "true" + EDUID_PROVIDER_URL: https://connect.test.surfconext.nl/oidc + EDUID_REGISTRATION_URL: https://login.test.eduid.nl/register + EDU_ID_CLIENT: edubadges + EDU_ID_SECRET: supersecret + SURF_CONEXT_CLIENT: test.edubadges.nl + SURF_CONEXT_SECRET: supersecret + OIDC_RS_ENTITY_ID: test.edubadges.rs.nl + OIDC_RS_SECRET: supersecret + run: | + python manage.py test --noinput From 8bd3c8e6093cefd8648defad807c6f7fae98a755 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 12 Jan 2026 16:01:58 +0100 Subject: [PATCH 019/139] Grant privileges to test db user --- .github/workflows/tests.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9038fc4b1..3913349f8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,6 +51,13 @@ jobs: echo "Waiting for MySQL..." sleep 2 done + + - name: Grant MySQL test database privileges + run: | + mysql -h 127.0.0.1 -u root -proot <<'EOF' + GRANT ALL PRIVILEGES ON test_badgr.* TO 'badgr'@'%'; + FLUSH PRIVILEGES; + EOF - name: Run Django tests env: From 310f47505592b1cf6267c973a0c9ad664df454e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 09:26:25 +0000 Subject: [PATCH 020/139] Bump urllib3 from 1.26.19 to 2.6.3 Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.19 to 2.6.3. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.19...2.6.3) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.6.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1d80c51c9..966fa2587 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ git+https://github.com/edubadges/pylti1.3@master#egg=PyLTI1p3 # after python3 upgrade social-auth-app-django==5.4.2 -urllib3==1.26.19 +urllib3==2.6.3 # graphql graphene-django==3.2.2 From 574daced418f2cbe2066f4e24418a603b95347eb Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 13 Jan 2026 11:12:05 +0100 Subject: [PATCH 021/139] Update import of urllib --- apps/directaward/models.py | 2 +- apps/staff/models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/directaward/models.py b/apps/directaward/models.py index 907a9a1d6..ffc80500f 100644 --- a/apps/directaward/models.py +++ b/apps/directaward/models.py @@ -1,4 +1,4 @@ -import urllib +import urllib.parse import uuid import datetime from django.conf import settings diff --git a/apps/staff/models.py b/apps/staff/models.py index 26166d6e7..bd48d60bb 100644 --- a/apps/staff/models.py +++ b/apps/staff/models.py @@ -1,4 +1,4 @@ -import urllib +import urllib.parse from auditlog.registry import auditlog from django.conf import settings From bc979410e5c45831f67499335a8a8f9e0e0f35d9 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 14 Jan 2026 15:08:15 +0100 Subject: [PATCH 022/139] Add linkedin_url field to badge instance detail serializer --- apps/mobile_api/serializers.py | 44 ++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 3f40470e2..214559ef1 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers import json +from urllib.parse import urlencode from badgeuser.models import BadgeUser, UserProvisionment, TermsAgreement, Terms, TermsUrl from directaward.models import DirectAward @@ -76,12 +77,51 @@ class Meta: class BadgeInstanceDetailSerializer(serializers.ModelSerializer): badgeclass = BadgeClassDetailSerializer() + linkedin_url = serializers.SerializerMethodField() class Meta: model = BadgeInstance fields = ["id", "created_at", "entity_id", "issued_on", "award_type", "revoked", "expires_at", "acceptance", - "public", "badgeclass"] - + "public", "badgeclass", "linkedin_url"] + + def _get_linkedin_org_id(self, badgeclass): + issuer = badgeclass.issuer + faculty = getattr(issuer, "faculty", None) + if not faculty: + return 206815 + + if getattr(faculty, "linkedin_org_identifier", None): + return faculty.linkedin_org_identifier + + institution = getattr(faculty, "institution", None) + if getattr(institution, "linkedin_org_identifier", None): + return institution.linkedin_org_identifier + + return 206815 + + def get_linkedin_url(self, obj): + request = self.context.get("request") + if not request or not obj.issued_on: + return None + + organization_id = self._get_linkedin_org_id(obj.badgeclass) + + cert_url = request.build_absolute_uri( + f"/public/assertions/{obj.entity_id}" + ) + + params = { + "startTask": "CERTIFICATION_NAME", + "name": obj.badgeclass.name, + "organizationId": organization_id, + "issueYear": obj.issued_on.year, + "issueMonth": obj.issued_on.month, + "certUrl": cert_url, + "certId": obj.entity_id, + "original_referer": request.build_absolute_uri("/"), + } + + return f"https://www.linkedin.com/profile/add?{urlencode(params)}" class DirectAwardSerializer(serializers.ModelSerializer): badgeclass = BadgeClassSerializer() From 001e3c0ac7c46876c4549e7b8413e41876ac8994 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 15 Jan 2026 14:02:44 +0100 Subject: [PATCH 023/139] Retrieve faculty directly fro badgeclass issuer Should be always available --- apps/mobile_api/serializers.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 214559ef1..f9f7ede3f 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -85,10 +85,7 @@ class Meta: "public", "badgeclass", "linkedin_url"] def _get_linkedin_org_id(self, badgeclass): - issuer = badgeclass.issuer - faculty = getattr(issuer, "faculty", None) - if not faculty: - return 206815 + faculty = badgeclass.issuer.faculty if getattr(faculty, "linkedin_org_identifier", None): return faculty.linkedin_org_identifier From 411937b8addaac808c24184f21ddea1c5ab5fdb9 Mon Sep 17 00:00:00 2001 From: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:25:13 +0100 Subject: [PATCH 024/139] Updated CHANGELOG for release 8.4 --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a4b75cd..2c0fa645a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [8.4] - 2026-01-14 + +#### Full GitHub changelogs: + +Backend: https://github.com/edubadges/edubadges-server/compare/v8.3.3...v8.4
+ +- Merge pull request #239 from edubadges/dependabot/pip/urllib3-2.6.3 +- Merge pull request #241 from edubadges/chore/run-django-tests-in-ci-cd +- Update import of urllib +- Bump urllib3 from 1.26.19 to 2.6.3 +- Grant privileges to test db user +- Add workflow to run django tests +- Merge pull request #240 from edubadges/chore/fix-tests +- Fix tests for removed constraint for badgeclass +- Fix request data that was no valid json +- Add required badgeclass type to request data +- Disable extension validation in tests +- Fix assertion for showing archived badges in issuer response +- Fix urls and expected response code in institution test +- Remove edit directaward functionality from tests +- Assert correct type +- Fix staff permission in test to show issuers +- Fix broken test helpers for enrollment setup +- Disable auth signals and logging in tests +- Add dedicated settings for testing +- Suppress cssutils CSS validation errors in test environment +- Fix naive datetime defaults in legacy migrations +- Remove setlocale usage and localize email dates in templates +- Fix for MA7QDbnn Added expiration date based on the badgeclass when a user claims a DA See https://trello.com/c/MA7QDbnn/1143-vervallen-edubadge-werkt-niet +- WIP for https://trello.com/c/tsJHRy6A/ After the user is created, the correct staffs can be added as super-user +- Added delete account endpoint for mobile API https://trello.com/c/WYW0JiGA/1105-changes-needed-for-making-apis-mobile-app-ready +- Merge pull request #226 from edubadges/feature/remove-imported-badge-functionality +- Fixes remove-imported-badge-functionality See https://trello.com/c/W4o0VLeC/1132-remove-imported-badge-functionality +- Not needed anymore to increase MAX_URL_LENGTH as Django 4.2.27 fixes this. +- Merge pull request #220 from edubadges/dependabot/pip/django-4.2.27 +- Ignore .serena directory +- DA audit traiL: action instead of method +- Filter DA audit trail with method CREATE +- Merge pull request #224 from edubadges/feature/da_audittrail_view +- feat: adding direct award audit trail API used by super users +- Bump django from 4.2.26 to 4.2.27 +- Updated CHANGELOG for 8.3.3 release + ## [8.3.3] - 2025-12-02 #### Full GitHub changelogs: From 7eb48344395dab93107c2fa63f2e8103b2f46089 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Mon, 19 Jan 2026 10:27:01 +0100 Subject: [PATCH 025/139] Added grade_achieved to mobile seerializer --- apps/mobile_api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index f9f7ede3f..5abe75042 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -72,7 +72,7 @@ class BadgeInstanceSerializer(serializers.ModelSerializer): class Meta: model = BadgeInstance fields = ["id", "created_at", "entity_id", "issued_on", "award_type", "revoked", "expires_at", "acceptance", - "public", "badgeclass"] + "public", "badgeclass", "grade_achieved"] class BadgeInstanceDetailSerializer(serializers.ModelSerializer): From 14ce28a98c237d96796a08dca817e58da40cedcf Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Mon, 19 Jan 2026 11:24:00 +0100 Subject: [PATCH 026/139] Added stackable to the badgeclass serializer --- apps/mobile_api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 5abe75042..9e2d9e486 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -62,7 +62,7 @@ class BadgeClassDetailSerializer(serializers.ModelSerializer): class Meta: model = BadgeClass fields = ["id", "name", "entity_id", "image", "description", "formal", "participation", "assessment_type", - "assessment_id_verified", "assessment_supervised", "quality_assurance_name", + "assessment_id_verified", "assessment_supervised", "quality_assurance_name", "stackable", "badgeclassextension_set", "issuer"] From 001673fbac2519b479989e82981df8986ebe778d Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 19 Jan 2026 13:23:20 +0100 Subject: [PATCH 027/139] Refactor charfields to foreign key relationships Refactor DirectAwardAuditTrail to use proper ForeignKey relations instead of CharField entity IDs. Includes a data migration to populate historical records and removes the legacy fields. The migration is also backwards compatible. --- ..._and_directaward_relations_and_populate.py | 85 +++++++++++++++++++ apps/directaward/models.py | 4 +- 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py diff --git a/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py b/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py new file mode 100644 index 000000000..655db20ba --- /dev/null +++ b/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py @@ -0,0 +1,85 @@ +# Generated by Django 4.2.27 on 2026-01-19 11:57 + +from django.db import migrations, models + + +def forwards_populate_audit_trail_fks(apps, schema_editor): + DirectAwardAuditTrail = apps.get_model('directaward', 'DirectAwardAuditTrail') + DirectAward = apps.get_model('directaward', 'DirectAward') + BadgeClass = apps.get_model('issuer', 'BadgeClass') + + for audit in DirectAwardAuditTrail.objects.all().iterator(): + if audit.direct_award_entity_id and not audit.direct_award: + audit.direct_award = ( + DirectAward.objects + .filter(entity_id=audit.direct_award_entity_id) + .first() + ) + + if audit.badgeclass_entity_id and not audit.badgeclass: + audit.badgeclass = ( + BadgeClass.objects + .filter(entity_id=audit.badgeclass_entity_id) + .first() + ) + + audit.save(update_fields=['direct_award', 'badgeclass']) + + +def backwards_restore_entity_ids(apps, schema_editor): + DirectAwardAuditTrail = apps.get_model('directaward', 'DirectAwardAuditTrail') + + for audit in DirectAwardAuditTrail.objects.all().iterator(): + if audit.direct_award and not audit.direct_award_entity_id: + audit.direct_award_entity_id = audit.direct_award.entity_id + + if audit.badgeclass and not audit.badgeclass_entity_id: + audit.badgeclass_entity_id = audit.badgeclass.entity_id + + audit.save(update_fields=[ + 'direct_award_entity_id', + 'badgeclass_entity_id', + ]) + +class Migration(migrations.Migration): + + dependencies = [ + ('directaward', '0023_directawardbundle_direct_award_removed_count'), + ('issuer', + '0117_rename_badgeinstance_recipient_identifier_badgeclass_revoked_issuer_badg_recipie_6a2cd8_idx_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='directawardaudittrail', + old_name='badgeclass_id', + new_name='badgeclass_entity_id', + ), + migrations.RenameField( + model_name='directawardaudittrail', + old_name='direct_award_id', + new_name='direct_award_entity_id', + ), + migrations.AddField( + model_name='directawardaudittrail', + name='badgeclass', + field=models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, to='issuer.badgeclass'), + ), + migrations.AddField( + model_name='directawardaudittrail', + name='direct_award', + field=models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, to='directaward.directaward'), + ), + migrations.RunPython( + forwards_populate_audit_trail_fks, + backwards_restore_entity_ids, + ), + migrations.RemoveField( + model_name='directawardaudittrail', + name='badgeclass_entity_id', + ), + migrations.RemoveField( + model_name='directawardaudittrail', + name='direct_award_entity_id', + ), + ] diff --git a/apps/directaward/models.py b/apps/directaward/models.py index ffc80500f..61cd87ca1 100644 --- a/apps/directaward/models.py +++ b/apps/directaward/models.py @@ -263,5 +263,5 @@ class DirectAwardAuditTrail(models.Model): user_agent_info = models.CharField(max_length=255, blank=True) action = models.CharField(max_length=40) change_summary = models.CharField(max_length=199, blank=True) - direct_award_id = models.CharField(max_length=255, blank=True) - badgeclass_id = models.CharField(max_length=255, blank=True) + direct_award = models.ForeignKey('directaward.DirectAward', on_delete=models.SET_NULL, null=True, blank=True) + badgeclass = models.ForeignKey('issuer.BadgeClass', on_delete=models.SET_NULL, null=True, blank=True) From 82503d7690677cdb4b521ee707af6569eba6719d Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 19 Jan 2026 13:33:59 +0100 Subject: [PATCH 028/139] Refactor audit trail api view into a ListAPIView Refactor the audit trail API endpoint to a ListAPIView and improve the serializer using the new model relations. --- apps/directaward/api.py | 17 ++++++--- apps/directaward/api_urls.py | 4 +-- apps/directaward/serializer.py | 63 +++++++++------------------------- 3 files changed, 30 insertions(+), 54 deletions(-) diff --git a/apps/directaward/api.py b/apps/directaward/api.py index 2441d5e2b..b3fb3c859 100644 --- a/apps/directaward/api.py +++ b/apps/directaward/api.py @@ -4,6 +4,7 @@ from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiExample, OpenApiResponse, OpenApiParameter from rest_framework import serializers from rest_framework import status +from rest_framework.generics import ListAPIView from rest_framework.response import Response from rest_framework.views import APIView @@ -680,8 +681,9 @@ def put(self, request, **kwargs): ) -class DirectAwardAuditTrailView(APIView): +class DirectAwardAuditTrailListView(ListAPIView): permission_classes = (IsSuperUser,) + serializer_class = DirectAwardAuditTrailSerializer @extend_schema( description='Get all direct award audit trail entries (superuser only)', @@ -708,7 +710,12 @@ class DirectAwardAuditTrailView(APIView): 403: permission_denied_response, }, ) - def get(self, request, *args, **kwargs): - audit_trails = DirectAwardAuditTrail.objects.filter(action='CREATE').order_by('-action_datetime') - serializer = DirectAwardAuditTrailSerializer(audit_trails, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + + def get_queryset(self): + return ( + DirectAwardAuditTrail.objects + .filter( + action='CREATE', + ) + .order_by('-action_datetime') + ) diff --git a/apps/directaward/api_urls.py b/apps/directaward/api_urls.py index 9a9df6507..804bdc053 100644 --- a/apps/directaward/api_urls.py +++ b/apps/directaward/api_urls.py @@ -7,7 +7,7 @@ DirectAwardRevoke, DirectAwardDelete, DirectAwardBundleView, - DirectAwardAuditTrailView, + DirectAwardAuditTrailListView, ) urlpatterns = [ @@ -16,5 +16,5 @@ path('accept/', DirectAwardAccept.as_view(), name='direct_award_accept'), path('revoke-direct-awards', DirectAwardRevoke.as_view(), name='direct_award_revoke'), path('delete-direct-awards', DirectAwardDelete.as_view(), name='direct_award_delete'), - path('audittrail', DirectAwardAuditTrailView.as_view(), name='direct_award_audittrail'), + path('audittrail', DirectAwardAuditTrailListView.as_view(), name='direct_award_audittrail'), ] diff --git a/apps/directaward/serializer.py b/apps/directaward/serializer.py index 52c8bfd90..ab71c200a 100644 --- a/apps/directaward/serializer.py +++ b/apps/directaward/serializer.py @@ -168,10 +168,22 @@ def to_representation(self, instance): class DirectAwardAuditTrailSerializer(serializers.ModelSerializer): - badgeclass_name = serializers.SerializerMethodField() - institution_name = serializers.SerializerMethodField() - recipient_email = serializers.SerializerMethodField() - recipient_eppn = serializers.SerializerMethodField() + badgeclass_name = serializers.CharField( + source='badgeclass.name', + read_only=True, + ) + institution_name = serializers.CharField( + source='badgeclass.institution.name', + read_only=True, + ) + recipient_email = serializers.EmailField( + source='direct_award.recipient_email', + read_only=True + ) + recipient_eppn = serializers.CharField( + source='direct_award.eppn', + read_only=True + ) class Meta: model = DirectAwardAuditTrail @@ -183,46 +195,3 @@ class Meta: 'recipient_email', 'recipient_eppn', ] - - def get_badgeclass_name(self, obj): - """Get the badge class name from the badgeclass_id""" - if obj.badgeclass_id: - try: - badgeclass = BadgeClass.objects.get(id=obj.badgeclass_id) - return badgeclass.name - except BadgeClass.DoesNotExist: - return None - return None - - def get_institution_name(self, obj): - """Get the institution name from the badgeclass""" - if obj.badgeclass_id: - try: - badgeclass = BadgeClass.objects.get(id=obj.badgeclass_id) - institution = badgeclass.institution - return institution.name if institution else None - except BadgeClass.DoesNotExist: - return None - return None - - def get_recipient_email(self, obj): - """Get the recipient email from the direct award""" - if obj.badgeclass_id: - try: - directaward = DirectAward.objects.get(entity_id=obj.direct_award_id) - recipient_email = directaward.recipient_email - return recipient_email if directaward else None - except DirectAward.DoesNotExist: - return None - return None - - def get_recipient_eppn(self, obj): - """Get the recipient eppn from the direct award""" - if obj.badgeclass_id: - try: - directaward = DirectAward.objects.get(entity_id=obj.direct_award_id) - eppn = directaward.eppn - return eppn if eppn else '' - except DirectAward.DoesNotExist: - return None - return None From ebee67db605334258e3a506df00a67b3ac72bc51 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 19 Jan 2026 13:34:33 +0100 Subject: [PATCH 029/139] Improve performance with select_related and extra filter --- apps/directaward/api.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/directaward/api.py b/apps/directaward/api.py index b3fb3c859..d292520f0 100644 --- a/apps/directaward/api.py +++ b/apps/directaward/api.py @@ -716,6 +716,12 @@ def get_queryset(self): DirectAwardAuditTrail.objects .filter( action='CREATE', + direct_award__isnull=False, + ) + .select_related( + 'direct_award', + 'badgeclass', + 'badgeclass__institution', ) .order_by('-action_datetime') ) From e72419298ba8377e047e50a35d247de40eb541dd Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 19 Jan 2026 13:35:19 +0100 Subject: [PATCH 030/139] Update audit trail signal receiver to set fk relations properly --- apps/directaward/signals.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/directaward/signals.py b/apps/directaward/signals.py index dbc258b6b..5e1916aed 100644 --- a/apps/directaward/signals.py +++ b/apps/directaward/signals.py @@ -4,6 +4,8 @@ from django.dispatch import receiver from .models import DirectAwardAuditTrail +from directaward.models import DirectAward +from issuer.models import BadgeClass # Signals doc: https://docs.djangoproject.com/en/4.2/topics/signals/ audit_trail_signal = django.dispatch.Signal() # creates a custom signal and specifies the args required. @@ -25,17 +27,25 @@ def get_client_ip(request): def direct_award_audit_trail(sender, user, request, direct_award_id, badgeclass_id, method, summary, **kwargs): try: user_agent_info = (request.headers.get('user-agent', '')[:255],) + + direct_award = None + badgeclass = None + if direct_award_id: + direct_award = DirectAward.objects.filter(entity_id=direct_award_id).first() + if badgeclass_id: + badgeclass = BadgeClass.objects.filter(entity_id=badgeclass_id).first() + audit_trail = DirectAwardAuditTrail.objects.create( user=user, user_agent_info=user_agent_info, login_IP=get_client_ip(request), action=method, change_summary=summary, - direct_award_id=direct_award_id, - badgeclass_id=badgeclass_id, + direct_award=direct_award, + badgeclass=badgeclass, ) logger.info( - f'direct_award_audit_trail created {audit_trail.id} for user {audit_trail.user} and directaward {audit_trail.direct_award_id}' + f'direct_award_audit_trail created {audit_trail.id} for user {audit_trail.user} and directaward {direct_award_id}' ) except Exception as e: logger.error('direct_award_audit_trail request: %s, error: %s' % (request, e)) From e900377d4b8423c3dd757b185fc76f8c3531a3ad Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 20 Jan 2026 16:56:53 +0100 Subject: [PATCH 031/139] Fix migration to filter on actual ids --- ...r_badgeclass_and_directaward_relations_and_populate.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py b/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py index 655db20ba..c6e32366f 100644 --- a/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py +++ b/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py @@ -12,14 +12,14 @@ def forwards_populate_audit_trail_fks(apps, schema_editor): if audit.direct_award_entity_id and not audit.direct_award: audit.direct_award = ( DirectAward.objects - .filter(entity_id=audit.direct_award_entity_id) + .filter(id=audit.direct_award_entity_id) .first() ) if audit.badgeclass_entity_id and not audit.badgeclass: audit.badgeclass = ( BadgeClass.objects - .filter(entity_id=audit.badgeclass_entity_id) + .filter(id=audit.badgeclass_entity_id) .first() ) @@ -31,10 +31,10 @@ def backwards_restore_entity_ids(apps, schema_editor): for audit in DirectAwardAuditTrail.objects.all().iterator(): if audit.direct_award and not audit.direct_award_entity_id: - audit.direct_award_entity_id = audit.direct_award.entity_id + audit.direct_award_entity_id = audit.direct_award.id if audit.badgeclass and not audit.badgeclass_entity_id: - audit.badgeclass_entity_id = audit.badgeclass.entity_id + audit.badgeclass_entity_id = audit.badgeclass.id audit.save(update_fields=[ 'direct_award_entity_id', From f0ff521c7a243041c93c9aa0b34093f30fd41c1d Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 20 Jan 2026 16:59:14 +0100 Subject: [PATCH 032/139] Select related institution through issuer and faculty As the badgeclass model itself doesn't have a FK relationship, only a property --- apps/directaward/api.py | 2 +- apps/directaward/serializer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/directaward/api.py b/apps/directaward/api.py index d292520f0..0ed7df1c7 100644 --- a/apps/directaward/api.py +++ b/apps/directaward/api.py @@ -721,7 +721,7 @@ def get_queryset(self): .select_related( 'direct_award', 'badgeclass', - 'badgeclass__institution', + 'badgeclass__issuer__faculty__institution', ) .order_by('-action_datetime') ) diff --git a/apps/directaward/serializer.py b/apps/directaward/serializer.py index ab71c200a..a9bcb5222 100644 --- a/apps/directaward/serializer.py +++ b/apps/directaward/serializer.py @@ -173,7 +173,7 @@ class DirectAwardAuditTrailSerializer(serializers.ModelSerializer): read_only=True, ) institution_name = serializers.CharField( - source='badgeclass.institution.name', + source='badgeclass.issuer.faculty.institution.name', read_only=True, ) recipient_email = serializers.EmailField( From 4318e30d5dbe8aa1c3b8fb74ef16533d2ad19da5 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 20 Jan 2026 17:07:15 +0100 Subject: [PATCH 033/139] Add a one-off management command to backfill badgeclass ids --- apps/directaward/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../backfill_audittrail_badgeclass_ids.py | 34 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 apps/directaward/management/__init__.py create mode 100644 apps/directaward/management/commands/__init__.py create mode 100644 apps/directaward/management/commands/backfill_audittrail_badgeclass_ids.py diff --git a/apps/directaward/management/__init__.py b/apps/directaward/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/directaward/management/commands/__init__.py b/apps/directaward/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/directaward/management/commands/backfill_audittrail_badgeclass_ids.py b/apps/directaward/management/commands/backfill_audittrail_badgeclass_ids.py new file mode 100644 index 000000000..a769f3f28 --- /dev/null +++ b/apps/directaward/management/commands/backfill_audittrail_badgeclass_ids.py @@ -0,0 +1,34 @@ +from django.core.management.base import BaseCommand +from django.db import transaction + +from directaward.models import DirectAwardAuditTrail + + +class Command(BaseCommand): + help = "Backfill badgeclass FK on DirectAwardAuditTrail using direct_award.badgeclass" + + def handle(self, *args, **options): + qs = ( + DirectAwardAuditTrail.objects + .filter(badgeclass__isnull=True, direct_award__isnull=False) + .select_related('direct_award__badgeclass') + ) + + total = qs.count() + self.stdout.write(f"Found {total} audit trail records to backfill") + + updated = 0 + + with transaction.atomic(): + for audit in qs.iterator(chunk_size=500): + badgeclass = audit.direct_award.badgeclass + if badgeclass is None: + continue + + audit.badgeclass = badgeclass + audit.save(update_fields=['badgeclass']) + updated += 1 + + self.stdout.write( + self.style.SUCCESS(f"Successfully backfilled {updated} audit trail records") + ) From e8368058d21b2d11027b7cc9758eccfb15428b02 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Mon, 26 Jan 2026 11:24:38 +0100 Subject: [PATCH 034/139] Added badge_class_type in mobile API --- apps/mobile_api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 9e2d9e486..190955198 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -63,7 +63,7 @@ class Meta: model = BadgeClass fields = ["id", "name", "entity_id", "image", "description", "formal", "participation", "assessment_type", "assessment_id_verified", "assessment_supervised", "quality_assurance_name", "stackable", - "badgeclassextension_set", "issuer"] + "badgeclassextension_set", "issuer", "badge_class_type", "expiration_period"] class BadgeInstanceSerializer(serializers.ModelSerializer): From ffe9846b02191fc4baac79611bb15af72f26e52b Mon Sep 17 00:00:00 2001 From: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:17:15 +0100 Subject: [PATCH 035/139] Feat: improve mobile api swagger, initial commit --- apps/mobile_api/api.py | 430 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 407 insertions(+), 23 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 30af24cd9..0d563654e 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -174,7 +174,23 @@ class AcceptGeneralTerms(APIView): @extend_schema( methods=['GET'], description='Accept the general terms', - examples=[], + responses={ + 200: OpenApiResponse( + description='Terms accepted successfully', + response=inline_serializer( + name='AcceptGeneralTermsResponse', fields={'status': serializers.CharField()} + ), + examples=[ + OpenApiExample( + 'Terms Accepted', + value={'status': 'ok'}, + description='User has successfully accepted the general terms', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def get(self, request, **kwargs): logger = logging.getLogger('Badgr.Debug') @@ -191,7 +207,33 @@ class BadgeInstances(APIView): @extend_schema( methods=['GET'], description='Get all assertions for the user', - examples=[], + responses={ + 200: OpenApiResponse( + description='List of badge instances', + response=BadgeInstanceSerializer(many=True), + examples=[ + OpenApiExample( + 'Badge Instances List', + value=[ + { + 'entity_id': '123e4567-e89b-12d3-a456-426614174000', + 'badgeclass': { + 'entity_id': 'badgeclass-123', + 'name': 'Python Programming', + 'description': 'Completed Python programming course', + 'image': 'https://example.com/badge-image.png', + }, + 'issued_on': '2023-01-15T10:30:00Z', + 'recipient_identifier': 'user@example.com', + }, + ], + description='Array of badge instances belonging to the user', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def get(self, request, **kwargs): # ForeignKey / OneToOneField → select_related @@ -223,7 +265,45 @@ class BadgeInstanceDetail(APIView): description='entity_id of the badge instance', ) ], - examples=[], + responses={ + 200: OpenApiResponse( + description='Badge instance details', + response=BadgeInstanceDetailSerializer, + examples=[ + OpenApiExample( + 'Badge Instance Details', + value={ + 'entity_id': '123e4567-e89b-12d3-a456-426614174000', + 'badgeclass': { + 'entity_id': 'badgeclass-123', + 'name': 'Python Programming', + 'description': 'Completed Python programming course', + 'image': 'https://example.com/badge-image.png', + 'criteria': 'https://example.com/criteria', + }, + 'issued_on': '2023-01-15T10:30:00Z', + 'recipient_identifier': 'user@example.com', + 'evidence': 'https://example.com/evidence', + 'narrative': 'User completed all assignments and final project', + }, + description='Detailed information about a specific badge instance', + response_only=True, + ), + ], + ), + 404: OpenApiResponse( + description='Badge instance not found', + examples=[ + OpenApiExample( + 'Not Found', + value={'detail': 'Badge instance not found'}, + description='The requested badge instance does not exist or user does not have access', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def get(self, request, entity_id, **kwargs): instance = ( @@ -246,7 +326,34 @@ class UnclaimedDirectAwards(APIView): @extend_schema( methods=['GET'], description='Get all unclaimed awarded badges for the user', - examples=[], + responses={ + 200: OpenApiResponse( + description='List of unclaimed direct awards', + response=DirectAwardSerializer(many=True), + examples=[ + OpenApiExample( + 'Unclaimed Direct Awards', + value=[ + { + 'entity_id': 'direct-award-123', + 'badgeclass': { + 'entity_id': 'badgeclass-456', + 'name': 'Data Science Certificate', + 'description': 'Awarded for completing data science program', + 'image': 'https://example.com/data-science-badge.png', + }, + 'status': 'Unaccepted', + 'created_at': '2023-02-20T09:15:00Z', + 'recipient_email': 'user@example.com', + }, + ], + description='Array of unclaimed direct awards available to the user', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def get(self, request, **kwargs): # ForeignKey / OneToOneField → select_related @@ -284,7 +391,69 @@ class DirectAwardDetail(APIView): description='entity_id of the direct award', ) ], - examples=[], + responses={ + 200: OpenApiResponse( + description='Direct award details', + response=DirectAwardDetailSerializer, + examples=[ + OpenApiExample( + 'Direct Award Details', + value=[ + { + 'id': 9596, + 'created_at': '2026-01-16T10:56:44.293475+01:00', + 'entity_id': 'y8uStIzMQ--JY59DIKnvWw', + 'badgeclass': { + 'id': 6, + 'name': 'test direct award', + 'entity_id': 'B3uWEIZSTh6wniHBbzVtbA', + 'image_url': 'https://api-demo.edubadges.nl/media/uploads/badges/issuer_badgeclass_6c3b5f04-292b-41fa-8df6-d5029386bd3f.png', + 'issuer': { + 'name_dutch': 'SURF Edubadges', + 'name_english': 'SURF Edubadges', + 'image_dutch': 'null', + 'image_english': '/media/uploads/issuers/issuer_logo_ccd075bb-23cb-40b2-8780-b5a7eda9de1c.png', + 'faculty': { + 'name_dutch': 'SURF', + 'name_english': 'SURF', + 'image_dutch': 'null', + 'image_english': 'null', + 'on_behalf_of': 'false', + 'on_behalf_of_display_name': 'null', + 'on_behalf_of_url': 'null', + 'institution': { + 'name_dutch': 'University Voorbeeld', + 'name_english': 'University Example', + 'image_dutch': '/media/uploads/institution/d0273589-2c7a-4834-8c35-fef4695f176a.png', + 'image_english': '/media/uploads/institution/eae5465f-98b1-4849-ac2d-47d4e1cd1252.png', + 'identifier': 'university-example.org', + 'alternative_identifier': 'university-example.org.tempguestidp.edubadges.nl', + 'grondslag_formeel': 'gerechtvaardigd_belang', + 'grondslag_informeel': 'gerechtvaardigd_belang', + }, + }, + }, + }, + } + ], + description='Detailed information about a specific direct award', + response_only=True, + ), + ], + ), + 404: OpenApiResponse( + description='Direct award not found', + examples=[ + OpenApiExample( + 'Not Found', + value={'detail': 'Direct award not found'}, + description='The requested direct award does not exist or user does not have access', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) # ForeignKey / OneToOneField → select_related # ManyToManyField / reverse FK → prefetch_related @@ -300,7 +469,6 @@ def get(self, request, entity_id, **kwargs): .get() ) serializer = DirectAwardDetailSerializer(instance) - data = serializer.data return Response(serializer.data) @@ -310,7 +478,34 @@ class Enrollments(APIView): @extend_schema( methods=['GET'], description='Get all enrollments for the user', - examples=[], + responses={ + 200: OpenApiResponse( + description='List of enrollments', + response=StudentsEnrolledSerializer(many=True), + examples=[ + OpenApiExample( + 'Enrollments List', + value=[ + { + 'entity_id': 'enrollment-123', + 'badge_class': { + 'entity_id': 'badgeclass-789', + 'name': 'Advanced Machine Learning', + 'description': 'Enrolled in advanced ML course', + }, + 'user': 'user@example.com', + 'date_enrolled': '2023-03-10T14:25:00Z', + 'date_awarded': None, + 'status': 'Active', + }, + ], + description='Array of course enrollments for the user', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def get(self, request, **kwargs): # ForeignKey / OneToOneField → select_related @@ -342,7 +537,49 @@ class EnrollmentDetail(APIView): description='entity_id of the enrollment', ) ], - examples=[], + responses={ + 200: OpenApiResponse( + description='Enrollment details', + response=StudentsEnrolledDetailSerializer, + examples=[ + OpenApiExample( + 'Enrollment Details', + value={ + 'entity_id': 'enrollment-123', + 'badge_class': { + 'entity_id': 'badgeclass-789', + 'name': 'Advanced Machine Learning', + 'description': 'Enrolled in advanced ML course', + 'image': 'https://example.com/ml-badge.png', + 'criteria': 'https://example.com/criteria', + }, + 'user': 'user@example.com', + 'date_enrolled': '2023-03-10T14:25:00Z', + 'date_awarded': None, + 'status': 'Active', + 'issuer': { + 'name': 'University of Example', + 'url': 'https://example.edu', + }, + }, + description='Detailed information about a specific enrollment', + response_only=True, + ), + ], + ), + 404: OpenApiResponse( + description='Enrollment not found', + examples=[ + OpenApiExample( + 'Not Found', + value={'detail': 'Enrollment not found'}, + description='The requested enrollment does not exist or user does not have access', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) # ForeignKey / OneToOneField → select_related # ManyToManyField / reverse FK → prefetch_related @@ -372,7 +609,42 @@ def get(self, request, entity_id, **kwargs): description='entity_id of the enrollment', ) ], - examples=[], + responses={ + 204: OpenApiResponse( + description='Enrollment deleted successfully', + examples=[ + OpenApiExample( + 'Deleted', + value=None, + description='Enrollment was successfully deleted', + response_only=True, + ), + ], + ), + 404: OpenApiResponse( + description='Enrollment not found', + examples=[ + OpenApiExample( + 'Not Found', + value={'detail': 'Enrollment not found'}, + description='The requested enrollment does not exist', + response_only=True, + ), + ], + ), + 400: OpenApiResponse( + description='Cannot delete awarded enrollment', + examples=[ + OpenApiExample( + 'Awarded Enrollment', + value={'detail': 'Awarded enrollments cannot be withdrawn'}, + description='Cannot delete an enrollment that has already been awarded', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def delete(self, request, entity_id, **kwargs): enrollment = get_object_or_404(StudentsEnrolled, user=request.user, entity_id=entity_id) @@ -388,7 +660,28 @@ class BadgeCollectionsListView(APIView): @extend_schema( methods=['GET'], description='Get all badge collections for the user', - examples=[], + responses={ + 200: OpenApiResponse( + description='List of badge collections', + response=BadgeCollectionSerializer(many=True), + examples=[ + OpenApiExample( + 'Badge Collections List', + value=[ + { + 'entity_id': 'YpQpVLu6QsmXiWZ7YhrSPQ', + 'name': 'My Achievements', + 'desacription': 'Collection of my programming achievements', + 'badge_instances': [311, 312], + }, + ], + description='Array of badge collections created by the user', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def get(self, request, **kwargs): collections = BadgeInstanceCollection.objects.filter(user=request.user) @@ -397,17 +690,38 @@ def get(self, request, **kwargs): @extend_schema( request=BadgeInstanceCollectionSerializer, - responses=BadgeInstanceCollectionSerializer, description='Create a new BadgeInstanceCollection', - parameters=[ - OpenApiParameter( - name='entity_id', - type=OpenApiTypes.STR, - location=OpenApiParameter.PATH, - required=True, - description='entity_id of the enrollment', - ) - ], + responses={ + 201: OpenApiResponse( + description='Badge collection created successfully', + response=BadgeInstanceCollectionSerializer, + examples=[ + OpenApiExample( + 'Created Collection', + value={ + 'entity_id': 'collection-123', + 'name': 'My Achievements', + 'description': 'Collection of my programming achievements', + 'badge_instances': [311], + }, + description='Newly created badge collection', + response_only=True, + ), + ], + ), + 400: OpenApiResponse( + description='Invalid request data', + examples=[ + OpenApiExample( + 'Invalid Data', + value={'name': ['This field is required.']}, + description='Validation errors in the request data', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def post(self, request): serializer = BadgeInstanceCollectionSerializer(data=request.data, context={'request': request}) @@ -421,7 +735,6 @@ class BadgeCollectionsDetailView(APIView): @extend_schema( request=BadgeInstanceCollectionSerializer, - responses=BadgeInstanceCollectionSerializer, description='Update an existing BadgeInstanceCollection by ID', parameters=[ OpenApiParameter( @@ -429,9 +742,56 @@ class BadgeCollectionsDetailView(APIView): type=OpenApiTypes.STR, location=OpenApiParameter.PATH, required=True, - description='entity_id of the enrollment', + description='entity_id of the collection', ) ], + responses={ + 200: OpenApiResponse( + description='Badge collection updated successfully', + response=BadgeInstanceCollectionSerializer, + examples=[ + OpenApiExample( + 'Updated Collection', + value={ + 'entity_id': 'collection-123', + 'name': 'My Updated Achievements', + 'description': 'Updated collection of my programming achievements', + 'badge_instances': [ + { + 'entity_id': 'badge-456', + 'name': 'Python Programming', + }, + ], + }, + description='Updated badge collection', + response_only=True, + ), + ], + ), + 404: OpenApiResponse( + description='Badge collection not found', + examples=[ + OpenApiExample( + 'Not Found', + value={'detail': 'Badge collection not found'}, + description='The requested badge collection does not exist', + response_only=True, + ), + ], + ), + 400: OpenApiResponse( + description='Invalid request data', + examples=[ + OpenApiExample( + 'Invalid Data', + value={'name': ['This field is required.']}, + description='Validation errors in the request data', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def put(self, request, entity_id): badge_collection = get_object_or_404(BadgeInstanceCollection, user=request.user, entity_id=entity_id) @@ -444,7 +804,6 @@ def put(self, request, entity_id): @extend_schema( request=None, - responses={204: None}, description='Delete a BadgeInstanceCollection by ID', parameters=[ OpenApiParameter( @@ -455,6 +814,31 @@ def put(self, request, entity_id): description='entity_id of the enrollment', ) ], + responses={ + 204: OpenApiResponse( + description='Badge collection deleted successfully', + examples=[ + OpenApiExample( + 'Deleted', + value=None, + description='Badge collection was successfully deleted', + response_only=True, + ), + ], + ), + 404: OpenApiResponse( + description='Badge collection not found', + examples=[ + OpenApiExample( + 'Not Found', + value={'detail': 'Badge collection not found'}, + description='The requested badge collection does not exist', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def delete(self, request, entity_id): badge_collection = get_object_or_404(BadgeInstanceCollection, entity_id=entity_id, user=request.user) From 32d22a4943f849d4144923ce915660403ccce309 Mon Sep 17 00:00:00 2001 From: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:17:25 +0100 Subject: [PATCH 036/139] Adding .zed to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 389efa1b7..63f7faffa 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ pyrightconfig.json start.fish sourceandcharm.sh .serena +.zed From 7df7af910407a17098852ecb624abee06de1f942 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 23 Jan 2026 15:47:43 +0100 Subject: [PATCH 037/139] fix: mobile API auth to return 401 instead of 403 --- apps/mainsite/mobile_api_authentication.py | 60 +++++++++++++++------- apps/mobile_api/api.py | 44 +++++++--------- 2 files changed, 60 insertions(+), 44 deletions(-) diff --git a/apps/mainsite/mobile_api_authentication.py b/apps/mainsite/mobile_api_authentication.py index 0b2901a3e..8c037cbf9 100644 --- a/apps/mainsite/mobile_api_authentication.py +++ b/apps/mainsite/mobile_api_authentication.py @@ -7,14 +7,14 @@ from allauth.socialaccount.models import SocialAccount from django.conf import settings from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed -GENERAL_TERMS_PATH = "/mobile/api/accept-general-terms" +GENERAL_TERMS_PATH = '/mobile/api/accept-general-terms' -API_LOGIN_PATH = "/mobile/api/login" +API_LOGIN_PATH = '/mobile/api/login' class TemporaryUser: - def __init__(self, user_payload, bearer_token): # Not saved to DB self.user_payload = user_payload @@ -36,10 +36,15 @@ def authenticate(self, request): logger.info(f'MobileAPIAuthentication {request.META}') authorization = request.environ.get('HTTP_AUTHORIZATION') if not authorization: - logger.info('MobileAPIAuthentication: return None as no authorization header') - return None + logger.info('MobileAPIAuthentication: raise AuthenticationFailed as no authorization header') + raise AuthenticationFailed('Authentication credentials were not provided.') + + bearer_token = authorization[len('bearer ') :] + if not bearer_token: + logger.info('MobileAPIAuthentication: raise AuthenticationFailed as no bearer_token in authorization') + raise AuthenticationFailed('Authentication credentials were not provided.') - bearer_token = authorization[len('bearer '):] + bearer_token = authorization[len('bearer ') :] if not bearer_token: logger.info('MobileAPIAuthentication: return None as no bearer_token in authorization') return None @@ -47,11 +52,24 @@ def authenticate(self, request): headers = {'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded'} url = f'{settings.EDUID_PROVIDER_URL}/introspect' auth = (settings.OIDC_RS_ENTITY_ID, settings.OIDC_RS_SECRET) - response = requests.post(url, data=urllib.parse.urlencode({'token': bearer_token}), auth=auth, headers=headers, - timeout=60) + response = requests.post( + url, data=urllib.parse.urlencode({'token': bearer_token}), auth=auth, headers=headers, timeout=60 + ) if response.status_code != 200: logger.info(f'MobileAPIAuthentication bad response from oidcng: {response.status_code} {response.json()}') - return None + raise AuthenticationFailed('Invalid authentication credentials.') + + introspect_json = response.json() + logger.info(f'MobileAPIAuthentication introspect {introspect_json}') + + if not introspect_json['active']: + logger.info(f'MobileAPIAuthentication inactive introspect_json {introspect_json}') + raise AuthenticationFailed('Invalid authentication credentials.') + if settings.EDUID_IDENTIFIER not in introspect_json: + logger.info( + f'MobileAPIAuthentication raise AuthenticationFailed as no {settings.EDUID_IDENTIFIER} in introspect_json {introspect_json}' + ) + raise AuthenticationFailed('Invalid authentication credentials.') introspect_json = response.json() logger.info(f'MobileAPIAuthentication introspect {introspect_json}') @@ -59,8 +77,10 @@ def authenticate(self, request): if not introspect_json['active']: logger.info(f'MobileAPIAuthentication inactive introspect_json {introspect_json}') return None - if not settings.EDUID_IDENTIFIER in introspect_json: - logger.info(f'MobileAPIAuthentication return None as no {settings.EDUID_IDENTIFIER} in introspect_json {introspect_json}') + if settings.EDUID_IDENTIFIER not in introspect_json: + logger.info( + f'MobileAPIAuthentication return None as no {settings.EDUID_IDENTIFIER} in introspect_json {introspect_json}' + ) return None identifier_ = introspect_json[settings.EDUID_IDENTIFIER] @@ -73,9 +93,11 @@ def authenticate(self, request): logger.info(f'MobileAPIAuthentication created TemporaryUser {introspect_json["email"]} for login') return TemporaryUser(introspect_json, bearer_token), bearer_token else: - # If not heading to login-endpoint, we return None resulting in 403 - logger.info(f'MobileAPIAuthentication TemporaryUser {introspect_json["email"]} not allowed to access {request.path}') - return None + # If not heading to login-endpoint, we raise AuthenticationFailed resulting in 401 + logger.info( + f'MobileAPIAuthentication TemporaryUser {introspect_json["email"]} not allowed to access {request.path}' + ) + raise AuthenticationFailed('Authentication credentials were not provided.') # SocialAccount always has a User user = social_account.user agree_terms_endpoint = request.path == GENERAL_TERMS_PATH @@ -85,10 +107,12 @@ def authenticate(self, request): request.mobile_api_call = True return user, bearer_token elif not user.general_terms_accepted() or not user.validated_name: - # If not heading to login-endpoint or agree-terms, we return None resulting in 403 - logger.info(f'MobileAPIAuthentication User {user.email} has not accepted the general terms. ' - f'Not allowed to access {request.path}') - return None + # If not heading to login-endpoint or agree-terms, we raise AuthenticationFailed resulting in 401 + logger.info( + f'MobileAPIAuthentication User {user.email} has not accepted the general terms. ' + f'Not allowed to access {request.path}' + ) + raise AuthenticationFailed('Authentication credentials were not provided.') logger.info(f'MobileAPIAuthentication forwarding User {user.email} to {request.path}') request.mobile_api_call = True diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 0d563654e..ff7b05e7c 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1,41 +1,40 @@ import logging +import requests +from badgeuser.models import StudentAffiliation +from badgrsocialauth.providers.eduid.provider import EduIDProvider +from directaward.models import DirectAward, DirectAwardBundle +from django.conf import settings +from django.db.models import Q, Subquery from django.shortcuts import get_object_or_404 from drf_spectacular.utils import ( - extend_schema, - inline_serializer, OpenApiExample, - OpenApiResponse, OpenApiParameter, + OpenApiResponse, OpenApiTypes, + extend_schema, + inline_serializer, ) -from rest_framework import serializers -from rest_framework.response import Response -from rest_framework.views import APIView -from django.db.models import Q, Subquery -from rest_framework import status -from badgeuser.models import StudentAffiliation -from badgrsocialauth.providers.eduid.provider import EduIDProvider -from directaward.models import DirectAward, DirectAwardBundle from issuer.models import BadgeInstance, BadgeInstanceCollection from issuer.serializers import BadgeInstanceCollectionSerializer from lti_edu.models import StudentsEnrolled from mainsite.exceptions import BadgrApiException400 from mainsite.mobile_api_authentication import TemporaryUser from mainsite.permissions import MobileAPIPermission -from mobile_api.helper import process_eduid_response, RevalidatedNameException, NoValidatedNameException +from mobile_api.helper import NoValidatedNameException, RevalidatedNameException, process_eduid_response from mobile_api.serializers import ( + BadgeCollectionSerializer, BadgeInstanceDetailSerializer, + BadgeInstanceSerializer, + DirectAwardDetailSerializer, DirectAwardSerializer, - StudentsEnrolledSerializer, StudentsEnrolledDetailSerializer, - BadgeCollectionSerializer, + StudentsEnrolledSerializer, UserSerializer, - DirectAwardDetailSerializer, ) -from mobile_api.serializers import BadgeInstanceSerializer -import requests -from django.conf import settings +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView permission_denied_response = OpenApiResponse( response=inline_serializer(name='PermissionDeniedResponse', fields={'detail': serializers.CharField()}), @@ -52,14 +51,7 @@ class Login(APIView): methods=['GET'], description='Login and validate the user', responses={ - 403: OpenApiResponse( - description='User does not have permission', - examples=[ - OpenApiExample( - 'No permission', value={'detail': 'You do not have permission to perform this action.'} - ) - ], - ), + 403: permission_denied_response, 200: OpenApiResponse( description='Successful responses with examples', response=dict, # or inline custom serializer class From 1e178d389a7918a707363444460ce41755eb5b2f Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 23 Jan 2026 16:52:30 +0100 Subject: [PATCH 038/139] fix: return entity_id's instead of id's of badgeinstances within collections # Conflicts: # apps/mobile_api/serializers.py # Conflicts: # apps/mobile_api/serializers.py --- apps/mobile_api/serializers.py | 117 +++++++++++++++++++++++++-------- 1 file changed, 91 insertions(+), 26 deletions(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 190955198..417d6eb5a 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -12,8 +12,16 @@ class InstitutionSerializer(serializers.ModelSerializer): class Meta: model = Institution - fields = ["name_dutch", "name_english", "image_dutch", "image_english", - "identifier", "alternative_identifier", "grondslag_formeel", "grondslag_informeel"] + fields = [ + 'name_dutch', + 'name_english', + 'image_dutch', + 'image_english', + 'identifier', + 'alternative_identifier', + 'grondslag_formeel', + 'grondslag_informeel', + ] class FacultySerializer(serializers.ModelSerializer): @@ -21,8 +29,16 @@ class FacultySerializer(serializers.ModelSerializer): class Meta: model = Faculty - fields = ["name_dutch", "name_english", "image_dutch", "image_english", "on_behalf_of", - "on_behalf_of_display_name", "on_behalf_of_url", "institution"] + fields = [ + 'name_dutch', + 'name_english', + 'image_dutch', + 'image_english', + 'on_behalf_of', + 'on_behalf_of_display_name', + 'on_behalf_of_url', + 'institution', + ] class IssuerSerializer(serializers.ModelSerializer): @@ -30,7 +46,7 @@ class IssuerSerializer(serializers.ModelSerializer): class Meta: model = Issuer - fields = ["name_dutch", "name_english", "image_dutch", "image_english", "faculty"] + fields = ['name_dutch', 'name_english', 'image_dutch', 'image_english', 'faculty'] class BadgeClassExtensionSerializer(serializers.ModelSerializer): @@ -38,12 +54,12 @@ class BadgeClassExtensionSerializer(serializers.ModelSerializer): class Meta: model = BadgeClassExtension - fields = ["name", "value"] + fields = ['name', 'value'] def get_value(self, obj): json_dict = json.loads(obj.original_json) # Consistent naming convention enables to parse "type": ["Extension", "extensions:ECTSExtension"], "ECTS": 2.5} - extension_key = json_dict["type"][1].split(":")[1].removesuffix("Extension") + extension_key = json_dict['type'][1].split(':')[1].removesuffix('Extension') return json_dict[extension_key] @@ -52,7 +68,7 @@ class BadgeClassSerializer(serializers.ModelSerializer): class Meta: model = BadgeClass - fields = ["id", "name", "entity_id", "image_url", "issuer"] + fields = ['id', 'name', 'entity_id', 'image_url', 'issuer'] class BadgeClassDetailSerializer(serializers.ModelSerializer): @@ -61,9 +77,24 @@ class BadgeClassDetailSerializer(serializers.ModelSerializer): class Meta: model = BadgeClass - fields = ["id", "name", "entity_id", "image", "description", "formal", "participation", "assessment_type", - "assessment_id_verified", "assessment_supervised", "quality_assurance_name", "stackable", - "badgeclassextension_set", "issuer", "badge_class_type", "expiration_period"] + fields = [ + 'id', + 'name', + 'entity_id', + 'image', + 'description', + 'formal', + 'participation', + 'assessment_type', + 'assessment_id_verified', + 'assessment_supervised', + 'quality_assurance_name', + 'stackable', + 'badgeclassextension_set', + 'issuer', + 'badge_class_type', + 'expiration_period', + ] class BadgeInstanceSerializer(serializers.ModelSerializer): @@ -71,8 +102,19 @@ class BadgeInstanceSerializer(serializers.ModelSerializer): class Meta: model = BadgeInstance - fields = ["id", "created_at", "entity_id", "issued_on", "award_type", "revoked", "expires_at", "acceptance", - "public", "badgeclass", "grade_achieved"] + fields = [ + 'id', + 'created_at', + 'entity_id', + 'issued_on', + 'award_type', + 'revoked', + 'expires_at', + 'acceptance', + 'public', + 'badgeclass', + 'grade_achieved', + ] class BadgeInstanceDetailSerializer(serializers.ModelSerializer): @@ -81,8 +123,19 @@ class BadgeInstanceDetailSerializer(serializers.ModelSerializer): class Meta: model = BadgeInstance - fields = ["id", "created_at", "entity_id", "issued_on", "award_type", "revoked", "expires_at", "acceptance", - "public", "badgeclass", "linkedin_url"] + fields = [ + 'id', + 'created_at', + 'entity_id', + 'issued_on', + 'award_type', + 'revoked', + 'expires_at', + 'acceptance', + 'public', + 'badgeclass', + 'linkedin_url', + ] def _get_linkedin_org_id(self, badgeclass): faculty = badgeclass.issuer.faculty @@ -125,7 +178,7 @@ class DirectAwardSerializer(serializers.ModelSerializer): class Meta: model = DirectAward - fields = ["id", "created_at", "entity_id", "badgeclass"] + fields = ['id', 'created_at', 'entity_id', 'badgeclass'] class DirectAwardDetailSerializer(serializers.ModelSerializer): @@ -134,7 +187,7 @@ class DirectAwardDetailSerializer(serializers.ModelSerializer): class Meta: model = DirectAward - fields = ["id", "created_at", "status", "entity_id", "badgeclass", "terms"] + fields = ['id', 'created_at', 'status', 'entity_id', 'badgeclass', 'terms'] def get_terms(self, obj): institution_terms = obj.badgeclass.issuer.faculty.institution.terms.all() @@ -147,7 +200,7 @@ class StudentsEnrolledSerializer(serializers.ModelSerializer): class Meta: model = StudentsEnrolled - fields = ["id", "entity_id", "date_created", "denied", "date_awarded", "badge_class"] + fields = ['id', 'entity_id', 'date_created', 'denied', 'date_awarded', 'badge_class'] class StudentsEnrolledDetailSerializer(serializers.ModelSerializer): @@ -155,21 +208,25 @@ class StudentsEnrolledDetailSerializer(serializers.ModelSerializer): class Meta: model = StudentsEnrolled - fields = ["id", "entity_id", "date_created", "denied", "date_awarded", "badge_class"] + fields = ['id', 'entity_id', 'date_created', 'denied', 'date_awarded', 'badge_class'] class BadgeCollectionSerializer(serializers.ModelSerializer): - badge_instances = serializers.PrimaryKeyRelatedField(many=True, queryset=BadgeInstance.objects.all()) + badge_instances = serializers.SerializerMethodField() class Meta: model = BadgeInstanceCollection - fields = ["id", "created_at", "entity_id", "badge_instances", "name", "public", "description"] + fields = ['id', 'created_at', 'entity_id', 'badge_instances', 'name', 'public', 'description'] + + def get_badge_instances(self, obj): + """Return entity_id instead of database id for badge instances""" + return [badge_instance.entity_id for badge_instance in obj.badge_instances.all()] class TermsUrlSerializer(serializers.ModelSerializer): class Meta: model = TermsUrl - fields = ["url", "language", "excerpt"] + fields = ['url', 'language', 'excerpt'] class TermsSerializer(serializers.ModelSerializer): @@ -178,7 +235,7 @@ class TermsSerializer(serializers.ModelSerializer): class Meta: model = Terms - fields = ["entity_id", "terms_type", "institution", "terms_urls"] + fields = ['entity_id', 'terms_type', 'institution', 'terms_urls'] class TermsAgreementSerializer(serializers.ModelSerializer): @@ -186,7 +243,7 @@ class TermsAgreementSerializer(serializers.ModelSerializer): class Meta: model = TermsAgreement - fields = ["entity_id", "agreed", "agreed_version", "terms"] + fields = ['entity_id', 'agreed', 'agreed_version', 'terms'] class UserSerializer(serializers.ModelSerializer): @@ -195,8 +252,16 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = BadgeUser - fields = ["id", "email", "last_name", "first_name", "validated_name", "schac_homes", "terms_agreed", - "termsagreement_set"] + fields = [ + 'id', + 'email', + 'last_name', + 'first_name', + 'validated_name', + 'schac_homes', + 'terms_agreed', + 'termsagreement_set', + ] def get_terms_agreed(self, obj): return obj.general_terms_accepted() From 4600b6841c5d87b7fc4916af34a89ac9eb382ff7 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 23 Jan 2026 16:54:10 +0100 Subject: [PATCH 039/139] chore: improved the swagger doc by adding full models of badge instances, direct award, and collections --- apps/mobile_api/api.py | 231 +++++++++++++++++++++++++++++++++-------- 1 file changed, 187 insertions(+), 44 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index ff7b05e7c..45b41893a 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -208,15 +208,47 @@ class BadgeInstances(APIView): 'Badge Instances List', value=[ { - 'entity_id': '123e4567-e89b-12d3-a456-426614174000', + 'id': 2, + 'created_at': '2021-04-20T16:20:30.528668+02:00', + 'entity_id': 'I41eovHQReGI_SG5KM6dSQ', + 'issued_on': '2021-04-20T16:20:30.521307+02:00', + 'award_type': 'requested', + 'revoked': 'false', + 'expires_at': '2030-04-20T16:20:30.521307+02:00', + 'acceptance': 'Accepted', + 'public': 'true', 'badgeclass': { - 'entity_id': 'badgeclass-123', - 'name': 'Python Programming', - 'description': 'Completed Python programming course', - 'image': 'https://example.com/badge-image.png', + 'id': 3, + 'name': 'Edubadge account complete', + 'entity_id': 'nwsL-dHyQpmvOOKBscsN_A', + 'image_url': 'https://api-demo.edubadges.nl/media/uploads/badges/issuer_badgeclass_548517aa-cbab-4a7b-a971-55cdcce0e2a5.png', + 'issuer': { + 'name_dutch': 'SURF Edubadges', + 'name_english': 'SURF Edubadges', + 'image_dutch': 'null', + 'image_english': '/media/uploads/issuers/issuer_logo_ccd075bb-23cb-40b2-8780-b5a7eda9de1c.png', + 'faculty': { + 'name_dutch': 'SURF', + 'name_english': 'SURF', + 'image_dutch': 'null', + 'image_english': 'null', + 'on_behalf_of': 'false', + 'on_behalf_of_display_name': 'null', + 'on_behalf_of_url': 'null', + 'institution': { + 'name_dutch': 'University Voorbeeld', + 'name_english': 'University Example', + 'image_dutch': '/media/uploads/institution/d0273589-2c7a-4834-8c35-fef4695f176a.png', + 'image_english': '/media/uploads/institution/eae5465f-98b1-4849-ac2d-47d4e1cd1252.png', + 'identifier': 'university-example.org', + 'alternative_identifier': 'university-example.org.tempguestidp.edubadges.nl', + 'grondslag_formeel': 'gerechtvaardigd_belang', + 'grondslag_informeel': 'gerechtvaardigd_belang', + }, + }, + }, }, - 'issued_on': '2023-01-15T10:30:00Z', - 'recipient_identifier': 'user@example.com', + 'grade_achieved': '33', }, ], description='Array of badge instances belonging to the user', @@ -265,18 +297,62 @@ class BadgeInstanceDetail(APIView): OpenApiExample( 'Badge Instance Details', value={ - 'entity_id': '123e4567-e89b-12d3-a456-426614174000', + 'id': 2, + 'created_at': '2021-04-20T16:20:30.528668+02:00', + 'entity_id': 'I41eovHQReGI_SG5KM6dSQ', + 'issued_on': '2021-04-20T16:20:30.521307+02:00', + 'award_type': 'requested', + 'revoked': 'false', + 'expires_at': 'null', + 'acceptance': 'Accepted', + 'public': 'true', 'badgeclass': { - 'entity_id': 'badgeclass-123', - 'name': 'Python Programming', - 'description': 'Completed Python programming course', - 'image': 'https://example.com/badge-image.png', - 'criteria': 'https://example.com/criteria', + 'id': 3, + 'name': 'Edubadge account complete', + 'entity_id': 'nwsL-dHyQpmvOOKBscsN_A', + 'image': '/media/uploads/badges/issuer_badgeclass_548517aa-cbab-4a7b-a971-55cdcce0e2a5.png', + 'description': '### Welcome to edubadges. Let your life long learning begin! ###\r\n\r\nYou are now ready to collect all your edubadges in your backpack. In your backpack you can store and manage them safely.\r\n\r\nShare them anytime you like and with whom you like.\r\n\r\nEdubadges are visual representations of your knowledge, skills and competences.', + 'formal': 'false', + 'participation': 'blended', + 'assessment_type': 'written_exam', + 'assessment_id_verified': 'false', + 'assessment_supervised': 'false', + 'quality_assurance_name': 'null', + 'stackable': 'false', + 'badgeclassextension_set': [ + {'name': 'extensions:LanguageExtension', 'value': 'en_EN'}, + { + 'name': 'extensions:LearningOutcomeExtension', + 'value': 'This is an edubadge for demonstration purposes. The learning outcome for this edubadge is:\n\n* you have a basic understanding of edubadges,\n* you have a basic understanding how to use eduID.\n', + }, + ], + 'issuer': { + 'name_dutch': 'SURF Edubadges', + 'name_english': 'SURF Edubadges', + 'image_dutch': 'null', + 'image_english': '/media/uploads/issuers/issuer_logo_ccd075bb-23cb-40b2-8780-b5a7eda9de1c.png', + 'faculty': { + 'name_dutch': 'SURF', + 'name_english': 'SURF', + 'image_dutch': 'null', + 'image_english': 'null', + 'on_behalf_of': 'false', + 'on_behalf_of_display_name': 'null', + 'on_behalf_of_url': 'null', + 'institution': { + 'name_dutch': 'University Voorbeeld', + 'name_english': 'University Example', + 'image_dutch': '/media/uploads/institution/d0273589-2c7a-4834-8c35-fef4695f176a.png', + 'image_english': '/media/uploads/institution/eae5465f-98b1-4849-ac2d-47d4e1cd1252.png', + 'identifier': 'university-example.org', + 'alternative_identifier': 'university-example.org.tempguestidp.edubadges.nl', + 'grondslag_formeel': 'gerechtvaardigd_belang', + 'grondslag_informeel': 'gerechtvaardigd_belang', + }, + }, + }, }, - 'issued_on': '2023-01-15T10:30:00Z', - 'recipient_identifier': 'user@example.com', - 'evidence': 'https://example.com/evidence', - 'narrative': 'User completed all assignments and final project', + 'linkedin_url': 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&name=Edubadge%20account%20complete&organizationId=206815&issueYear=2021&issueMonth=3&certUrl=https%3A%2F%2Fdemo.edubadges.nl%2Fpublic%2Fassertions%2FI41eovHQReGI_SG5KM6dSQ&certId=I41eovHQReGI_SG5KM6dSQ&original_referer=https%3A%2F%2Fdemo.edubadges.nl', }, description='Detailed information about a specific badge instance', response_only=True, @@ -327,17 +403,41 @@ class UnclaimedDirectAwards(APIView): 'Unclaimed Direct Awards', value=[ { - 'entity_id': 'direct-award-123', + 'id': 9606, + 'created_at': '2026-01-23T16:19:08.699037+01:00', + 'entity_id': 'Lgnh9njyStmGiI_w8396Xg', 'badgeclass': { - 'entity_id': 'badgeclass-456', - 'name': 'Data Science Certificate', - 'description': 'Awarded for completing data science program', - 'image': 'https://example.com/data-science-badge.png', + 'id': 113, + 'name': 'unclaimed test', + 'entity_id': 'X4MQyOYPS9yoMyZwZik1Jg', + 'image_url': 'https://api-demo.edubadges.nl/media/uploads/badges/issuer_badgeclass_32c9f91d-e731-40d4-99d4-c06ec6922f31.png', + 'issuer': { + 'name_dutch': 'SURF Edubadges', + 'name_english': 'SURF Edubadges', + 'image_dutch': 'null', + 'image_english': '/media/uploads/issuers/issuer_logo_ccd075bb-23cb-40b2-8780-b5a7eda9de1c.png', + 'faculty': { + 'name_dutch': 'SURF', + 'name_english': 'SURF', + 'image_dutch': 'null', + 'image_english': 'null', + 'on_behalf_of': 'false', + 'on_behalf_of_display_name': 'null', + 'on_behalf_of_url': 'null', + 'institution': { + 'name_dutch': 'University Voorbeeld', + 'name_english': 'University Example', + 'image_dutch': '/media/uploads/institution/d0273589-2c7a-4834-8c35-fef4695f176a.png', + 'image_english': '/media/uploads/institution/eae5465f-98b1-4849-ac2d-47d4e1cd1252.png', + 'identifier': 'university-example.org', + 'alternative_identifier': 'university-example.org.tempguestidp.edubadges.nl', + 'grondslag_formeel': 'gerechtvaardigd_belang', + 'grondslag_informeel': 'gerechtvaardigd_belang', + }, + }, + }, }, - 'status': 'Unaccepted', - 'created_at': '2023-02-20T09:15:00Z', - 'recipient_email': 'user@example.com', - }, + } ], description='Array of unclaimed direct awards available to the user', response_only=True, @@ -479,16 +579,42 @@ class Enrollments(APIView): 'Enrollments List', value=[ { - 'entity_id': 'enrollment-123', + 'id': 40, + 'entity_id': 'UMcx7xCPS4yBuztOj2IDEw', + 'date_created': '2023-09-04T14:42:03.046498+02:00', + 'denied': 'false', + 'date_awarded': '2023-09-04T15:02:15.088536+02:00', 'badge_class': { - 'entity_id': 'badgeclass-789', - 'name': 'Advanced Machine Learning', - 'description': 'Enrolled in advanced ML course', + 'id': 119, + 'name': 'Test enrollment', + 'entity_id': '_KI6moSxQ3mAzPEfYUHnLg', + 'image_url': 'https://api-demo.edubadges.nl/media/uploads/badges/issuer_badgeclass_3b1a3c87-d7c6-488f-a1f9-1d3019a137ee.png', + 'issuer': { + 'name_dutch': 'SURF Edubadges', + 'name_english': 'SURF Edubadges', + 'image_dutch': 'null', + 'image_english': '/media/uploads/issuers/issuer_logo_ccd075bb-23cb-40b2-8780-b5a7eda9de1c.png', + 'faculty': { + 'name_dutch': 'SURF', + 'name_english': 'SURF', + 'image_dutch': 'null', + 'image_english': 'null', + 'on_behalf_of': 'false', + 'on_behalf_of_display_name': 'null', + 'on_behalf_of_url': 'null', + 'institution': { + 'name_dutch': 'University Voorbeeld', + 'name_english': 'University Example', + 'image_dutch': '/media/uploads/institution/d0273589-2c7a-4834-8c35-fef4695f176a.png', + 'image_english': '/media/uploads/institution/eae5465f-98b1-4849-ac2d-47d4e1cd1252.png', + 'identifier': 'university-example.org', + 'alternative_identifier': 'university-example.org.tempguestidp.edubadges.nl', + 'grondslag_formeel': 'gerechtvaardigd_belang', + 'grondslag_informeel': 'gerechtvaardigd_belang', + }, + }, + }, }, - 'user': 'user@example.com', - 'date_enrolled': '2023-03-10T14:25:00Z', - 'date_awarded': None, - 'status': 'Active', }, ], description='Array of course enrollments for the user', @@ -577,11 +703,11 @@ class EnrollmentDetail(APIView): # ManyToManyField / reverse FK → prefetch_related def get(self, request, entity_id, **kwargs): enrollment = ( - StudentsEnrolled.objects.select_related('badgeclass') - .prefetch_related('badgeclass__badgeclassextension_set') - .select_related('badgeclass__issuer') - .select_related('badgeclass__issuer__faculty') - .select_related('badgeclass__issuer__faculty__institution') + StudentsEnrolled.objects.select_related('badge_class') + .prefetch_related('badge_class__badgeclassextension_set') + .select_related('badge_class__issuer') + .select_related('badge_class__issuer__faculty') + .select_related('badge_class__issuer__faculty__institution') .filter(user=request.user) .filter(entity_id=entity_id) .get() @@ -661,10 +787,27 @@ class BadgeCollectionsListView(APIView): 'Badge Collections List', value=[ { - 'entity_id': 'YpQpVLu6QsmXiWZ7YhrSPQ', - 'name': 'My Achievements', - 'desacription': 'Collection of my programming achievements', - 'badge_instances': [311, 312], + 'id': 9, + 'created_at': '2025-10-07T12:41:36.332147+02:00', + 'entity_id': 'lt3O3SUpS9Culz0IrA3rOg', + 'badge_instances': [ + 'badge-96-entity-id', + 'badge-175-entity-id', + 'badge-176-entity-id', + 'badge-287-entity-id', + ], + 'name': 'Test collection 1', + 'public': 'false', + 'description': 'test', + }, + { + 'id': 11, + 'created_at': '2025-10-27T16:14:42.650246+01:00', + 'entity_id': 'dhuf6Qx2RMCtRKBw0iHGcg', + 'badge_instances': ['badge-96-entity-id', 'badge-175-entity-id'], + 'name': 'Test collection 2', + 'public': 'true', + 'description': 'Test2', }, ], description='Array of badge collections created by the user', @@ -727,7 +870,7 @@ class BadgeCollectionsDetailView(APIView): @extend_schema( request=BadgeInstanceCollectionSerializer, - description='Update an existing BadgeInstanceCollection by ID', + description='Update an existing BadgeInstanceCollection by entity_id', parameters=[ OpenApiParameter( name='entity_id', From 4c5b6222aea458a4cc7d6f704312893a19602061 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 23 Jan 2026 17:12:31 +0100 Subject: [PATCH 040/139] fix: use for badge-instances/entity_id path one view (BadgeInstanceDetail) and add logic to support PUT method in BadgeInstanceDetail --- apps/mobile_api/api.py | 86 +++++++++++++++++++++++++++++++++++++ apps/mobile_api/api_urls.py | 25 ++++++++--- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 45b41893a..ef9997d50 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -387,6 +387,92 @@ def get(self, request, entity_id, **kwargs): serializer = BadgeInstanceDetailSerializer(instance) return Response(serializer.data) + @extend_schema( + methods=['PUT'], + description='Update a badge instance', + parameters=[ + OpenApiParameter( + name='entity_id', + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + required=True, + description='entity_id of the badge instance', + ) + ], + request=BadgeInstanceDetailSerializer, + responses={ + 200: OpenApiResponse( + description='Badge instance updated successfully', + response=BadgeInstanceDetailSerializer, + examples=[ + OpenApiExample( + 'Updated Badge Instance', + value={ + 'id': 2, + 'created_at': '2021-04-20T16:20:30.528668+02:00', + 'entity_id': 'I41eovHQReGI_SG5KM6dSQ', + 'issued_on': '2021-04-20T16:20:30.521307+02:00', + 'award_type': 'requested', + 'revoked': 'false', + 'expires_at': 'null', + 'acceptance': 'Accepted', + 'public': 'true', + 'badgeclass': { + 'id': 3, + 'name': 'Edubadge account complete', + 'entity_id': 'nwsL-dHyQpmvOOKBscsN_A', + 'image': '/media/uploads/badges/issuer_badgeclass_548517aa-cbab-4a7b-a971-55cdcce0e2a5.png', + 'description': '### Welcome to edubadges. Let your life long learning begin! ###\r\n\r\nYou are now ready to collect all your edubadges in your backpack. In your backpack you can store and manage them safely.\r\n\r\nShare them anytime you like and with whom you like.\r\n\r\nEdubadges are visual representations of your knowledge, skills and competences.', + 'formal': 'false', + }, + }, + description='Updated badge instance details', + response_only=True, + ), + ], + ), + 404: OpenApiResponse( + description='Badge instance not found', + examples=[ + OpenApiExample( + 'Not Found', + value={'detail': 'Badge instance not found'}, + description='The requested badge instance does not exist or user does not have access', + response_only=True, + ), + ], + ), + 400: OpenApiResponse( + description='Invalid request data', + examples=[ + OpenApiExample( + 'Invalid Data', + value={'public': ['This field is required.']}, + description='Validation errors in the request data', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, + ) + def put(self, request, entity_id, **kwargs): + instance = ( + BadgeInstance.objects.select_related('badgeclass') + .select_related('badgeclass__issuer') + .select_related('badgeclass__issuer__faculty') + .select_related('badgeclass__issuer__faculty__institution') + .filter(user=request.user) + .filter(entity_id=entity_id) + .get() + ) + + serializer = BadgeInstanceDetailSerializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + class UnclaimedDirectAwards(APIView): permission_classes = (MobileAPIPermission,) diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index 794a60714..c940dd70d 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -4,18 +4,29 @@ from badgeuser.api import AcceptTermsView, BadgeUserDetail from directaward.api import DirectAwardAccept from lti_edu.api import StudentsEnrolledList -from mobile_api.api import BadgeInstances, BadgeInstanceDetail, UnclaimedDirectAwards, Enrollments, EnrollmentDetail, \ - BadgeCollectionsListView, BadgeCollectionsDetailView, Login, AcceptGeneralTerms, DirectAwardDetail +from mobile_api.api import ( + BadgeInstances, + BadgeInstanceDetail, + UnclaimedDirectAwards, + Enrollments, + EnrollmentDetail, + BadgeCollectionsListView, + BadgeCollectionsDetailView, + Login, + AcceptGeneralTerms, + DirectAwardDetail, +) urlpatterns = [ path('accept-general-terms', AcceptGeneralTerms.as_view(), name='mobile_api_accept_general_terms'), path('badge-collections', BadgeCollectionsListView.as_view(), name='mobile_api_badge_collections'), - path('badge-collections/', BadgeCollectionsDetailView.as_view(), - name='mobile_api_badge_collection_update'), + path( + 'badge-collections/', + BadgeCollectionsDetailView.as_view(), + name='mobile_api_badge_collection_update', + ), path('badge-instances', BadgeInstances.as_view(), name='mobile_api_badge_instances'), - path('badge-instances/', BadgeInstanceDetail.as_view(), - name='mobile_api_badge_instance_detail'), - path('badge-instances/', BackpackAssertionDetail.as_view(), name='mobile_api_badge_instance_udate'), + path('badge-instances/', BadgeInstanceDetail.as_view(), name='mobile_api_badge_instance_detail'), path('direct-awards', UnclaimedDirectAwards.as_view(), name='mobile_api_direct_awards'), path('direct-awards/', DirectAwardDetail.as_view(), name='mobile_api_direct_awards_detail'), path('direct-awards-accept/', DirectAwardAccept.as_view(), name='direct_award_accept'), From ad7b5770e56c7a136359b54cf0de23edd6cadfe8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 23 Jan 2026 17:42:21 +0100 Subject: [PATCH 041/139] fix: have badge instance PUT method only allow acceptance and public field --- apps/mobile_api/api.py | 46 ++++++++++++++++++++++++++++++---- apps/mobile_api/serializers.py | 6 ++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index ef9997d50..5747310a2 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -389,7 +389,7 @@ def get(self, request, entity_id, **kwargs): @extend_schema( methods=['PUT'], - description='Update a badge instance', + description='Update badge instance acceptance status and public visibility', parameters=[ OpenApiParameter( name='entity_id', @@ -399,7 +399,24 @@ def get(self, request, entity_id, **kwargs): description='entity_id of the badge instance', ) ], - request=BadgeInstanceDetailSerializer, + request=inline_serializer( + name='BadgeInstanceUpdateRequest', + fields={ + 'acceptance': serializers.CharField(required=False, help_text='Acceptance status of the badge'), + 'public': serializers.BooleanField(required=False, help_text='Whether the badge should be public'), + }, + ), + examples=[ + OpenApiExample( + 'Update Badge Instance Request', + value={ + 'acceptance': 'Accepted', + 'public': True, + }, + description='Example request to update badge acceptance and public status', + request_only=True, + ), + ], responses={ 200: OpenApiResponse( description='Badge instance updated successfully', @@ -467,10 +484,29 @@ def put(self, request, entity_id, **kwargs): .get() ) - serializer = BadgeInstanceDetailSerializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - serializer.save() + # Only allow updating acceptance and public fields + acceptance = request.data.get('acceptance') + public = request.data.get('public') + + # Validate acceptance field if provided + if acceptance is not None: + if acceptance not in ['Accepted', 'Unaccepted', 'Rejected']: + return Response( + {'detail': 'Invalid acceptance value. Must be one of: Accepted, Unaccepted, Rejected'}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Only allow changing to 'Accepted' if currently not accepted + if instance.acceptance in ['Unaccepted', 'Rejected'] and acceptance == 'Accepted': + instance.acceptance = 'Accepted' + # Update public field if provided + if public is not None: + instance.public = public + + instance.save() + + # Return the updated instance with full details + serializer = BadgeInstanceDetailSerializer(instance) return Response(serializer.data) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 417d6eb5a..67e9db3c6 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -1,12 +1,12 @@ -from rest_framework import serializers import json from urllib.parse import urlencode -from badgeuser.models import BadgeUser, UserProvisionment, TermsAgreement, Terms, TermsUrl +from badgeuser.models import BadgeUser, Terms, TermsAgreement, TermsUrl from directaward.models import DirectAward from institution.models import Faculty, Institution -from issuer.models import BadgeInstance, BadgeClass, BadgeClassExtension, Issuer, BadgeInstanceCollection +from issuer.models import BadgeClass, BadgeClassExtension, BadgeInstance, BadgeInstanceCollection, Issuer from lti_edu.models import StudentsEnrolled +from rest_framework import serializers class InstitutionSerializer(serializers.ModelSerializer): From 6f3616e38beee031c5b53d979d1842bb640bfbaa Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 27 Jan 2026 13:09:30 +0100 Subject: [PATCH 042/139] Use slug related field instead of serializer method field --- apps/mobile_api/serializers.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 67e9db3c6..b66286a46 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -212,16 +212,16 @@ class Meta: class BadgeCollectionSerializer(serializers.ModelSerializer): - badge_instances = serializers.SerializerMethodField() + badge_instances = serializers.SlugRelatedField( + many=True, + read_only=True, + slug_field='entity_id' + ) class Meta: model = BadgeInstanceCollection fields = ['id', 'created_at', 'entity_id', 'badge_instances', 'name', 'public', 'description'] - def get_badge_instances(self, obj): - """Return entity_id instead of database id for badge instances""" - return [badge_instance.entity_id for badge_instance in obj.badge_instances.all()] - class TermsUrlSerializer(serializers.ModelSerializer): class Meta: From f595116c6f1221b7c6fafe5b9425b70239aa8d07 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 27 Jan 2026 13:09:51 +0100 Subject: [PATCH 043/139] Prefetch related badge instances to minimize queries --- apps/mobile_api/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 5747310a2..e7881b5aa 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -941,7 +941,7 @@ class BadgeCollectionsListView(APIView): }, ) def get(self, request, **kwargs): - collections = BadgeInstanceCollection.objects.filter(user=request.user) + collections = BadgeInstanceCollection.objects.filter(user=request.user).prefetch_related('badge_instances') serializer = BadgeCollectionSerializer(collections, many=True) return Response(serializer.data) From ba62d498cec3ecb5577237bc8a258315ffaf156c Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 26 Jan 2026 11:03:51 +0100 Subject: [PATCH 044/139] Add catalog list view with pagination This is a replacement for the api endpoint in queries with the raw sql --- apps/mobile_api/api.py | 44 ++++++++++++++++-- apps/mobile_api/api_urls.py | 2 + apps/mobile_api/pagination.py | 6 +++ apps/mobile_api/serializers.py | 82 ++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 apps/mobile_api/pagination.py diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index e7881b5aa..cb356b971 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1,11 +1,13 @@ import logging import requests +from rest_framework.permissions import AllowAny + from badgeuser.models import StudentAffiliation from badgrsocialauth.providers.eduid.provider import EduIDProvider from directaward.models import DirectAward, DirectAwardBundle from django.conf import settings -from django.db.models import Q, Subquery +from django.db.models import Q, Subquery, Count from django.shortcuts import get_object_or_404 from drf_spectacular.utils import ( OpenApiExample, @@ -15,13 +17,14 @@ extend_schema, inline_serializer, ) -from issuer.models import BadgeInstance, BadgeInstanceCollection +from issuer.models import BadgeInstance, BadgeInstanceCollection, BadgeClass from issuer.serializers import BadgeInstanceCollectionSerializer from lti_edu.models import StudentsEnrolled from mainsite.exceptions import BadgrApiException400 from mainsite.mobile_api_authentication import TemporaryUser from mainsite.permissions import MobileAPIPermission from mobile_api.helper import NoValidatedNameException, RevalidatedNameException, process_eduid_response +from mobile_api.pagination import CatalogPagination from mobile_api.serializers import ( BadgeCollectionSerializer, BadgeInstanceDetailSerializer, @@ -31,8 +34,9 @@ StudentsEnrolledDetailSerializer, StudentsEnrolledSerializer, UserSerializer, + CatalogBadgeClassSerializer, ) -from rest_framework import serializers, status +from rest_framework import serializers, status, generics from rest_framework.response import Response from rest_framework.views import APIView @@ -1101,3 +1105,37 @@ def delete(self, request, entity_id): badge_collection = get_object_or_404(BadgeInstanceCollection, entity_id=entity_id, user=request.user) badge_collection.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class CatalogBadgeClassListView(generics.ListAPIView): + permission_classes = (AllowAny,) + serializer_class = CatalogBadgeClassSerializer + pagination_class = CatalogPagination + + def get_queryset(self): + return ( + BadgeClass.objects + .select_related( + 'issuer', + 'issuer__faculty', + 'issuer__faculty__institution', + ) + .filter( + is_private=False, + issuer__archived=False, + issuer__faculty__archived=False, + ) + .exclude( + issuer__faculty__visibility_type='TEST' + ) + .annotate( + selfRequestedAssertionsCount=Count( + 'badgeinstance', + filter=Q(badgeinstance__award_type='requested'), + ), + directAwardedAssertionsCount=Count( + 'badgeinstance', + filter=Q(badgeinstance__award_type='direct_award'), + ), + ) + ) diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index c940dd70d..38d740a6c 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -15,6 +15,7 @@ Login, AcceptGeneralTerms, DirectAwardDetail, + CatalogBadgeClassListView, ) urlpatterns = [ @@ -36,4 +37,5 @@ path('terms/accept', AcceptTermsView.as_view(), name='mobile_api_user_terms_accept'), path('enroll', StudentsEnrolledList.as_view(), name='mobile_api_lti_edu_enroll_student'), path('profile', BadgeUserDetail.as_view(), name='mobile_api_user_profile'), + path('catalog', CatalogBadgeClassListView.as_view(), name='mobile_api_catalog_badge_class'), ] diff --git a/apps/mobile_api/pagination.py b/apps/mobile_api/pagination.py new file mode 100644 index 000000000..304c8efa8 --- /dev/null +++ b/apps/mobile_api/pagination.py @@ -0,0 +1,6 @@ +from rest_framework.pagination import PageNumberPagination + +class CatalogPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = 'page_size' + max_page_size = 100 diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index b66286a46..e33f2f9f5 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -265,3 +265,85 @@ class Meta: def get_terms_agreed(self, obj): return obj.general_terms_accepted() + + +class CatalogBadgeClassSerializer(serializers.ModelSerializer): + # BadgeClass fields + created_at = serializers.DateTimeField(read_only=True) + name = serializers.CharField() + image = serializers.ImageField() + archived = serializers.BooleanField() + entity_id = serializers.CharField(read_only=True) + is_private = serializers.BooleanField() + is_micro_credentials = serializers.BooleanField() + badge_class_type = serializers.CharField() + + # Issuer fields + issuer_name_english = serializers.CharField(source='issuer.name_english', read_only=True) + issuer_name_dutch = serializers.CharField(source='issuer.name_dutch', read_only=True) + issuer_entity_id = serializers.CharField(source='issuer.entity_id', read_only=True) + issuer_image_dutch = serializers.CharField(source='issuer.image_dutch', read_only=True) + issuer_image_english = serializers.CharField(source='issuer.image_english', read_only=True) + + # Faculty fields + faculty_name_english = serializers.CharField(source='issuer.faculty.name_english', read_only=True) + faculty_name_dutch = serializers.CharField(source='issuer.faculty.name_dutch', read_only=True) + faculty_entity_id = serializers.CharField(source='issuer.faculty.entity_id', read_only=True) + faculty_image_dutch = serializers.CharField(source='issuer.faculty.image_dutch', read_only=True) + faculty_image_english = serializers.CharField(source='issuer.faculty.image_english', read_only=True) + faculty_on_behalf_of = serializers.BooleanField(source='issuer.faculty.on_behalf_of', read_only=True) + faculty_type = serializers.CharField(source='issuer.faculty.faculty_type', read_only=True) + + # Institution fields + institution_name_english = serializers.CharField(source='issuer.faculty.institution.name_english', read_only=True) + institution_name_dutch = serializers.CharField(source='issuer.faculty.institution.name_dutch', read_only=True) + institution_entity_id = serializers.CharField(source='issuer.faculty.institution.entity_id', read_only=True) + institution_image_dutch = serializers.CharField(source='issuer.faculty.institution.image_dutch', read_only=True) + institution_image_english = serializers.CharField(source='issuer.faculty.institution.image_english', read_only=True) + institution_type = serializers.CharField(source='issuer.faculty.institution.institution_type', read_only=True) + + # Annotated counts + self_requested_assertions_count = serializers.IntegerField(read_only=True) + direct_awarded_assertions_count = serializers.IntegerField(read_only=True) + + class Meta: + model = BadgeClass + fields = [ + # BadgeClass + 'created_at', + 'name', + 'image', + 'archived', + 'entity_id', + 'is_private', + 'is_micro_credentials', + 'badge_class_type', + + # Issuer + 'issuer_name_english', + 'issuer_name_dutch', + 'issuer_entity_id', + 'issuer_image_dutch', + 'issuer_image_english', + + # Faculty + 'faculty_name_english', + 'faculty_name_dutch', + 'faculty_entity_id', + 'faculty_image_dutch', + 'faculty_image_english', + 'faculty_on_behalf_of', + 'faculty_type', + + # Institution + 'institution_name_english', + 'institution_name_dutch', + 'institution_entity_id', + 'institution_image_dutch', + 'institution_image_english', + 'institution_type', + + # Counts + 'self_requested_assertions_count', + 'direct_awarded_assertions_count' + ] From b0ed25ab102e38ccfc07f076274ce160c2d41cac Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 26 Jan 2026 11:04:47 +0100 Subject: [PATCH 045/139] Add filter class so endpoint can be filtered with query params --- apps/mobile_api/api.py | 2 ++ apps/mobile_api/filters.py | 28 ++++++++++++++++++++++++++++ requirements.txt | 1 + 3 files changed, 31 insertions(+) create mode 100644 apps/mobile_api/filters.py diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index cb356b971..6cac06ed4 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -23,6 +23,7 @@ from mainsite.exceptions import BadgrApiException400 from mainsite.mobile_api_authentication import TemporaryUser from mainsite.permissions import MobileAPIPermission +from mobile_api.filters import CatalogBadgeClassFilter from mobile_api.helper import NoValidatedNameException, RevalidatedNameException, process_eduid_response from mobile_api.pagination import CatalogPagination from mobile_api.serializers import ( @@ -1110,6 +1111,7 @@ def delete(self, request, entity_id): class CatalogBadgeClassListView(generics.ListAPIView): permission_classes = (AllowAny,) serializer_class = CatalogBadgeClassSerializer + filterset_class = CatalogBadgeClassFilter pagination_class = CatalogPagination def get_queryset(self): diff --git a/apps/mobile_api/filters.py b/apps/mobile_api/filters.py new file mode 100644 index 000000000..fd9737317 --- /dev/null +++ b/apps/mobile_api/filters.py @@ -0,0 +1,28 @@ +import django_filters as filters +from django.db.models import Q + +from issuer.models import BadgeClass + +class CatalogBadgeClassFilter(filters.FilterSet): + name = filters.CharFilter(field_name='name', lookup_expr='icontains') + institution = filters.CharFilter( + field_name='issuer__faculty__institution__entity_id' + ) + is_micro = filters.BooleanFilter(field_name='is_micro_credentials') + + q = filters.CharFilter(method='filter_q', label='Search') + + class Meta: + model = BadgeClass + fields = ['name', 'institution', 'is_micro', 'q'] + + def filter_q(self, queryset, name, value): + return queryset.filter( + Q(name__icontains=value) | + Q(issuer__name_english__icontains=value) | + Q(issuer__name_dutch__icontains=value) | + Q(issuer__faculty__name_english__icontains=value) | + Q(issuer__faculty__name_dutch__icontains=value) | + Q(issuer__faculty__institution__name_english__icontains=value) | + Q(issuer__faculty__institution__name_dutch__icontains=value) + ) diff --git a/requirements.txt b/requirements.txt index 966fa2587..a6ade26dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,6 +26,7 @@ django-object-actions==4.3.0 pymemcache==4.0.0 djangorestframework==3.15.2 +django-filter==25.1 # Django Allauth django-allauth==0.51.0 From 7437740e2872ed8712617517a7cd6da5a4a14700 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 26 Jan 2026 11:05:03 +0100 Subject: [PATCH 046/139] Add schema example --- apps/mobile_api/api.py | 130 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 6cac06ed4..2df51350d 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1114,6 +1114,136 @@ class CatalogBadgeClassListView(generics.ListAPIView): filterset_class = CatalogBadgeClassFilter pagination_class = CatalogPagination + @extend_schema( + methods=['GET'], + description='Get a paginated list of badge classes. Supports filtering, searching (q), and page_size.', + parameters=[ + OpenApiParameter( + name='page', + type=OpenApiTypes.INT, + location='query', + required=False, + description='Page number for pagination' + ), + OpenApiParameter( + name='page_size', + type=OpenApiTypes.INT, + location='query', + required=False, + description='Number of items per page' + ), + OpenApiParameter( + name='name', + type=OpenApiTypes.STR, + location='query', + required=False, + description='Filter badge classes by name' + ), + OpenApiParameter( + name='institution', + type=OpenApiTypes.STR, + location='query', + required=False, + description='Filter badge classes by institution entity_id' + ), + OpenApiParameter( + name='is_micro', + type=OpenApiTypes.BOOL, + location='query', + required=False, + description='Filter for micro-credentials (true/false)' + ), + OpenApiParameter( + name='q', + type=OpenApiTypes.STR, + location='query', + required=False, + description='General search across badge class, issuer, faculty, and institution names' + ), + ], + responses={ + 200: OpenApiResponse( + description='Paginated list of badge classes', + response=CatalogBadgeClassSerializer(many=True), + examples=[ + OpenApiExample( + 'Filtered and Paginated Badge Classes Example', + value={ + "count": 124, + "next": "https://api.example.com/catalog/badge-classes/?page=2&page_size=2&q=edubadge", + "previous": None, + "results": [ + { + "created_at": "2025-05-02T12:20:51.573423", + "name": "Edubadge account complete", + "image": "uploads/badges/edubadge_student.png", + "archived": 0, + "entity_id": "qNGehQ2dRTKyjNtiDvhWsQ", + "is_private": 0, + "is_micro_credentials": 0, + "badge_class_type": "regular", + "issuer_name_english": "Team edubadges", + "issuer_name_dutch": "Team edubadges", + "issuer_entity_id": "WOLxSjpWQouas1123Z809Q", + "issuer_image_dutch": "", + "issuer_image_english": "uploads/issuers/surf.png", + "faculty_name_english": "eduBadges", + "faculty_name_dutch": "null", + "faculty_entity_id": "lVu1kbaqSDyJV_1Bu8_bcw", + "faculty_image_dutch": "", + "faculty_image_english": "", + "faculty_on_behalf_of": 0, + "faculty_type": "null", + "institution_name_english": "SURF", + "institution_name_dutch": "SURF", + "institution_entity_id": "NiqkZiz2TaGT8B4RRwG8Fg", + "institution_image_dutch": "uploads/issuers/surf.png", + "institution_image_english": "uploads/issuers/surf.png", + "institution_type": "null", + "self_requested_assertions_count": 1, + "direct_awarded_assertions_count": 0 + }, + { + "created_at": "2025-05-02T12:20:57.914064", + "name": "Growth and Development", + "image": "uploads/badges/eduid.png", + "archived": 0, + "entity_id": "Ge4D7gf1RLGYNZlSiCv-qA", + "is_private": 0, + "is_micro_credentials": 0, + "badge_class_type": "regular", + "issuer_name_english": "Medicine", + "issuer_name_dutch": "null", + "issuer_entity_id": "yuflXDK8ROukQkxSPmh5ag", + "issuer_image_dutch": "", + "issuer_image_english": "uploads/issuers/surf.png", + "faculty_name_english": "Medicine", + "faculty_name_dutch": "null", + "faculty_entity_id": "yYPphJ3bS5qszI7P69degA", + "faculty_image_dutch": "", + "faculty_image_english": "", + "faculty_on_behalf_of": 0, + "faculty_type": "null", + "institution_name_english": "university-example.org", + "institution_name_dutch": "null", + "institution_entity_id": "5rZhvRonT3OyyLQhhmuPmw", + "institution_image_dutch": "uploads/institution/surf.png", + "institution_image_english": "uploads/institution/surf.png", + "institution_type": "WO", + "self_requested_assertions_count": 0, + "direct_awarded_assertions_count": 0 + } + ] + }, + response_only=True, + ) + ], + ), + 500: OpenApiResponse( + description='Internal server error occurred while retrieving badge classes.' + ), + } + ) def get_queryset(self): return ( BadgeClass.objects From a4741c5ed9d2db24bf44269678ceb3b52553c5d6 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 26 Jan 2026 16:25:41 +0100 Subject: [PATCH 047/139] Replace profile api view with custom one for mobile api --- apps/mobile_api/api.py | 35 ++++++++++++++++++++++++++++++++-- apps/mobile_api/api_urls.py | 5 +++-- apps/mobile_api/serializers.py | 16 ++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 2df51350d..a3e3da1cf 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1,7 +1,7 @@ import logging import requests -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated from badgeuser.models import StudentAffiliation from badgrsocialauth.providers.eduid.provider import EduIDProvider @@ -35,7 +35,7 @@ StudentsEnrolledDetailSerializer, StudentsEnrolledSerializer, UserSerializer, - CatalogBadgeClassSerializer, + CatalogBadgeClassSerializer, UserProfileSerializer, ) from rest_framework import serializers, status, generics from rest_framework.response import Response @@ -1271,3 +1271,34 @@ def get_queryset(self): ), ) ) + + +class UserProfileView(APIView): + permission_classes = (IsAuthenticated, MobileAPIPermission) + http_method_names = ('get', 'delete') + + @extend_schema( + description="Get the authenticated user's profile", + responses={200: UserProfileSerializer}, + ) + def get(self, request): + serializer = UserProfileSerializer( + request.user, + context={'request': request}, + ) + return Response(serializer.data) + + @extend_schema( + description="Delete the authenticated user", + responses={ + 204: OpenApiResponse( + description="User account deleted successfully" + ), + 403: OpenApiResponse( + description="Permission denied" + ) + } + ) + def delete(self, request): + request.user.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index 38d740a6c..be99c07c9 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -1,7 +1,7 @@ from django.urls import path from backpack.api import BackpackAssertionDetail -from badgeuser.api import AcceptTermsView, BadgeUserDetail +from badgeuser.api import AcceptTermsView from directaward.api import DirectAwardAccept from lti_edu.api import StudentsEnrolledList from mobile_api.api import ( @@ -16,6 +16,7 @@ AcceptGeneralTerms, DirectAwardDetail, CatalogBadgeClassListView, + UserProfileView, ) urlpatterns = [ @@ -36,6 +37,6 @@ path('login', Login.as_view(), name='mobile_api_login'), path('terms/accept', AcceptTermsView.as_view(), name='mobile_api_user_terms_accept'), path('enroll', StudentsEnrolledList.as_view(), name='mobile_api_lti_edu_enroll_student'), - path('profile', BadgeUserDetail.as_view(), name='mobile_api_user_profile'), + path('profile', UserProfileView.as_view(), name='mobile_api_user_profile'), path('catalog', CatalogBadgeClassListView.as_view(), name='mobile_api_catalog_badge_class'), ] diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index e33f2f9f5..5a117e72e 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -267,6 +267,22 @@ def get_terms_agreed(self, obj): return obj.general_terms_accepted() +class UserProfileSerializer(serializers.ModelSerializer): + institution = serializers.SlugRelatedField(slug_field='name', read_only=True) + + class Meta: + model = BadgeUser + fields = [ + 'entity_id', + 'first_name', + 'last_name', + 'email', + 'institution', + 'marketing_opt_in', + 'is_superuser', + ] + + class CatalogBadgeClassSerializer(serializers.ModelSerializer): # BadgeClass fields created_at = serializers.DateTimeField(read_only=True) From 2bf5c5983c5b3d21cd15ae190ab7811616d99c75 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 26 Jan 2026 16:40:13 +0100 Subject: [PATCH 048/139] Add registration and consent data to user profile --- apps/badgeuser/models.py | 4 ++++ apps/mobile_api/serializers.py | 11 +++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/badgeuser/models.py b/apps/badgeuser/models.py index 5bb5a7ea3..962a5df2d 100644 --- a/apps/badgeuser/models.py +++ b/apps/badgeuser/models.py @@ -580,6 +580,10 @@ def general_terms_accepted(self): nr_accepted += 1 return general_terms.__len__() == nr_accepted + @property + def terms_agreed(self): + return self.general_terms_accepted() + @property def full_name(self): return self.get_full_name() diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 5a117e72e..35eda8ef3 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -248,7 +248,7 @@ class Meta: class UserSerializer(serializers.ModelSerializer): termsagreement_set = TermsAgreementSerializer(many=True, read_only=True) - terms_agreed = serializers.SerializerMethodField() + terms_agreed = serializers.BooleanField(source='terms_agreed', read_only=True) class Meta: model = BadgeUser @@ -263,12 +263,11 @@ class Meta: 'termsagreement_set', ] - def get_terms_agreed(self, obj): - return obj.general_terms_accepted() - class UserProfileSerializer(serializers.ModelSerializer): institution = serializers.SlugRelatedField(slug_field='name', read_only=True) + termsagreement_set = TermsAgreementSerializer(many=True, read_only=True) + terms_agreed = serializers.BooleanField(source='terms_agreed', read_only=True) class Meta: model = BadgeUser @@ -280,6 +279,10 @@ class Meta: 'institution', 'marketing_opt_in', 'is_superuser', + 'validated_name', + 'schac_homes', + 'terms_agreed', + 'termsagreement_set', ] From a722c5f9b38dc1e161b6b7e30b520e12a2d1dce9 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 27 Jan 2026 15:57:04 +0100 Subject: [PATCH 049/139] Remove source from terms_agreed --- apps/mobile_api/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 35eda8ef3..4755b57d6 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -248,7 +248,7 @@ class Meta: class UserSerializer(serializers.ModelSerializer): termsagreement_set = TermsAgreementSerializer(many=True, read_only=True) - terms_agreed = serializers.BooleanField(source='terms_agreed', read_only=True) + terms_agreed = serializers.BooleanField(read_only=True) class Meta: model = BadgeUser @@ -267,7 +267,7 @@ class Meta: class UserProfileSerializer(serializers.ModelSerializer): institution = serializers.SlugRelatedField(slug_field='name', read_only=True) termsagreement_set = TermsAgreementSerializer(many=True, read_only=True) - terms_agreed = serializers.BooleanField(source='terms_agreed', read_only=True) + terms_agreed = serializers.BooleanField(read_only=True) class Meta: model = BadgeUser From 7a8804a9cf105c772429cac84b8db9faca5f85a7 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 27 Jan 2026 16:14:40 +0100 Subject: [PATCH 050/139] Annotate correct related objects --- apps/mobile_api/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index a3e3da1cf..7a0f02755 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1262,11 +1262,11 @@ def get_queryset(self): ) .annotate( selfRequestedAssertionsCount=Count( - 'badgeinstance', + 'badgeinstances', filter=Q(badgeinstance__award_type='requested'), ), directAwardedAssertionsCount=Count( - 'badgeinstance', + 'badgeinstances', filter=Q(badgeinstance__award_type='direct_award'), ), ) From f03041a45815a477b0a3dac1848615b4888f3907 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 27 Jan 2026 20:20:42 +0100 Subject: [PATCH 051/139] Annotate correct related objects (badgeinstances) #2 --- apps/mobile_api/api.py | 168 ++++++++++++++++++++--------------------- 1 file changed, 80 insertions(+), 88 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 7a0f02755..e6923c501 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -35,7 +35,8 @@ StudentsEnrolledDetailSerializer, StudentsEnrolledSerializer, UserSerializer, - CatalogBadgeClassSerializer, UserProfileSerializer, + CatalogBadgeClassSerializer, + UserProfileSerializer, ) from rest_framework import serializers, status, generics from rest_framework.response import Response @@ -1123,42 +1124,42 @@ class CatalogBadgeClassListView(generics.ListAPIView): type=OpenApiTypes.INT, location='query', required=False, - description='Page number for pagination' + description='Page number for pagination', ), OpenApiParameter( name='page_size', type=OpenApiTypes.INT, location='query', required=False, - description='Number of items per page' + description='Number of items per page', ), OpenApiParameter( name='name', type=OpenApiTypes.STR, location='query', required=False, - description='Filter badge classes by name' + description='Filter badge classes by name', ), OpenApiParameter( name='institution', type=OpenApiTypes.STR, location='query', required=False, - description='Filter badge classes by institution entity_id' + description='Filter badge classes by institution entity_id', ), OpenApiParameter( name='is_micro', type=OpenApiTypes.BOOL, location='query', required=False, - description='Filter for micro-credentials (true/false)' + description='Filter for micro-credentials (true/false)', ), OpenApiParameter( name='q', type=OpenApiTypes.STR, location='query', required=False, - description='General search across badge class, issuer, faculty, and institution names' + description='General search across badge class, issuer, faculty, and institution names', ), ], responses={ @@ -1169,85 +1170,82 @@ class CatalogBadgeClassListView(generics.ListAPIView): OpenApiExample( 'Filtered and Paginated Badge Classes Example', value={ - "count": 124, - "next": "https://api.example.com/catalog/badge-classes/?page=2&page_size=2&q=edubadge", - "previous": None, - "results": [ + 'count': 124, + 'next': 'https://api.example.com/catalog/badge-classes/?page=2&page_size=2&q=edubadge', + 'previous': None, + 'results': [ { - "created_at": "2025-05-02T12:20:51.573423", - "name": "Edubadge account complete", - "image": "uploads/badges/edubadge_student.png", - "archived": 0, - "entity_id": "qNGehQ2dRTKyjNtiDvhWsQ", - "is_private": 0, - "is_micro_credentials": 0, - "badge_class_type": "regular", - "issuer_name_english": "Team edubadges", - "issuer_name_dutch": "Team edubadges", - "issuer_entity_id": "WOLxSjpWQouas1123Z809Q", - "issuer_image_dutch": "", - "issuer_image_english": "uploads/issuers/surf.png", - "faculty_name_english": "eduBadges", - "faculty_name_dutch": "null", - "faculty_entity_id": "lVu1kbaqSDyJV_1Bu8_bcw", - "faculty_image_dutch": "", - "faculty_image_english": "", - "faculty_on_behalf_of": 0, - "faculty_type": "null", - "institution_name_english": "SURF", - "institution_name_dutch": "SURF", - "institution_entity_id": "NiqkZiz2TaGT8B4RRwG8Fg", - "institution_image_dutch": "uploads/issuers/surf.png", - "institution_image_english": "uploads/issuers/surf.png", - "institution_type": "null", - "self_requested_assertions_count": 1, - "direct_awarded_assertions_count": 0 + 'created_at': '2025-05-02T12:20:51.573423', + 'name': 'Edubadge account complete', + 'image': 'uploads/badges/edubadge_student.png', + 'archived': 0, + 'entity_id': 'qNGehQ2dRTKyjNtiDvhWsQ', + 'is_private': 0, + 'is_micro_credentials': 0, + 'badge_class_type': 'regular', + 'issuer_name_english': 'Team edubadges', + 'issuer_name_dutch': 'Team edubadges', + 'issuer_entity_id': 'WOLxSjpWQouas1123Z809Q', + 'issuer_image_dutch': '', + 'issuer_image_english': 'uploads/issuers/surf.png', + 'faculty_name_english': 'eduBadges', + 'faculty_name_dutch': 'null', + 'faculty_entity_id': 'lVu1kbaqSDyJV_1Bu8_bcw', + 'faculty_image_dutch': '', + 'faculty_image_english': '', + 'faculty_on_behalf_of': 0, + 'faculty_type': 'null', + 'institution_name_english': 'SURF', + 'institution_name_dutch': 'SURF', + 'institution_entity_id': 'NiqkZiz2TaGT8B4RRwG8Fg', + 'institution_image_dutch': 'uploads/issuers/surf.png', + 'institution_image_english': 'uploads/issuers/surf.png', + 'institution_type': 'null', + 'self_requested_assertions_count': 1, + 'direct_awarded_assertions_count': 0, }, { - "created_at": "2025-05-02T12:20:57.914064", - "name": "Growth and Development", - "image": "uploads/badges/eduid.png", - "archived": 0, - "entity_id": "Ge4D7gf1RLGYNZlSiCv-qA", - "is_private": 0, - "is_micro_credentials": 0, - "badge_class_type": "regular", - "issuer_name_english": "Medicine", - "issuer_name_dutch": "null", - "issuer_entity_id": "yuflXDK8ROukQkxSPmh5ag", - "issuer_image_dutch": "", - "issuer_image_english": "uploads/issuers/surf.png", - "faculty_name_english": "Medicine", - "faculty_name_dutch": "null", - "faculty_entity_id": "yYPphJ3bS5qszI7P69degA", - "faculty_image_dutch": "", - "faculty_image_english": "", - "faculty_on_behalf_of": 0, - "faculty_type": "null", - "institution_name_english": "university-example.org", - "institution_name_dutch": "null", - "institution_entity_id": "5rZhvRonT3OyyLQhhmuPmw", - "institution_image_dutch": "uploads/institution/surf.png", - "institution_image_english": "uploads/institution/surf.png", - "institution_type": "WO", - "self_requested_assertions_count": 0, - "direct_awarded_assertions_count": 0 - } - ] + 'created_at': '2025-05-02T12:20:57.914064', + 'name': 'Growth and Development', + 'image': 'uploads/badges/eduid.png', + 'archived': 0, + 'entity_id': 'Ge4D7gf1RLGYNZlSiCv-qA', + 'is_private': 0, + 'is_micro_credentials': 0, + 'badge_class_type': 'regular', + 'issuer_name_english': 'Medicine', + 'issuer_name_dutch': 'null', + 'issuer_entity_id': 'yuflXDK8ROukQkxSPmh5ag', + 'issuer_image_dutch': '', + 'issuer_image_english': 'uploads/issuers/surf.png', + 'faculty_name_english': 'Medicine', + 'faculty_name_dutch': 'null', + 'faculty_entity_id': 'yYPphJ3bS5qszI7P69degA', + 'faculty_image_dutch': '', + 'faculty_image_english': '', + 'faculty_on_behalf_of': 0, + 'faculty_type': 'null', + 'institution_name_english': 'university-example.org', + 'institution_name_dutch': 'null', + 'institution_entity_id': '5rZhvRonT3OyyLQhhmuPmw', + 'institution_image_dutch': 'uploads/institution/surf.png', + 'institution_image_english': 'uploads/institution/surf.png', + 'institution_type': 'WO', + 'self_requested_assertions_count': 0, + 'direct_awarded_assertions_count': 0, + }, + ], }, response_only=True, ) ], ), - 500: OpenApiResponse( - description='Internal server error occurred while retrieving badge classes.' - ), - } + 500: OpenApiResponse(description='Internal server error occurred while retrieving badge classes.'), + }, ) def get_queryset(self): return ( - BadgeClass.objects - .select_related( + BadgeClass.objects.select_related( 'issuer', 'issuer__faculty', 'issuer__faculty__institution', @@ -1257,17 +1255,15 @@ def get_queryset(self): issuer__archived=False, issuer__faculty__archived=False, ) - .exclude( - issuer__faculty__visibility_type='TEST' - ) + .exclude(issuer__faculty__visibility_type='TEST') .annotate( selfRequestedAssertionsCount=Count( 'badgeinstances', - filter=Q(badgeinstance__award_type='requested'), + filter=Q(badgeinstances__award_type='requested'), ), directAwardedAssertionsCount=Count( 'badgeinstances', - filter=Q(badgeinstance__award_type='direct_award'), + filter=Q(badgeinstances__award_type='direct_award'), ), ) ) @@ -1289,15 +1285,11 @@ def get(self, request): return Response(serializer.data) @extend_schema( - description="Delete the authenticated user", + description='Delete the authenticated user', responses={ - 204: OpenApiResponse( - description="User account deleted successfully" - ), - 403: OpenApiResponse( - description="Permission denied" - ) - } + 204: OpenApiResponse(description='User account deleted successfully'), + 403: OpenApiResponse(description='Permission denied'), + }, ) def delete(self, request): request.user.delete() From 8d675d2d4e178e9c79a2a89fbd10d004bbdf4f66 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 28 Jan 2026 09:22:01 +0100 Subject: [PATCH 052/139] Add filter backend globally and locally --- apps/mainsite/settings.py | 3 +++ apps/mobile_api/api.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/mainsite/settings.py b/apps/mainsite/settings.py index 004c5e962..6172e57b8 100644 --- a/apps/mainsite/settings.py +++ b/apps/mainsite/settings.py @@ -446,6 +446,9 @@ def legacy_boolean_parsing(env_key, default_value): 'ALLOWED_VERSIONS': ['v1', 'v2'], 'EXCEPTION_HANDLER': 'entity.views.exception_handler', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend', + ], } ## diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index e6923c501..dbd055650 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1,6 +1,7 @@ import logging import requests +from django_filters.rest_framework import DjangoFilterBackend from rest_framework.permissions import AllowAny, IsAuthenticated from badgeuser.models import StudentAffiliation @@ -1113,10 +1114,12 @@ class CatalogBadgeClassListView(generics.ListAPIView): permission_classes = (AllowAny,) serializer_class = CatalogBadgeClassSerializer filterset_class = CatalogBadgeClassFilter + filter_backends = [DjangoFilterBackend] pagination_class = CatalogPagination @extend_schema( methods=['GET'], + filters=True, description='Get a paginated list of badge classes. Supports filtering, searching (q), and page_size.', parameters=[ OpenApiParameter( From 4b749690390542f5f7d7b6f7b6d22d3552e8fb89 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 2 Dec 2025 15:23:06 +0100 Subject: [PATCH 053/139] Updated CHANGELOG for 8.3.3 release --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3ff5582..16a4b75cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [8.3.3] - 2025-12-02 + +#### Full GitHub changelogs: + +Backend: https://github.com/edubadges/edubadges-server/compare/v8.3.2...v8.3.3
+ +- Update to Django 4.2.26 +- Updating swagger annotations +- Remove referer header requirement from auth provider views +- Merge pull request #215 from edubadges/feature/reduce_error_logs +- Only allow for super-users to perform impersonation +- Added extra logging to MobileAPIAuthentication +- Slug fields were removed in 2020 from all models +- Catch TypeError when trying to load JSON from imported badge +- Adding DIRS var to TEMPLATES object +- Return 404 in case badgr app is none +- Added is_authenticated checks +- Increase MAX_URL_LENGTH even more, to 16384 +- Increased MAX_URL_LENGTH times 4 to be able to exceed 2048 chars which is to low for our use-cases +- Quick fix for Unsafe redirect exceeding 2048 characters +- Do not use SIS authentication for mobile flow + ## [8.3.2] - 2025-11-14 #### Full GitHub changelogs: From ad4798a55cf742812228ca2b60fbb0c7b0e00000 Mon Sep 17 00:00:00 2001 From: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:25:13 +0100 Subject: [PATCH 054/139] Updated CHANGELOG for release 8.4 --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a4b75cd..2c0fa645a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [8.4] - 2026-01-14 + +#### Full GitHub changelogs: + +Backend: https://github.com/edubadges/edubadges-server/compare/v8.3.3...v8.4
+ +- Merge pull request #239 from edubadges/dependabot/pip/urllib3-2.6.3 +- Merge pull request #241 from edubadges/chore/run-django-tests-in-ci-cd +- Update import of urllib +- Bump urllib3 from 1.26.19 to 2.6.3 +- Grant privileges to test db user +- Add workflow to run django tests +- Merge pull request #240 from edubadges/chore/fix-tests +- Fix tests for removed constraint for badgeclass +- Fix request data that was no valid json +- Add required badgeclass type to request data +- Disable extension validation in tests +- Fix assertion for showing archived badges in issuer response +- Fix urls and expected response code in institution test +- Remove edit directaward functionality from tests +- Assert correct type +- Fix staff permission in test to show issuers +- Fix broken test helpers for enrollment setup +- Disable auth signals and logging in tests +- Add dedicated settings for testing +- Suppress cssutils CSS validation errors in test environment +- Fix naive datetime defaults in legacy migrations +- Remove setlocale usage and localize email dates in templates +- Fix for MA7QDbnn Added expiration date based on the badgeclass when a user claims a DA See https://trello.com/c/MA7QDbnn/1143-vervallen-edubadge-werkt-niet +- WIP for https://trello.com/c/tsJHRy6A/ After the user is created, the correct staffs can be added as super-user +- Added delete account endpoint for mobile API https://trello.com/c/WYW0JiGA/1105-changes-needed-for-making-apis-mobile-app-ready +- Merge pull request #226 from edubadges/feature/remove-imported-badge-functionality +- Fixes remove-imported-badge-functionality See https://trello.com/c/W4o0VLeC/1132-remove-imported-badge-functionality +- Not needed anymore to increase MAX_URL_LENGTH as Django 4.2.27 fixes this. +- Merge pull request #220 from edubadges/dependabot/pip/django-4.2.27 +- Ignore .serena directory +- DA audit traiL: action instead of method +- Filter DA audit trail with method CREATE +- Merge pull request #224 from edubadges/feature/da_audittrail_view +- feat: adding direct award audit trail API used by super users +- Bump django from 4.2.26 to 4.2.27 +- Updated CHANGELOG for 8.3.3 release + ## [8.3.3] - 2025-12-02 #### Full GitHub changelogs: From 4beeef785c6669a8ed2505c06611a0e5aec84e08 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 28 Jan 2026 14:50:28 +0100 Subject: [PATCH 055/139] Remove q filter and replace is_micro with institution_type filter --- apps/mobile_api/api.py | 14 +++----------- apps/mobile_api/filters.py | 30 +++++++++++++----------------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index dbd055650..820da78a6 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1120,7 +1120,7 @@ class CatalogBadgeClassListView(generics.ListAPIView): @extend_schema( methods=['GET'], filters=True, - description='Get a paginated list of badge classes. Supports filtering, searching (q), and page_size.', + description='Get a paginated list of badge classes. Supports filtering and page_size.', parameters=[ OpenApiParameter( name='page', @@ -1151,18 +1151,10 @@ class CatalogBadgeClassListView(generics.ListAPIView): description='Filter badge classes by institution entity_id', ), OpenApiParameter( - name='is_micro', - type=OpenApiTypes.BOOL, + name='institution_type', location='query', required=False, - description='Filter for micro-credentials (true/false)', - ), - OpenApiParameter( - name='q', - type=OpenApiTypes.STR, - location='query', - required=False, - description='General search across badge class, issuer, faculty, and institution names', + description='Filter badge classes by institution_type (MBO/HBO/WO)', ), ], responses={ diff --git a/apps/mobile_api/filters.py b/apps/mobile_api/filters.py index fd9737317..4c21aa7a4 100644 --- a/apps/mobile_api/filters.py +++ b/apps/mobile_api/filters.py @@ -1,28 +1,24 @@ import django_filters as filters -from django.db.models import Q from issuer.models import BadgeClass +INSTITUTION_TYPE_FILTER_CHOICES = [ + ('MBO', 'MBO'), + ('HBO', 'HBO'), + ('WO', 'WO'), +] + class CatalogBadgeClassFilter(filters.FilterSet): name = filters.CharFilter(field_name='name', lookup_expr='icontains') institution = filters.CharFilter( - field_name='issuer__faculty__institution__entity_id' + field_name='issuer__faculty__institution__entity_id', + ) + institution_type = filters.ChoiceFilter( + field_name='issuer__faculty__institution__institution_type', + choices=INSTITUTION_TYPE_FILTER_CHOICES, + help_text="Filter by institution type. Omit this parameter to include all types.", ) - is_micro = filters.BooleanFilter(field_name='is_micro_credentials') - - q = filters.CharFilter(method='filter_q', label='Search') class Meta: model = BadgeClass - fields = ['name', 'institution', 'is_micro', 'q'] - - def filter_q(self, queryset, name, value): - return queryset.filter( - Q(name__icontains=value) | - Q(issuer__name_english__icontains=value) | - Q(issuer__name_dutch__icontains=value) | - Q(issuer__faculty__name_english__icontains=value) | - Q(issuer__faculty__name_dutch__icontains=value) | - Q(issuer__faculty__institution__name_english__icontains=value) | - Q(issuer__faculty__institution__name_dutch__icontains=value) - ) + fields = ['name', 'institution', 'institution_type'] From 3c2c4b85ebf87fa8e434916d941449b7291eb9be Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Wed, 28 Jan 2026 15:20:25 +0100 Subject: [PATCH 056/139] Added grade_achieved in the BadgeInstanceDetailSerializer --- apps/mobile_api/serializers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 4755b57d6..30c322864 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -114,6 +114,7 @@ class Meta: 'public', 'badgeclass', 'grade_achieved', + "include_grade_achieved" ] @@ -135,6 +136,9 @@ class Meta: 'public', 'badgeclass', 'linkedin_url', + 'grade_achieved', + 'include_grade_achieved', + 'include_evidence' ] def _get_linkedin_org_id(self, badgeclass): @@ -173,6 +177,7 @@ def get_linkedin_url(self, obj): return f"https://www.linkedin.com/profile/add?{urlencode(params)}" + class DirectAwardSerializer(serializers.ModelSerializer): badgeclass = BadgeClassSerializer() From 8fad5530fb97eddfaec5b4390d24d7cf20e1a680 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 28 Jan 2026 15:30:06 +0100 Subject: [PATCH 057/139] Find direct award and badgeclass on id and not entity_id --- apps/directaward/signals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/directaward/signals.py b/apps/directaward/signals.py index 5e1916aed..a41ada8fb 100644 --- a/apps/directaward/signals.py +++ b/apps/directaward/signals.py @@ -31,9 +31,9 @@ def direct_award_audit_trail(sender, user, request, direct_award_id, badgeclass_ direct_award = None badgeclass = None if direct_award_id: - direct_award = DirectAward.objects.filter(entity_id=direct_award_id).first() + direct_award = DirectAward.objects.filter(id=direct_award_id).first() if badgeclass_id: - badgeclass = BadgeClass.objects.filter(entity_id=badgeclass_id).first() + badgeclass = BadgeClass.objects.filter(id=badgeclass_id).first() audit_trail = DirectAwardAuditTrail.objects.create( user=user, From 14c1e6f99cc2df9ee4a857bb4823ab7cb687893d Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 28 Jan 2026 15:58:07 +0100 Subject: [PATCH 058/139] Use entity id for direct awards --- apps/directaward/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/directaward/signals.py b/apps/directaward/signals.py index a41ada8fb..793c27b25 100644 --- a/apps/directaward/signals.py +++ b/apps/directaward/signals.py @@ -31,7 +31,7 @@ def direct_award_audit_trail(sender, user, request, direct_award_id, badgeclass_ direct_award = None badgeclass = None if direct_award_id: - direct_award = DirectAward.objects.filter(id=direct_award_id).first() + direct_award = DirectAward.objects.filter(entity_id=direct_award_id).first() if badgeclass_id: badgeclass = BadgeClass.objects.filter(id=badgeclass_id).first() From 62f5dd084d4550143ab0573b44a4dd2361a2051f Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Thu, 29 Jan 2026 10:58:39 +0100 Subject: [PATCH 059/139] Added endpoint to make a badge instance public --- apps/mobile_api/api_urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index be99c07c9..688bf782d 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -35,6 +35,7 @@ path('enrollments', Enrollments.as_view(), name='mobile_api_enrollments'), path('enrollments/', EnrollmentDetail.as_view(), name='mobile_api_enrollment_detail'), path('login', Login.as_view(), name='mobile_api_login'), + path('badge/public', BackpackAssertionDetail.as_view(), name='mobile_api_badge_public'), path('terms/accept', AcceptTermsView.as_view(), name='mobile_api_user_terms_accept'), path('enroll', StudentsEnrolledList.as_view(), name='mobile_api_lti_edu_enroll_student'), path('profile', UserProfileView.as_view(), name='mobile_api_user_profile'), From fb0221591aefa10f2fd2e6ddbe3b2b91e77329ee Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 2 Feb 2026 10:36:06 +0100 Subject: [PATCH 060/139] Add terms to catalog badge class serializer --- apps/issuer/models.py | 7 ++++ apps/mobile_api/api.py | 58 ++++++++++++++++++++++++++++++++++ apps/mobile_api/serializers.py | 10 ++++++ 3 files changed, 75 insertions(+) diff --git a/apps/issuer/models.py b/apps/issuer/models.py index 5a94d7625..cc2c0dbbe 100644 --- a/apps/issuer/models.py +++ b/apps/issuer/models.py @@ -666,6 +666,13 @@ def publish(self): super(BadgeClass, self).publish() self.issuer.publish() + def get_required_terms(self): + """ + Return the Terms object that applies to this badge class. + Raises ValueError if required terms are missing. + """ + return self._get_terms() + def _get_terms(self): terms = self.institution.cached_terms() if not terms: diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 820da78a6..6da5c2348 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1178,6 +1178,33 @@ class CatalogBadgeClassListView(generics.ListAPIView): 'is_private': 0, 'is_micro_credentials': 0, 'badge_class_type': 'regular', + 'required_terms': { + 'entity_id': 'terms-123', + 'terms_type': 'FORMAL_BADGE', + 'institution': { + 'name_dutch': 'SURF', + 'name_english': 'SURF', + 'image_dutch': '', + 'image_english': '', + 'identifier': 'surf.nl', + 'alternative_identifier': None, + 'grondslag_formeel': 'gerechtvaardigd_belang', + 'grondslag_informeel': 'gerechtvaardigd_belang' + }, + 'terms_urls': [ + { + 'url': 'https://example.org/terms/nl', + 'language': 'nl', + 'excerpt': 'Door deel te nemen accepteer je...' + }, + { + 'url': 'https://example.org/terms/en', + 'language': 'en', + 'excerpt': 'By participating you accept...' + } + ] + }, + 'user_has_accepted_terms': True, 'issuer_name_english': 'Team edubadges', 'issuer_name_dutch': 'Team edubadges', 'issuer_entity_id': 'WOLxSjpWQouas1123Z809Q', @@ -1208,6 +1235,33 @@ class CatalogBadgeClassListView(generics.ListAPIView): 'is_private': 0, 'is_micro_credentials': 0, 'badge_class_type': 'regular', + 'required_terms': { + 'entity_id': 'terms-123', + 'terms_type': 'FORMAL_BADGE', + 'institution': { + 'name_dutch': 'SURF', + 'name_english': 'SURF', + 'image_dutch': '', + 'image_english': '', + 'identifier': 'surf.nl', + 'alternative_identifier': None, + 'grondslag_formeel': 'gerechtvaardigd_belang', + 'grondslag_informeel': 'gerechtvaardigd_belang' + }, + 'terms_urls': [ + { + 'url': 'https://example.org/terms/nl', + 'language': 'nl', + 'excerpt': 'Door deel te nemen accepteer je...' + }, + { + 'url': 'https://example.org/terms/en', + 'language': 'en', + 'excerpt': 'By participating you accept...' + } + ] + }, + 'user_has_accepted_terms': True, 'issuer_name_english': 'Medicine', 'issuer_name_dutch': 'null', 'issuer_entity_id': 'yuflXDK8ROukQkxSPmh5ag', @@ -1245,6 +1299,10 @@ def get_queryset(self): 'issuer__faculty', 'issuer__faculty__institution', ) + .prefetch_related( + 'issuer__faculty__institution__terms', + 'issuer__faculty__institution__terms__terms_urls', + ) .filter( is_private=False, issuer__archived=False, diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 30c322864..45a27a34f 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -301,6 +301,7 @@ class CatalogBadgeClassSerializer(serializers.ModelSerializer): is_private = serializers.BooleanField() is_micro_credentials = serializers.BooleanField() badge_class_type = serializers.CharField() + required_terms = serializers.SerializerMethodField() # Issuer fields issuer_name_english = serializers.CharField(source='issuer.name_english', read_only=True) @@ -342,6 +343,7 @@ class Meta: 'is_private', 'is_micro_credentials', 'badge_class_type', + 'required_terms', # Issuer 'issuer_name_english', @@ -371,3 +373,11 @@ class Meta: 'self_requested_assertions_count', 'direct_awarded_assertions_count' ] + + def get_required_terms(self, obj): + try: + terms = obj.get_required_terms() + except ValueError: + return None # Should not break the serializer + + return TermsSerializer(terms, context=self.context).data From ff4faa48d87d3a3c46e55b8e4b8e13e75ea02354 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 2 Feb 2026 10:36:35 +0100 Subject: [PATCH 061/139] Add boolean for whether user has accepted the terms --- apps/mobile_api/serializers.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 45a27a34f..e3f63e75c 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -302,6 +302,7 @@ class CatalogBadgeClassSerializer(serializers.ModelSerializer): is_micro_credentials = serializers.BooleanField() badge_class_type = serializers.CharField() required_terms = serializers.SerializerMethodField() + user_has_accepted_terms = serializers.SerializerMethodField() # Issuer fields issuer_name_english = serializers.CharField(source='issuer.name_english', read_only=True) @@ -344,6 +345,7 @@ class Meta: 'is_micro_credentials', 'badge_class_type', 'required_terms', + 'user_has_accepted_terms', # Issuer 'issuer_name_english', @@ -381,3 +383,11 @@ def get_required_terms(self, obj): return None # Should not break the serializer return TermsSerializer(terms, context=self.context).data + + def get_user_has_accepted_terms(self, obj): + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return False + + user = request.user + return obj.terms_accepted(user) From c73b8c43b6de88fb4af7a4b0b2c32c8a9f5c92c1 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 3 Feb 2026 16:20:34 +0100 Subject: [PATCH 062/139] Add mobile api endpoint for badge class detail --- apps/mobile_api/api.py | 14 ++++++++++++++ apps/mobile_api/api_urls.py | 2 ++ 2 files changed, 16 insertions(+) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 6da5c2348..546024e6e 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -38,6 +38,7 @@ UserSerializer, CatalogBadgeClassSerializer, UserProfileSerializer, + BadgeClassDetailSerializer, ) from rest_framework import serializers, status, generics from rest_framework.response import Response @@ -1347,3 +1348,16 @@ def get(self, request): def delete(self, request): request.user.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class BadgeClassDetailView(generics.RetrieveAPIView): + permission_classes = (IsAuthenticated, MobileAPIPermission) + lookup_field = 'entity_id' + serializer_class = BadgeClassDetailSerializer + queryset = BadgeClass.objects.select_related( + 'issuer', + 'issuer__faculty', + 'issuer__faculty__institution', + ).prefetch_related( + 'badgeclassextension_set' + ) diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index 688bf782d..7c624e31e 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -7,6 +7,7 @@ from mobile_api.api import ( BadgeInstances, BadgeInstanceDetail, + BadgeClassDetailView, UnclaimedDirectAwards, Enrollments, EnrollmentDetail, @@ -27,6 +28,7 @@ BadgeCollectionsDetailView.as_view(), name='mobile_api_badge_collection_update', ), + path('badge-classes/', BadgeClassDetailView.as_view(), name='mobile_api_badge_class_detail'), path('badge-instances', BadgeInstances.as_view(), name='mobile_api_badge_instances'), path('badge-instances/', BadgeInstanceDetail.as_view(), name='mobile_api_badge_instance_detail'), path('direct-awards', UnclaimedDirectAwards.as_view(), name='mobile_api_direct_awards'), From 2049380b4e9bc2ea5c5f6e5086164ff9dcded517 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 3 Feb 2026 12:56:36 +0100 Subject: [PATCH 063/139] Add mobile institution api endpoint Only institutions that satisfy all filters will be included # Conflicts: # apps/mobile_api/api.py --- apps/mobile_api/api.py | 19 +++++++++++++++++++ apps/mobile_api/api_urls.py | 2 ++ apps/mobile_api/serializers.py | 10 ++++++++++ 3 files changed, 31 insertions(+) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 546024e6e..9823977ee 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -3,6 +3,7 @@ import requests from django_filters.rest_framework import DjangoFilterBackend from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.generics import ListAPIView from badgeuser.models import StudentAffiliation from badgrsocialauth.providers.eduid.provider import EduIDProvider @@ -18,6 +19,8 @@ extend_schema, inline_serializer, ) + +from institution.models import Institution from issuer.models import BadgeInstance, BadgeInstanceCollection, BadgeClass from issuer.serializers import BadgeInstanceCollectionSerializer from lti_edu.models import StudentsEnrolled @@ -39,6 +42,7 @@ CatalogBadgeClassSerializer, UserProfileSerializer, BadgeClassDetailSerializer, + InstitutionListSerializer, ) from rest_framework import serializers, status, generics from rest_framework.response import Response @@ -1361,3 +1365,18 @@ class BadgeClassDetailView(generics.RetrieveAPIView): ).prefetch_related( 'badgeclassextension_set' ) + + +class InstitutionListView(ListAPIView): + permission_classes = (IsAuthenticated, MobileAPIPermission) + serializer_class = InstitutionListSerializer + + def get_queryset(self): + return ( + Institution.objects.filter( + faculty__issuer__badgeclass__is_private=False, + faculty__issuer__archived=False, + faculty__archived=False, + faculty__visibility_type='PUBLIC', + ).distinct() + ) diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index 7c624e31e..6a63db470 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -18,6 +18,7 @@ DirectAwardDetail, CatalogBadgeClassListView, UserProfileView, + InstitutionListView, ) urlpatterns = [ @@ -42,4 +43,5 @@ path('enroll', StudentsEnrolledList.as_view(), name='mobile_api_lti_edu_enroll_student'), path('profile', UserProfileView.as_view(), name='mobile_api_user_profile'), path('catalog', CatalogBadgeClassListView.as_view(), name='mobile_api_catalog_badge_class'), + path('institutions', InstitutionListView.as_view(), name='mobile_api_institution_list'), ] diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index e3f63e75c..0673fdd68 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -24,6 +24,16 @@ class Meta: ] +class InstitutionListSerializer(serializers.ModelSerializer): + class Meta: + model = Institution + fields = [ + 'entity_id', + 'name_dutch', + 'name_english', + ] + + class FacultySerializer(serializers.ModelSerializer): institution = InstitutionSerializer(read_only=True) From f490ca8234e0180c2047758f11b99ca91bd7300f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:51:19 +0000 Subject: [PATCH 064/139] Bump django from 4.2.27 to 4.2.28 Bumps [django](https://github.com/django/django) from 4.2.27 to 4.2.28. - [Commits](https://github.com/django/django/compare/4.2.27...4.2.28) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.28 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a6ade26dc..2505abd6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Django stuff -Django==4.2.27 +Django==4.2.28 semver==2.6.0 pytz==2022.2.1 From 84cf1ee3c4a893d0484e48daadefccf80a486f68 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 4 Feb 2026 08:58:47 +0100 Subject: [PATCH 065/139] Add datamigration to populate institution email --- .../0067_populate_institution_email.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 apps/institution/migrations/0067_populate_institution_email.py diff --git a/apps/institution/migrations/0067_populate_institution_email.py b/apps/institution/migrations/0067_populate_institution_email.py new file mode 100644 index 000000000..a6aedbf11 --- /dev/null +++ b/apps/institution/migrations/0067_populate_institution_email.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.27 on 2026-02-04 07:41 + +from django.db import migrations + + +def populate_institution_email(apps, schema_editor): + Institution = apps.get_model("institution", "Institution") + InstitutionStaff = apps.get_model("institution", "InstitutionStaff") + + for institution in Institution.objects.filter(email__isnull=True): + # Find oldest active admin + staff = ( + InstitutionStaff.objects.filter( + institution=institution, + may_administrate_users=True, + user__is_active=True, + ) + .select_related("user") + .order_by("user__date_joined") + .first() + ) + + if staff and staff.user and staff.user.email: + institution.email = staff.user.email + institution.save(update_fields=["email"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('institution', '0066_institution_email'), + ] + + operations = [ + ] From 30c250b23c3b06d0b33308878506f759cb85ee47 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 4 Feb 2026 11:09:57 +0100 Subject: [PATCH 066/139] Use correct related name for badgeclass issuer FK --- apps/mobile_api/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 9823977ee..c03db3fe8 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1374,7 +1374,7 @@ class InstitutionListView(ListAPIView): def get_queryset(self): return ( Institution.objects.filter( - faculty__issuer__badgeclass__is_private=False, + faculty__issuer__badgeclasses__is_private=False, faculty__issuer__archived=False, faculty__archived=False, faculty__visibility_type='PUBLIC', From 53e951ea831f19617f8d2e724daa00e262b61614 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 4 Feb 2026 15:15:42 +0100 Subject: [PATCH 067/139] Flip filtering logic around to make query faster And this also should fix the visibility issue. I think it was because of the faculty visibility type --- apps/mobile_api/api.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index c03db3fe8..72065339e 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1372,11 +1372,18 @@ class InstitutionListView(ListAPIView): serializer_class = InstitutionListSerializer def get_queryset(self): - return ( - Institution.objects.filter( - faculty__issuer__badgeclasses__is_private=False, - faculty__issuer__archived=False, - faculty__archived=False, - faculty__visibility_type='PUBLIC', - ).distinct() - ) + institution_entity_ids = BadgeClass.objects.select_related( + 'issuer', + 'issuer__faculty', + 'issuer__faculty__institution', + ).filter( + is_private=False, + issuer__archived=False, + issuer__faculty__archived=False, + ).exclude( + issuer__faculty__visibility_type='TEST' + ).values_list( + 'issuer__faculty__institution__entity_id', + flat=True, + ).distinct() + return Institution.objects.filter(entity_id__in=institution_entity_ids) From 8b4bf040b6b107a47c346ba012cd6e81b4699880 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 5 Feb 2026 11:37:28 +0100 Subject: [PATCH 068/139] Add required terms to direct award detail view And refactor to use a RetrieveAPIView --- apps/mobile_api/api.py | 173 ++++++++++++++++----------------- apps/mobile_api/api_urls.py | 4 +- apps/mobile_api/serializers.py | 24 +++-- 3 files changed, 102 insertions(+), 99 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 72065339e..4678efa1c 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -602,100 +602,91 @@ def get(self, request, **kwargs): return Response(serializer.data) -class DirectAwardDetail(APIView): - permission_classes = (MobileAPIPermission,) - - @extend_schema( - methods=['GET'], - description='Get direct award details for the user', - parameters=[ - OpenApiParameter( - name='entity_id', - type=OpenApiTypes.STR, - location=OpenApiParameter.PATH, - required=True, - description='entity_id of the direct award', - ) - ], - responses={ - 200: OpenApiResponse( - description='Direct award details', - response=DirectAwardDetailSerializer, - examples=[ - OpenApiExample( - 'Direct Award Details', - value=[ - { - 'id': 9596, - 'created_at': '2026-01-16T10:56:44.293475+01:00', - 'entity_id': 'y8uStIzMQ--JY59DIKnvWw', - 'badgeclass': { - 'id': 6, - 'name': 'test direct award', - 'entity_id': 'B3uWEIZSTh6wniHBbzVtbA', - 'image_url': 'https://api-demo.edubadges.nl/media/uploads/badges/issuer_badgeclass_6c3b5f04-292b-41fa-8df6-d5029386bd3f.png', - 'issuer': { - 'name_dutch': 'SURF Edubadges', - 'name_english': 'SURF Edubadges', - 'image_dutch': 'null', - 'image_english': '/media/uploads/issuers/issuer_logo_ccd075bb-23cb-40b2-8780-b5a7eda9de1c.png', - 'faculty': { - 'name_dutch': 'SURF', - 'name_english': 'SURF', - 'image_dutch': 'null', - 'image_english': 'null', - 'on_behalf_of': 'false', - 'on_behalf_of_display_name': 'null', - 'on_behalf_of_url': 'null', - 'institution': { - 'name_dutch': 'University Voorbeeld', - 'name_english': 'University Example', - 'image_dutch': '/media/uploads/institution/d0273589-2c7a-4834-8c35-fef4695f176a.png', - 'image_english': '/media/uploads/institution/eae5465f-98b1-4849-ac2d-47d4e1cd1252.png', - 'identifier': 'university-example.org', - 'alternative_identifier': 'university-example.org.tempguestidp.edubadges.nl', - 'grondslag_formeel': 'gerechtvaardigd_belang', - 'grondslag_informeel': 'gerechtvaardigd_belang', - }, - }, +@extend_schema( + description="Get direct award details for the authenticated user", + responses={ + 200: OpenApiResponse( + response=DirectAwardDetailSerializer, + examples=[ + OpenApiExample( + "Example Direct Award", + value={ + "id": 9596, + "created_at": "2026-01-16T10:56:44.293475+01:00", + "status": "Unaccepted", + "entity_id": "y8uStIzMQ--JY59DIKnvWw", + "badgeclass": { + "id": 6, + "name": "test direct award", + "entity_id": "B3uWEIZSTh6wniHBbzVtbA", + "image_url": "https://api-demo.edubadges.nl/media/uploads/badges/issuer_badgeclass_6c3b5f04-292b-41fa-8df6-d5029386bd3f.png", + "issuer": { + "name_dutch": "SURF Edubadges", + "name_english": "SURF Edubadges", + "image_dutch": "null", + "image_english": "/media/uploads/issuers/issuer_logo_ccd075bb-23cb-40b2-8780-b5a7eda9de1c.png", + "faculty": { + "name_dutch": "SURF", + "name_english": "SURF", + "image_dutch": "null", + "image_english": "null", + "on_behalf_of": "false", + "on_behalf_of_display_name": "null", + "on_behalf_of_url": "null", + "institution": { + "name_dutch": "University Voorbeeld", + "name_english": "University Example", + "image_dutch": "/media/uploads/institution/d0273589-2c7a-4834-8c35-fef4695f176a.png", + "image_english": "/media/uploads/institution/eae5465f-98b1-4849-ac2d-47d4e1cd1252.png", + "identifier": "university-example.org", + "alternative_identifier": "university-example.org.tempguestidp.edubadges.nl", + "grondslag_formeel": "gerechtvaardigd_belang", + "grondslag_informeel": "gerechtvaardigd_belang", }, }, - } - ], - description='Detailed information about a specific direct award', - response_only=True, - ), - ], - ), - 404: OpenApiResponse( - description='Direct award not found', - examples=[ - OpenApiExample( - 'Not Found', - value={'detail': 'Direct award not found'}, - description='The requested direct award does not exist or user does not have access', - response_only=True, - ), - ], - ), - 403: permission_denied_response, - }, + }, + }, + "required_terms": { + "entity_id": "terms-123", + "terms_type": "FORMAL_BADGE", + "terms_urls": [ + {"url": "https://example.org/terms/nl", "language": "nl", "excerpt": "..."}, + {"url": "https://example.org/terms/en", "language": "en", "excerpt": "..."}, + ], + "institution": { + "name_dutch": "University Voorbeeld", + "name_english": "University Example", + "image_dutch": "/media/uploads/institution/d0273589-2c7a-4834-8c35-fef4695f176a.png", + "image_english": "/media/uploads/institution/eae5465f-98b1-4849-ac2d-47d4e1cd1252.png", + "identifier": "university-example.org", + "alternative_identifier": "university-example.org.tempguestidp.edubadges.nl", + "grondslag_formeel": "gerechtvaardigd_belang", + "grondslag_informeel": "gerechtvaardigd_belang", + }, + }, + "user_has_accepted_terms": False, + }, + ), + ], + ), + 403: permission_denied_response, + 404: OpenApiResponse(description="Direct award not found"), + }, +) +class DirectAwardDetailView(generics.RetrieveAPIView): + permission_classes = (MobileAPIPermission,) + serializer_class = DirectAwardDetailSerializer + lookup_field = "entity_id" + + queryset = DirectAward.objects.select_related( + 'badgeclass', + 'badgeclass__issuer', + 'badgeclass__issuer__faculty', + 'badgeclass__issuer__faculty__institution', + ).prefetch_related( + 'badgeclass__badgeclassextension_set', + 'badgeclass__issuer__faculty__institution__terms', ) - # ForeignKey / OneToOneField → select_related - # ManyToManyField / reverse FK → prefetch_related - def get(self, request, entity_id, **kwargs): - instance = ( - DirectAward.objects.select_related('badgeclass') - .prefetch_related('badgeclass__badgeclassextension_set') - .select_related('badgeclass__issuer') - .select_related('badgeclass__issuer__faculty') - .select_related('badgeclass__issuer__faculty__institution') - .prefetch_related('badgeclass__issuer__faculty__institution__terms') - .filter(entity_id=entity_id) - .get() - ) - serializer = DirectAwardDetailSerializer(instance) - return Response(serializer.data) class Enrollments(APIView): diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index 6a63db470..70dedffea 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -15,7 +15,7 @@ BadgeCollectionsDetailView, Login, AcceptGeneralTerms, - DirectAwardDetail, + DirectAwardDetailView, CatalogBadgeClassListView, UserProfileView, InstitutionListView, @@ -33,7 +33,7 @@ path('badge-instances', BadgeInstances.as_view(), name='mobile_api_badge_instances'), path('badge-instances/', BadgeInstanceDetail.as_view(), name='mobile_api_badge_instance_detail'), path('direct-awards', UnclaimedDirectAwards.as_view(), name='mobile_api_direct_awards'), - path('direct-awards/', DirectAwardDetail.as_view(), name='mobile_api_direct_awards_detail'), + path('direct-awards/', DirectAwardDetailView.as_view(), name='mobile_api_direct_awards_detail'), path('direct-awards-accept/', DirectAwardAccept.as_view(), name='direct_award_accept'), path('enrollments', Enrollments.as_view(), name='mobile_api_enrollments'), path('enrollments/', EnrollmentDetail.as_view(), name='mobile_api_enrollment_detail'), diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 0673fdd68..c4839cf0d 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -198,16 +198,28 @@ class Meta: class DirectAwardDetailSerializer(serializers.ModelSerializer): badgeclass = BadgeClassDetailSerializer() - terms = serializers.SerializerMethodField() + required_terms = serializers.SerializerMethodField() + user_has_accepted_terms = serializers.SerializerMethodField() class Meta: model = DirectAward - fields = ['id', 'created_at', 'status', 'entity_id', 'badgeclass', 'terms'] + fields = ['id', 'created_at', 'status', 'entity_id', 'badgeclass', 'required_terms', 'user_has_accepted_terms'] + + def get_required_terms(self, obj): + try: + terms = obj.badgeclass.get_required_terms() + except ValueError: + return None # Should not break the serializer - def get_terms(self, obj): - institution_terms = obj.badgeclass.issuer.faculty.institution.terms.all() - serializer = TermsSerializer(institution_terms, many=True) - return serializer.data + return TermsSerializer(terms, context=self.context).data + + def get_user_has_accepted_terms(self, obj): + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return False + + user = request.user + return obj.badgeclass.terms_accepted(user) class StudentsEnrolledSerializer(serializers.ModelSerializer): From ed92cea8992b851b9c846817c6519127e20b71dc Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 5 Feb 2026 13:14:35 +0100 Subject: [PATCH 069/139] Install fcm-django and configure in settings --- Dockerfile | 1 + apps/mainsite/settings.py | 24 ++++++++++++++++++++++++ requirements.txt | 10 +++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0f06cf883..cb7cd10ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ COPY . /app # Set execute permissions on entrypoint script RUN chmod +x /app/docker/entrypoint.sh +RUN pip install --upgrade pip setuptools wheel # Install any needed packages specified in requirements.txt RUN pip install --no-cache-dir -r requirements.txt diff --git a/apps/mainsite/settings.py b/apps/mainsite/settings.py index 6172e57b8..a89a1add6 100644 --- a/apps/mainsite/settings.py +++ b/apps/mainsite/settings.py @@ -59,6 +59,7 @@ def legacy_boolean_parsing(env_key, default_value): 'django_celery_results', 'drf_spectacular', 'drf_spectacular_sidecar', + 'fcm_django', # OAuth 2 provider 'oauth2_provider', # eduBadges apps @@ -652,3 +653,26 @@ def legacy_boolean_parsing(env_key, default_value): AUDITLOG_DISABLE_REMOTE_ADDR = True API_PROXY = {'HOST': OB3_AGENT_URL_UNIME} + +# FCM Django (Firebase Cloud Messaging for push notifications on mobile) +def get_fcm_settings(): + if any(os.environ.get(var) is None for var in [ + "FIREBASE_TYPE", + "FIREBASE_PROJECT_ID", + "FIREBASE_PRIVATE_KEY_ID", + "FIREBASE_PRIVATE_KEY", + "FIREBASE_CLIENT_EMAIL", + ]): + return {} + return { + "type": os.environ["FIREBASE_TYPE"], + "project_id": os.environ["FIREBASE_PROJECT_ID"], + "private_key_id": os.environ["FIREBASE_PRIVATE_KEY_ID"], + "private_key": os.environ["FIREBASE_PRIVATE_KEY"].replace("\\n", "\n"), + "client_email": os.environ["FIREBASE_CLIENT_EMAIL"], + "token_uri": os.environ.get( + "FIREBASE_TOKEN_URI", "https://oauth2.googleapis.com/token" + ), + } + +FCM_DJANGO_SETTINGS = get_fcm_settings() diff --git a/requirements.txt b/requirements.txt index 2505abd6c..b21e0407b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -83,7 +83,7 @@ cryptography==44.0.1 enum34==1.1.6 idna==3.10 ipaddress==1.0.16 -pyasn1==0.1.9 +pyasn1>=0.6.1,<0.7.0 pycparser==2.14 six==1.13.0 @@ -136,3 +136,11 @@ django-prometheus==2.3.1 loki-logger-handler==1.1.1 django-auditlog==3.0.0 + +fcm-django==2.3.1 +firebase-admin==7.1.0 +google-api-core==2.29.0 +protobuf>=6.31.1,<7.0.0 +grpcio<2 +grpcio-status==1.76.0 +pyasn1-modules==0.4.2 From 0b83bc9db1bada3c9481d5d1f49ba3feb49f3935 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 5 Feb 2026 13:15:10 +0100 Subject: [PATCH 070/139] Add system check to warn if env variables are missing --- apps/mobile_api/apps.py | 9 +++++++++ apps/mobile_api/checks.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 apps/mobile_api/apps.py create mode 100644 apps/mobile_api/checks.py diff --git a/apps/mobile_api/apps.py b/apps/mobile_api/apps.py new file mode 100644 index 000000000..b707dcb60 --- /dev/null +++ b/apps/mobile_api/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + +class MobileApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'mobile_api' + + def ready(self): + # Import your checks module so Django sees it + import mobile_api.checks diff --git a/apps/mobile_api/checks.py b/apps/mobile_api/checks.py new file mode 100644 index 000000000..325359643 --- /dev/null +++ b/apps/mobile_api/checks.py @@ -0,0 +1,29 @@ +import os + +from django.core.checks import register, Warning + + +@register() +def check_firebase_env_vars(app_configs, **kwargs): + """ + Check that all required Firebase env variables are set. + """ + required_vars = [ + "FIREBASE_TYPE", + "FIREBASE_PROJECT_ID", + "FIREBASE_PRIVATE_KEY_ID", + "FIREBASE_PRIVATE_KEY", + "FIREBASE_CLIENT_EMAIL", + ] + + missing = [var for var in required_vars if not os.environ.get(var)] + + if missing: + return [ + Warning( + "Missing Firebase environment variables: {}".format(", ".join(missing)), + hint="Set these env vars for push notifications to work.", + id="fcm_django.W001", + ) + ] + return [] From eb143af1224537ce2fc43d08aab2c1683f71709d Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 5 Feb 2026 14:21:58 +0100 Subject: [PATCH 071/139] Add register device endpoint for mobile push notifications --- apps/mobile_api/api.py | 31 +++++++++++++++++++++++++++++++ apps/mobile_api/api_urls.py | 2 ++ 2 files changed, 33 insertions(+) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 4678efa1c..b7cedc739 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -4,6 +4,7 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.generics import ListAPIView +from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet, FCMDeviceSerializer from badgeuser.models import StudentAffiliation from badgrsocialauth.providers.eduid.provider import EduIDProvider @@ -1378,3 +1379,33 @@ def get_queryset(self): flat=True, ).distinct() return Institution.objects.filter(entity_id__in=institution_entity_ids) + + +@extend_schema( + description=""" + Register a device for push notifications + + - If the device is already registered, sending the same `registration_id` will update the existing record. + - Use this endpoint for both new registrations and updates. + - Use field 'active' to toggle push notifications for this user. + - Omitting 'active' in an update will keep the previous value, and for create the default value of active is True. + - 'registration_id' and 'type' are required, 'name' and 'device_id' are optional. + """, + request=FCMDeviceSerializer, + examples=[ + OpenApiExample( + 'Example Device Registration', + summary='Example payload to register or update a device', + value={ + 'registration_id': 'fcm-token-123456', + 'type': 'ios', + 'name': "John's iPhone", + 'device_id': 'abc123...', + 'active': True, + }, + request_only=True + ) + ], +) +class RegisterDeviceViewSet(FCMDeviceAuthorizedViewSet): + pass diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index 70dedffea..0c7d695d5 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -19,6 +19,7 @@ CatalogBadgeClassListView, UserProfileView, InstitutionListView, + RegisterDeviceViewSet, ) urlpatterns = [ @@ -44,4 +45,5 @@ path('profile', UserProfileView.as_view(), name='mobile_api_user_profile'), path('catalog', CatalogBadgeClassListView.as_view(), name='mobile_api_catalog_badge_class'), path('institutions', InstitutionListView.as_view(), name='mobile_api_institution_list'), + path('register-device', RegisterDeviceViewSet.as_view({'post': 'create'}), name='mobile_api_register_device'), ] From 8a1c3ebf933e2da11beac4c1a4df5b03f5e50698 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 5 Feb 2026 15:40:23 +0100 Subject: [PATCH 072/139] Filter the direct awards on entity id because that is currently stored --- ...actor_badgeclass_and_directaward_relations_and_populate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py b/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py index c6e32366f..cad16ed09 100644 --- a/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py +++ b/apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py @@ -12,7 +12,7 @@ def forwards_populate_audit_trail_fks(apps, schema_editor): if audit.direct_award_entity_id and not audit.direct_award: audit.direct_award = ( DirectAward.objects - .filter(id=audit.direct_award_entity_id) + .filter(entity_id=audit.direct_award_entity_id) .first() ) @@ -31,7 +31,7 @@ def backwards_restore_entity_ids(apps, schema_editor): for audit in DirectAwardAuditTrail.objects.all().iterator(): if audit.direct_award and not audit.direct_award_entity_id: - audit.direct_award_entity_id = audit.direct_award.id + audit.direct_award_entity_id = audit.direct_award.entity_id if audit.badgeclass and not audit.badgeclass_entity_id: audit.badgeclass_entity_id = audit.badgeclass.id From c4af65a4bd266d606e7bd14b94d7b31e9d0a8c1f Mon Sep 17 00:00:00 2001 From: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:52:32 +0100 Subject: [PATCH 073/139] Added changelog for 8.4.1 --- CHANGELOG.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0fa645a..dd7a0b469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,79 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [8.4.1] - 2026-02-05 + +#### Full GitHub changelogs: + +Backend: https://github.com/edubadges/edubadges-server/compare/v8.4...v8.4.1
+ +- Merge branch 'develop' +- Merge pull request #261 from edubadges/feature/add-terms-to-direct-award-endpoint +- Add required terms to direct award detail view +- Merge pull request #258 from edubadges/feature/populate-institution-email +- Merge pull request #260 from edubadges/bugfix/fix-institution-mobile-endpoint-filtering +- Flip filtering logic around to make query faster +- Merge pull request #259 from edubadges/bugfix/institution-mobile-endpoint-use-correct-related-name +- Use correct related name for badgeclass issuer FK +- Add datamigration to populate institution email +- Merge pull request #257 from edubadges/dependabot/pip/django-4.2.28 +- Bump django from 4.2.27 to 4.2.28 +- Merge pull request #255 from edubadges/feature/add-mobile-institution-api-endpoint +- Add mobile institution api endpoint +- Merge pull request #256 from edubadges/feature/add-mobile-api-endpoint-for-badge-class-detail +- Add mobile api endpoint for badge class detail +- Merge pull request #254 from edubadges/feature/add-terms-to-mobile-catalog +- Add boolean for whether user has accepted the terms +- Add terms to catalog badge class serializer +- Added endpoint to make a badge instance public +- Merge pull request #253 from edubadges/bugfix/fix-entity-id-for-direct-award +- Use entity id for direct awards +- Merge pull request #252 from edubadges/bugfix/fix-creation-of-audit-trail-objects-in-signal +- Merge pull request #251 from edubadges/feature/update-filters-for-mobile-api-catalog-endpoint +- Find direct award and badgeclass on id and not entity_id +- Added grade_achieved in the BadgeInstanceDetailSerializer +- Remove q filter and replace is_micro with institution_type filter +- Updated CHANGELOG for release 8.4 +- Updated CHANGELOG for 8.3.3 release +- Merge pull request #250 from edubadges/bugfix/fix-swagger-ui-for-filterable-fields +- Add filter backend globally and locally +- Annotate correct related objects (badgeinstances) #2 +- Annotate correct related objects +- Remove source from terms_agreed +- Merge pull request #249 from edubadges/feature/mobile-profile-add-extra-metadata +- Add registration and consent data to user profile +- Replace profile api view with custom one for mobile api +- Merge pull request #248 from edubadges/feature/mobile-catalog-endpoint-with-filtering-and-pagination +- Add schema example +- Add filter class so endpoint can be filtered with query params +- Add catalog list view with pagination +- Merge pull request #247 from edubadges/improve_mobile_api_swagger +- Prefetch related badge instances to minimize queries +- Use slug related field instead of serializer method field +- fix: have badge instance PUT method only allow acceptance and public field +- fix: use for badge-instances/entity_id path one view (BadgeInstanceDetail) and add logic to support PUT method in BadgeInstanceDetail +- chore: improved the swagger doc by adding full models of badge instances, direct award, and collections +- fix: return entity_id's instead of id's of badgeinstances within collections +- fix: mobile API auth to return 401 instead of 403 +- Adding .zed to gitignore +- Feat: improve mobile api swagger, initial commit +- Added badge_class_type in mobile API +- Merge pull request #244 from edubadges/bugfix/fix-audittrail-errors +- Add a one-off management command to backfill badgeclass ids +- Select related institution through issuer and faculty +- Fix migration to filter on actual ids +- Merge pull request #243 from edubadges/feature/improve-performance-of-direct-award-audit-trail-endpoint +- Update audit trail signal receiver to set fk relations properly +- Improve performance with select_related and extra filter +- Refactor audit trail api view into a ListAPIView +- Refactor charfields to foreign key relationships +- Added stackable to the badgeclass serializer +- Added grade_achieved to mobile seerializer +- Updated CHANGELOG for release 8.4 +- Merge pull request #242 from edubadges/feature/add-linkedin-url-to-mobile-badgeinstance-api-endpoint +- Retrieve faculty directly fro badgeclass issuer +- Add linkedin_url field to badge instance detail serializer + ## [8.4] - 2026-01-14 #### Full GitHub changelogs: From 25b1bea7a7dc7c529598b477f09efaf47fbad1ff Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 5 Feb 2026 16:41:56 +0100 Subject: [PATCH 074/139] Add merge migrations that were generated on production server --- ...0052_alter_institution_sis_default_user.py | 24 +++++++++++++++++++ .../migrations/0053_merge_20240226_1350.py | 13 ++++++++++ .../migrations/0058_merge_20240814_0929.py | 13 ++++++++++ .../migrations/0066_merge_20241113_1458.py | 13 ++++++++++ ...titution_email_0066_merge_20241113_1458.py | 13 ++++++++++ 5 files changed, 76 insertions(+) create mode 100644 apps/institution/migrations/0052_alter_institution_sis_default_user.py create mode 100644 apps/institution/migrations/0053_merge_20240226_1350.py create mode 100644 apps/institution/migrations/0058_merge_20240814_0929.py create mode 100644 apps/institution/migrations/0066_merge_20241113_1458.py create mode 100644 apps/institution/migrations/0067_merge_0066_institution_email_0066_merge_20241113_1458.py diff --git a/apps/institution/migrations/0052_alter_institution_sis_default_user.py b/apps/institution/migrations/0052_alter_institution_sis_default_user.py new file mode 100644 index 000000000..1c1ffada8 --- /dev/null +++ b/apps/institution/migrations/0052_alter_institution_sis_default_user.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.19 on 2023-08-28 20:29 + +from django.conf import settings +from django.db import migrations, models + +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('institution', '0051_auto_20230501_1130'), + ] + + operations = [ + migrations.AlterField( + model_name='institution', + name='sis_default_user', + field=models.ForeignKey(blank=True, default=None, + help_text='The edubadges user that will be used for Direct Awards through the SIS API. Must be an administrator of this institution', + null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='sis_institution', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/institution/migrations/0053_merge_20240226_1350.py b/apps/institution/migrations/0053_merge_20240226_1350.py new file mode 100644 index 000000000..04c88e8c9 --- /dev/null +++ b/apps/institution/migrations/0053_merge_20240226_1350.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.24 on 2024-02-26 12:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('institution', '0052_alter_institution_sis_default_user'), + ('institution', '0052_auto_20240109_1351'), + ] + + operations = [ + ] diff --git a/apps/institution/migrations/0058_merge_20240814_0929.py b/apps/institution/migrations/0058_merge_20240814_0929.py new file mode 100644 index 000000000..988e16ba1 --- /dev/null +++ b/apps/institution/migrations/0058_merge_20240814_0929.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.25 on 2024-08-14 07:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('institution', '0053_merge_20240226_1350'), + ('institution', '0057_institution_country_code'), + ] + + operations = [ + ] diff --git a/apps/institution/migrations/0066_merge_20241113_1458.py b/apps/institution/migrations/0066_merge_20241113_1458.py new file mode 100644 index 000000000..189023d3f --- /dev/null +++ b/apps/institution/migrations/0066_merge_20241113_1458.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.25 on 2024-11-13 13:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('institution', '0058_merge_20240814_0929'), + ('institution', '0065_default_test_visibility_type_surf_institution'), + ] + + operations = [ + ] diff --git a/apps/institution/migrations/0067_merge_0066_institution_email_0066_merge_20241113_1458.py b/apps/institution/migrations/0067_merge_0066_institution_email_0066_merge_20241113_1458.py new file mode 100644 index 000000000..0aeeb022d --- /dev/null +++ b/apps/institution/migrations/0067_merge_0066_institution_email_0066_merge_20241113_1458.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.25 on 2024-12-18 13:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('institution', '0066_institution_email'), + ('institution', '0066_merge_20241113_1458'), + ] + + operations = [ + ] From eca12a155077e3b3164f4229295809744a1712d5 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 5 Feb 2026 16:42:15 +0100 Subject: [PATCH 075/139] Fix populate institution email data migration --- ...titution_email.py => 0068_populate_institution_email.py} | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) rename apps/institution/migrations/{0067_populate_institution_email.py => 0068_populate_institution_email.py} (77%) diff --git a/apps/institution/migrations/0067_populate_institution_email.py b/apps/institution/migrations/0068_populate_institution_email.py similarity index 77% rename from apps/institution/migrations/0067_populate_institution_email.py rename to apps/institution/migrations/0068_populate_institution_email.py index a6aedbf11..c132bd763 100644 --- a/apps/institution/migrations/0067_populate_institution_email.py +++ b/apps/institution/migrations/0068_populate_institution_email.py @@ -5,7 +5,7 @@ def populate_institution_email(apps, schema_editor): Institution = apps.get_model("institution", "Institution") - InstitutionStaff = apps.get_model("institution", "InstitutionStaff") + InstitutionStaff = apps.get_model("staff", "InstitutionStaff") for institution in Institution.objects.filter(email__isnull=True): # Find oldest active admin @@ -28,8 +28,10 @@ def populate_institution_email(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('institution', '0066_institution_email'), + ('institution', '0067_merge_0066_institution_email_0066_merge_20241113_1458'), + ('staff', '0008_auto_20200526_1536'), ] operations = [ + migrations.RunPython(populate_institution_email), ] From a837bd7097716d506b667d80809c8daf9c404b1d Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 9 Feb 2026 09:58:52 +0100 Subject: [PATCH 076/139] Add missing migrations I ran makemigrations and some migrations were created. This means that some models were updated in the code, but the migrations weren't created yet. This change should reflect that in the DB's --- .../0079_delete_importbadgeallowedurl.py | 16 ++++++++++++++ .../0069_alter_institution_staff.py | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 apps/badgeuser/migrations/0079_delete_importbadgeallowedurl.py create mode 100644 apps/institution/migrations/0069_alter_institution_staff.py diff --git a/apps/badgeuser/migrations/0079_delete_importbadgeallowedurl.py b/apps/badgeuser/migrations/0079_delete_importbadgeallowedurl.py new file mode 100644 index 000000000..8b7115977 --- /dev/null +++ b/apps/badgeuser/migrations/0079_delete_importbadgeallowedurl.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.28 on 2026-02-05 15:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('badgeuser', '0078_importbadgeallowedurl'), + ] + + operations = [ + migrations.DeleteModel( + name='ImportBadgeAllowedUrl', + ), + ] diff --git a/apps/institution/migrations/0069_alter_institution_staff.py b/apps/institution/migrations/0069_alter_institution_staff.py new file mode 100644 index 000000000..4931424ac --- /dev/null +++ b/apps/institution/migrations/0069_alter_institution_staff.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.28 on 2026-02-09 08:51 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('staff', '0008_auto_20200526_1536'), + ('institution', '0068_populate_institution_email'), + ] + + operations = [ + migrations.AlterField( + model_name='institution', + name='staff', + field=models.ManyToManyField(related_name='+', through='staff.InstitutionStaff', to=settings.AUTH_USER_MODEL), + ), + ] From 288e434c17a433214b93cedcfc33c9578f8289a7 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 9 Feb 2026 10:00:36 +0100 Subject: [PATCH 077/139] Rename image_url to image in badge class serializer --- apps/mobile_api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index c4839cf0d..554e30465 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -78,7 +78,7 @@ class BadgeClassSerializer(serializers.ModelSerializer): class Meta: model = BadgeClass - fields = ['id', 'name', 'entity_id', 'image_url', 'issuer'] + fields = ['id', 'name', 'entity_id', 'image', 'issuer'] class BadgeClassDetailSerializer(serializers.ModelSerializer): From fc78d2749ca2bb7084361439129ec35244434bc8 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 9 Feb 2026 11:44:04 +0100 Subject: [PATCH 078/139] Add agreed_at date to terms agreement model --- .../0080_termsagreement_agreed_at.py | 18 ++++++++++++++++++ apps/badgeuser/models.py | 5 +++++ 2 files changed, 23 insertions(+) create mode 100644 apps/badgeuser/migrations/0080_termsagreement_agreed_at.py diff --git a/apps/badgeuser/migrations/0080_termsagreement_agreed_at.py b/apps/badgeuser/migrations/0080_termsagreement_agreed_at.py new file mode 100644 index 000000000..f965eaf47 --- /dev/null +++ b/apps/badgeuser/migrations/0080_termsagreement_agreed_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.28 on 2026-02-09 10:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('badgeuser', '0079_delete_importbadgeallowedurl'), + ] + + operations = [ + migrations.AddField( + model_name='termsagreement', + name='agreed_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/apps/badgeuser/models.py b/apps/badgeuser/models.py index 962a5df2d..64f774ef2 100644 --- a/apps/badgeuser/models.py +++ b/apps/badgeuser/models.py @@ -877,6 +877,10 @@ def accept(self, user): returns: TermsAgreement""" # must work for updating increment and for accepting the first time terms_agreement, created = TermsAgreement.objects.get_or_create(user=user, terms=self) + + if not terms_agreement.agreed: + terms_agreement.agreed_at = timezone.now() + terms_agreement.agreed_version = self.version terms_agreement.agreed = True terms_agreement.save() @@ -894,6 +898,7 @@ class TermsAgreement(BaseAuditedModel, BaseVersionedEntity, CacheModel): terms = models.ForeignKey('badgeuser.Terms', on_delete=models.CASCADE) agreed = models.BooleanField(default=True) agreed_version = models.PositiveIntegerField(null=True) + agreed_at = models.DateTimeField(null=True, blank=True) class StudentAffiliation(models.Model): From fdd488c99ae86702a9ecc599de99a906332feed8 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 9 Feb 2026 11:44:24 +0100 Subject: [PATCH 079/139] Add data migration to backfill historical agreed terms --- ...0081_populate_termsagreement_agreed_add.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/badgeuser/migrations/0081_populate_termsagreement_agreed_add.py diff --git a/apps/badgeuser/migrations/0081_populate_termsagreement_agreed_add.py b/apps/badgeuser/migrations/0081_populate_termsagreement_agreed_add.py new file mode 100644 index 000000000..daf0ed016 --- /dev/null +++ b/apps/badgeuser/migrations/0081_populate_termsagreement_agreed_add.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.28 on 2026-02-09 10:06 + +from django.db import migrations +from django.db.models import F + + +def populate_termsagreement_agreed_add(apps, schema_editor): + TermsAgreement = apps.get_model('badgeuser', 'TermsAgreement') + TermsAgreement.objects.filter( + agreed=True, + agreed_at__isnull=True + ).update(agreed_at=F("updated_at")) + + +class Migration(migrations.Migration): + + dependencies = [ + ('badgeuser', '0080_termsagreement_agreed_at'), + ] + + operations = [ + migrations.RunPython(populate_termsagreement_agreed_add, migrations.RunPython.noop), + ] From fc75c984f019365a81078e26f059a25c4f8d31a3 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 9 Feb 2026 11:44:48 +0100 Subject: [PATCH 080/139] Add agreed_at to terms agreement serializer --- apps/mobile_api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index c4839cf0d..90e48a559 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -270,7 +270,7 @@ class TermsAgreementSerializer(serializers.ModelSerializer): class Meta: model = TermsAgreement - fields = ['entity_id', 'agreed', 'agreed_version', 'terms'] + fields = ['entity_id', 'agreed', 'agreed_version', 'agreed_at', 'terms'] class UserSerializer(serializers.ModelSerializer): From 2a887183f4e0603764fee9566e25e61858a2df82 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 9 Feb 2026 14:42:28 +0100 Subject: [PATCH 081/139] Update students enrolled serializer fields to match direct awards --- apps/mobile_api/api.py | 94 ++++++++++++++++++++++++---------- apps/mobile_api/serializers.py | 22 +++++--- 2 files changed, 81 insertions(+), 35 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index b7cedc739..7064392d1 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -37,8 +37,8 @@ BadgeInstanceSerializer, DirectAwardDetailSerializer, DirectAwardSerializer, - StudentsEnrolledDetailSerializer, StudentsEnrolledSerializer, + StudentsEnrolledDetailSerializer, UserSerializer, CatalogBadgeClassSerializer, UserProfileSerializer, @@ -707,27 +707,28 @@ class Enrollments(APIView): { 'id': 40, 'entity_id': 'UMcx7xCPS4yBuztOj2IDEw', - 'date_created': '2023-09-04T14:42:03.046498+02:00', - 'denied': 'false', - 'date_awarded': '2023-09-04T15:02:15.088536+02:00', - 'badge_class': { + 'created_at': '2023-09-04T14:42:03.046498+02:00', + 'denied': False, + 'issued_on': '2023-09-04T15:02:15.088536+02:00', + 'acceptance': 'Unaccepted', + 'badgeclass': { 'id': 119, 'name': 'Test enrollment', 'entity_id': '_KI6moSxQ3mAzPEfYUHnLg', - 'image_url': 'https://api-demo.edubadges.nl/media/uploads/badges/issuer_badgeclass_3b1a3c87-d7c6-488f-a1f9-1d3019a137ee.png', + 'image': 'https://api-demo.edubadges.nl/media/uploads/badges/issuer_badgeclass_3b1a3c87-d7c6-488f-a1f9-1d3019a137ee.png', 'issuer': { 'name_dutch': 'SURF Edubadges', 'name_english': 'SURF Edubadges', - 'image_dutch': 'null', + 'image_dutch': None, 'image_english': '/media/uploads/issuers/issuer_logo_ccd075bb-23cb-40b2-8780-b5a7eda9de1c.png', 'faculty': { 'name_dutch': 'SURF', 'name_english': 'SURF', - 'image_dutch': 'null', - 'image_english': 'null', - 'on_behalf_of': 'false', - 'on_behalf_of_display_name': 'null', - 'on_behalf_of_url': 'null', + 'image_dutch': None, + 'image_english': None, + 'on_behalf_of': False, + 'on_behalf_of_display_name': None, + 'on_behalf_of_url': None, 'institution': { 'name_dutch': 'University Voorbeeld', 'name_english': 'University Example', @@ -789,24 +790,61 @@ class EnrollmentDetail(APIView): OpenApiExample( 'Enrollment Details', value={ - 'entity_id': 'enrollment-123', - 'badge_class': { - 'entity_id': 'badgeclass-789', - 'name': 'Advanced Machine Learning', - 'description': 'Enrolled in advanced ML course', - 'image': 'https://example.com/ml-badge.png', - 'criteria': 'https://example.com/criteria', - }, - 'user': 'user@example.com', - 'date_enrolled': '2023-03-10T14:25:00Z', - 'date_awarded': None, - 'status': 'Active', - 'issuer': { - 'name': 'University of Example', - 'url': 'https://example.edu', + 'id': 40, + 'entity_id': 'UMcx7xCPS4yBuztOj2IDEw', + 'created_at': '2023-09-04T14:42:03.046498+02:00', + 'denied': False, + 'issued_on': '2023-09-04T15:02:15.088536+02:00', + 'acceptance': 'Unaccepted', + 'badgeclass': { + 'id': 119, + 'name': 'Test enrollment', + 'entity_id': '_KI6moSxQ3mAzPEfYUHnLg', + 'image': 'https://api-demo.edubadges.nl/media/uploads/badges/issuer_badgeclass_3b1a3c87-d7c6-488f-a1f9-1d3019a137ee.png', + 'description': 'This is a detailed badge class description', + 'formal': True, + 'participation': 'optional', + 'assessment_type': 'exam', + 'assessment_id_verified': True, + 'assessment_supervised': False, + 'quality_assurance_name': 'QA Name', + 'stackable': False, + 'badgeclassextension_set': [ + { + 'name': 'ECTS', + 'value': 2.5 + } + ], + 'badge_class_type': 'standard', + 'expiration_period': None, + 'issuer': { + 'name_dutch': 'SURF Edubadges', + 'name_english': 'SURF Edubadges', + 'image_dutch': None, + 'image_english': '/media/uploads/issuers/issuer_logo_ccd075bb-23cb-40b2-8780-b5a7eda9de1c.png', + 'faculty': { + 'name_dutch': 'SURF', + 'name_english': 'SURF', + 'image_dutch': None, + 'image_english': None, + 'on_behalf_of': False, + 'on_behalf_of_display_name': None, + 'on_behalf_of_url': None, + 'institution': { + 'name_dutch': 'University Voorbeeld', + 'name_english': 'University Example', + 'image_dutch': '/media/uploads/institution/d0273589-2c7a-4834-8c35-fef4695f176a.png', + 'image_english': '/media/uploads/institution/eae5465f-98b1-4849-ac2d-47d4e1cd1252.png', + 'identifier': 'university-example.org', + 'alternative_identifier': 'university-example.org.tempguestidp.edubadges.nl', + 'grondslag_formeel': 'gerechtvaardigd_belang', + 'grondslag_informeel': 'gerechtvaardigd_belang', + }, + }, + }, }, }, - description='Detailed information about a specific enrollment', + description='Detailed information about a specific enrollment with full badgeclass details', response_only=True, ), ], diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 554e30465..a471493b0 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -222,20 +222,28 @@ def get_user_has_accepted_terms(self, obj): return obj.badgeclass.terms_accepted(user) +STATUS_MAP = { + True: "Rejected", + False: "Unaccepted" +} + + class StudentsEnrolledSerializer(serializers.ModelSerializer): - badge_class = BadgeClassSerializer() + badgeclass = BadgeClassSerializer() + created_at = serializers.DateTimeField(source='date_created', read_only=True) + issued_on = serializers.DateTimeField(source='date_awarded', read_only=True) + acceptance = serializers.SerializerMethodField() class Meta: model = StudentsEnrolled - fields = ['id', 'entity_id', 'date_created', 'denied', 'date_awarded', 'badge_class'] + fields = ['id', 'entity_id', 'created_at', 'denied', 'acceptance', 'issued_on', 'badgeclass'] + def get_acceptance(self, obj): + return STATUS_MAP[obj.denied] -class StudentsEnrolledDetailSerializer(serializers.ModelSerializer): - badge_class = BadgeClassDetailSerializer() - class Meta: - model = StudentsEnrolled - fields = ['id', 'entity_id', 'date_created', 'denied', 'date_awarded', 'badge_class'] +class StudentsEnrolledDetailSerializer(StudentsEnrolledSerializer): + badgeclass = BadgeClassDetailSerializer() class BadgeCollectionSerializer(serializers.ModelSerializer): From e5ceabac5ffb2b731e94aed431c2bd1d0775a708 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 10 Feb 2026 09:26:59 +0100 Subject: [PATCH 082/139] Add comment to clarify logic --- apps/badgeuser/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/badgeuser/models.py b/apps/badgeuser/models.py index 64f774ef2..53e55e8ab 100644 --- a/apps/badgeuser/models.py +++ b/apps/badgeuser/models.py @@ -878,6 +878,7 @@ def accept(self, user): # must work for updating increment and for accepting the first time terms_agreement, created = TermsAgreement.objects.get_or_create(user=user, terms=self) + # Only set the agreed_at date if the terms_agreement wasn't agreed yet (for newly created ones and older ones that have agreed false) if not terms_agreement.agreed: terms_agreement.agreed_at = timezone.now() From c6ddfae909c588110d85cba5dc10df2b73966ed2 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 10 Feb 2026 10:10:44 +0100 Subject: [PATCH 083/139] Replace badge collection views with viewset and unified serializer --- apps/mobile_api/api.py | 225 +++------------------------------ apps/mobile_api/api_urls.py | 22 ++-- apps/mobile_api/serializers.py | 80 +++++++++++- 3 files changed, 108 insertions(+), 219 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 7064392d1..2a2f67632 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -18,12 +18,11 @@ OpenApiResponse, OpenApiTypes, extend_schema, - inline_serializer, + inline_serializer, extend_schema_view, ) from institution.models import Institution from issuer.models import BadgeInstance, BadgeInstanceCollection, BadgeClass -from issuer.serializers import BadgeInstanceCollectionSerializer from lti_edu.models import StudentsEnrolled from mainsite.exceptions import BadgrApiException400 from mainsite.mobile_api_authentication import TemporaryUser @@ -45,7 +44,7 @@ BadgeClassDetailSerializer, InstitutionListSerializer, ) -from rest_framework import serializers, status, generics +from rest_framework import serializers, status, generics, viewsets from rest_framework.response import Response from rest_framework.views import APIView @@ -936,213 +935,25 @@ def delete(self, request, entity_id, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) -class BadgeCollectionsListView(APIView): - permission_classes = (MobileAPIPermission,) - - @extend_schema( - methods=['GET'], - description='Get all badge collections for the user', - responses={ - 200: OpenApiResponse( - description='List of badge collections', - response=BadgeCollectionSerializer(many=True), - examples=[ - OpenApiExample( - 'Badge Collections List', - value=[ - { - 'id': 9, - 'created_at': '2025-10-07T12:41:36.332147+02:00', - 'entity_id': 'lt3O3SUpS9Culz0IrA3rOg', - 'badge_instances': [ - 'badge-96-entity-id', - 'badge-175-entity-id', - 'badge-176-entity-id', - 'badge-287-entity-id', - ], - 'name': 'Test collection 1', - 'public': 'false', - 'description': 'test', - }, - { - 'id': 11, - 'created_at': '2025-10-27T16:14:42.650246+01:00', - 'entity_id': 'dhuf6Qx2RMCtRKBw0iHGcg', - 'badge_instances': ['badge-96-entity-id', 'badge-175-entity-id'], - 'name': 'Test collection 2', - 'public': 'true', - 'description': 'Test2', - }, - ], - description='Array of badge collections created by the user', - response_only=True, - ), - ], - ), - 403: permission_denied_response, - }, - ) - def get(self, request, **kwargs): - collections = BadgeInstanceCollection.objects.filter(user=request.user).prefetch_related('badge_instances') - serializer = BadgeCollectionSerializer(collections, many=True) - return Response(serializer.data) - - @extend_schema( - request=BadgeInstanceCollectionSerializer, - description='Create a new BadgeInstanceCollection', - responses={ - 201: OpenApiResponse( - description='Badge collection created successfully', - response=BadgeInstanceCollectionSerializer, - examples=[ - OpenApiExample( - 'Created Collection', - value={ - 'entity_id': 'collection-123', - 'name': 'My Achievements', - 'description': 'Collection of my programming achievements', - 'badge_instances': [311], - }, - description='Newly created badge collection', - response_only=True, - ), - ], - ), - 400: OpenApiResponse( - description='Invalid request data', - examples=[ - OpenApiExample( - 'Invalid Data', - value={'name': ['This field is required.']}, - description='Validation errors in the request data', - response_only=True, - ), - ], - ), - 403: permission_denied_response, - }, - ) - def post(self, request): - serializer = BadgeInstanceCollectionSerializer(data=request.data, context={'request': request}) - serializer.is_valid(raise_exception=True) - badge_collection = serializer.save() - return Response(BadgeInstanceCollectionSerializer(badge_collection).data, status=status.HTTP_201_CREATED) - - -class BadgeCollectionsDetailView(APIView): +@extend_schema_view( + list=extend_schema(description="List badge collections"), + retrieve=extend_schema(description="Retrieve badge collection"), + create=extend_schema(description="Create badge collection"), + update=extend_schema(description="Update badge collection"), + partial_update=extend_schema(description="Partially update badge collection"), + destroy=extend_schema(description="Delete badge collection"), +) +class BadgeCollectionViewSet(viewsets.ModelViewSet): permission_classes = (MobileAPIPermission,) + serializer_class = BadgeCollectionSerializer + lookup_field = "entity_id" - @extend_schema( - request=BadgeInstanceCollectionSerializer, - description='Update an existing BadgeInstanceCollection by entity_id', - parameters=[ - OpenApiParameter( - name='entity_id', - type=OpenApiTypes.STR, - location=OpenApiParameter.PATH, - required=True, - description='entity_id of the collection', - ) - ], - responses={ - 200: OpenApiResponse( - description='Badge collection updated successfully', - response=BadgeInstanceCollectionSerializer, - examples=[ - OpenApiExample( - 'Updated Collection', - value={ - 'entity_id': 'collection-123', - 'name': 'My Updated Achievements', - 'description': 'Updated collection of my programming achievements', - 'badge_instances': [ - { - 'entity_id': 'badge-456', - 'name': 'Python Programming', - }, - ], - }, - description='Updated badge collection', - response_only=True, - ), - ], - ), - 404: OpenApiResponse( - description='Badge collection not found', - examples=[ - OpenApiExample( - 'Not Found', - value={'detail': 'Badge collection not found'}, - description='The requested badge collection does not exist', - response_only=True, - ), - ], - ), - 400: OpenApiResponse( - description='Invalid request data', - examples=[ - OpenApiExample( - 'Invalid Data', - value={'name': ['This field is required.']}, - description='Validation errors in the request data', - response_only=True, - ), - ], - ), - 403: permission_denied_response, - }, - ) - def put(self, request, entity_id): - badge_collection = get_object_or_404(BadgeInstanceCollection, user=request.user, entity_id=entity_id) - serializer = BadgeInstanceCollectionSerializer( - badge_collection, data=request.data, context={'request': request}, partial=False + def get_queryset(self): + return ( + BadgeInstanceCollection.objects + .filter(user=self.request.user) + .prefetch_related("badge_instances") ) - serializer.is_valid(raise_exception=True) - badge_collection = serializer.save() - return Response(BadgeInstanceCollectionSerializer(badge_collection).data, status=status.HTTP_200_OK) - - @extend_schema( - request=None, - description='Delete a BadgeInstanceCollection by ID', - parameters=[ - OpenApiParameter( - name='entity_id', - type=OpenApiTypes.STR, - location=OpenApiParameter.PATH, - required=True, - description='entity_id of the enrollment', - ) - ], - responses={ - 204: OpenApiResponse( - description='Badge collection deleted successfully', - examples=[ - OpenApiExample( - 'Deleted', - value=None, - description='Badge collection was successfully deleted', - response_only=True, - ), - ], - ), - 404: OpenApiResponse( - description='Badge collection not found', - examples=[ - OpenApiExample( - 'Not Found', - value={'detail': 'Badge collection not found'}, - description='The requested badge collection does not exist', - response_only=True, - ), - ], - ), - 403: permission_denied_response, - }, - ) - def delete(self, request, entity_id): - badge_collection = get_object_or_404(BadgeInstanceCollection, entity_id=entity_id, user=request.user) - badge_collection.delete() - return Response(status=status.HTTP_204_NO_CONTENT) class CatalogBadgeClassListView(generics.ListAPIView): diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index 0c7d695d5..576b44bd2 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -1,4 +1,5 @@ -from django.urls import path +from django.urls import path, include +from rest_framework import routers from backpack.api import BackpackAssertionDetail from badgeuser.api import AcceptTermsView @@ -11,8 +12,6 @@ UnclaimedDirectAwards, Enrollments, EnrollmentDetail, - BadgeCollectionsListView, - BadgeCollectionsDetailView, Login, AcceptGeneralTerms, DirectAwardDetailView, @@ -20,16 +19,20 @@ UserProfileView, InstitutionListView, RegisterDeviceViewSet, + BadgeCollectionViewSet, +) + + +router = routers.DefaultRouter() + +router.register( + "badge-collections", + BadgeCollectionViewSet, + basename="badge-collections", ) urlpatterns = [ path('accept-general-terms', AcceptGeneralTerms.as_view(), name='mobile_api_accept_general_terms'), - path('badge-collections', BadgeCollectionsListView.as_view(), name='mobile_api_badge_collections'), - path( - 'badge-collections/', - BadgeCollectionsDetailView.as_view(), - name='mobile_api_badge_collection_update', - ), path('badge-classes/', BadgeClassDetailView.as_view(), name='mobile_api_badge_class_detail'), path('badge-instances', BadgeInstances.as_view(), name='mobile_api_badge_instances'), path('badge-instances/', BadgeInstanceDetail.as_view(), name='mobile_api_badge_instance_detail'), @@ -46,4 +49,5 @@ path('catalog', CatalogBadgeClassListView.as_view(), name='mobile_api_catalog_badge_class'), path('institutions', InstitutionListView.as_view(), name='mobile_api_institution_list'), path('register-device', RegisterDeviceViewSet.as_view({'post': 'create'}), name='mobile_api_register_device'), + path('', include(router.urls)), ] diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index a471493b0..6098f0b29 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -1,6 +1,8 @@ import json from urllib.parse import urlencode +from drf_spectacular.utils import extend_schema_serializer, OpenApiExample + from badgeuser.models import BadgeUser, Terms, TermsAgreement, TermsUrl from directaward.models import DirectAward from institution.models import Faculty, Institution @@ -246,16 +248,88 @@ class StudentsEnrolledDetailSerializer(StudentsEnrolledSerializer): badgeclass = BadgeClassDetailSerializer() +@extend_schema_serializer( + examples=[ + OpenApiExample( + "BadgeCollection", + value={ + "entity_id": "EallxIUARlebkDxox3jYTw", + "name": "My certificates", + "description": "Stuff I’m proud of", + "public": False, + "badge_instances": [ + "JtNF5yC1QriHtbN5Ufro5A", + "kstvuQ0rTDuoXp7PdgSo4A", + ], + }, + response_only=False, + ), + ] +) class BadgeCollectionSerializer(serializers.ModelSerializer): badge_instances = serializers.SlugRelatedField( many=True, - read_only=True, - slug_field='entity_id' + slug_field="entity_id", + queryset=BadgeInstance.objects.all(), + required=False, + help_text="List of BadgeInstance entity_ids belonging to the current user", ) class Meta: model = BadgeInstanceCollection - fields = ['id', 'created_at', 'entity_id', 'badge_instances', 'name', 'public', 'description'] + fields = [ + "id", + "created_at", + "entity_id", + "name", + "description", + "public", + "badge_instances", + ] + read_only_fields = ["id", "created_at", "entity_id"] + + def validate_badge_instances(self, badge_instances): + user = self.context["request"].user + + for badge in badge_instances: + if badge.user_id != user.id: + raise serializers.ValidationError( + "All badge_instances must belong to the current user." + ) + + return badge_instances + + def create(self, validated_data): + badges = validated_data.pop("badge_instances", []) + + collection = BadgeInstanceCollection.objects.create( + user=self.context["request"].user, + **validated_data, + ) + + if badges: + collection.badge_instances.set(badges) + + return collection + + def update(self, instance, validated_data): + badges = validated_data.pop("badge_instances", None) + + if badges == []: + raise serializers.ValidationError( + "badge_instances cannot be empty when explicitly provided." + ) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.save() + + # Only update M2M if explicitly provided + if badges is not None: + instance.badge_instances.set(badges) + + return instance class TermsUrlSerializer(serializers.ModelSerializer): From b889f2460ae7766db6816b9a814ebb4eedba3cb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:49:12 +0000 Subject: [PATCH 084/139] Bump cryptography from 44.0.1 to 46.0.5 Bumps [cryptography](https://github.com/pyca/cryptography) from 44.0.1 to 46.0.5. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/44.0.1...46.0.5) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b21e0407b..b6946980d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -79,7 +79,7 @@ python-json-logger==0.1.2 # SSL Support cffi==1.14.5 -cryptography==44.0.1 +cryptography==46.0.5 enum34==1.1.6 idna==3.10 ipaddress==1.0.16 From eeb87b4883a2662e8b8941725bf0ce208cea2d96 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 11 Feb 2026 14:58:22 +0100 Subject: [PATCH 085/139] Add sorting to mobile api badge instances and catalog endpoints --- apps/mobile_api/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 2a2f67632..8d343ee16 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -281,6 +281,7 @@ def get(self, request, **kwargs): .select_related('badgeclass__issuer__faculty') .select_related('badgeclass__issuer__faculty__institution') .filter(user=request.user) + .order_by('-issued_on') ) serializer = BadgeInstanceSerializer(instances, many=True) return Response(serializer.data) @@ -1164,7 +1165,7 @@ def get_queryset(self): 'badgeinstances', filter=Q(badgeinstances__award_type='direct_award'), ), - ) + ).order_by('name') ) From 317f5699d74a689c94c6cf78dbb6e4cd69a15e86 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 12 Feb 2026 10:32:12 +0100 Subject: [PATCH 086/139] Add source for related badge class serializers --- apps/mobile_api/serializers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 057f510e9..1eec049d2 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -104,7 +104,7 @@ class Meta: 'stackable', 'badgeclassextension_set', 'issuer', - 'badge_class_type', + 'badge_class_type', 'expiration_period', ] @@ -231,7 +231,7 @@ def get_user_has_accepted_terms(self, obj): class StudentsEnrolledSerializer(serializers.ModelSerializer): - badgeclass = BadgeClassSerializer() + badgeclass = BadgeClassSerializer(source="badge_class") created_at = serializers.DateTimeField(source='date_created', read_only=True) issued_on = serializers.DateTimeField(source='date_awarded', read_only=True) acceptance = serializers.SerializerMethodField() @@ -245,7 +245,7 @@ def get_acceptance(self, obj): class StudentsEnrolledDetailSerializer(StudentsEnrolledSerializer): - badgeclass = BadgeClassDetailSerializer() + badgeclass = BadgeClassDetailSerializer(source="badge_class") @extend_schema_serializer( From 952b33a8a25509e327fb17d564b8502256c79298 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 12 Feb 2026 10:41:50 +0100 Subject: [PATCH 087/139] Add helper function for sending push notifications --- apps/mobile_api/push_notifications.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/mobile_api/push_notifications.py diff --git a/apps/mobile_api/push_notifications.py b/apps/mobile_api/push_notifications.py new file mode 100644 index 000000000..3290f3a1b --- /dev/null +++ b/apps/mobile_api/push_notifications.py @@ -0,0 +1,16 @@ +from fcm_django.models import FCMDevice +from firebase_admin import messaging + + +def send_push_notification(user, title, body, data): + devices = FCMDevice.objects.filter(user=user, active=True) + if not devices: + return None + + message = messaging.Message( + notification=messaging.Notification(title=title, body=body), + data=data, + ) + + firebase_response = devices.send_message(message=message) + return firebase_response From dea6c904138ba8f03a314a5b1adfd7a0f27a29f8 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 12 Feb 2026 11:04:19 +0100 Subject: [PATCH 088/139] Add logging for debugging purposes --- apps/mobile_api/push_notifications.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/mobile_api/push_notifications.py b/apps/mobile_api/push_notifications.py index 3290f3a1b..fe542c1c9 100644 --- a/apps/mobile_api/push_notifications.py +++ b/apps/mobile_api/push_notifications.py @@ -1,10 +1,15 @@ +import logging + from fcm_django.models import FCMDevice from firebase_admin import messaging +logger = logging.getLogger(__name__) + def send_push_notification(user, title, body, data): devices = FCMDevice.objects.filter(user=user, active=True) if not devices: + logger.info(f"No FCM devices found for user {user.id} ({user.entity_id})") return None message = messaging.Message( @@ -12,5 +17,16 @@ def send_push_notification(user, title, body, data): data=data, ) + logger.info(f"Sending push to {devices.count()} devices for user {user.id} ({user.entity_id})") firebase_response = devices.send_message(message=message) + + batch_response = firebase_response.response + if batch_response.failure_count > 0: + logger.warning( + f"{batch_response.failure_count} push notifications failed. " + f"Deactivated devices: {firebase_response.deactivated_registration_ids}" + ) + else: + logger.info(f"All {batch_response.success_count} push notifications sent successfully") + return firebase_response From fb13c439d0eb496557e7d2a28d0e53035bfbe36e Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 12 Feb 2026 11:05:16 +0100 Subject: [PATCH 089/139] Send push notifications when edubadge received --- apps/directaward/models.py | 28 ++++++++++++++++++++++++++++ apps/issuer/models.py | 16 ++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/directaward/models.py b/apps/directaward/models.py index 61cd87ca1..da4006d0c 100644 --- a/apps/directaward/models.py +++ b/apps/directaward/models.py @@ -11,6 +11,7 @@ from mainsite.exceptions import BadgrValidationError from mainsite.models import BaseAuditedModel from mainsite.utils import send_mail, EmailMessageMaker +from mobile_api.push_notifications import send_push_notification class DirectAward(BaseAuditedModel, BaseVersionedEntity, CacheModel): @@ -142,6 +143,7 @@ def get_permissions(self, user): return self.badgeclass.get_permissions(user) def notify_recipient(self): + from badgeuser.models import BadgeUser html_message = EmailMessageMaker.create_direct_award_student_mail(self) plain_text = strip_tags(html_message) send_mail( @@ -151,6 +153,19 @@ def notify_recipient(self): recipient_list=[self.recipient_email], ) + user = BadgeUser.objects.filter(email=self.recipient_email).first() + send_push_notification( + user=user, + title="Edubadge received", + body="You earned an edubadge, claim it now!", + data={ + "title_key": "push.badge_received_title", + "body_key": "push.badge_received_body", + "badge": self.name, + } + ) + + class DirectAwardBundle(BaseAuditedModel, BaseVersionedEntity, CacheModel): initial_total = models.IntegerField() @@ -225,6 +240,7 @@ def recipient_emails(self): return [da.recipient_email for da in self.cached_direct_awards()] def notify_recipients(self): + from badgeuser.models import BadgeUser html_message = EmailMessageMaker.create_direct_award_student_mail(self) plain_text = strip_tags(html_message) send_mail( @@ -234,6 +250,18 @@ def notify_recipients(self): bcc=self.recipient_emails, ) + for user in BadgeUser.objects.filter(email__in=self.recipient_emails): + send_push_notification( + user=user, + title="Edubadge received", + body="You earned an edubadge, claim it now!", + data={ + "title_key": "push.badge_received_title", + "body_key": "push.badge_received_body", + "badge": self.badgeclass.name, + } + ) + def notify_awarder(self): html_message = EmailMessageMaker.create_direct_award_bundle_mail(self) plain_text = strip_tags(html_message) diff --git a/apps/issuer/models.py b/apps/issuer/models.py index cc2c0dbbe..3b57f2ca5 100644 --- a/apps/issuer/models.py +++ b/apps/issuer/models.py @@ -33,6 +33,7 @@ from mainsite.mixins import ImageUrlGetterMixin, DefaultLanguageMixin from mainsite.models import BadgrApp, BaseAuditedModel, ArchiveMixin from mainsite.utils import OriginSetting, generate_entity_uri, EmailMessageMaker, send_mail +from mobile_api.push_notifications import send_push_notification from signing import tsob from signing.models import AssertionTimeStamp, PublicKeyIssuer from signing.models import PublicKey @@ -858,6 +859,8 @@ def issue( include_evidence=True, **kwargs, ): + from badgeuser.models import BadgeUser + if not recipient.validated_name and enforce_validated_name and not self.award_non_validated_name_allowed: raise serializers.ValidationError('You need a validated_name from an Institution to issue badges.') assertion = BadgeInstance.objects.create( @@ -875,6 +878,19 @@ def issue( recipient.email_user( subject='Je hebt een edubadge ontvangen! You received an edubadge!', html_message=message ) + + user = BadgeUser.objects.filter(email=recipient.email).first() + send_push_notification( + user=user, + title="Edubadge received", + body="You earned an edubadge, claim it now!", + data={ + "title_key": "push.badge_received_title", + "body_key": "push.badge_received_body", + "badge": self.name, + } + ) + return assertion def issue_signed(self, recipient, created_by=None, allow_uppercase=False, signer=None, extensions=None, **kwargs): From ea4d415aab0eacf5e4522efdda9a681bc9e2f424 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 12 Feb 2026 17:23:51 +0100 Subject: [PATCH 090/139] Add narrative to badge instance detail endpoint for mobile api --- apps/mobile_api/api.py | 2 ++ apps/mobile_api/serializers.py | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 8d343ee16..c6df10ba7 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -366,6 +366,7 @@ class BadgeInstanceDetail(APIView): }, }, 'linkedin_url': 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&name=Edubadge%20account%20complete&organizationId=206815&issueYear=2021&issueMonth=3&certUrl=https%3A%2F%2Fdemo.edubadges.nl%2Fpublic%2Fassertions%2FI41eovHQReGI_SG5KM6dSQ&certId=I41eovHQReGI_SG5KM6dSQ&original_referer=https%3A%2F%2Fdemo.edubadges.nl', + 'narrative': "Personal message from the awarder to the receiver", }, description='Detailed information about a specific badge instance', response_only=True, @@ -390,6 +391,7 @@ def get(self, request, entity_id, **kwargs): instance = ( BadgeInstance.objects.select_related('badgeclass') .prefetch_related('badgeclass__badgeclassextension_set') + .prefetch_related('badgeinstanceevidence_set') .select_related('badgeclass__issuer') .select_related('badgeclass__issuer__faculty') .select_related('badgeclass__issuer__faculty__institution') diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 1eec049d2..480805246 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -133,6 +133,7 @@ class Meta: class BadgeInstanceDetailSerializer(serializers.ModelSerializer): badgeclass = BadgeClassDetailSerializer() linkedin_url = serializers.SerializerMethodField() + narrative = serializers.SerializerMethodField() class Meta: model = BadgeInstance @@ -150,7 +151,8 @@ class Meta: 'linkedin_url', 'grade_achieved', 'include_grade_achieved', - 'include_evidence' + 'include_evidence', + 'narrative', ] def _get_linkedin_org_id(self, badgeclass): @@ -189,6 +191,10 @@ def get_linkedin_url(self, obj): return f"https://www.linkedin.com/profile/add?{urlencode(params)}" + def get_narrative(self, obj): + evidence = obj.badgeinstanceevidence_set.first() + return evidence.narrative if evidence else None + class DirectAwardSerializer(serializers.ModelSerializer): badgeclass = BadgeClassSerializer() From 33aafc4d46a29674a9a6ccac36684009d42907c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:11:29 +0000 Subject: [PATCH 091/139] Bump sqlparse from 0.5.0 to 0.5.4 Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.5.0 to 0.5.4. - [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG) - [Commits](https://github.com/andialbrecht/sqlparse/compare/0.5.0...0.5.4) --- updated-dependencies: - dependency-name: sqlparse dependency-version: 0.5.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b21e0407b..6e41dd01c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ django-cors-middleware==1.5.0 django-autoslug==1.9.8 # wsgiref==0.1.2 # python3 incompatible -sqlparse==0.5.0 +sqlparse==0.5.4 netaddr django-extensions==3.2.3 From 49afb8a9314fb6eb25e06c3374c1eb9fa3ffaef3 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 12 Feb 2026 12:49:47 +0100 Subject: [PATCH 092/139] Replace firebase env variables with json file configuration --- .gitignore | 4 ++++ apps/mainsite/settings.py | 24 ++---------------------- apps/mobile_api/checks.py | 31 ++++++++++++++----------------- docker-compose.yml | 3 ++- env_vars.sh.example | 1 + secrets/.keep | 0 6 files changed, 23 insertions(+), 40 deletions(-) create mode 100644 secrets/.keep diff --git a/.gitignore b/.gitignore index 63f7faffa..7cb4e3b93 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ start.fish sourceandcharm.sh .serena .zed + +# secrets +/secrets +!/secrets/.keep diff --git a/apps/mainsite/settings.py b/apps/mainsite/settings.py index a89a1add6..fa0d49006 100644 --- a/apps/mainsite/settings.py +++ b/apps/mainsite/settings.py @@ -654,25 +654,5 @@ def legacy_boolean_parsing(env_key, default_value): API_PROXY = {'HOST': OB3_AGENT_URL_UNIME} -# FCM Django (Firebase Cloud Messaging for push notifications on mobile) -def get_fcm_settings(): - if any(os.environ.get(var) is None for var in [ - "FIREBASE_TYPE", - "FIREBASE_PROJECT_ID", - "FIREBASE_PRIVATE_KEY_ID", - "FIREBASE_PRIVATE_KEY", - "FIREBASE_CLIENT_EMAIL", - ]): - return {} - return { - "type": os.environ["FIREBASE_TYPE"], - "project_id": os.environ["FIREBASE_PROJECT_ID"], - "private_key_id": os.environ["FIREBASE_PRIVATE_KEY_ID"], - "private_key": os.environ["FIREBASE_PRIVATE_KEY"].replace("\\n", "\n"), - "client_email": os.environ["FIREBASE_CLIENT_EMAIL"], - "token_uri": os.environ.get( - "FIREBASE_TOKEN_URI", "https://oauth2.googleapis.com/token" - ), - } - -FCM_DJANGO_SETTINGS = get_fcm_settings() +# FCM Django (Tell Firebase Admin SDK where the service account JSON is) +os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = os.environ.get("FIREBASE_JSON_FILE") diff --git a/apps/mobile_api/checks.py b/apps/mobile_api/checks.py index 325359643..160ff599a 100644 --- a/apps/mobile_api/checks.py +++ b/apps/mobile_api/checks.py @@ -1,29 +1,26 @@ import os - from django.core.checks import register, Warning - @register() -def check_firebase_env_vars(app_configs, **kwargs): +def check_firebase_json_file(app_configs, **kwargs): """ - Check that all required Firebase env variables are set. + Check that the Firebase service account JSON file exists. """ - required_vars = [ - "FIREBASE_TYPE", - "FIREBASE_PROJECT_ID", - "FIREBASE_PRIVATE_KEY_ID", - "FIREBASE_PRIVATE_KEY", - "FIREBASE_CLIENT_EMAIL", - ] - - missing = [var for var in required_vars if not os.environ.get(var)] - - if missing: + json_file = os.environ.get("FIREBASE_JSON_FILE") + if not json_file: return [ Warning( - "Missing Firebase environment variables: {}".format(", ".join(missing)), - hint="Set these env vars for push notifications to work.", + "FIREBASE_JSON_FILE environment variable not set.", + hint="Set FIREBASE_JSON_FILE to the path of your Firebase service account JSON file.", id="fcm_django.W001", ) ] + if not os.path.exists(json_file): + return [ + Warning( + f"Firebase service account JSON file not found at {json_file}", + hint="Make sure the file exists and the Django process can read it.", + id="fcm_django.W002", + ) + ] return [] diff --git a/docker-compose.yml b/docker-compose.yml index a11891bdf..44321dae8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: - EMAIL_USE_TLS=0 - EMAIL_HOST=mailhog - EMAIL_PORT=1025 + - FIREBASE_JSON_FILE=/secrets/serviceaccount.json - LTI_FRONTEND_URL=localhost - MEMCACHED=memcached:11211 - OIDC_RS_ENTITY_ID=test.edubadges.rs.nl @@ -64,7 +65,7 @@ services: condition: service_started ports: - "8000:8000" - + db: image: mysql:8.4 container_name: badgr_mysql_db diff --git a/env_vars.sh.example b/env_vars.sh.example index 1d161bc3d..6ae2a10fe 100644 --- a/env_vars.sh.example +++ b/env_vars.sh.example @@ -2,6 +2,7 @@ export BADGR_DB_PASSWORD="" export OIDC_RS_SECRET="ask-a-colleague" export EDU_ID_SECRET="ask-a-colleague" export SURF_CONEXT_SECRET="ask-a-colleague" +export FIREBASE_JSON_FILE="path/to/serviceaccount.json" # Only needed when wallet import is used export OB3_AGENT_AUTHZ_TOKEN_SPHEREON="s3cr3t" diff --git a/secrets/.keep b/secrets/.keep new file mode 100644 index 000000000..e69de29bb From 8d3833870c53893af7319e0f506368681dc94801 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 12 Feb 2026 12:51:14 +0100 Subject: [PATCH 093/139] Make push notification sending fail gracefully For when the service account json file is missing --- apps/mobile_api/push_notifications.py | 30 +++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/mobile_api/push_notifications.py b/apps/mobile_api/push_notifications.py index fe542c1c9..0e86ad010 100644 --- a/apps/mobile_api/push_notifications.py +++ b/apps/mobile_api/push_notifications.py @@ -2,6 +2,7 @@ from fcm_django.models import FCMDevice from firebase_admin import messaging +from google.auth.exceptions import DefaultCredentialsError logger = logging.getLogger(__name__) @@ -18,15 +19,22 @@ def send_push_notification(user, title, body, data): ) logger.info(f"Sending push to {devices.count()} devices for user {user.id} ({user.entity_id})") - firebase_response = devices.send_message(message=message) - - batch_response = firebase_response.response - if batch_response.failure_count > 0: - logger.warning( - f"{batch_response.failure_count} push notifications failed. " - f"Deactivated devices: {firebase_response.deactivated_registration_ids}" - ) + try: + firebase_response = devices.send_message(message=message) + except DefaultCredentialsError as e: + logger.error(f"Cannot send FCM push: credentials file missing or unreadable. {e}") + return None + except Exception as e: + logger.error(f"Failed to send push: {e}") + return None else: - logger.info(f"All {batch_response.success_count} push notifications sent successfully") - - return firebase_response + batch_response = firebase_response.response + if batch_response.failure_count > 0: + logger.warning( + f"{batch_response.failure_count} push notifications failed. " + f"Deactivated devices: {firebase_response.deactivated_registration_ids}" + ) + else: + logger.info(f"All {batch_response.success_count} push notifications sent successfully") + + return firebase_response From 05d71a46b7f5364822fe508f3379906d9e6e0d52 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 16 Feb 2026 09:55:44 +0100 Subject: [PATCH 094/139] Only set google env variable if json file env variable is set --- apps/mainsite/settings.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mainsite/settings.py b/apps/mainsite/settings.py index fa0d49006..372c00371 100644 --- a/apps/mainsite/settings.py +++ b/apps/mainsite/settings.py @@ -655,4 +655,7 @@ def legacy_boolean_parsing(env_key, default_value): API_PROXY = {'HOST': OB3_AGENT_URL_UNIME} # FCM Django (Tell Firebase Admin SDK where the service account JSON is) -os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = os.environ.get("FIREBASE_JSON_FILE") +firebase_json = os.environ.get("FIREBASE_JSON_FILE") + +if firebase_json: + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = firebase_json From fd8813ea70afbba2703e9242a301d00ccbdad73a Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 16 Feb 2026 10:28:51 +0100 Subject: [PATCH 095/139] Remove trailing slashes from badge collections endpoints For consistency --- apps/mobile_api/api_urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index 576b44bd2..556a8cb3b 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -23,7 +23,7 @@ ) -router = routers.DefaultRouter() +router = routers.DefaultRouter(trailing_slash=False) router.register( "badge-collections", From e2a18716666168dd6bf3bff9651b60630c41afe2 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 17 Feb 2026 11:43:57 +0100 Subject: [PATCH 096/139] Prevent crash when there is no user to send push notification to --- apps/mobile_api/push_notifications.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mobile_api/push_notifications.py b/apps/mobile_api/push_notifications.py index 0e86ad010..53abd7a32 100644 --- a/apps/mobile_api/push_notifications.py +++ b/apps/mobile_api/push_notifications.py @@ -8,6 +8,9 @@ logger = logging.getLogger(__name__) def send_push_notification(user, title, body, data): + if not user: + logger.info(f"No user found, skipping push notification.") + return None devices = FCMDevice.objects.filter(user=user, active=True) if not devices: logger.info(f"No FCM devices found for user {user.id} ({user.entity_id})") From b8110535faf259f0220d36aa395cd973fa709eb8 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 17 Feb 2026 15:22:44 +0100 Subject: [PATCH 097/139] Add recipient name to direct award and badge instance models --- ...rectaward_recipient_first_name_and_more.py | 23 +++++++++++++++++++ apps/directaward/models.py | 2 ++ .../0118_badgeinstance_recipient_name.py | 18 +++++++++++++++ apps/issuer/models.py | 1 + 4 files changed, 44 insertions(+) create mode 100644 apps/directaward/migrations/0025_directaward_recipient_first_name_and_more.py create mode 100644 apps/issuer/migrations/0118_badgeinstance_recipient_name.py diff --git a/apps/directaward/migrations/0025_directaward_recipient_first_name_and_more.py b/apps/directaward/migrations/0025_directaward_recipient_first_name_and_more.py new file mode 100644 index 000000000..6ff5defbf --- /dev/null +++ b/apps/directaward/migrations/0025_directaward_recipient_first_name_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.28 on 2026-02-17 13:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('directaward', '0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate'), + ] + + operations = [ + migrations.AddField( + model_name='directaward', + name='recipient_first_name', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='directaward', + name='recipient_surname', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/directaward/models.py b/apps/directaward/models.py index da4006d0c..55aaa6a51 100644 --- a/apps/directaward/models.py +++ b/apps/directaward/models.py @@ -16,6 +16,8 @@ class DirectAward(BaseAuditedModel, BaseVersionedEntity, CacheModel): recipient_email = models.EmailField() + recipient_first_name = models.CharField(max_length=255, blank=True, null=True) + recipient_surname = models.CharField(max_length=255, blank=True, null=True) eppn = models.CharField(max_length=254, blank=True, null=True, default=None) badgeclass = models.ForeignKey('issuer.BadgeClass', on_delete=models.CASCADE) bundle = models.ForeignKey('directaward.DirectAwardBundle', null=True, on_delete=models.CASCADE) diff --git a/apps/issuer/migrations/0118_badgeinstance_recipient_name.py b/apps/issuer/migrations/0118_badgeinstance_recipient_name.py new file mode 100644 index 000000000..fe43913cb --- /dev/null +++ b/apps/issuer/migrations/0118_badgeinstance_recipient_name.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.28 on 2026-02-17 13:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0117_rename_badgeinstance_recipient_identifier_badgeclass_revoked_issuer_badg_recipie_6a2cd8_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='badgeinstance', + name='recipient_name', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/issuer/models.py b/apps/issuer/models.py index 3b57f2ca5..faff3c8f7 100644 --- a/apps/issuer/models.py +++ b/apps/issuer/models.py @@ -1047,6 +1047,7 @@ class BadgeInstance(BaseAuditedModel, ImageUrlGetterMixin, BaseVersionedEntity, ) recipient_identifier = models.CharField(max_length=512, blank=False, null=False, db_index=True) + recipient_name = models.CharField(max_length=255, blank=True, null=True) image = models.FileField(upload_to='uploads/badges', blank=True, null=True, db_index=True) From 4e4af22eddd6d29817c4f442d19dc01959a1552e Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 17 Feb 2026 15:23:24 +0100 Subject: [PATCH 098/139] Handle recipient names in the serializer for direct awards --- apps/directaward/serializer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/directaward/serializer.py b/apps/directaward/serializer.py index a9bcb5222..84bfac8f5 100644 --- a/apps/directaward/serializer.py +++ b/apps/directaward/serializer.py @@ -21,6 +21,8 @@ class Meta: badgeclass = BadgeClassSlugRelatedField(slug_field='entity_id', required=False) eppn = serializers.CharField(required=False, allow_blank=True, allow_null=True) recipient_email = serializers.EmailField(required=False) + first_name = serializers.CharField(required=False) + surname = serializers.CharField(required=False) status = serializers.CharField(required=False) evidence_url = serializers.URLField(required=False, allow_blank=True, allow_null=True) narrative = serializers.CharField(required=False, allow_blank=True, allow_null=True) @@ -107,6 +109,8 @@ def create(self, validated_data): direct_award['status'] = status direct_award['created_by'] = validated_data['created_by'] direct_award['expiration_date'] = expiration_date + direct_award['recipient_first_name'] = direct_award.pop('first_name', None) or None + direct_award['recipient_surname'] = direct_award.pop('surname', None) or None try: da_created = DirectAward.objects.create( bundle=direct_award_bundle, From 8cffe2ff5a63bfd4c79b02688be4f3c2fc06db15 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 17 Feb 2026 15:24:41 +0100 Subject: [PATCH 099/139] Pass recipient name along in the award method to issue --- apps/directaward/models.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/directaward/models.py b/apps/directaward/models.py index 55aaa6a51..5ff130283 100644 --- a/apps/directaward/models.py +++ b/apps/directaward/models.py @@ -118,6 +118,14 @@ def award(self, recipient): datetime.datetime.now().replace(microsecond=0, second=0, minute=0, hour=0) + self.badgeclass.expiration_period ) + + # The validated name should take precedence over the user filled in recipient name + recipient_name = None + if recipient.validated_name: + recipient_name = recipient.validated_name + elif self.recipient_first_name and self.recipient_surname: + recipient_name = f'{self.recipient_first_name} {self.recipient_surname}' + assertion = self.badgeclass.issue( recipient=recipient, created_by=self.created_by, @@ -131,6 +139,7 @@ def award(self, recipient): evidence=evidence, include_evidence=evidence is not None, grade_achieved=self.grade_achieved, + recipient_name=recipient_name, ) # delete any pending enrollments for this badgeclass and user recipient.cached_pending_enrollments().filter(badge_class=self.badgeclass).delete() From 4707382ed3b35fb16fb2a1c1c0e3ac93bbce4b4a Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 17 Feb 2026 15:25:13 +0100 Subject: [PATCH 100/139] Update get recipient name to return recipient name when it exists --- apps/issuer/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/issuer/models.py b/apps/issuer/models.py index faff3c8f7..b75e06df3 100644 --- a/apps/issuer/models.py +++ b/apps/issuer/models.py @@ -1162,7 +1162,9 @@ def submit_for_timestamping(self, signer): timestamp.submit_assertion() def get_recipient_name(self): - if self.user: + if self.recipient_name: + return self.recipient_name + elif self.user and self.user.validated_name: return self.user.validated_name else: return None From 3974d60fde2eb5026e2732393eac116b5c814576 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 17 Feb 2026 16:55:49 +0100 Subject: [PATCH 101/139] Update direct award serializer to allow null for first and last names --- apps/directaward/serializer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/directaward/serializer.py b/apps/directaward/serializer.py index 84bfac8f5..288d943f6 100644 --- a/apps/directaward/serializer.py +++ b/apps/directaward/serializer.py @@ -21,8 +21,8 @@ class Meta: badgeclass = BadgeClassSlugRelatedField(slug_field='entity_id', required=False) eppn = serializers.CharField(required=False, allow_blank=True, allow_null=True) recipient_email = serializers.EmailField(required=False) - first_name = serializers.CharField(required=False) - surname = serializers.CharField(required=False) + first_name = serializers.CharField(required=False, allow_blank=True, allow_null=True) + surname = serializers.CharField(required=False, allow_blank=True, allow_null=True) status = serializers.CharField(required=False) evidence_url = serializers.URLField(required=False, allow_blank=True, allow_null=True) narrative = serializers.CharField(required=False, allow_blank=True, allow_null=True) From cf58c955480851d9e895a5fe880857f423ce9ef1 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Feb 2026 09:13:55 +0100 Subject: [PATCH 102/139] Add test for direct award creation with recipient name --- apps/directaward/tests/test_direct_award.py | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/directaward/tests/test_direct_award.py b/apps/directaward/tests/test_direct_award.py index 492ee27f1..46c1c154c 100644 --- a/apps/directaward/tests/test_direct_award.py +++ b/apps/directaward/tests/test_direct_award.py @@ -108,6 +108,40 @@ def test_revoke_direct_award(self): content_type='application/json') self.assertEqual(response.status_code, 200) + def test_create_direct_award_bundle_with_recipient_names(self): + teacher1 = self.setup_teacher(authenticate=True) + self.setup_staff_membership(teacher1, teacher1.institution, may_award=True) + faculty = self.setup_faculty(institution=teacher1.institution) + issuer = self.setup_issuer(created_by=teacher1, faculty=faculty) + badgeclass = self.setup_badgeclass(issuer=issuer) + + post_data = { + 'badgeclass': badgeclass.entity_id, + 'batch_mode': True, + 'notify_recipients': True, + 'direct_awards': [ + { + 'recipient_email': 'john@example.com', + 'eppn': 'john_eppn', + 'first_name': 'John', + 'surname': 'Doe', + } + ] + } + + response = self.client.post( + '/directaward/create', + json.dumps(post_data), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 201) + + bundle = DirectAwardBundle.objects.get(entity_id=response.data['entity_id']) + direct_award = bundle.directaward_set.first() + + self.assertEqual(direct_award.recipient_first_name, 'John') + self.assertEqual(direct_award.recipient_surname, 'Doe') class DirectAwardSchemaTest(BadgrTestCase): From 645a630697577eb5cd1c258ee0fbfa246252fb5d Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Feb 2026 11:12:21 +0100 Subject: [PATCH 103/139] Remove validation for validated name on award and issue --- apps/directaward/models.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/directaward/models.py b/apps/directaward/models.py index 5ff130283..1c347c25e 100644 --- a/apps/directaward/models.py +++ b/apps/directaward/models.py @@ -97,11 +97,6 @@ def award(self, recipient): ): raise BadgrValidationError('Cannot award, eppn / email does not match', 999) - if not recipient.validated_name: - raise BadgrValidationError( - 'Cannot award, you do not have a validated name', - 999, - ) evidence = None if self.evidence_url or self.narrative: evidence = [ @@ -140,6 +135,7 @@ def award(self, recipient): include_evidence=evidence is not None, grade_achieved=self.grade_achieved, recipient_name=recipient_name, + enforce_validated_name=False, ) # delete any pending enrollments for this badgeclass and user recipient.cached_pending_enrollments().filter(badge_class=self.badgeclass).delete() From 8446e1f0b7de64786520425a13d75ebda09c3b51 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Feb 2026 11:12:41 +0100 Subject: [PATCH 104/139] Fix validation on bundle type --- apps/directaward/models.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/directaward/models.py b/apps/directaward/models.py index 1c347c25e..d1a066f92 100644 --- a/apps/directaward/models.py +++ b/apps/directaward/models.py @@ -90,12 +90,19 @@ def award(self, recipient): """Accept the direct award and make an assertion out of it""" from issuer.models import BadgeInstance - if ( - self.eppn not in recipient.eppns - and self.recipient_email != recipient.email - and self.bundle.identifier_type != DirectAwardBundle.IDENTIFIER_EMAIL - ): - raise BadgrValidationError('Cannot award, eppn / email does not match', 999) + if self.bundle.identifier_type == DirectAwardBundle.IDENTIFIER_EPPN: + if self.eppn not in recipient.eppns: + raise BadgrValidationError( + 'Cannot award, eppn does not match', + 999, + ) + + elif self.bundle.identifier_type == DirectAwardBundle.IDENTIFIER_EMAIL: + if self.recipient_email != recipient.email: + raise BadgrValidationError( + 'Cannot award, email does not match', + 999, + ) evidence = None if self.evidence_url or self.narrative: From 29bec1d73fe91e77b5a82ba9a1fbc6efab70681e Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Feb 2026 11:13:00 +0100 Subject: [PATCH 105/139] Add tests for direct award on email and wrong eppn --- apps/issuer/tests/test_issuer.py | 56 +++++++++++++++++++++++++++++++- apps/mainsite/tests/base.py | 4 +-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/apps/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py index a31e3964a..99b6bcc6e 100644 --- a/apps/issuer/tests/test_issuer.py +++ b/apps/issuer/tests/test_issuer.py @@ -10,7 +10,7 @@ from issuer.models import Issuer, BadgeClass from issuer.testfiles.helper import badgeclass_json, issuer_json from lti_edu.models import StudentsEnrolled -from mainsite.exceptions import BadgrValidationFieldError, BadgrValidationMultipleFieldError +from mainsite.exceptions import BadgrValidationFieldError, BadgrValidationMultipleFieldError, BadgrValidationError from mainsite.tests import BadgrTestCase @@ -477,6 +477,60 @@ def test_badgeinstance_get_json(self): self.assertEqual(assertion_data['evidence'][0]['id'], 'http://valid.com') self.assertEqual(assertion_data['narrative'], 'assertion narrative') + def test_direct_award_sets_recipient_name_on_badgeinstance(self): + teacher = self.setup_teacher(authenticate=True) + self.setup_staff_membership( + teacher, teacher.institution, may_award=True, may_read=True, may_create=True, may_update=True + ) + faculty = self.setup_faculty(institution=teacher.institution) + issuer = self.setup_issuer(faculty=faculty, created_by=teacher) + badgeclass = self.setup_badgeclass(issuer=issuer) + + # Create a direct award with first and surname filled + bundle = self.setup_direct_award_bundle(badgeclass=badgeclass, created_by=teacher, identifier_type='email') + direct_award = self.setup_direct_award( + badgeclass=badgeclass, + bundle=bundle, + recipient_email='student@example.com', + recipient_first_name='John', + recipient_surname='Doe', + ) + + # Create a student without validated_name so fallback name is used + student = self.setup_student(email='student@example.com') + student.validated_name = None + student.save() + + # Award the direct award + assertion = direct_award.award(student) + + self.assertEqual(assertion.recipient_name, 'John Doe') + + def test_direct_award_with_incorrect_eppn_fails(self): + teacher = self.setup_teacher(authenticate=True) + self.setup_staff_membership( + teacher, teacher.institution, may_award=True, may_read=True, may_create=True, may_update=True + ) + faculty = self.setup_faculty(institution=teacher.institution) + issuer = self.setup_issuer(faculty=faculty, created_by=teacher) + badgeclass = self.setup_badgeclass(issuer=issuer) + eppn_bundle = self.setup_direct_award_bundle(badgeclass=badgeclass, created_by=teacher, identifier_type='eppn') + eppn_direct_award = self.setup_direct_award( + badgeclass=badgeclass, + bundle=eppn_bundle, + recipient_email='student@example.com', + recipient_first_name='John', + recipient_surname='Doe', + eppn='some-other-eppn', + ) + + student = self.setup_student(email='student@example.com') + student.validated_name = None + student.save() + + with self.assertRaises(BadgrValidationError): + eppn_direct_award.award(student) + class IssuerSchemaTest(BadgrTestCase): def test_issuer_schema(self): diff --git a/apps/mainsite/tests/base.py b/apps/mainsite/tests/base.py index 3b48c85d6..ec5925b6b 100644 --- a/apps/mainsite/tests/base.py +++ b/apps/mainsite/tests/base.py @@ -117,10 +117,10 @@ def setup_teacher(self, first_name='', last_name='', authenticate=False, institu user.save() return user - def setup_student(self, first_name='', last_name='', authenticate=False, affiliated_institutions=[]): + def setup_student(self, first_name='', last_name='', authenticate=False, affiliated_institutions=[], email=None): first_name = string_randomiser('student_first_name') if not first_name else first_name last_name = string_randomiser('student_last_name') if not last_name else last_name - user = self.setup_user(first_name, last_name, authenticate, institution=None) + user = self.setup_user(first_name, last_name, authenticate, institution=None, email=email) self.add_eduid_socialaccount(user) affiliations = [ {'schac_home': inst.identifier, 'eppn': string_randomiser('eppn')} for inst in affiliated_institutions From 4d23e7735f5ee2070c12ee7c98178210632c8b17 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 17 Feb 2026 16:56:36 +0100 Subject: [PATCH 106/139] Update sample csv file for bulk upload for email only --- .../mainsite/static/sample_direct_award_email_only.csv | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/mainsite/static/sample_direct_award_email_only.csv b/apps/mainsite/static/sample_direct_award_email_only.csv index 2f23313f2..df97a59d6 100644 --- a/apps/mainsite/static/sample_direct_award_email_only.csv +++ b/apps/mainsite/static/sample_direct_award_email_only.csv @@ -1,5 +1,5 @@ -Recipient mailadres;Narrative (optional);Evidence URL (optional);Evidence NAME (optional);Evidence Description (optional);Grade (optional) -john@example.org;Lorum ipsum dolor set amet.;https://evicence.url/;Thesis of John;Description of the evidence;ECTS8 -andrew@example.org;;https://evicence.url/;;; -mary@shachome.org;;;;; -peter@shachome.org +Recipient mailadres;Recipient First name;Recipient Surname;Narrative (optional);Evidence URL (optional);Evidence NAME (optional);Evidence Description (optional);Grade (optional) +john@example.org;John;Doe;Lorum ipsum dolor set amet.;https://evicence.url/;Thesis of John;Description of the evidence;ECTS8 +andrew@example.org;Andrew;Garfield;;https://evicence.url/;;; +mary@shachome.org;Mary;Magdalena;;;;; +peter@shachome.org;Peter;Parker;;;;; From 6c1c3269119288299d5172aba6a4da5857527c73 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 18 Feb 2026 09:56:34 +0100 Subject: [PATCH 107/139] Add validated name and recipient name to identity endpoint --- apps/public/public_api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/public/public_api.py b/apps/public/public_api.py index 4c3b10f53..0ff769697 100644 --- a/apps/public/public_api.py +++ b/apps/public/public_api.py @@ -626,7 +626,11 @@ def get(self, request, *args, **kwargs): instance = BadgeInstance.objects.get(salt=salt) if instance.public: if identity == instance.get_hashed_identity(): - return Response({'name': instance.get_recipient_name()}) + return Response({ + 'name': instance.get_recipient_name(), #TODO: for backward compatibility, remove once frontend is updated. + 'validated_name': instance.get_validated_name(), + 'recipient_name': instance.get_recipient_name(), + }) return Response(status=status.HTTP_404_NOT_FOUND) From 678fb64bb1b8f3d7a9903e99b502e9eea46f7cb6 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 18 Feb 2026 09:57:20 +0100 Subject: [PATCH 108/139] Update badge instance recipient name methods to be more concise --- apps/issuer/models.py | 10 ++++------ apps/issuer/tests/test_issuer.py | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/issuer/models.py b/apps/issuer/models.py index b75e06df3..1d5956779 100644 --- a/apps/issuer/models.py +++ b/apps/issuer/models.py @@ -1162,12 +1162,10 @@ def submit_for_timestamping(self, signer): timestamp.submit_assertion() def get_recipient_name(self): - if self.recipient_name: - return self.recipient_name - elif self.user and self.user.validated_name: - return self.user.validated_name - else: - return None + return self.recipient_name or None + + def get_validated_name(self): + return getattr(self.user, "validated_name", None) def get_email_address(self): if self.user: diff --git a/apps/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py index 99b6bcc6e..5ea23cb2b 100644 --- a/apps/issuer/tests/test_issuer.py +++ b/apps/issuer/tests/test_issuer.py @@ -345,7 +345,7 @@ def test_get_name_from_recipient_identifer(self): assertion.save() response = self.client.get('/public/assertions/identity/{}/{}'.format(eduid_hash, salt)) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['name'], assertion.get_recipient_name()) + self.assertEqual(response.data['validated_name'], assertion.get_validated_name()) # class IssuerExtensionsTest(BadgrTestCase): From c762294621325617b1c344c70423d0969266d2b5 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Feb 2026 13:35:04 +0100 Subject: [PATCH 109/139] Add datamigration to populate recipient names for badge instances --- .../0119_populate_recipient_name.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/issuer/migrations/0119_populate_recipient_name.py diff --git a/apps/issuer/migrations/0119_populate_recipient_name.py b/apps/issuer/migrations/0119_populate_recipient_name.py new file mode 100644 index 000000000..c1d0cdb9c --- /dev/null +++ b/apps/issuer/migrations/0119_populate_recipient_name.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.28 on 2026-02-19 10:54 + +from django.db import migrations, transaction + + +def populate_recipient_name(apps, schema_editor): + BadgeInstance = apps.get_model("issuer", "BadgeInstance") + + BATCH_SIZE = 3000 + total = BadgeInstance.objects.filter(recipient_name__isnull=True).count() + + print(f"Starting to populate recipient_name for {total} BadgeInstances...") + + badges_to_update = [] + processed_count = 0 + updated_count = 0 + + for badge in BadgeInstance.objects.filter(recipient_name__isnull=True).select_related("user").iterator(chunk_size=BATCH_SIZE): + validated_name = getattr(badge.user, "validated_name", None) if badge.user else None + if validated_name: + badge.recipient_name = validated_name + badges_to_update.append(badge) + updated_count += 1 + + processed_count += 1 + + # process in batches + if processed_count % BATCH_SIZE == 0: + with transaction.atomic(): + BadgeInstance.objects.bulk_update(badges_to_update, ['recipient_name']) + print(f"Processed {processed_count} / {total} badges, updated {updated_count} badges...") + badges_to_update = [] + + # update any remaining badges + if badges_to_update: + with transaction.atomic(): + BadgeInstance.objects.bulk_update(badges_to_update, ['recipient_name']) + print(f"Processed all {total} badges.") + + print("Finished populating recipient_name.") + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0118_badgeinstance_recipient_name'), + ] + + operations = [ + migrations.RunPython(populate_recipient_name), + ] From df7cbe283b5dc0650817fee76dc154e02a2e741b Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Feb 2026 15:00:51 +0100 Subject: [PATCH 110/139] Remove redirect to allow login without validated name --- apps/badgrsocialauth/providers/eduid/views.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/badgrsocialauth/providers/eduid/views.py b/apps/badgrsocialauth/providers/eduid/views.py index 138f9d7f7..26fdcb148 100644 --- a/apps/badgrsocialauth/providers/eduid/views.py +++ b/apps/badgrsocialauth/providers/eduid/views.py @@ -146,14 +146,10 @@ def callback(request): logger.debug(error) return render_authentication_error(request, EduIDProvider.id, error=error) eppn_json = response.json() - validated_name = bool([info['validated_name'] for info in eppn_json if 'validated_name' in info]) + keyword_arguments["validated_name"] = bool([info["validated_name"] for info in eppn_json if "validated_name" in info]) + keyword_arguments["re_sign"] = False if not social_account else True signup_redirect = badgr_app.signup_redirect args = urllib.parse.urlencode(keyword_arguments) - if not validated_name: - validate_redirect = signup_redirect.replace('signup', 'validate') - return HttpResponseRedirect(f'{validate_redirect}?{args}') - - keyword_arguments['re_sign'] = False if not social_account else True return HttpResponseRedirect(f'{signup_redirect}?{args}') return after_terms_agreement(request, **keyword_arguments) From cf236b204dc1c617cb433985dd267a6f26e5d0a1 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 24 Feb 2026 11:46:10 +0100 Subject: [PATCH 111/139] Refactor mobile api endpoints for terms agreements Now there is a dedicated viewset for retrieving, creating and updating terms agreements with minimal fields --- apps/mobile_api/api.py | 23 +++++++++++++- apps/mobile_api/api_urls.py | 7 +++- apps/mobile_api/serializers.py | 58 +++++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index c6df10ba7..1fc9c1513 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -6,7 +6,7 @@ from rest_framework.generics import ListAPIView from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet, FCMDeviceSerializer -from badgeuser.models import StudentAffiliation +from badgeuser.models import StudentAffiliation, TermsAgreement from badgrsocialauth.providers.eduid.provider import EduIDProvider from directaward.models import DirectAward, DirectAwardBundle from django.conf import settings @@ -43,6 +43,9 @@ UserProfileSerializer, BadgeClassDetailSerializer, InstitutionListSerializer, + TermsAgreementSerializer, + TermsAgreementCreateSerializer, + TermsAgreementUpdateSerializer, ) from rest_framework import serializers, status, generics, viewsets from rest_framework.response import Response @@ -1261,3 +1264,21 @@ def get_queryset(self): ) class RegisterDeviceViewSet(FCMDeviceAuthorizedViewSet): pass + + +class TermsAgreementViewSet(viewsets.ModelViewSet): + queryset = TermsAgreement.objects.all() + permission_classes = (IsAuthenticated, MobileAPIPermission) + http_method_names = ('get', 'post', 'patch') + lookup_field = 'entity_id' + + def get_queryset(self): + return TermsAgreement.objects.filter(user=self.request.user) + + def get_serializer_class(self): + if self.action == 'create': + return TermsAgreementCreateSerializer + elif self.action == 'partial_update': + return TermsAgreementUpdateSerializer + else: + return TermsAgreementSerializer diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index 556a8cb3b..3c7ad57ec 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -20,6 +20,7 @@ InstitutionListView, RegisterDeviceViewSet, BadgeCollectionViewSet, + TermsAgreementViewSet, ) @@ -30,6 +31,11 @@ BadgeCollectionViewSet, basename="badge-collections", ) +router.register( + "terms-agreements", + TermsAgreementViewSet, + basename="terms-agreements", +) urlpatterns = [ path('accept-general-terms', AcceptGeneralTerms.as_view(), name='mobile_api_accept_general_terms'), @@ -43,7 +49,6 @@ path('enrollments/', EnrollmentDetail.as_view(), name='mobile_api_enrollment_detail'), path('login', Login.as_view(), name='mobile_api_login'), path('badge/public', BackpackAssertionDetail.as_view(), name='mobile_api_badge_public'), - path('terms/accept', AcceptTermsView.as_view(), name='mobile_api_user_terms_accept'), path('enroll', StudentsEnrolledList.as_view(), name='mobile_api_lti_edu_enroll_student'), path('profile', UserProfileView.as_view(), name='mobile_api_user_profile'), path('catalog', CatalogBadgeClassListView.as_view(), name='mobile_api_catalog_badge_class'), diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 480805246..e66457b76 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -1,7 +1,8 @@ import json from urllib.parse import urlencode -from drf_spectacular.utils import extend_schema_serializer, OpenApiExample +from django.utils import timezone +from drf_spectacular.utils import extend_schema_serializer, OpenApiExample, extend_schema from badgeuser.models import BadgeUser, Terms, TermsAgreement, TermsUrl from directaward.models import DirectAward @@ -361,6 +362,61 @@ class Meta: fields = ['entity_id', 'agreed', 'agreed_version', 'agreed_at', 'terms'] +@extend_schema_serializer( + examples=[ + OpenApiExample( + "Accept Terms Example", + summary="Accept a term", + description="User accepts a specific term by entity_id", + value={ + "terms": "t1t2t3t4" + }, + ), + ] +) +class TermsAgreementCreateSerializer(serializers.ModelSerializer): + terms = serializers.SlugRelatedField( + queryset=Terms.objects.all(), + slug_field="entity_id" + ) + + class Meta: + model = TermsAgreement + fields = ['terms'] + + def create(self, validated_data): + user = self.context['request'].user + terms = validated_data['terms'] + return terms.accept(user) + + +@extend_schema_serializer( + examples=[ + OpenApiExample( + "Update a Terms Agreement", + summary="Update a terms agreement", + description="Toggle agreed state of a Terms Agreement", + value={ + "agreed": False, + }, + ), + ] +) +class TermsAgreementUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = TermsAgreement + fields = ['agreed'] + read_only_fields = ['agreed_version', 'agreed_at', 'terms'] + + def update(self, instance, validated_data): + instance.agreed = validated_data['agreed'] + if instance.agreed and not instance.agreed_at: + instance.agreed_at = timezone.now() + instance.save() + instance.user.remove_cached_data(['cached_terms_agreements']) + return instance + + class UserSerializer(serializers.ModelSerializer): termsagreement_set = TermsAgreementSerializer(many=True, read_only=True) terms_agreed = serializers.BooleanField(read_only=True) From 30117e8db2f1621e5131fd37381aef1bc5031a50 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 26 Feb 2026 13:17:17 +0100 Subject: [PATCH 112/139] Add user_may_enroll method to badgeclass --- apps/issuer/models.py | 13 ++++++ apps/issuer/tests/test_issuer.py | 75 +++++++++++++++++++++++++++++++- apps/mainsite/tests/base.py | 2 +- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/apps/issuer/models.py b/apps/issuer/models.py index 1d5956779..53c4cf780 100644 --- a/apps/issuer/models.py +++ b/apps/issuer/models.py @@ -1000,6 +1000,19 @@ def get_filtered_json( def cached_badgrapp(self): return self.cached_issuer.cached_badgrapp + def user_may_enroll(self, user): + if self.self_enrollment_disabled: + return False + + if self.institution.identifier in user.schac_homes: + return True + + if not self.formal: + allowed_institutions = [institution.identifier for institution in self.award_allowed_institutions.all()] + return set(user.schac_homes) & set(allowed_institutions) + + return False + class BadgeInstance(BaseAuditedModel, ImageUrlGetterMixin, BaseVersionedEntity, BaseOpenBadgeObjectModel): entity_class_name = 'Assertion' diff --git a/apps/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py index 5ea23cb2b..dbbe6c39d 100644 --- a/apps/issuer/tests/test_issuer.py +++ b/apps/issuer/tests/test_issuer.py @@ -11,7 +11,7 @@ from issuer.testfiles.helper import badgeclass_json, issuer_json from lti_edu.models import StudentsEnrolled from mainsite.exceptions import BadgrValidationFieldError, BadgrValidationMultipleFieldError, BadgrValidationError -from mainsite.tests import BadgrTestCase +from mainsite.tests import BadgrTestCase, string_randomiser class IssuerAPITest(BadgrTestCase): @@ -531,6 +531,79 @@ def test_direct_award_with_incorrect_eppn_fails(self): with self.assertRaises(BadgrValidationError): eppn_direct_award.award(student) + def _create_badge_and_student( + self, self_enrollment_disabled=False, formal=False, same_institution=False, schac_home_match_in_allowed_institutions=False, + ): + """Helper to create a badgeclass and test users""" + teacher = self.setup_teacher() + faculty = self.setup_faculty(institution=teacher.institution) + issuer = self.setup_issuer(faculty=faculty, created_by=teacher) + + other_institution = self.setup_institution() + badgeclass = self.setup_badgeclass( + issuer=issuer, + self_enrollment_disabled=self_enrollment_disabled, + formal=formal, + ) + + if same_institution: + student = self.setup_student(affiliated_institutions=[teacher.institution]) + else: + student = self.setup_student(affiliated_institutions=[other_institution]) + + if not same_institution and schac_home_match_in_allowed_institutions: + badgeclass.award_allowed_institutions.add(other_institution) + badgeclass.save() + + return badgeclass, student + + def test_enrollment_disabled_blocks_all_enrollments(self): + test_cases = [ + (True, True, True), + (True, True, False), + (True, False, True), + (True, False, False), + (False, True, True), + (False, True, False), + (False, False, True), + (False, False, False), + ] + + for formal, same_institution, schac_home_match_in_allowed_institutions in test_cases: + badgeclass, student = self._create_badge_and_student( + self_enrollment_disabled=True, formal=formal, same_institution=same_institution, schac_home_match_in_allowed_institutions=schac_home_match_in_allowed_institutions + ) + self.assertFalse(badgeclass.user_may_enroll(student)) + + def test_enrollment_allowed_for_formal_on_same_institution(self): + # Not allowed when not same institution + badgeclass, student = self._create_badge_and_student(self_enrollment_disabled=False, formal=True, same_institution=False) + self.assertFalse(badgeclass.user_may_enroll(student)) + + # Allowed when same institution + badgeclass, student = self._create_badge_and_student(self_enrollment_disabled=False, formal=True, same_institution=True) + self.assertTrue(badgeclass.user_may_enroll(student)) + + # Having a match of schac home in allowed institutions should not make a difference for formal badges + badgeclass, student = self._create_badge_and_student(self_enrollment_disabled=False, formal=True, same_institution=False, schac_home_match_in_allowed_institutions=True) + self.assertFalse(badgeclass.user_may_enroll(student)) + + badgeclass, student = self._create_badge_and_student(self_enrollment_disabled=False, formal=True, same_institution=True, schac_home_match_in_allowed_institutions=True) + self.assertTrue(badgeclass.user_may_enroll(student)) + + def test_enrollment_allowed_for_informal_when_schac_home_matches(self): + # Not allowed when not same institution and no schac home match + badgeclass, student = self._create_badge_and_student(self_enrollment_disabled=False, formal=False, same_institution=False, schac_home_match_in_allowed_institutions=False) + self.assertFalse(badgeclass.user_may_enroll(student)) + + # Allowed when same institution + badgeclass, student = self._create_badge_and_student(self_enrollment_disabled=False, formal=False, same_institution=True, schac_home_match_in_allowed_institutions=False) + self.assertTrue(badgeclass.user_may_enroll(student)) + + # Allowed when not same institution but with schac home match + badgeclass, student = self._create_badge_and_student(self_enrollment_disabled=False, formal=False, same_institution=False, schac_home_match_in_allowed_institutions=True) + self.assertTrue(badgeclass.user_may_enroll(student)) + class IssuerSchemaTest(BadgrTestCase): def test_issuer_schema(self): diff --git a/apps/mainsite/tests/base.py b/apps/mainsite/tests/base.py index ec5925b6b..b0b476e83 100644 --- a/apps/mainsite/tests/base.py +++ b/apps/mainsite/tests/base.py @@ -286,7 +286,7 @@ def setup_badgeclass(self, issuer, **kwargs): if not kwargs.get('image', False): kwargs['image'] = resize_image(open(self.get_test_image_path(), 'r')) return BadgeClass.objects.create( - issuer=issuer, formal=False, description='Description', criteria_text='Criteria text', **kwargs + issuer=issuer, formal=kwargs.pop('formal', False), description='Description', criteria_text='Criteria text', **kwargs ) def setup_assertion(self, recipient, badgeclass, created_by, **kwargs): From f5649bb6db286d073f03cac23c9ffe80432415b1 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 26 Feb 2026 13:18:25 +0100 Subject: [PATCH 113/139] Add self enrollment enabled field to serializer --- apps/mobile_api/api.py | 2 ++ apps/mobile_api/serializers.py | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 1fc9c1513..105c8cb96 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1057,6 +1057,7 @@ class CatalogBadgeClassListView(generics.ListAPIView): ] }, 'user_has_accepted_terms': True, + 'self_enrollment_enabled': True, 'issuer_name_english': 'Team edubadges', 'issuer_name_dutch': 'Team edubadges', 'issuer_entity_id': 'WOLxSjpWQouas1123Z809Q', @@ -1114,6 +1115,7 @@ class CatalogBadgeClassListView(generics.ListAPIView): ] }, 'user_has_accepted_terms': True, + 'self_enrollment_enabled': True, 'issuer_name_english': 'Medicine', 'issuer_name_dutch': 'null', 'issuer_entity_id': 'yuflXDK8ROukQkxSPmh5ag', diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index e66457b76..56d825e80 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -87,6 +87,7 @@ class Meta: class BadgeClassDetailSerializer(serializers.ModelSerializer): issuer = IssuerSerializer(read_only=True) badgeclassextension_set = BadgeClassExtensionSerializer(many=True, read_only=True) + self_enrollment_enabled = serializers.SerializerMethodField() class Meta: model = BadgeClass @@ -107,8 +108,12 @@ class Meta: 'issuer', 'badge_class_type', 'expiration_period', + 'self_enrollment_enabled', ] + def get_self_enrollment_enabled(self, obj): + return not obj.self_enrollment_disabled + class BadgeInstanceSerializer(serializers.ModelSerializer): badgeclass = BadgeClassSerializer() @@ -469,6 +474,7 @@ class CatalogBadgeClassSerializer(serializers.ModelSerializer): badge_class_type = serializers.CharField() required_terms = serializers.SerializerMethodField() user_has_accepted_terms = serializers.SerializerMethodField() + self_enrollment_enabled = serializers.SerializerMethodField() # Issuer fields issuer_name_english = serializers.CharField(source='issuer.name_english', read_only=True) @@ -512,6 +518,7 @@ class Meta: 'badge_class_type', 'required_terms', 'user_has_accepted_terms', + 'self_enrollment_enabled', # Issuer 'issuer_name_english', @@ -557,3 +564,7 @@ def get_user_has_accepted_terms(self, obj): user = request.user return obj.terms_accepted(user) + + def get_self_enrollment_enabled(self, obj): + return not obj.self_enrollment_disabled + From df37f9352f37f0cca59f70772b35ed8af9ad94e2 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 26 Feb 2026 13:18:44 +0100 Subject: [PATCH 114/139] Add user may enroll boolean to serializer --- apps/mobile_api/api.py | 2 ++ apps/mobile_api/serializers.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 105c8cb96..af4811b65 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1058,6 +1058,7 @@ class CatalogBadgeClassListView(generics.ListAPIView): }, 'user_has_accepted_terms': True, 'self_enrollment_enabled': True, + 'user_may_enroll': True, 'issuer_name_english': 'Team edubadges', 'issuer_name_dutch': 'Team edubadges', 'issuer_entity_id': 'WOLxSjpWQouas1123Z809Q', @@ -1116,6 +1117,7 @@ class CatalogBadgeClassListView(generics.ListAPIView): }, 'user_has_accepted_terms': True, 'self_enrollment_enabled': True, + 'user_may_enroll': True, 'issuer_name_english': 'Medicine', 'issuer_name_dutch': 'null', 'issuer_entity_id': 'yuflXDK8ROukQkxSPmh5ag', diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 56d825e80..2d7adcc71 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -88,6 +88,7 @@ class BadgeClassDetailSerializer(serializers.ModelSerializer): issuer = IssuerSerializer(read_only=True) badgeclassextension_set = BadgeClassExtensionSerializer(many=True, read_only=True) self_enrollment_enabled = serializers.SerializerMethodField() + user_may_enroll = serializers.SerializerMethodField() class Meta: model = BadgeClass @@ -109,11 +110,19 @@ class Meta: 'badge_class_type', 'expiration_period', 'self_enrollment_enabled', + 'user_may_enroll', ] def get_self_enrollment_enabled(self, obj): return not obj.self_enrollment_disabled + def get_user_may_enroll(self, obj): + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return False + user = request.user + return obj.may_enroll(user) + class BadgeInstanceSerializer(serializers.ModelSerializer): badgeclass = BadgeClassSerializer() @@ -475,6 +484,7 @@ class CatalogBadgeClassSerializer(serializers.ModelSerializer): required_terms = serializers.SerializerMethodField() user_has_accepted_terms = serializers.SerializerMethodField() self_enrollment_enabled = serializers.SerializerMethodField() + user_may_enroll = serializers.SerializerMethodField() # Issuer fields issuer_name_english = serializers.CharField(source='issuer.name_english', read_only=True) @@ -519,6 +529,7 @@ class Meta: 'required_terms', 'user_has_accepted_terms', 'self_enrollment_enabled', + 'user_may_enroll', # Issuer 'issuer_name_english', @@ -568,3 +579,9 @@ def get_user_has_accepted_terms(self, obj): def get_self_enrollment_enabled(self, obj): return not obj.self_enrollment_disabled + def get_user_may_enroll(self, obj): + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return False + user = request.user + return obj.may_enroll(user) From b53e3ba6a9bd27a901a80cc71ce9e73abbfbd737 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 26 Feb 2026 17:13:20 +0100 Subject: [PATCH 115/139] Show the enrollment enabled and user may enroll as booleans in swagger --- apps/mobile_api/serializers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 2d7adcc71..3fe311dba 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -2,7 +2,7 @@ from urllib.parse import urlencode from django.utils import timezone -from drf_spectacular.utils import extend_schema_serializer, OpenApiExample, extend_schema +from drf_spectacular.utils import extend_schema_serializer, OpenApiExample, extend_schema, extend_schema_field from badgeuser.models import BadgeUser, Terms, TermsAgreement, TermsUrl from directaward.models import DirectAward @@ -113,9 +113,11 @@ class Meta: 'user_may_enroll', ] + @extend_schema_field(serializers.BooleanField) def get_self_enrollment_enabled(self, obj): return not obj.self_enrollment_disabled + @extend_schema_field(serializers.BooleanField) def get_user_may_enroll(self, obj): request = self.context.get("request") if not request or not request.user.is_authenticated: @@ -576,9 +578,11 @@ def get_user_has_accepted_terms(self, obj): user = request.user return obj.terms_accepted(user) + @extend_schema_field(serializers.BooleanField) def get_self_enrollment_enabled(self, obj): return not obj.self_enrollment_disabled + @extend_schema_field(serializers.BooleanField) def get_user_may_enroll(self, obj): request = self.context.get("request") if not request or not request.user.is_authenticated: From db294965bbc4267704baf6338e8a0b45c47e10e3 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Wed, 4 Mar 2026 10:32:13 +0100 Subject: [PATCH 116/139] Bugfix for AttributeError: 'BadgeClass' object has no attribute 'may_enroll' --- apps/mobile_api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 3fe311dba..65f5729e8 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -588,4 +588,4 @@ def get_user_may_enroll(self, obj): if not request or not request.user.is_authenticated: return False user = request.user - return obj.may_enroll(user) + return obj.user_may_enroll(user) From d66ea8450b6ea32f74dde72e4706fb303ed95de2 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Wed, 4 Mar 2026 10:42:57 +0100 Subject: [PATCH 117/139] Fix for AttributeError: 'BadgeClass' object has no attribute 'may_enroll' --- apps/mobile_api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 65f5729e8..7da9216aa 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -123,7 +123,7 @@ def get_user_may_enroll(self, obj): if not request or not request.user.is_authenticated: return False user = request.user - return obj.may_enroll(user) + return obj.user_may_enroll(user) class BadgeInstanceSerializer(serializers.ModelSerializer): From c0c7815879bcd9642d6ef0eab3327885b41fdd77 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:11:22 +0000 Subject: [PATCH 118/139] Bump django from 4.2.28 to 4.2.29 Bumps [django](https://github.com/django/django) from 4.2.28 to 4.2.29. - [Commits](https://github.com/django/django/compare/4.2.28...4.2.29) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.29 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b21e0407b..66144be92 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Django stuff -Django==4.2.28 +Django==4.2.29 semver==2.6.0 pytz==2022.2.1 From e68b680421ea1aea68336a5e7a8383a959b93748 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:10:36 +0000 Subject: [PATCH 119/139] Bump markdown from 2.6.8 to 3.8.1 Bumps [markdown](https://github.com/Python-Markdown/markdown) from 2.6.8 to 3.8.1. - [Release notes](https://github.com/Python-Markdown/markdown/releases) - [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md) - [Commits](https://github.com/Python-Markdown/markdown/commits/3.8.1) --- updated-dependencies: - dependency-name: markdown dependency-version: 3.8.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b21e0407b..ae175c84b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -63,7 +63,7 @@ rfc3987==1.3.4 jsonfield==3.1.0 # markdown support -Markdown==2.6.8 +Markdown==3.8.1 django-markdownify==0.1.0 bleach==3.3.0 From 6f39340f15a5a39aebb973c0b4d10696c931b75b Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 9 Mar 2026 10:08:44 +0100 Subject: [PATCH 120/139] Add detail endpoint for retrieving registered devices for mobile api --- apps/mobile_api/api_urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile_api/api_urls.py b/apps/mobile_api/api_urls.py index 3c7ad57ec..d867d41f2 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -53,6 +53,7 @@ path('profile', UserProfileView.as_view(), name='mobile_api_user_profile'), path('catalog', CatalogBadgeClassListView.as_view(), name='mobile_api_catalog_badge_class'), path('institutions', InstitutionListView.as_view(), name='mobile_api_institution_list'), - path('register-device', RegisterDeviceViewSet.as_view({'post': 'create'}), name='mobile_api_register_device'), + path('register-devices', RegisterDeviceViewSet.as_view({'post': 'create'}), name='mobile_api_register_devices_list'), + path('register-devices/', RegisterDeviceViewSet.as_view({'get': 'retrieve'}), name='mobile_api_register_devices_detail'), path('', include(router.urls)), ] From 3a4db0efe12f56e31b90edf8ca2be035d178fa74 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 10 Mar 2026 09:26:52 +0100 Subject: [PATCH 121/139] Pass recipient names to badge instance for direct awards on email --- apps/directaward/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/directaward/models.py b/apps/directaward/models.py index d1a066f92..2f5dacad9 100644 --- a/apps/directaward/models.py +++ b/apps/directaward/models.py @@ -121,12 +121,12 @@ def award(self, recipient): + self.badgeclass.expiration_period ) - # The validated name should take precedence over the user filled in recipient name + # The recipient name filled in for the direct award (available only with awarding via email) should take precedence over the validated name recipient_name = None - if recipient.validated_name: - recipient_name = recipient.validated_name - elif self.recipient_first_name and self.recipient_surname: + if self.recipient_first_name and self.recipient_surname: recipient_name = f'{self.recipient_first_name} {self.recipient_surname}' + elif recipient.validated_name: + recipient_name = recipient.validated_name assertion = self.badgeclass.issue( recipient=recipient, From ab3b09bd1e7849e5059399b0780dc8d020cb4b85 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Tue, 10 Mar 2026 10:46:07 +0100 Subject: [PATCH 122/139] Wrap saving of direct award in try except to avoid crash --- .../commands/reminders_direct_awards.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/mainsite/management/commands/reminders_direct_awards.py b/apps/mainsite/management/commands/reminders_direct_awards.py index 10a6135cc..c62e9570e 100644 --- a/apps/mainsite/management/commands/reminders_direct_awards.py +++ b/apps/mainsite/management/commands/reminders_direct_awards.py @@ -2,7 +2,7 @@ from datetime import timedelta from django.core.management.base import BaseCommand -from django.db import connections +from django.db import connections, IntegrityError from django.utils import timezone from mainsite import settings @@ -53,11 +53,18 @@ def handle(self, *args, **kwargs): html_message = EmailMessageMaker.direct_award_reminder_student_mail(direct_award) direct_award.reminders = index + 1 _remove_cached_direct_awards(direct_award) - direct_award.save() - send_mail(subject='Reminder: your edubadge will expire', - message=None, - html_message=html_message, - recipient_list=[direct_award.recipient_email]) + try: + direct_award.save() + send_mail( + subject='Reminder: your edubadge will expire', + message=None, + html_message=html_message, + recipient_list=[direct_award.recipient_email] + ) + except IntegrityError: + # Already exists, just skip it + print(f"Skipped duplicate direct award: {direct_award}") + index += 1 direct_awards = DirectAward.objects.filter(expiration_date__lt=now, From d33329ac887f8fac2c6f6a84b235e97cdfebcc2c Mon Sep 17 00:00:00 2001 From: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:34:06 +0100 Subject: [PATCH 123/139] Create trivy.yml --- .github/workflows/trivy.yml | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/trivy.yml diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 000000000..8cbf25517 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,42 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: trivy + +on: + push: + branches: [ "develop" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "develop" ] + +permissions: + contents: read + +jobs: + build: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner in repo mode + uses: aquasecurity/trivy-action@0.33.1 + with: + version: 'v0.69.2' + scan-type: 'fs' + ignore-unfixed: true + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + exit-code: '0' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v4 From eaea3f50f783146c8b93a912881238ce20219f23 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:34:50 +0000 Subject: [PATCH 124/139] Bump aquasecurity/trivy-action in /.github/workflows Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.33.1 to 0.34.0. - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/0.33.1...0.34.0) --- updated-dependencies: - dependency-name: aquasecurity/trivy-action dependency-version: 0.34.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/trivy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 8cbf25517..9011b5663 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy vulnerability scanner in repo mode - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@0.34.0 with: version: 'v0.69.2' scan-type: 'fs' From 3451acbabd99b7952789cca11231e0354f67a235 Mon Sep 17 00:00:00 2001 From: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:10:09 +0100 Subject: [PATCH 125/139] Update trivy.yml to upload sarif_file --- .github/workflows/trivy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 8cbf25517..bb8fafddd 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -40,3 +40,6 @@ jobs: - name: Upload Trivy scan results to GitHub Security tab uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: 'trivy-results.sarif' + From a54281edb41d1948cc4a758be67fa2f684ee5ecb Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 10 Mar 2026 13:49:36 +0100 Subject: [PATCH 126/139] feat: updated cffi to 2.0.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b6946980d..0eedadbf7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -78,7 +78,7 @@ importlib-metadata==4.13.0 python-json-logger==0.1.2 # SSL Support -cffi==1.14.5 +cffi==2.0.0 cryptography==46.0.5 enum34==1.1.6 idna==3.10 From 58b4e55f1a53489e51a6c70372b8d552fb1b6d25 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 12 Mar 2026 17:10:12 +0100 Subject: [PATCH 127/139] Refactor management query for issuer members --- apps/insights/api.py | 85 ++++++++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 27 deletions(-) diff --git a/apps/insights/api.py b/apps/insights/api.py index d484a8591..513285c84 100644 --- a/apps/insights/api.py +++ b/apps/insights/api.py @@ -656,38 +656,69 @@ class IssuerMembers(APIView): permission_classes = (TeachPermission,) def get(self, request, **kwargs): - is_super_user = hasattr(request.user, 'is_superuser') and request.user.is_superuser - institution_part = '' if is_super_user else f'ins.id = {request.user.institution.id} and ' + user = request.user + is_super_user = user.is_superuser + + issuers = Issuer.objects.select_related( + "faculty__institution" + ).prefetch_related( + "badgeclasses", + "issuerstaff_set__user", + ) - with connection.cursor() as cursor: - cursor.execute( - f""" -select i.id, u.email, u.first_name, u.last_name, i.name_english as issuer_name_en, i.name_dutch as issuer_name_nl, -si.may_update as issuer_staff -from users u -inner join staff_issuerstaff si on u.id = si.user_id -inner join issuer_issuer i on i.id = si.issuer_id -inner join institution_faculty f on f.id = i.faculty_id -inner join institution_institution ins on ins.id = f.institution_id -where {institution_part} si.may_update is not null and i.id is not null order by i.id; - """, - [], - ) - issuer_overview = dict_fetch_all(cursor) + if not is_super_user: + issuers = issuers.filter(faculty__institution=user.institution) - def determine_role(row): - return 'Issuer Admin' if row['issuer_staff'] else 'Issuer Awarder' + results = [] - results = [] + for issuer in issuers: + badgeclasses = issuer.badgeclasses.all() + + for staff in issuer.issuerstaff_set.all(): + role = self.get_role(staff) - for row in issuer_overview: + for badgeclass in badgeclasses: + results.append( + { + "issuer_name": issuer.name, + "email": staff.user.email, + "name": staff.user.get_full_name(), + "role": role, + "permissie/edubadge": badgeclass.name, + } + ) + + # Institution admins + institution_staff = InstitutionStaff.objects.select_related( + "user", "institution" + ).filter(may_administrate_users=True) + + if not is_super_user: + institution_staff = institution_staff.filter(institution=user.institution) + + for inst_staff in institution_staff: + badgeclasses = BadgeClass.objects.filter( + issuer__faculty__institution=inst_staff.institution + ).select_related("issuer") + + for badgeclass in badgeclasses: results.append( { - 'issuer_name': row['issuer_name_en'] if row['issuer_name_en'] else row['issuer_name_nl'], - 'email': row['email'], - 'name': f'{row["first_name"]} {row["last_name"]}', - 'role': determine_role(row), + "issuer_name": badgeclass.issuer.name, + "email": inst_staff.user.email, + "name": inst_staff.user.get_full_name(), + "role": "instellingsadmin", + "permissie/edubadge": badgeclass.name, } ) - sorted_results = sorted(results, key=lambda a: (a['issuer_name'] or '',)) - return Response(sorted_results, status=status.HTTP_200_OK) + + return Response(results, status=status.HTTP_200_OK) + + def get_role(self, staff): + if staff.may_update: + return "Issuer admin" + if staff.may_award: + return "awarder" + if staff.may_read: + return "badge raadpleger" + return "unknown" From 4f4260164791a084b78f4c83709bd275332dc158 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Mon, 16 Mar 2026 11:02:47 +0100 Subject: [PATCH 128/139] Add request to context of badge instance serializer --- apps/mobile_api/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index af4811b65..38529f82a 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -402,7 +402,7 @@ def get(self, request, entity_id, **kwargs): .filter(entity_id=entity_id) .get() ) - serializer = BadgeInstanceDetailSerializer(instance) + serializer = BadgeInstanceDetailSerializer(instance, context={"request": request}) return Response(serializer.data) @extend_schema( From 6c650ac035508cff466cfeeef0e19f94167e5473 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 18 Mar 2026 11:34:42 +0100 Subject: [PATCH 129/139] Refactor login view to remove restrictions on link account and revalidate Additionally made it more readable with helper functions --- apps/mobile_api/api.py | 69 ++++++++++++++++---------------------- apps/mobile_api/eduid.py | 20 +++++++++++ apps/mobile_api/helper.py | 70 +++++++++++++++++++++++---------------- 3 files changed, 90 insertions(+), 69 deletions(-) create mode 100644 apps/mobile_api/eduid.py diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 38529f82a..e8060349b 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -7,9 +7,7 @@ from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet, FCMDeviceSerializer from badgeuser.models import StudentAffiliation, TermsAgreement -from badgrsocialauth.providers.eduid.provider import EduIDProvider from directaward.models import DirectAward, DirectAwardBundle -from django.conf import settings from django.db.models import Q, Subquery, Count from django.shortcuts import get_object_or_404 from drf_spectacular.utils import ( @@ -27,8 +25,9 @@ from mainsite.exceptions import BadgrApiException400 from mainsite.mobile_api_authentication import TemporaryUser from mainsite.permissions import MobileAPIPermission +from mobile_api.eduid import EduIDClient from mobile_api.filters import CatalogBadgeClassFilter -from mobile_api.helper import NoValidatedNameException, RevalidatedNameException, process_eduid_response +from mobile_api.helper import provision_user_from_temporary, extract_bearer_token, sync_user_with_eduid from mobile_api.pagination import CatalogPagination from mobile_api.serializers import ( BadgeCollectionSerializer, @@ -130,49 +129,37 @@ def get(self, request, **kwargs): logger = logging.getLogger('Badgr.Debug') user = request.user - """ - Check if the user is known, has agreed to the terms and has a validated_name. If the user is not known - then check if there is a validate name and provision the user. If all is well, then return the user information - """ - temporary_user = isinstance(user, TemporaryUser) - if temporary_user: + + # Resolve user + token + if isinstance(user, TemporaryUser): bearer_token = user.bearer_token + user = provision_user_from_temporary(request, user) else: - authorization = request.environ.get('HTTP_AUTHORIZATION') - bearer_token = authorization[len('bearer ') :] - - headers = { - 'Accept': 'application/json, application/json;charset=UTF-8', - 'Authorization': f'Bearer {bearer_token}', - } - url = f'{settings.EDUID_API_BASE_URL}/myconext/api/eduid/links' - response = requests.get(url, headers=headers, timeout=60) - if response.status_code != 200: - error = f'Server error: eduID eppn endpoint error ({response.status_code})' - logger.error(error) - return Response(data={'error': str(error)}, status=response.status_code) - - eduid_response = response.json() - validated_names = [info['validated_name'] for info in eduid_response if 'validated_name' in info] - if not validated_names: - # The user must go back to eduID and link an account - return Response(data={'status': 'link-account'}) - if temporary_user: - # User must be created / provisioned together with social account - provider = EduIDProvider(request) - social_login = provider.sociallogin_from_response(request, user.user_payload) - social_login.save(request) - user = social_login.user + bearer_token = extract_bearer_token(request) + + # Fetch eduID data try: - process_eduid_response(eduid_response, user) - except RevalidatedNameException: - return Response(data={'status': 'revalidate-name'}) - except NoValidatedNameException: - return Response(data={'status': 'link-account'}) + client = EduIDClient(bearer_token) + eduid_data = client.get_links() + + except requests.Timeout: + logger.warning('eduID timeout') + return Response({'error': 'eduID timeout'}, status=504) + + except requests.HTTPError as e: + logger.exception('eduID returned error') + return Response({'error': 'eduID error'}, status=502) + + except requests.RequestException: + logger.exception('eduID unavailable') + return Response({'error': 'eduID unavailable'}, status=502) + + # Sync user state + sync_user_with_eduid(user, eduid_data, logger) user.save() - serializer = UserSerializer(user) - return Response(serializer.data) + + return Response(UserSerializer(user).data) class AcceptGeneralTerms(APIView): diff --git a/apps/mobile_api/eduid.py b/apps/mobile_api/eduid.py new file mode 100644 index 000000000..b7d7ceb8a --- /dev/null +++ b/apps/mobile_api/eduid.py @@ -0,0 +1,20 @@ +import requests +from django.conf import settings + + +class EduIDClient: + def __init__(self, bearer_token: str): + self.bearer_token = bearer_token + + def get_links(self): + url = f"{settings.EDUID_API_BASE_URL}/myconext/api/eduid/links" + + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.bearer_token}", + } + + response = requests.get(url, headers=headers, timeout=10) + response.raise_for_status() + + return response.json() diff --git a/apps/mobile_api/helper.py b/apps/mobile_api/helper.py index 6b0065cc4..3fabd934e 100644 --- a/apps/mobile_api/helper.py +++ b/apps/mobile_api/helper.py @@ -1,34 +1,48 @@ -import logging -from rest_framework.exceptions import APIException -from badgeuser.models import BadgeUser +from rest_framework.exceptions import AuthenticationFailed +from badgrsocialauth.providers.eduid.provider import EduIDProvider -class RevalidatedNameException(APIException): - pass -class NoValidatedNameException(APIException): - pass +def sync_user_with_eduid(user, eduid_data, logger): + validated_names = [] + preferred_name = None -def process_eduid_response(eduid_response: dict, user: BadgeUser): - logger = logging.getLogger('Badgr.Debug') + user.clear_affiliations() - validated_names = [info['validated_name'] for info in eduid_response if 'validated_name' in info] + for info in eduid_data: + # validated names + if "validated_name" in info: + validated_names.append(info["validated_name"]) + if info.get("preferred"): + preferred_name = info["validated_name"] - user.clear_affiliations() - for info in eduid_response: - if 'eppn' in info and 'schac_home_organization' in info: - user.add_affiliations([{'eppn': info['eppn'].lower(), - 'schac_home': info['schac_home_organization'], }]) - logger.info(f'Stored affiliations {info["eppn"]} {info["schac_home_organization"]}') - - if user.validated_name and len(validated_names) == 0: - raise RevalidatedNameException - if len(validated_names) > 0: - # Use the preferred linked account for the validated_name. - preferred_validated_name = [info['validated_name'] for info in eduid_response if info['preferred']] - if not preferred_validated_name: - # This should never happen as it would be a bug in eduID, but let's be defensive - preferred_validated_name = [validated_names[0]] - user.validated_name = preferred_validated_name[0] - else: + # affiliations + if "eppn" in info and "schac_home_organization" in info: + user.add_affiliations([{ + "eppn": info["eppn"].lower(), + "schac_home": info["schac_home_organization"], + }]) + + if not validated_names: user.validated_name = None - raise NoValidatedNameException + return + + user.validated_name = preferred_name or validated_names[0] + + +def extract_bearer_token(request) -> str: + auth = request.headers.get("Authorization", "") + + if not auth.lower().startswith("bearer "): + raise AuthenticationFailed("Invalid or missing Authorization header") + + return auth.split(" ", 1)[1] + + +def provision_user_from_temporary(request, temp_user): + provider = EduIDProvider(request) + social_login = provider.sociallogin_from_response( + request, + temp_user.user_payload + ) + social_login.save(request) + return social_login.user From c5126bbccae6cc548d77ea13e442d13878d54605 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Wed, 18 Mar 2026 11:35:02 +0100 Subject: [PATCH 130/139] Update open api examples --- apps/mobile_api/api.py | 112 +++++++++++++++++++++++++++++------------ 1 file changed, 80 insertions(+), 32 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index e8060349b..4ddea079f 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -63,66 +63,114 @@ class Login(APIView): @extend_schema( methods=['GET'], - description='Login and validate the user', + description='Retrieve current user and sync eduID data', responses={ - 403: permission_denied_response, 200: OpenApiResponse( - description='Successful responses with examples', - response=dict, # or inline custom serializer class + response=UserSerializer, + description='urrent user data, synchronized with eduID. The presence of `validated_name` indicates whether the user has connected his eduID account to an other service like a bank or institution.', examples=[ OpenApiExample( - 'User needs to link account in eduID', - value={'status': 'link-account'}, - description="Redirect the user back to eduID with 'acr_values' = 'https://eduid.nl/trust/validate-names'", - response_only=True, - ), - OpenApiExample( - 'User needs to revalidate name in eduID', - value={'status': 'revalidate-name'}, - description="Redirect the user back to eduID with 'acr_values' = 'https://eduid.nl/trust/validate-names'", + name='User with validated name', + value={ + 'email': 'jdoe@example.com', + 'last_name': 'Doe', + 'first_name': 'John', + 'validated_name': 'John Doe', + 'schac_homes': ['university-example.org'], + 'terms_agreed': True, + 'termsagreement_set': [ + { + 'agreed': True, + 'agreed_version': 1, + 'terms': { + 'terms_type': 'service_agreement_student' + }, + }, + { + 'agreed': True, + 'agreed_version': 1, + 'terms': { + 'terms_type': 'terms_of_service', + 'institution': None + }, + }, + ], + }, + description='User has a validated name in eduID.', response_only=True, ), OpenApiExample( - 'User needs to agree to terms', + name='User without validated name', value={ 'email': 'jdoe@example.com', 'last_name': 'Doe', 'first_name': 'John', - 'validated_name': 'John Doe', - 'schac_homes': ['university-example.org'], + 'validated_name': None, + 'schac_homes': [], 'terms_agreed': False, 'termsagreement_set': [], }, - description="Show the terms and use the 'accept-general-terms' endpoint", + description='User does not have a validated name (yet).', response_only=True, ), OpenApiExample( - 'User valid', + name='User without agreed terms', value={ 'email': 'jdoe@example.com', 'last_name': 'Doe', 'first_name': 'John', 'validated_name': 'John Doe', 'schac_homes': ['university-example.org'], - 'terms_agreed': True, - 'termsagreement_set': [ - { - 'agreed': True, - 'agreed_version': 1, - 'terms': {'terms_type': 'service_agreement_student'}, - }, - { - 'agreed': True, - 'agreed_version': 1, - 'terms': {'terms_type': 'terms_of_service', 'institution': None}, - }, - ], + 'terms_agreed': False, + 'termsagreement_set': [], }, - description='The user is valid, proceed with fetching all badge-instances and OPEN direct-awards', + description='User did not accept terms (yet).', response_only=True, ), ], ), + + # 🔐 Auth / permission errors + 401: OpenApiResponse( + description='Authentication failed (missing or invalid bearer token)', + examples=[ + OpenApiExample( + name='Missing token', + value={'detail': 'Authentication credentials were not provided.'}, + ), + OpenApiExample( + name='Invalid token', + value={'detail': 'Invalid or expired token.'}, + ), + ], + ), + + 403: permission_denied_response, + + # 🌐 External dependency errors + 502: OpenApiResponse( + description='eduID service error or unavailable', + examples=[ + OpenApiExample( + name='eduID unavailable', + value={'error': 'eduID unavailable'}, + ), + OpenApiExample( + name='eduID error', + value={'error': 'eduID error'}, + ), + ], + ), + + 504: OpenApiResponse( + description='eduID request timeout', + examples=[ + OpenApiExample( + name='Timeout', + value={'error': 'eduID timeout'}, + ), + ], + ), }, ) def get(self, request, **kwargs): From d484af1c71b93f9930c5c9fe26a33c98f9a2080c Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Mar 2026 10:47:45 +0100 Subject: [PATCH 131/139] Order fields more logically --- apps/mobile_api/api.py | 11 +++++++++-- apps/mobile_api/serializers.py | 22 +++++++++++++++------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 4ddea079f..dfa73f422 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -67,7 +67,7 @@ class Login(APIView): responses={ 200: OpenApiResponse( response=UserSerializer, - description='urrent user data, synchronized with eduID. The presence of `validated_name` indicates whether the user has connected his eduID account to an other service like a bank or institution.', + description='Current user data, synchronized with eduID. The presence of `validated_name` indicates whether the user has connected his eduID account to an other service like a bank or institution.', examples=[ OpenApiExample( name='User with validated name', @@ -357,6 +357,10 @@ class BadgeInstanceDetail(APIView): 'expires_at': 'null', 'acceptance': 'Accepted', 'public': 'true', + 'linkedin_url': 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&name=Edubadge%20account%20complete&organizationId=206815&issueYear=2021&issueMonth=3&certUrl=https%3A%2F%2Fdemo.edubadges.nl%2Fpublic%2Fassertions%2FI41eovHQReGI_SG5KM6dSQ&certId=I41eovHQReGI_SG5KM6dSQ&original_referer=https%3A%2F%2Fdemo.edubadges.nl', + 'include_grade_achieved': 'true', + 'grade_achieved': '8', + 'include_evidence': 'true', 'badgeclass': { 'id': 3, 'name': 'Edubadge account complete', @@ -364,12 +368,16 @@ class BadgeInstanceDetail(APIView): 'image': '/media/uploads/badges/issuer_badgeclass_548517aa-cbab-4a7b-a971-55cdcce0e2a5.png', 'description': '### Welcome to edubadges. Let your life long learning begin! ###\r\n\r\nYou are now ready to collect all your edubadges in your backpack. In your backpack you can store and manage them safely.\r\n\r\nShare them anytime you like and with whom you like.\r\n\r\nEdubadges are visual representations of your knowledge, skills and competences.', 'formal': 'false', + 'badge_class_type': 'regular', + 'expiration_period': 'null', 'participation': 'blended', 'assessment_type': 'written_exam', 'assessment_id_verified': 'false', 'assessment_supervised': 'false', 'quality_assurance_name': 'null', 'stackable': 'false', + 'self_enrollment_enabled': 'true', + 'user_may_enroll': 'false', 'badgeclassextension_set': [ {'name': 'extensions:LanguageExtension', 'value': 'en_EN'}, { @@ -403,7 +411,6 @@ class BadgeInstanceDetail(APIView): }, }, }, - 'linkedin_url': 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&name=Edubadge%20account%20complete&organizationId=206815&issueYear=2021&issueMonth=3&certUrl=https%3A%2F%2Fdemo.edubadges.nl%2Fpublic%2Fassertions%2FI41eovHQReGI_SG5KM6dSQ&certId=I41eovHQReGI_SG5KM6dSQ&original_referer=https%3A%2F%2Fdemo.edubadges.nl', 'narrative': "Personal message from the awarder to the receiver", }, description='Detailed information about a specific badge instance', diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 7da9216aa..b9b346759 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -99,18 +99,18 @@ class Meta: 'image', 'description', 'formal', + 'badge_class_type', + 'expiration_period', 'participation', 'assessment_type', 'assessment_id_verified', 'assessment_supervised', 'quality_assurance_name', 'stackable', - 'badgeclassextension_set', - 'issuer', - 'badge_class_type', - 'expiration_period', 'self_enrollment_enabled', 'user_may_enroll', + 'badgeclassextension_set', + 'issuer', ] @extend_schema_field(serializers.BooleanField) @@ -164,12 +164,12 @@ class Meta: 'expires_at', 'acceptance', 'public', - 'badgeclass', 'linkedin_url', - 'grade_achieved', 'include_grade_achieved', + 'grade_achieved', 'include_evidence', 'narrative', + 'badgeclass', ] def _get_linkedin_org_id(self, badgeclass): @@ -228,7 +228,15 @@ class DirectAwardDetailSerializer(serializers.ModelSerializer): class Meta: model = DirectAward - fields = ['id', 'created_at', 'status', 'entity_id', 'badgeclass', 'required_terms', 'user_has_accepted_terms'] + fields = [ + 'id', + 'created_at', + 'status', + 'entity_id', + 'badgeclass', + 'required_terms', + 'user_has_accepted_terms', + ] def get_required_terms(self, obj): try: From 4ddf5a2f0b0d5346f2cbc6f6d350eaa6c31ac7ea Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Mar 2026 10:48:27 +0100 Subject: [PATCH 132/139] Add alignments --- apps/mobile_api/api.py | 20 +++++++++++++++++++- apps/mobile_api/serializers.py | 21 ++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index dfa73f422..2c59c318e 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -378,6 +378,22 @@ class BadgeInstanceDetail(APIView): 'stackable': 'false', 'self_enrollment_enabled': 'true', 'user_may_enroll': 'false', + 'alignments': [ + { + 'target_name': 'EQF', + 'target_url': 'https://ec.europa.eu/ploteus/content/descriptors-page', + 'target_description': 'European Qualifications Framework', + 'target_framework': 'EQF', + 'target_code': '7', + }, + { + 'target_name': 'ECTS', + 'target_url': 'https://ec.europa.eu/education/resources-and-tools/european-credit-transfer-and-accumulation-system-ects_en', + 'target_description': 'European Credit Transfer and Accumulation System', + 'target_framework': 'ECTS', + 'target_code': '2.5', + }, + ], 'badgeclassextension_set': [ {'name': 'extensions:LanguageExtension', 'value': 'en_EN'}, { @@ -437,6 +453,7 @@ def get(self, request, entity_id, **kwargs): BadgeInstance.objects.select_related('badgeclass') .prefetch_related('badgeclass__badgeclassextension_set') .prefetch_related('badgeinstanceevidence_set') + .prefetch_related('badgeclass__badgeclassalignment_set') .select_related('badgeclass__issuer') .select_related('badgeclass__issuer__faculty') .select_related('badgeclass__issuer__faculty__institution') @@ -1256,7 +1273,8 @@ class BadgeClassDetailView(generics.RetrieveAPIView): 'issuer__faculty', 'issuer__faculty__institution', ).prefetch_related( - 'badgeclassextension_set' + 'badgeclassextension_set', + 'badgeclassalignment_set', ) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index b9b346759..1949adf63 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -7,7 +7,8 @@ from badgeuser.models import BadgeUser, Terms, TermsAgreement, TermsUrl from directaward.models import DirectAward from institution.models import Faculty, Institution -from issuer.models import BadgeClass, BadgeClassExtension, BadgeInstance, BadgeInstanceCollection, Issuer +from issuer.models import BadgeClass, BadgeClassExtension, BadgeInstance, BadgeInstanceCollection, Issuer, \ + BadgeInstanceEvidence, BadgeClassAlignment from lti_edu.models import StudentsEnrolled from rest_framework import serializers @@ -84,11 +85,28 @@ class Meta: fields = ['id', 'name', 'entity_id', 'image', 'issuer'] +class BadgeClassAlignmentSerializer(serializers.ModelSerializer): + class Meta: + model = BadgeClassAlignment + fields = [ + 'target_name', + 'target_url', + 'target_description', + 'target_framework', + 'target_code', + ] + + class BadgeClassDetailSerializer(serializers.ModelSerializer): issuer = IssuerSerializer(read_only=True) badgeclassextension_set = BadgeClassExtensionSerializer(many=True, read_only=True) self_enrollment_enabled = serializers.SerializerMethodField() user_may_enroll = serializers.SerializerMethodField() + alignments = BadgeClassAlignmentSerializer( + source='badgeclassalignment_set', + many=True, + read_only=True + ) class Meta: model = BadgeClass @@ -109,6 +127,7 @@ class Meta: 'stackable', 'self_enrollment_enabled', 'user_may_enroll', + 'alignments', 'badgeclassextension_set', 'issuer', ] From 1e9956d38f0b0c6d38154fa6ebc261d250d04314 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Mar 2026 10:49:01 +0100 Subject: [PATCH 133/139] Add evidences --- apps/mobile_api/api.py | 15 ++++++++++++++- apps/mobile_api/serializers.py | 18 ++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 2c59c318e..d70239bec 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -361,6 +361,20 @@ class BadgeInstanceDetail(APIView): 'include_grade_achieved': 'true', 'grade_achieved': '8', 'include_evidence': 'true', + 'evidences': [ + { + 'evidence_url': 'https://example.com', + 'narrative': 'Personal message from the awarder to the receiver', + 'name': 'evidence name', + 'description': 'evidence description', + }, + { + 'evidence_url': 'https://example.com', + 'narrative': 'Personal message from the awarder to the receiver', + 'name': 'evidence name', + 'description': 'evidence description', + }, + ], 'badgeclass': { 'id': 3, 'name': 'Edubadge account complete', @@ -427,7 +441,6 @@ class BadgeInstanceDetail(APIView): }, }, }, - 'narrative': "Personal message from the awarder to the receiver", }, description='Detailed information about a specific badge instance', response_only=True, diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 1949adf63..64187a892 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -165,11 +165,25 @@ class Meta: "include_grade_achieved" ] +class BadgeInstanceEvidenceSerializer(serializers.ModelSerializer): + class Meta: + model = BadgeInstanceEvidence + fields = [ + 'evidence_url', + 'narrative', + 'name', + 'description', + ] + class BadgeInstanceDetailSerializer(serializers.ModelSerializer): badgeclass = BadgeClassDetailSerializer() linkedin_url = serializers.SerializerMethodField() - narrative = serializers.SerializerMethodField() + evidences = BadgeInstanceEvidenceSerializer( + source='badgeinstanceevidence_set', + many=True, + read_only=True + ) class Meta: model = BadgeInstance @@ -187,7 +201,7 @@ class Meta: 'include_grade_achieved', 'grade_achieved', 'include_evidence', - 'narrative', + 'evidences', 'badgeclass', ] From 27f2034c99b59e04de6cc59aeb0bf6108079fe18 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Mar 2026 10:49:48 +0100 Subject: [PATCH 134/139] Add quality assurance fields --- apps/mobile_api/api.py | 4 +++- apps/mobile_api/serializers.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index d70239bec..f6a21967f 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -388,7 +388,9 @@ class BadgeInstanceDetail(APIView): 'assessment_type': 'written_exam', 'assessment_id_verified': 'false', 'assessment_supervised': 'false', - 'quality_assurance_name': 'null', + 'quality_assurance_name': 'FAKE1.0', + 'quality_assurance_url': 'https://example.com/qaf/FAKE1.0', + 'quality_assurance_description': 'Quality assurance framework FAKE1.0', 'stackable': 'false', 'self_enrollment_enabled': 'true', 'user_may_enroll': 'false', diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 64187a892..60a9dac52 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -124,6 +124,8 @@ class Meta: 'assessment_id_verified', 'assessment_supervised', 'quality_assurance_name', + 'quality_assurance_url', + 'quality_assurance_description', 'stackable', 'self_enrollment_enabled', 'user_may_enroll', From 1ea706178d709b90b9477416f9aa496f32cefdfe Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Mar 2026 10:50:04 +0100 Subject: [PATCH 135/139] Add criteria text --- apps/mobile_api/api.py | 1 + apps/mobile_api/serializers.py | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index f6a21967f..7d5de32bc 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -394,6 +394,7 @@ class BadgeInstanceDetail(APIView): 'stackable': 'false', 'self_enrollment_enabled': 'true', 'user_may_enroll': 'false', + 'criteria_text': 'In order to earn this badge, you must complete the course and show proficiency in things.', 'alignments': [ { 'target_name': 'EQF', diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 60a9dac52..3465bf33f 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -127,6 +127,7 @@ class Meta: 'quality_assurance_url', 'quality_assurance_description', 'stackable', + 'criteria_text', 'self_enrollment_enabled', 'user_may_enroll', 'alignments', From 96d5f62b2630483baa9e4d076ad4112f261f9321 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Mar 2026 10:50:22 +0100 Subject: [PATCH 136/139] Add eqf nlqf level verified --- apps/mobile_api/api.py | 1 + apps/mobile_api/serializers.py | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 7d5de32bc..d138e39c3 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -395,6 +395,7 @@ class BadgeInstanceDetail(APIView): 'self_enrollment_enabled': 'true', 'user_may_enroll': 'false', 'criteria_text': 'In order to earn this badge, you must complete the course and show proficiency in things.', + 'eqf_nlqf_level_verified': 'false', 'alignments': [ { 'target_name': 'EQF', diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 3465bf33f..93fbb5683 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -130,6 +130,7 @@ class Meta: 'criteria_text', 'self_enrollment_enabled', 'user_may_enroll', + 'eqf_nlqf_level_verified', 'alignments', 'badgeclassextension_set', 'issuer', From 17dda646362ed921cd47a0a67d648f49173d6594 Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Mar 2026 10:50:35 +0100 Subject: [PATCH 137/139] Add grade achieved to direct award serializer --- apps/mobile_api/api.py | 1 + apps/mobile_api/serializers.py | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index d138e39c3..9d22fd325 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -747,6 +747,7 @@ def get(self, request, **kwargs): }, }, "user_has_accepted_terms": False, + "grade_achieved": "8,0", }, ), ], diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 93fbb5683..968f8f4d9 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -273,6 +273,7 @@ class Meta: 'badgeclass', 'required_terms', 'user_has_accepted_terms', + 'grade_achieved', ] def get_required_terms(self, obj): From 8f03665ccde52c0b05041f70df1864f6987f47ce Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Mar 2026 14:33:20 +0100 Subject: [PATCH 138/139] Use UI_URL from settings for the cert url in the linkedin url --- apps/mobile_api/serializers.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 968f8f4d9..aec095d13 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -1,8 +1,9 @@ import json -from urllib.parse import urlencode +from urllib.parse import urlencode, urljoin +from django.conf import settings from django.utils import timezone -from drf_spectacular.utils import extend_schema_serializer, OpenApiExample, extend_schema, extend_schema_field +from drf_spectacular.utils import extend_schema_serializer, OpenApiExample, extend_schema_field from badgeuser.models import BadgeUser, Terms, TermsAgreement, TermsUrl from directaward.models import DirectAward @@ -228,7 +229,8 @@ def get_linkedin_url(self, obj): organization_id = self._get_linkedin_org_id(obj.badgeclass) - cert_url = request.build_absolute_uri( + cert_url = urljoin( + settings.UI_URL, f"/public/assertions/{obj.entity_id}" ) @@ -240,7 +242,7 @@ def get_linkedin_url(self, obj): "issueMonth": obj.issued_on.month, "certUrl": cert_url, "certId": obj.entity_id, - "original_referer": request.build_absolute_uri("/"), + "original_referer": settings.UI_URL, } return f"https://www.linkedin.com/profile/add?{urlencode(params)}" From f6554566ad0770d8edaef12fd3ab9ed79a431d0a Mon Sep 17 00:00:00 2001 From: Thomas Kalverda Date: Thu, 19 Mar 2026 16:07:52 +0100 Subject: [PATCH 139/139] Add filter to badge instances endpoint to remove revoked badges --- apps/mobile_api/api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 9d22fd325..6777fb25e 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -318,7 +318,10 @@ def get(self, request, **kwargs): .select_related('badgeclass__issuer') .select_related('badgeclass__issuer__faculty') .select_related('badgeclass__issuer__faculty__institution') - .filter(user=request.user) + .filter( + user=request.user, + revoked=False, + ) .order_by('-issued_on') ) serializer = BadgeInstanceSerializer(instances, many=True)