diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..3913349f8 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,94 @@ +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: 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: + 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 diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index b515cbbd9..498363edd 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' diff --git a/.gitignore b/.gitignore index 389efa1b7..7cb4e3b93 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,8 @@ pyrightconfig.json start.fish sourceandcharm.sh .serena +.zed + +# secrets +/secrets +!/secrets/.keep diff --git a/CHANGELOG.md b/CHANGELOG.md index 89917b5d2..dd7a0b469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,204 @@ 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). -## [unreleased] +## [8.4.1] - 2026-02-05 -- Removed logging to loki, syslog and files. In k8s all logging goes to the default "console" - k8s then forwards to a.o. loki for us -- Removed "public" and "private" flag for BadgeInstances. All badges are now private. +#### 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: + +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: + +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: + +Backend: https://github.com/edubadges/edubadges-server/compare/v8.3.1...v8.3.2
+ +- Added enrollment endpoint for mobile API +- Merge pull request #210 from edubadges/dependabot/pip/django-4.2.26 +- Bump django from 4.2.25 to 4.2.26 +- Also apply virtual organization name for reminders +- Merge pull request #209 from edubadges/feature/mail-virtual-organization +- Fix for virtual organization DA email https://trello.com/c/8xUKHT9C/1116-virtuele-organisatie-wordt-niet-getoond-in-de-e-mail +- Fixed CMD in Dockerfile +- Added SELinux flag to app volume, made entrypoint executable + +## [8.3.1] - 2025-10-28 + +#### Full GitHub changelogs: + +Backend: https://github.com/edubadges/edubadges-server/compare/v8.3.0...v8.3.1
+ +- WIP for 8zmfgqmL - edubadges per sector +- Transferred openbadges-validator-core to edubadges repo +- Wip for mobile API +- Merge pull request #201 from edubadges/dependabot/pip/django-4.2.25 +- Updated reminder mail template to include creation date, improved ear… (#203) +- Bump django from 4.2.24 to 4.2.25 +- Merge pull request #199 from edubadges/feature/mobile-api +- Added more mobile endpoints +- Added mobile DirectAward detail endpoint +- Added mobile/api/login example responses +- Merge branch 'develop' into feature/mobile-api +- Merge pull request #200 from edubadges/dependabot/pip/django-4.2.24 +- WIP for provisioning users mobile API +- Bump django from 4.2.22 to 4.2.24 +- WIP for provisioning users mobile API +- Added default parameters in post processor +- Merge branch 'feature/reminder_unit_test' into develop +- Added discussion questions +- Added endpoint for unclaimed direct awards +- Added badge instance detail endpoint +- First WIP commit for new mobile API https://trello.com/c/WYW0JiGA/1105-changes-needed-for-making-apis-mobile-app-ready +- Fixed test cmd in README +- Updated README to include how to run tests +- Merge pull request #190 from edubadges/feature/impierce_update +- refactor: Move logic for presenting expires_at to serializer +- chore: move tests to the correct place in directory hierarchy +- refactor: Ensure the ExpiresAt can be "never" which isn't a valid datetime +- feat: Only allow unime for demo +- fix: Bring serialized payload in line with reqs for new unime-core +- feat: Add expires_at that is required with new impierce version +- Added missing init file +- Fixed unit tests, updated tests for reminders DA. +- Need to encode string before hashing +- Started adding tests for reminders_direct_awards +- Merge pull request #194 from edubadges/bug/reminders-direct-awards +- Fixed bug in reminders_direct_awards +- Set the issued_on date for accepted assertions When a requested badge is accepted, set the issued_on date of the new assertion with the value of the creation date of the enrollment +- Use preferred linked account for validated name +- Added stdout messages for running reminders_direct_award directly ## [8.3.0] - 2025-07-14 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/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/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/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), + ] diff --git a/apps/badgeuser/models.py b/apps/badgeuser/models.py index 5bb5a7ea3..53e55e8ab 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() @@ -873,6 +877,11 @@ 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) + + # 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() + terms_agreement.agreed_version = self.version terms_agreement.agreed = True terms_agreement.save() @@ -890,6 +899,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): 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/badgrsocialauth/providers/eduid/views.py b/apps/badgrsocialauth/providers/eduid/views.py index 2f1d93441..89454d76f 100644 --- a/apps/badgrsocialauth/providers/eduid/views.py +++ b/apps/badgrsocialauth/providers/eduid/views.py @@ -42,10 +42,10 @@ def login(request: HttpRequest): 'state': state, 'client_id': settings.EDU_ID_CLIENT, 'response_type': 'code', - 'scope': 'openid eduid.nl/links profile', + 'scope': 'openid eduid.nl/links', 'redirect_uri': f'{settings.HTTP_ORIGIN}/account/eduid/login/callback/', - 'claims': '{"id_token":{"preferred_username":null, "given_name":null,"family_name":null,"email":null,' - '"eduid":null, "eduperson_scoped_affiliation":null, "eduperson_principal_name":null}}', + 'claims': '{"id_token":{"preferred_username":null,"given_name":null,"family_name":null,"email":null,' + '"eduid":null, "eduperson_scoped_affiliation":null, "preferred_username":null, "uids":null}}', } validate_name = request.GET.get('validateName') if validate_name and validate_name.lower() == 'true': diff --git a/apps/directaward/api.py b/apps/directaward/api.py index 2441d5e2b..0ed7df1c7 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,18 @@ 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', + direct_award__isnull=False, + ) + .select_related( + 'direct_award', + 'badgeclass', + 'badgeclass__issuer__faculty__institution', + ) + .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/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") + ) 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..cad16ed09 --- /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(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.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/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 67fd2545f..26e0cdf86 100644 --- a/apps/directaward/models.py +++ b/apps/directaward/models.py @@ -1,4 +1,4 @@ -import urllib +import urllib.parse import uuid from cachemodel.decorators import cached_method @@ -11,11 +11,14 @@ from mainsite.exceptions import BadgrValidationError from mainsite.models import BaseAuditedModel from mainsite.settings import EWI_PILOT_EXPIRATION_DATE -from mainsite.utils import EmailMessageMaker, send_mail +from mainsite.utils import send_mail, EmailMessageMaker +from mobile_api.push_notifications import send_push_notification 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) @@ -88,18 +91,20 @@ 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 not recipient.validated_name: - raise BadgrValidationError( - 'Cannot award, you do not have a validated name', - 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: evidence = [ @@ -120,6 +125,13 @@ def award(self, recipient): else: expires_at = max_expiration + # 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 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, created_by=self.created_by, @@ -133,6 +145,8 @@ def award(self, recipient): evidence=evidence, 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() @@ -147,6 +161,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( @@ -156,6 +171,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() @@ -230,6 +258,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( @@ -239,6 +268,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) @@ -268,5 +309,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) diff --git a/apps/directaward/serializer.py b/apps/directaward/serializer.py index 52c8bfd90..288d943f6 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, 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) @@ -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, @@ -168,10 +172,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.issuer.faculty.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 +199,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 diff --git a/apps/directaward/signals.py b/apps/directaward/signals.py index dbc258b6b..793c27b25 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(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)) diff --git a/apps/directaward/tests/test_direct_award.py b/apps/directaward/tests/test_direct_award.py index 8ed61ed36..46c1c154c 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}), @@ -96,25 +95,53 @@ 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}]}), 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): 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 = [ + ] diff --git a/apps/institution/migrations/0068_populate_institution_email.py b/apps/institution/migrations/0068_populate_institution_email.py new file mode 100644 index 000000000..c132bd763 --- /dev/null +++ b/apps/institution/migrations/0068_populate_institution_email.py @@ -0,0 +1,37 @@ +# 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("staff", "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', '0067_merge_0066_institution_email_0066_merge_20241113_1458'), + ('staff', '0008_auto_20200526_1536'), + ] + + operations = [ + migrations.RunPython(populate_institution_email), + ] 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), + ), + ] 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'])) 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), ), ] 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/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), + ] diff --git a/apps/issuer/models.py b/apps/issuer/models.py index af349a64c..d912dd1e3 100644 --- a/apps/issuer/models.py +++ b/apps/issuer/models.py @@ -31,6 +31,7 @@ from mainsite.mixins import DefaultLanguageMixin, ImageUrlGetterMixin from mainsite.models import ArchiveMixin, BadgrApp, BaseAuditedModel from mainsite.utils import EmailMessageMaker, OriginSetting, generate_entity_uri, send_mail +from mobile_api.push_notifications import send_push_notification from openbadges_bakery import bake from rest_framework import serializers from signing import tsob @@ -852,6 +853,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( @@ -871,6 +874,18 @@ def issue( 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, + } + ) + # Log the badge instance creation event logger = badgrlog.BadgrLogger() logger.event(badgrlog.BadgeInstanceCreatedEvent(assertion)) @@ -1062,6 +1077,8 @@ class BadgeInstance(BaseAuditedModel, ImageUrlGetterMixin, BaseVersionedEntity, signature = models.TextField(blank=True, null=True, default=None) + public = models.BooleanField(default=False) + include_evidence = models.BooleanField(default=False) grade_achieved = models.CharField(max_length=254, blank=True, null=True, default=None) include_grade_achieved = models.BooleanField(default=False) @@ -1152,10 +1169,7 @@ def submit_for_timestamping(self, signer): timestamp.submit_assertion() def get_recipient_name(self): - if self.user: - return self.user.validated_name - else: - return None + return self.recipient_name or None def get_email_address(self): if self.user: diff --git a/apps/issuer/serializers.py b/apps/issuer/serializers.py index ae18152ec..ce5da7e9b 100644 --- a/apps/issuer/serializers.py +++ b/apps/issuer/serializers.py @@ -243,7 +243,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) @@ -281,9 +284,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/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py index f90f69d02..dbbe6c39d 100644 --- a/apps/issuer/tests/test_issuer.py +++ b/apps/issuer/tests/test_issuer.py @@ -7,10 +7,11 @@ 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 mainsite.exceptions import BadgrValidationFieldError, BadgrValidationMultipleFieldError -from mainsite.tests import BadgrTestCase +from lti_edu.models import StudentsEnrolled +from mainsite.exceptions import BadgrValidationFieldError, BadgrValidationMultipleFieldError, BadgrValidationError +from mainsite.tests import BadgrTestCase, string_randomiser class IssuerAPITest(BadgrTestCase): @@ -57,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' ) @@ -69,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', @@ -94,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' ) @@ -140,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' ) @@ -170,10 +175,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) @@ -282,8 +292,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=json.dumps({'denyReason': 'Not eligible'}), + content_type='application/json', + ) self.assertEqual(response.status_code, 200) def test_award_badge_expiration_date(self): @@ -331,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): @@ -403,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() @@ -443,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() @@ -461,11 +477,138 @@ 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) + + 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): 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}}' 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, 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/mainsite/settings.py b/apps/mainsite/settings.py index 8a634cdcc..931606270 100644 --- a/apps/mainsite/settings.py +++ b/apps/mainsite/settings.py @@ -472,6 +472,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', + ], } ## @@ -663,3 +666,9 @@ def legacy_boolean_parsing(env_key, default_value): } AUDITLOG_DISABLE_REMOTE_ADDR = True + +# FCM Django (Tell Firebase Admin SDK where the service account JSON is) +firebase_json = os.environ.get("FIREBASE_JSON_FILE") + +if firebase_json: + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = firebase_json diff --git a/apps/mainsite/settings_tests.py b/apps/mainsite/settings_tests.py index 928d7e069..aef801b49 100644 --- a/apps/mainsite/settings_tests.py +++ b/apps/mainsite/settings_tests.py @@ -1,12 +1,9 @@ # encoding: utf-8 + + from .settings import * # disable logging for tests LOGGING = {} - -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' +DISABLE_AUTH_SIGNALS = True +ENABLE_EXTENSION_VALIDATION = False 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;;;;; 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/test_utils.py b/apps/mainsite/test_utils.py index 36a310155..fdc3166ad 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 ( @@ -347,8 +348,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): @@ -362,8 +363,9 @@ 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__': 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) - - - diff --git a/apps/mainsite/tests/base.py b/apps/mainsite/tests/base.py index fe1a6a587..19d30a142 100644 --- a/apps/mainsite/tests/base.py +++ b/apps/mainsite/tests/base.py @@ -119,10 +119,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 @@ -288,7 +288,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): diff --git a/apps/mobile_api/api.py b/apps/mobile_api/api.py index 30af24cd9..38529f82a 100644 --- a/apps/mobile_api/api.py +++ b/apps/mobile_api/api.py @@ -1,41 +1,55 @@ import logging +import requests +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, 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 ( - extend_schema, - inline_serializer, OpenApiExample, - OpenApiResponse, OpenApiParameter, + OpenApiResponse, OpenApiTypes, + extend_schema, + inline_serializer, extend_schema_view, ) -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 institution.models import Institution +from issuer.models import BadgeInstance, BadgeInstanceCollection, BadgeClass 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.filters import CatalogBadgeClassFilter +from mobile_api.helper import NoValidatedNameException, RevalidatedNameException, process_eduid_response +from mobile_api.pagination import CatalogPagination from mobile_api.serializers import ( + BadgeCollectionSerializer, BadgeInstanceDetailSerializer, + BadgeInstanceSerializer, + DirectAwardDetailSerializer, DirectAwardSerializer, StudentsEnrolledSerializer, StudentsEnrolledDetailSerializer, - BadgeCollectionSerializer, UserSerializer, - DirectAwardDetailSerializer, + CatalogBadgeClassSerializer, + UserProfileSerializer, + BadgeClassDetailSerializer, + InstitutionListSerializer, + TermsAgreementSerializer, + TermsAgreementCreateSerializer, + TermsAgreementUpdateSerializer, ) -from mobile_api.serializers import BadgeInstanceSerializer -import requests -from django.conf import settings +from rest_framework import serializers, status, generics, viewsets +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 +66,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 @@ -174,7 +181,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 +214,65 @@ 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=[ + { + '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': { + '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', + }, + }, + }, + }, + 'grade_achieved': '33', + }, + ], + 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 @@ -203,6 +284,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) @@ -223,12 +305,195 @@ 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={ + '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', + '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', + }, + }, + }, + }, + '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, + ), + ], + ), + 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 = ( 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') + .filter(user=request.user) + .filter(entity_id=entity_id) + .get() + ) + serializer = BadgeInstanceDetailSerializer(instance, context={"request": request}) + return Response(serializer.data) + + @extend_schema( + methods=['PUT'], + description='Update badge instance acceptance status and public visibility', + parameters=[ + OpenApiParameter( + name='entity_id', + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + required=True, + description='entity_id of the badge instance', + ) + ], + 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', + 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') @@ -236,6 +501,29 @@ def get(self, request, entity_id, **kwargs): .filter(entity_id=entity_id) .get() ) + + # 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) @@ -246,7 +534,58 @@ 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=[ + { + 'id': 9606, + 'created_at': '2026-01-23T16:19:08.699037+01:00', + 'entity_id': 'Lgnh9njyStmGiI_w8396Xg', + 'badgeclass': { + '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', + }, + }, + }, + }, + } + ], + 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 @@ -269,39 +608,91 @@ def get(self, request, **kwargs): return Response(serializer.data) -class DirectAwardDetail(APIView): +@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", + }, + }, + }, + }, + "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" - @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', - ) - ], - examples=[], + 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) - data = serializer.data - return Response(serializer.data) class Enrollments(APIView): @@ -310,7 +701,61 @@ 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=[ + { + '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', + '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='Array of course enrollments for the user', + response_only=True, + ), + ], + ), + 403: permission_denied_response, + }, ) def get(self, request, **kwargs): # ForeignKey / OneToOneField → select_related @@ -342,17 +787,96 @@ class EnrollmentDetail(APIView): description='entity_id of the enrollment', ) ], - examples=[], + responses={ + 200: OpenApiResponse( + description='Enrollment details', + response=StudentsEnrolledDetailSerializer, + examples=[ + OpenApiExample( + 'Enrollment Details', + value={ + '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 with full badgeclass details', + 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 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() @@ -372,7 +896,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) @@ -382,81 +941,348 @@ def delete(self, request, entity_id, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) -class BadgeCollectionsListView(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" + + def get_queryset(self): + return ( + BadgeInstanceCollection.objects + .filter(user=self.request.user) + .prefetch_related("badge_instances") + ) - @extend_schema( - methods=['GET'], - description='Get all badge collections for the user', - examples=[], - ) - def get(self, request, **kwargs): - collections = BadgeInstanceCollection.objects.filter(user=request.user) - serializer = BadgeCollectionSerializer(collections, many=True) - return Response(serializer.data) + +class CatalogBadgeClassListView(generics.ListAPIView): + permission_classes = (AllowAny,) + serializer_class = CatalogBadgeClassSerializer + filterset_class = CatalogBadgeClassFilter + filter_backends = [DjangoFilterBackend] + pagination_class = CatalogPagination @extend_schema( - request=BadgeInstanceCollectionSerializer, - responses=BadgeInstanceCollectionSerializer, - description='Create a new BadgeInstanceCollection', + methods=['GET'], + filters=True, + description='Get a paginated list of badge classes. Supports filtering and page_size.', parameters=[ OpenApiParameter( - name='entity_id', + 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=OpenApiParameter.PATH, - required=True, - description='entity_id of the enrollment', - ) + 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='institution_type', + location='query', + required=False, + description='Filter badge classes by institution_type (MBO/HBO/WO)', + ), ], + 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', + '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, + 'self_enrollment_enabled': True, + 'user_may_enroll': True, + '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', + '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, + 'self_enrollment_enabled': True, + 'user_may_enroll': True, + '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 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) + def get_queryset(self): + return ( + BadgeClass.objects.select_related( + 'issuer', + 'issuer__faculty', + 'issuer__faculty__institution', + ) + .prefetch_related( + 'issuer__faculty__institution__terms', + 'issuer__faculty__institution__terms__terms_urls', + ) + .filter( + is_private=False, + issuer__archived=False, + issuer__faculty__archived=False, + ) + .exclude(issuer__faculty__visibility_type='TEST') + .annotate( + selfRequestedAssertionsCount=Count( + 'badgeinstances', + filter=Q(badgeinstances__award_type='requested'), + ), + directAwardedAssertionsCount=Count( + 'badgeinstances', + filter=Q(badgeinstances__award_type='direct_award'), + ), + ).order_by('name') + ) -class BadgeCollectionsDetailView(APIView): - permission_classes = (MobileAPIPermission,) +class UserProfileView(APIView): + permission_classes = (IsAuthenticated, MobileAPIPermission) + http_method_names = ('get', 'delete') @extend_schema( - request=BadgeInstanceCollectionSerializer, - responses=BadgeInstanceCollectionSerializer, - description='Update an existing BadgeInstanceCollection by ID', - parameters=[ - OpenApiParameter( - name='entity_id', - type=OpenApiTypes.STR, - location=OpenApiParameter.PATH, - required=True, - description='entity_id of the enrollment', - ) - ], + description="Get the authenticated user's profile", + responses={200: UserProfileSerializer}, ) - 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(self, request): + serializer = UserProfileSerializer( + request.user, + context={'request': request}, ) - serializer.is_valid(raise_exception=True) - badge_collection = serializer.save() - return Response(BadgeInstanceCollectionSerializer(badge_collection).data, status=status.HTTP_200_OK) + return Response(serializer.data) @extend_schema( - request=None, - responses={204: 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', - ) - ], + description='Delete the authenticated user', + responses={ + 204: OpenApiResponse(description='User account deleted successfully'), + 403: OpenApiResponse(description='Permission denied'), + }, ) - def delete(self, request, entity_id): - badge_collection = get_object_or_404(BadgeInstanceCollection, entity_id=entity_id, user=request.user) - badge_collection.delete() + 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' + ) + + +class InstitutionListView(ListAPIView): + permission_classes = (IsAuthenticated, MobileAPIPermission) + serializer_class = InstitutionListSerializer + + def get_queryset(self): + 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) + + +@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 + + +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 794a60714..d867d41f2 100644 --- a/apps/mobile_api/api_urls.py +++ b/apps/mobile_api/api_urls.py @@ -1,28 +1,59 @@ -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, BadgeUserDetail +from badgeuser.api import AcceptTermsView 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, + BadgeClassDetailView, + UnclaimedDirectAwards, + Enrollments, + EnrollmentDetail, + Login, + AcceptGeneralTerms, + DirectAwardDetailView, + CatalogBadgeClassListView, + UserProfileView, + InstitutionListView, + RegisterDeviceViewSet, + BadgeCollectionViewSet, + TermsAgreementViewSet, +) + + +router = routers.DefaultRouter(trailing_slash=False) + +router.register( + "badge-collections", + 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'), - 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'), - 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/', 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'), path('login', Login.as_view(), name='mobile_api_login'), - path('terms/accept', AcceptTermsView.as_view(), name='mobile_api_user_terms_accept'), + path('badge/public', BackpackAssertionDetail.as_view(), name='mobile_api_badge_public'), 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'), + path('institutions', InstitutionListView.as_view(), name='mobile_api_institution_list'), + 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)), ] 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..160ff599a --- /dev/null +++ b/apps/mobile_api/checks.py @@ -0,0 +1,26 @@ +import os +from django.core.checks import register, Warning + +@register() +def check_firebase_json_file(app_configs, **kwargs): + """ + Check that the Firebase service account JSON file exists. + """ + json_file = os.environ.get("FIREBASE_JSON_FILE") + if not json_file: + return [ + Warning( + "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/apps/mobile_api/filters.py b/apps/mobile_api/filters.py new file mode 100644 index 000000000..4c21aa7a4 --- /dev/null +++ b/apps/mobile_api/filters.py @@ -0,0 +1,24 @@ +import django_filters as filters + +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', + ) + 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.", + ) + + class Meta: + model = BadgeClass + fields = ['name', 'institution', 'institution_type'] 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/push_notifications.py b/apps/mobile_api/push_notifications.py new file mode 100644 index 000000000..53abd7a32 --- /dev/null +++ b/apps/mobile_api/push_notifications.py @@ -0,0 +1,43 @@ +import logging + +from fcm_django.models import FCMDevice +from firebase_admin import messaging +from google.auth.exceptions import DefaultCredentialsError + + +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})") + return None + + message = messaging.Message( + notification=messaging.Notification(title=title, body=body), + data=data, + ) + + logger.info(f"Sending push to {devices.count()} devices for user {user.id} ({user.entity_id})") + 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: + 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 diff --git a/apps/mobile_api/serializers.py b/apps/mobile_api/serializers.py index 3f40470e2..7da9216aa 100644 --- a/apps/mobile_api/serializers.py +++ b/apps/mobile_api/serializers.py @@ -1,18 +1,40 @@ -from rest_framework import serializers import json +from urllib.parse import urlencode + +from django.utils import timezone +from drf_spectacular.utils import extend_schema_serializer, OpenApiExample, extend_schema, extend_schema_field -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): 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 InstitutionListSerializer(serializers.ModelSerializer): + class Meta: + model = Institution + fields = [ + 'entity_id', + 'name_dutch', + 'name_english', + ] class FacultySerializer(serializers.ModelSerializer): @@ -20,8 +42,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): @@ -29,7 +59,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): @@ -37,12 +67,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] @@ -51,18 +81,49 @@ 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): 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 - fields = ["id", "name", "entity_id", "image", "description", "formal", "participation", "assessment_type", - "assessment_id_verified", "assessment_supervised", "quality_assurance_name", - "badgeclassextension_set", "issuer"] + 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', + 'self_enrollment_enabled', + '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: + return False + user = request.user + return obj.user_may_enroll(user) class BadgeInstanceSerializer(serializers.ModelSerializer): @@ -70,17 +131,86 @@ class BadgeInstanceSerializer(serializers.ModelSerializer): class Meta: model = BadgeInstance - fields = ["id", "created_at", "entity_id", "issued_on", "award_type", "revoked", "expires_at", "acceptance", - "public", "badgeclass"] + fields = [ + 'id', + 'created_at', + 'entity_id', + 'issued_on', + 'award_type', + 'revoked', + 'expires_at', + 'acceptance', + 'public', + 'badgeclass', + 'grade_achieved', + "include_grade_achieved" + ] class BadgeInstanceDetailSerializer(serializers.ModelSerializer): badgeclass = BadgeClassDetailSerializer() + linkedin_url = serializers.SerializerMethodField() + narrative = serializers.SerializerMethodField() class Meta: model = BadgeInstance - fields = ["id", "created_at", "entity_id", "issued_on", "award_type", "revoked", "expires_at", "acceptance", - "public", "badgeclass"] + fields = [ + 'id', + 'created_at', + 'entity_id', + 'issued_on', + 'award_type', + 'revoked', + 'expires_at', + 'acceptance', + 'public', + 'badgeclass', + 'linkedin_url', + 'grade_achieved', + 'include_grade_achieved', + 'include_evidence', + 'narrative', + ] + + def _get_linkedin_org_id(self, badgeclass): + faculty = badgeclass.issuer.faculty + + 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)}" + + def get_narrative(self, obj): + evidence = obj.badgeinstanceevidence_set.first() + return evidence.narrative if evidence else None class DirectAwardSerializer(serializers.ModelSerializer): @@ -88,51 +218,147 @@ 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): 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_terms(self, obj): - institution_terms = obj.badgeclass.issuer.faculty.institution.terms.all() - serializer = TermsSerializer(institution_terms, many=True) - return serializer.data + def get_required_terms(self, obj): + try: + terms = obj.badgeclass.get_required_terms() + except ValueError: + return None # Should not break the serializer + return TermsSerializer(terms, context=self.context).data -class StudentsEnrolledSerializer(serializers.ModelSerializer): - badge_class = BadgeClassSerializer() + def get_user_has_accepted_terms(self, obj): + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return False - class Meta: - model = StudentsEnrolled - fields = ["id", "entity_id", "date_created", "denied", "date_awarded", "badge_class"] + user = request.user + return obj.badgeclass.terms_accepted(user) -class StudentsEnrolledDetailSerializer(serializers.ModelSerializer): - badge_class = BadgeClassDetailSerializer() +STATUS_MAP = { + True: "Rejected", + False: "Unaccepted" +} - class Meta: - model = StudentsEnrolled - fields = ["id", "entity_id", "date_created", "denied", "date_awarded", "badge_class"] +class StudentsEnrolledSerializer(serializers.ModelSerializer): + 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() + class Meta: + model = StudentsEnrolled + fields = ['id', 'entity_id', 'created_at', 'denied', 'acceptance', 'issued_on', 'badgeclass'] + + def get_acceptance(self, obj): + return STATUS_MAP[obj.denied] + + +class StudentsEnrolledDetailSerializer(StudentsEnrolledSerializer): + badgeclass = BadgeClassDetailSerializer(source="badge_class") + + +@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.PrimaryKeyRelatedField(many=True, queryset=BadgeInstance.objects.all()) + badge_instances = serializers.SlugRelatedField( + many=True, + 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): class Meta: model = TermsUrl - fields = ["url", "language", "excerpt"] + fields = ['url', 'language', 'excerpt'] class TermsSerializer(serializers.ModelSerializer): @@ -141,7 +367,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): @@ -149,17 +375,217 @@ class TermsAgreementSerializer(serializers.ModelSerializer): class Meta: model = TermsAgreement - fields = ["entity_id", "agreed", "agreed_version", "terms"] + 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.SerializerMethodField() + terms_agreed = serializers.BooleanField(read_only=True) 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', + ] + + +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(read_only=True) - def get_terms_agreed(self, obj): - return obj.general_terms_accepted() + class Meta: + model = BadgeUser + fields = [ + 'entity_id', + 'first_name', + 'last_name', + 'email', + 'institution', + 'marketing_opt_in', + 'is_superuser', + 'validated_name', + 'schac_homes', + 'terms_agreed', + 'termsagreement_set', + ] + + +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() + 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) + 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', + 'required_terms', + 'user_has_accepted_terms', + 'self_enrollment_enabled', + 'user_may_enroll', + + # 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' + ] + + 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 + + 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) + + @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: + return False + user = request.user + return obj.user_may_enroll(user) diff --git a/apps/public/public_api.py b/apps/public/public_api.py index 1779d136a..e82a8a53d 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) 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 diff --git a/docker-compose.yml b/docker-compose.yml index bba88925c..6a8d16e91 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 diff --git a/docker/dev.Dockerfile b/docker/dev.Dockerfile index d48a4678b..a7e69a85e 100644 --- a/docker/dev.Dockerfile +++ b/docker/dev.Dockerfile @@ -20,6 +20,7 @@ COPY . /app COPY ./docker/entrypoint-dev.sh /entrypoint.sh RUN chmod +x /entrypoint.sh +RUN pip install --upgrade pip setuptools wheel # Install any needed packages specified in requirements.txt RUN uv pip install --system --no-cache -r requirements.txt diff --git a/env_vars.sh.example b/env_vars.sh.example index 08e8c256f..f66439807 100644 --- a/env_vars.sh.example +++ b/env_vars.sh.example @@ -3,6 +3,7 @@ export OIDC_RS_SECRET="ask-a-colleague" export EDU_ID_SECRET="ask-a-colleague" export SURF_CONEXT_SECRET="ask-a-colleague" export AWS_SECRET_ACCESS_KEY="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/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) diff --git a/requirements.txt b/requirements.txt index 732ee9477..6baec8b05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Django stuff -Django==4.2.25 +Django==4.2.29 semver==2.6.0 pytz==2022.2.1 @@ -27,6 +27,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 @@ -45,7 +46,7 @@ django-cors-headers==4.9.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 @@ -62,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 @@ -77,12 +78,12 @@ importlib-metadata==4.13.0 python-json-logger==0.1.2 # SSL Support -cffi==1.14.5 -cryptography==44.0.1 +cffi==2.0.0 +cryptography==46.0.5 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 @@ -113,7 +114,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 @@ -133,4 +134,10 @@ django-api-proxy==0.1.1 django-upgrade==1.23.1 django-prometheus==2.3.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 diff --git a/secrets/.keep b/secrets/.keep new file mode 100644 index 000000000..e69de29bb