From 3b20ba1fed3ea3c5e7f803879ea633ecc27e5bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A8r=20Kessels?= Date: Fri, 20 Mar 2026 08:41:29 +0100 Subject: [PATCH] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit ce48f1739a841213e28f708f97062d4c7a46e402 Merge: c8c1dbc8 4f426016 Author: Okke Harsta Date: Mon Mar 16 11:41:39 2026 +0100 Merge pull request #306 from edubadges/bugfix/fix-linkedin-url-for-badge-instance-endpoint-in-mobile-api Add request to context of badge instance serializer commit 4f4260164791a084b78f4c83709bd275332dc158 Author: Thomas Kalverda Date: Mon Mar 16 11:02:47 2026 +0100 Add request to context of badge instance serializer commit c8c1dbc81832b386a87524df241d4d9007c917af Merge: 6d2e4be5 e68b6804 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Mar 10 14:11:15 2026 +0100 Merge pull request #297 from edubadges/dependabot/pip/markdown-3.8.1 Bump markdown from 2.6.8 to 3.8.1 commit 6d2e4be5a2f3d3e79b782b38bf39c76043566bea Merge: 09edda41 33aafc4d Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Mar 10 14:11:08 2026 +0100 Merge pull request #275 from edubadges/dependabot/pip/sqlparse-0.5.4 Bump sqlparse from 0.5.0 to 0.5.4 commit 09edda41d10ee2b49ef55d5102ea3f672167a738 Merge: 030cf7d2 a54281ed Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Mar 10 14:10:01 2026 +0100 Merge pull request #268 from edubadges/dependabot/pip/cryptography-46.0.5 Bump cryptography from 44.0.1 to 46.0.5 commit 030cf7d2d592421736c70e49ffd8b322e9d446dd Merge: 3451acba eaea3f50 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Mar 10 13:55:14 2026 +0100 Merge pull request #303 from edubadges/dependabot/github_actions/dot-github/workflows/aquasecurity/trivy-action-0.34.0 Bump aquasecurity/trivy-action from 0.33.1 to 0.34.0 in /.github/workflows commit a54281edb41d1948cc4a758be67fa2f684ee5ecb Author: Daniel Date: Tue Mar 10 13:49:36 2026 +0100 feat: updated cffi to 2.0.0 commit 3451acbabd99b7952789cca11231e0354f67a235 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Mar 10 13:10:09 2026 +0100 Update trivy.yml to upload sarif_file commit eaea3f50f783146c8b93a912881238ce20219f23 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue Mar 10 10:34:50 2026 +0000 Bump aquasecurity/trivy-action in /.github/workflows Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.33.1 to 0.34.0. - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/0.33.1...0.34.0) --- updated-dependencies: - dependency-name: aquasecurity/trivy-action dependency-version: 0.34.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] commit 3d648540b24840ccf98bee3d2e24b7b821959604 Merge: d33329ac ab3b09bd Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Mar 10 11:34:37 2026 +0100 Merge pull request #302 from edubadges/feature/wrap-saving-direct-award-in-reminder-management-command-in-try-except-to-avoid-crash Wrap saving of direct award in try except to avoid crash commit d33329ac887f8fac2c6f6a84b235e97cdfebcc2c Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Mar 10 11:34:06 2026 +0100 Create trivy.yml commit e42bd1ba2e37203dd0edf1f5d5170de4faf2478a Merge: 94516340 c0c78158 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Mar 10 11:23:25 2026 +0100 Merge pull request #294 from edubadges/dependabot/pip/django-4.2.29 Bump django from 4.2.28 to 4.2.29 commit ab3b09bd1e7849e5059399b0780dc8d020cb4b85 Author: Thomas Kalverda Date: Tue Mar 10 10:46:07 2026 +0100 Wrap saving of direct award in try except to avoid crash commit 94516340df2a4461706b37a9827f2ed38bf81303 Merge: 0a714782 3a4db0ef Author: Thomas Kalverda Date: Tue Mar 10 09:56:14 2026 +0100 Merge pull request #301 from edubadges/feature/pass-direct-award-recipient-names-to-badge-instance-instead-of-validated-name Pass recipient names to badge instance for direct awards on email commit 3a4db0efe12f56e31b90edf8ca2be035d178fa74 Author: Thomas Kalverda Date: Tue Mar 10 09:26:52 2026 +0100 Pass recipient names to badge instance for direct awards on email commit 0a714782288c2edc9ee16d014bc1fbe27e20e43c Merge: d66ea845 6f39340f Author: Okke Harsta Date: Mon Mar 9 15:06:50 2026 +0100 Merge pull request #300 from edubadges/feature/add-retrieve-endpoint-for-mobile-register-devices Add detail endpoint for retrieving registered devices for mobile api commit 6f39340f15a5a39aebb973c0b4d10696c931b75b Author: Thomas Kalverda Date: Mon Mar 9 10:08:44 2026 +0100 Add detail endpoint for retrieving registered devices for mobile api commit e68b680421ea1aea68336a5e7a8383a959b93748 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu Mar 5 23:10:36 2026 +0000 Bump markdown from 2.6.8 to 3.8.1 Bumps [markdown](https://github.com/Python-Markdown/markdown) from 2.6.8 to 3.8.1. - [Release notes](https://github.com/Python-Markdown/markdown/releases) - [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md) - [Commits](https://github.com/Python-Markdown/markdown/commits/3.8.1) --- updated-dependencies: - dependency-name: markdown dependency-version: 3.8.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] commit c0c7815879bcd9642d6ef0eab3327885b41fdd77 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed Mar 4 23:11:22 2026 +0000 Bump django from 4.2.28 to 4.2.29 Bumps [django](https://github.com/django/django) from 4.2.28 to 4.2.29. - [Commits](https://github.com/django/django/compare/4.2.28...4.2.29) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.29 dependency-type: direct:production ... Signed-off-by: dependabot[bot] commit d66ea8450b6ea32f74dde72e4706fb303ed95de2 Author: Okke Harsta Date: Wed Mar 4 10:42:57 2026 +0100 Fix for AttributeError: 'BadgeClass' object has no attribute 'may_enroll' commit db294965bbc4267704baf6338e8a0b45c47e10e3 Author: Okke Harsta Date: Wed Mar 4 10:32:13 2026 +0100 Bugfix for AttributeError: 'BadgeClass' object has no attribute 'may_enroll' commit c7a00b7a91682c510488ada3c2b7f4271085aaa3 Merge: 90b5374b b53e3ba6 Author: Thomas Kalverda Date: Thu Feb 26 17:16:16 2026 +0100 Merge pull request #289 from edubadges/bugfix/show-new-fields-as-booleans-in-swagger-interface Show the enrollment enabled and user may enroll as booleans in swagger commit b53e3ba6a9bd27a901a80cc71ce9e73abbfbd737 Author: Thomas Kalverda Date: Thu Feb 26 17:13:20 2026 +0100 Show the enrollment enabled and user may enroll as booleans in swagger commit 90b5374b5f475175285de163b2e20fee4394d5f2 Merge: 9080681a df37f935 Author: Thomas Kalverda Date: Thu Feb 26 16:24:27 2026 +0100 Merge pull request #288 from edubadges/feature/add-enrollment-permission-to-mobile-api Feature/add enrollment permission to mobile api commit df37f9352f37f0cca59f70772b35ed8af9ad94e2 Author: Thomas Kalverda Date: Thu Feb 26 13:18:44 2026 +0100 Add user may enroll boolean to serializer commit f5649bb6db286d073f03cac23c9ffe80432415b1 Author: Thomas Kalverda Date: Thu Feb 26 13:18:25 2026 +0100 Add self enrollment enabled field to serializer commit 30117e8db2f1621e5131fd37381aef1bc5031a50 Author: Thomas Kalverda Date: Thu Feb 26 13:17:17 2026 +0100 Add user_may_enroll method to badgeclass commit 9080681a1e7a45099ae3a5c16bd50241b9c2ed03 Merge: d1953d9e cf236b20 Author: Thomas Kalverda Date: Tue Feb 24 13:52:24 2026 +0100 Merge pull request #286 from edubadges/feature/refactor-mobile-endpoints-for-terms-agreements Refactor mobile api endpoints for terms agreements commit cf236b204dc1c617cb433985dd267a6f26e5d0a1 Author: Thomas Kalverda Date: Tue Feb 24 11:46:10 2026 +0100 Refactor mobile api endpoints for terms agreements Now there is a dedicated viewset for retrieving, creating and updating terms agreements with minimal fields commit d1953d9e954507a174276f711d1e9877f70f2b33 Merge: aab54edc df7cbe28 Author: Thomas Kalverda Date: Thu Feb 19 16:15:22 2026 +0100 Merge pull request #285 from edubadges/feature/allow-login-without-validated-name Remove redirect to allow login without validated name commit aab54edcb4466c8420240273444574cbf081aea8 Merge: 14c8fe3d c7622946 Author: Thomas Kalverda Date: Thu Feb 19 15:01:28 2026 +0100 Merge pull request #283 from edubadges/feature/add-datamigration-to-populate-recipient-name-in-badge-instances Feature/add datamigration to populate recipient name in badge instances commit df7cbe283b5dc0650817fee76dc154e02a2e741b Author: Thomas Kalverda Date: Thu Feb 19 15:00:51 2026 +0100 Remove redirect to allow login without validated name commit 14c8fe3d039349ce0fa6fa237ecb0652e012119d Merge: f61dbbbd 678fb64b Author: Thomas Kalverda Date: Thu Feb 19 14:51:38 2026 +0100 Merge pull request #282 from edubadges/feature/update-identity-endpoint-for-validated-name-retrieval Feature/update identity endpoint for validated name retrieval commit f61dbbbdc129572772633822b5ea9fde8247e321 Merge: c738d64d 4d23e773 Author: Thomas Kalverda Date: Thu Feb 19 14:51:12 2026 +0100 Merge pull request #284 from edubadges/feature/update-csv-example-file-for-email-only Update sample csv file for bulk upload for email only commit c762294621325617b1c344c70423d0969266d2b5 Author: Thomas Kalverda Date: Thu Feb 19 13:35:04 2026 +0100 Add datamigration to populate recipient names for badge instances commit 678fb64bb1b8f3d7a9903e99b502e9eea46f7cb6 Author: Thomas Kalverda Date: Wed Feb 18 09:57:20 2026 +0100 Update badge instance recipient name methods to be more concise commit 6c1c3269119288299d5172aba6a4da5857527c73 Author: Thomas Kalverda Date: Wed Feb 18 09:56:34 2026 +0100 Add validated name and recipient name to identity endpoint commit 4d23e7735f5ee2070c12ee7c98178210632c8b17 Author: Thomas Kalverda Date: Tue Feb 17 16:56:36 2026 +0100 Update sample csv file for bulk upload for email only commit c738d64d0d7e2f95011d588deeba555bea987b08 Merge: 9a18dbd2 29bec1d7 Author: Thomas Kalverda Date: Thu Feb 19 13:10:16 2026 +0100 Merge pull request #280 from edubadges/feature/add-recipient-name-to-direct-award-and-badge-instance-models Feature/add recipient name to direct award and badge instance models commit 29bec1d73fe91e77b5a82ba9a1fbc6efab70681e Author: Thomas Kalverda Date: Thu Feb 19 11:13:00 2026 +0100 Add tests for direct award on email and wrong eppn commit 8446e1f0b7de64786520425a13d75ebda09c3b51 Author: Thomas Kalverda Date: Thu Feb 19 11:12:41 2026 +0100 Fix validation on bundle type commit 645a630697577eb5cd1c258ee0fbfa246252fb5d Author: Thomas Kalverda Date: Thu Feb 19 11:12:21 2026 +0100 Remove validation for validated name on award and issue commit cf58c955480851d9e895a5fe880857f423ce9ef1 Author: Thomas Kalverda Date: Thu Feb 19 09:13:55 2026 +0100 Add test for direct award creation with recipient name commit 3974d60fde2eb5026e2732393eac116b5c814576 Author: Thomas Kalverda Date: Tue Feb 17 16:55:49 2026 +0100 Update direct award serializer to allow null for first and last names commit 4707382ed3b35fb16fb2a1c1c0e3ac93bbce4b4a Author: Thomas Kalverda Date: Tue Feb 17 15:25:13 2026 +0100 Update get recipient name to return recipient name when it exists commit 8cffe2ff5a63bfd4c79b02688be4f3c2fc06db15 Author: Thomas Kalverda Date: Tue Feb 17 15:24:41 2026 +0100 Pass recipient name along in the award method to issue commit 4e4af22eddd6d29817c4f442d19dc01959a1552e Author: Thomas Kalverda Date: Tue Feb 17 15:23:24 2026 +0100 Handle recipient names in the serializer for direct awards commit b8110535faf259f0220d36aa395cd973fa709eb8 Author: Thomas Kalverda Date: Tue Feb 17 15:22:44 2026 +0100 Add recipient name to direct award and badge instance models commit 9a18dbd2378ba781a965a90c86add95414316700 Merge: dd366a46 e2a18716 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Feb 17 13:08:54 2026 +0100 Merge pull request #279 from edubadges/bugfix/fix-push-notifications-break-when-there-is-no-user Prevent crash when there is no user to send push notification to commit e2a18716666168dd6bf3bff9651b60630c41afe2 Author: Thomas Kalverda Date: Tue Feb 17 11:43:57 2026 +0100 Prevent crash when there is no user to send push notification to commit dd366a464fe3b443f7fb1f6b5efafae5762e6633 Merge: 744b6714 2f633b59 Author: Okke Harsta Date: Mon Feb 16 15:48:43 2026 +0100 Merge branch 'master' into develop commit 744b67143ecefe2c1ea781459293b22208308155 Merge: 772fbbc6 fd8813ea Author: Thomas Kalverda Date: Mon Feb 16 10:38:52 2026 +0100 Merge pull request #278 from edubadges/feature/remove-trailing-slash-from-badge-collections-viewset Remove trailing slashes from badge collections endpoints commit fd8813ea70afbba2703e9242a301d00ccbdad73a Author: Thomas Kalverda Date: Mon Feb 16 10:28:51 2026 +0100 Remove trailing slashes from badge collections endpoints For consistency commit 772fbbc6f2707eb31734708f3bc5ed4215cf9563 Merge: 28cd36ec 05d71a46 Author: Thomas Kalverda Date: Mon Feb 16 10:11:33 2026 +0100 Merge pull request #277 from edubadges/feature/refactor-firebase-configuration-and-check Feature/refactor firebase configuration and check commit 05d71a46b7f5364822fe508f3379906d9e6e0d52 Author: Thomas Kalverda Date: Mon Feb 16 09:55:44 2026 +0100 Only set google env variable if json file env variable is set commit 8d3833870c53893af7319e0f506368681dc94801 Author: Thomas Kalverda Date: Thu Feb 12 12:51:14 2026 +0100 Make push notification sending fail gracefully For when the service account json file is missing commit 49afb8a9314fb6eb25e06c3374c1eb9fa3ffaef3 Author: Thomas Kalverda Date: Thu Feb 12 12:49:47 2026 +0100 Replace firebase env variables with json file configuration commit 33aafc4d46a29674a9a6ccac36684009d42907c3 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri Feb 13 18:11:29 2026 +0000 Bump sqlparse from 0.5.0 to 0.5.4 Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.5.0 to 0.5.4. - [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG) - [Commits](https://github.com/andialbrecht/sqlparse/compare/0.5.0...0.5.4) --- updated-dependencies: - dependency-name: sqlparse dependency-version: 0.5.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] commit 28cd36ec89dbfcfe702af94f8d5bacbba993822f Merge: 4b41cca6 ea4d415a Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri Feb 13 09:15:26 2026 +0100 Merge pull request #274 from edubadges/feature/add-narrative-to-badge-instance-detail-for-mobile-api Add narrative to badge instance detail endpoint for mobile api commit 4b41cca6f9589704d3e77a1477089c8dc792a027 Merge: fb3e7e30 fb13c439 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri Feb 13 09:14:21 2026 +0100 Merge pull request #272 from edubadges/feature/send-push-notifications-on-badge-received Feature/send push notifications on badge received commit ea4d415aab0eacf5e4522efdda9a681bc9e2f424 Author: Thomas Kalverda Date: Thu Feb 12 17:23:51 2026 +0100 Add narrative to badge instance detail endpoint for mobile api commit fb3e7e308d860b2c1f54520a6ba6e03c353a5460 Merge: acff2f2f eeb87b48 Author: Thomas Kalverda Date: Thu Feb 12 13:27:16 2026 +0100 Merge pull request #269 from edubadges/feature/add-sorting-to-mobile-api-catalog-and-badge-instances Add sorting to mobile api badge instances and catalog endpoints commit fb13c439d0eb496557e7d2a28d0e53035bfbe36e Author: Thomas Kalverda Date: Thu Feb 12 11:05:16 2026 +0100 Send push notifications when edubadge received commit dea6c904138ba8f03a314a5b1adfd7a0f27a29f8 Author: Thomas Kalverda Date: Thu Feb 12 11:04:19 2026 +0100 Add logging for debugging purposes commit 952b33a8a25509e327fb17d564b8502256c79298 Author: Thomas Kalverda Date: Thu Feb 12 10:41:50 2026 +0100 Add helper function for sending push notifications commit acff2f2f96a26a446cb5b59134c2effe9cdd28ae Merge: 7cba71f4 317f5699 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu Feb 12 10:53:15 2026 +0100 Merge pull request #271 from edubadges/bugfix/add-correct-source-for-badge-class-relation Add source for related badge class serializers commit 317f5699d74a689c94c6cf78dbb6e4cd69a15e86 Author: Thomas Kalverda Date: Thu Feb 12 10:32:12 2026 +0100 Add source for related badge class serializers commit eeb87b4883a2662e8b8941725bf0ce208cea2d96 Author: Thomas Kalverda Date: Wed Feb 11 14:58:22 2026 +0100 Add sorting to mobile api badge instances and catalog endpoints commit 7cba71f40381b9760c3b67faa34123ea7beca734 Merge: eda79af3 c6ddfae9 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Wed Feb 11 08:57:45 2026 +0100 Merge pull request #267 from edubadges/feature/fix-inconsistencies-in-mobile-api Feature/fix inconsistencies in mobile api commit b889f2460ae7766db6816b9a814ebb4eedba3cb9 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed Feb 11 01:49:12 2026 +0000 Bump cryptography from 44.0.1 to 46.0.5 Bumps [cryptography](https://github.com/pyca/cryptography) from 44.0.1 to 46.0.5. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/44.0.1...46.0.5) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] commit eda79af3f77d8df7ffd0f0eb6768022fa41a2f3c Merge: e8080110 e5ceabac Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Feb 10 12:48:25 2026 +0100 Merge pull request #266 from edubadges/feature/add-agreed-at-date-to-terms-agreement-model Feature/add agreed at date to terms agreement model commit e8080110b21dc2567ad302e92f2162ed00f9c1c9 Merge: 64b2b799 a837bd70 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Feb 10 12:48:07 2026 +0100 Merge pull request #265 from edubadges/chore/add-missing-migrations Add missing migrations commit c6ddfae909c588110d85cba5dc10df2b73966ed2 Author: Thomas Kalverda Date: Tue Feb 10 10:10:44 2026 +0100 Replace badge collection views with viewset and unified serializer commit e5ceabac5ffb2b731e94aed431c2bd1d0775a708 Author: Thomas Kalverda Date: Tue Feb 10 09:26:59 2026 +0100 Add comment to clarify logic commit 2a887183f4e0603764fee9566e25e61858a2df82 Author: Thomas Kalverda Date: Mon Feb 9 14:42:28 2026 +0100 Update students enrolled serializer fields to match direct awards commit fc75c984f019365a81078e26f059a25c4f8d31a3 Author: Thomas Kalverda Date: Mon Feb 9 11:44:48 2026 +0100 Add agreed_at to terms agreement serializer commit fdd488c99ae86702a9ecc599de99a906332feed8 Author: Thomas Kalverda Date: Mon Feb 9 11:44:24 2026 +0100 Add data migration to backfill historical agreed terms commit fc78d2749ca2bb7084361439129ec35244434bc8 Author: Thomas Kalverda Date: Mon Feb 9 11:44:04 2026 +0100 Add agreed_at date to terms agreement model commit 288e434c17a433214b93cedcfc33c9578f8289a7 Author: Thomas Kalverda Date: Mon Feb 9 10:00:36 2026 +0100 Rename image_url to image in badge class serializer commit a837bd7097716d506b667d80809c8daf9c404b1d Author: Thomas Kalverda Date: Mon Feb 9 09:58:52 2026 +0100 Add missing migrations I ran makemigrations and some migrations were created. This means that some models were updated in the code, but the migrations weren't created yet. This change should reflect that in the DB's commit 64b2b799f09e163b6f9262959a540eb0821c8d69 Merge: 55c724ad eb143af1 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri Feb 6 16:29:20 2026 +0100 Merge pull request #264 from edubadges/feature/add-register-device-endpoint-for-push-notifications Feature/add register device endpoint for push notifications commit 2f633b5903dd6ba43be9a612283599452bfa829e Merge: 1199d1bb 55c724ad Author: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu Feb 5 16:47:37 2026 +0100 Merge branch 'develop' commit 55c724ad30b0a791dd2aaf651e0f2b7ac42a17bc Merge: 41821bcc eca12a15 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu Feb 5 16:47:19 2026 +0100 Merge pull request #263 from edubadges/bugfix/fix-migration-leaf-nodes-and-production-merge-migrations Bugfix/fix migration leaf nodes and production merge migrations commit eca12a155077e3b3164f4229295809744a1712d5 Author: Thomas Kalverda Date: Thu Feb 5 16:42:15 2026 +0100 Fix populate institution email data migration commit 25b1bea7a7dc7c529598b477f09efaf47fbad1ff Author: Thomas Kalverda Date: Thu Feb 5 16:41:56 2026 +0100 Add merge migrations that were generated on production server commit 1199d1bb9274a30e9a6316144a6e24451208e0b6 Merge: c4af65a4 41821bcc Author: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu Feb 5 15:53:12 2026 +0100 Merge branch 'develop' commit c4af65a4bd266d606e7bd14b94d7b31e9d0a8c1f Author: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu Feb 5 15:52:32 2026 +0100 Added changelog for 8.4.1 commit 41821bcc273a8a774ade36ed901c4741ecd59371 Merge: 7e66b45e 8a1c3ebf Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu Feb 5 15:46:40 2026 +0100 Merge pull request #262 from edubadges/bugfix/fix-filter-for-audittrail-data-migration-on-entity-id Filter the direct awards on entity id because that is currently stored commit 8a1c3ebf933e2da11beac4c1a4df5b03f5e50698 Author: Thomas Kalverda Date: Thu Feb 5 15:40:23 2026 +0100 Filter the direct awards on entity id because that is currently stored commit eb143af1224537ce2fc43d08aab2c1683f71709d Author: Thomas Kalverda Date: Thu Feb 5 14:21:58 2026 +0100 Add register device endpoint for mobile push notifications commit 8e2130ad9d59ea126739a05c0924f0973bf482c6 Merge: 411937b8 7e66b45e Author: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu Feb 5 14:17:06 2026 +0100 Merge branch 'develop' commit 0b83bc9db1bada3c9481d5d1f49ba3feb49f3935 Author: Thomas Kalverda Date: Thu Feb 5 13:15:10 2026 +0100 Add system check to warn if env variables are missing commit ed92cea8992b851b9c846817c6519127e20b71dc Author: Thomas Kalverda Date: Thu Feb 5 13:14:35 2026 +0100 Install fcm-django and configure in settings commit 7e66b45e6236060296c6c0a239dd9b2bf22d8e6e Merge: f8356259 8b4bf040 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu Feb 5 11:46:32 2026 +0100 Merge pull request #261 from edubadges/feature/add-terms-to-direct-award-endpoint Add required terms to direct award detail view commit 8b4bf040b6b107a47c346ba012cd6e81b4699880 Author: Thomas Kalverda Date: Thu Feb 5 11:37:28 2026 +0100 Add required terms to direct award detail view And refactor to use a RetrieveAPIView commit f8356259fe58c1342402d19552c890cef72b5fb5 Merge: a130b5d4 84cf1ee3 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Wed Feb 4 16:35:39 2026 +0100 Merge pull request #258 from edubadges/feature/populate-institution-email Add datamigration to populate institution email commit a130b5d4cfb504b159db1d34f436c8aa01049ed4 Merge: 49f8e446 53e951ea Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Wed Feb 4 16:18:59 2026 +0100 Merge pull request #260 from edubadges/bugfix/fix-institution-mobile-endpoint-filtering Flip filtering logic around to make query faster commit 53e951ea831f19617f8d2e724daa00e262b61614 Author: Thomas Kalverda Date: Wed Feb 4 15:15:42 2026 +0100 Flip filtering logic around to make query faster And this also should fix the visibility issue. I think it was because of the faculty visibility type commit 49f8e446c4dac582d7253e6971bdd30ecada43da Merge: f4215310 30c250b2 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Wed Feb 4 13:05:46 2026 +0100 Merge pull request #259 from edubadges/bugfix/institution-mobile-endpoint-use-correct-related-name Use correct related name for badgeclass issuer FK commit 30c250b23c3b06d0b33308878506f759cb85ee47 Author: Thomas Kalverda Date: Wed Feb 4 11:09:57 2026 +0100 Use correct related name for badgeclass issuer FK commit 84cf1ee3c4a893d0484e48daadefccf80a486f68 Author: Thomas Kalverda Date: Wed Feb 4 08:58:47 2026 +0100 Add datamigration to populate institution email commit f4215310f6f23009d6079f96e2ef92f5bd9ebb80 Merge: 3c216afe f490ca82 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Wed Feb 4 08:35:40 2026 +0100 Merge pull request #257 from edubadges/dependabot/pip/django-4.2.28 Bump django from 4.2.27 to 4.2.28 commit f490ca8234e0180c2047758f11b99ca91bd7300f Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue Feb 3 20:51:19 2026 +0000 Bump django from 4.2.27 to 4.2.28 Bumps [django](https://github.com/django/django) from 4.2.27 to 4.2.28. - [Commits](https://github.com/django/django/compare/4.2.27...4.2.28) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.28 dependency-type: direct:production ... Signed-off-by: dependabot[bot] commit 3c216afec03967c86a82621a307e9dc720183a1a Merge: 122f4b48 2049380b Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Feb 3 16:59:28 2026 +0100 Merge pull request #255 from edubadges/feature/add-mobile-institution-api-endpoint Add mobile institution api endpoint commit 2049380b4e9bc2ea5c5f6e5086164ff9dcded517 Author: Thomas Kalverda Date: Tue Feb 3 12:56:36 2026 +0100 Add mobile institution api endpoint Only institutions that satisfy all filters will be included # Conflicts: # apps/mobile_api/api.py commit 122f4b4834d37bd0eadba15263ecffaefaf654a3 Merge: 46669fe8 c73b8c43 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Feb 3 16:30:27 2026 +0100 Merge pull request #256 from edubadges/feature/add-mobile-api-endpoint-for-badge-class-detail Add mobile api endpoint for badge class detail commit c73b8c43b6de88fb4af7a4b0b2c32c8a9f5c92c1 Author: Thomas Kalverda Date: Tue Feb 3 16:20:34 2026 +0100 Add mobile api endpoint for badge class detail commit 46669fe817627a13bd50934c817ff61dea0a4a5f Merge: 62f5dd08 ff4faa48 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Feb 3 08:54:58 2026 +0100 Merge pull request #254 from edubadges/feature/add-terms-to-mobile-catalog Feature/add terms to mobile catalog commit ff4faa48d87d3a3c46e55b8e4b8e13e75ea02354 Author: Thomas Kalverda Date: Mon Feb 2 10:36:35 2026 +0100 Add boolean for whether user has accepted the terms commit fb0221591aefa10f2fd2e6ddbe3b2b91e77329ee Author: Thomas Kalverda Date: Mon Feb 2 10:36:06 2026 +0100 Add terms to catalog badge class serializer commit 62f5dd084d4550143ab0573b44a4dd2361a2051f Author: Okke Harsta Date: Thu Jan 29 10:58:39 2026 +0100 Added endpoint to make a badge instance public commit 2db0dd9c9a6e5f1089ed9bffb139d0f4d8001868 Merge: 8108e728 14c1e6f9 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu Jan 29 07:59:34 2026 +0100 Merge pull request #253 from edubadges/bugfix/fix-entity-id-for-direct-award Use entity id for direct awards commit 14c1e6f99cc2df9ee4a857bb4823ab7cb687893d Author: Thomas Kalverda Date: Wed Jan 28 15:58:07 2026 +0100 Use entity id for direct awards commit 8108e728fb801820a550728f109f37c652410d9f Merge: 318183fc 8fad5530 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Wed Jan 28 15:44:54 2026 +0100 Merge pull request #252 from edubadges/bugfix/fix-creation-of-audit-trail-objects-in-signal Find direct award and badgeclass on id and not entity_id commit 318183fc8c245a5810b89dc920f5effaba438a46 Merge: 3c2c4b85 4beeef78 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Wed Jan 28 15:44:44 2026 +0100 Merge pull request #251 from edubadges/feature/update-filters-for-mobile-api-catalog-endpoint Remove q filter and replace is_micro with institution_type filter commit 8fad5530fb97eddfaec5b4390d24d7cf20e1a680 Author: Thomas Kalverda Date: Wed Jan 28 15:30:06 2026 +0100 Find direct award and badgeclass on id and not entity_id commit 3c2c4b85ebf87fa8e434916d941449b7291eb9be Author: Okke Harsta Date: Wed Jan 28 15:20:25 2026 +0100 Added grade_achieved in the BadgeInstanceDetailSerializer commit 4beeef785c6669a8ed2505c06611a0e5aec84e08 Author: Thomas Kalverda Date: Wed Jan 28 14:50:28 2026 +0100 Remove q filter and replace is_micro with institution_type filter commit ad4798a55cf742812228ca2b60fbb0c7b0e00000 Author: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri Jan 16 10:25:13 2026 +0100 Updated CHANGELOG for release 8.4 commit 4b749690390542f5f7d7b6f7b6d22d3552e8fb89 Author: Daniel Date: Tue Dec 2 15:23:06 2025 +0100 Updated CHANGELOG for 8.3.3 release commit 1b88a4a0c06ed61cb32f7b417894a3d7c674d54d Merge: f03041a4 8d675d2d Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Wed Jan 28 09:28:20 2026 +0100 Merge pull request #250 from edubadges/bugfix/fix-swagger-ui-for-filterable-fields Add filter backend globally and locally commit 8d675d2d4e178e9c79a2a89fbd10d004bbdf4f66 Author: Thomas Kalverda Date: Wed Jan 28 09:22:01 2026 +0100 Add filter backend globally and locally commit f03041a45815a477b0a3dac1848615b4888f3907 Author: Daniel Date: Tue Jan 27 20:20:42 2026 +0100 Annotate correct related objects (badgeinstances) #2 commit 7a8804a9cf105c772429cac84b8db9faca5f85a7 Author: Thomas Kalverda Date: Tue Jan 27 16:14:40 2026 +0100 Annotate correct related objects commit a722c5f9b38dc1e161b6b7e30b520e12a2d1dce9 Author: Thomas Kalverda Date: Tue Jan 27 15:57:04 2026 +0100 Remove source from terms_agreed commit 8c308316fb1ce64306e30db45e2943aeb8417d2a Merge: 751b44bd 2bf5c598 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Jan 27 15:48:59 2026 +0100 Merge pull request #249 from edubadges/feature/mobile-profile-add-extra-metadata Feature/mobile profile add extra metadata commit 2bf5c5983c5b3d21cd15ae190ab7811616d99c75 Author: Thomas Kalverda Date: Mon Jan 26 16:40:13 2026 +0100 Add registration and consent data to user profile commit a4741c5ed9d2db24bf44269678ceb3b52553c5d6 Author: Thomas Kalverda Date: Mon Jan 26 16:25:41 2026 +0100 Replace profile api view with custom one for mobile api commit 751b44bd81988fa34c08d099b33a762c80fe0c2f Merge: b5ec35e0 7437740e Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Jan 27 15:45:33 2026 +0100 Merge pull request #248 from edubadges/feature/mobile-catalog-endpoint-with-filtering-and-pagination Feature/mobile catalog endpoint with filtering and pagination commit 7437740e2872ed8712617517a7cd6da5a4a14700 Author: Thomas Kalverda Date: Mon Jan 26 11:05:03 2026 +0100 Add schema example commit b0ed25ab102e38ccfc07f076274ce160c2d41cac Author: Thomas Kalverda Date: Mon Jan 26 11:04:47 2026 +0100 Add filter class so endpoint can be filtered with query params commit ba62d498cec3ecb5577237bc8a258315ffaf156c Author: Thomas Kalverda Date: Mon Jan 26 11:03:51 2026 +0100 Add catalog list view with pagination This is a replacement for the api endpoint in queries with the raw sql commit b5ec35e0221644922acc01249081b1a2db90d12b Merge: e8368058 f595116c Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Jan 27 15:37:43 2026 +0100 Merge pull request #247 from edubadges/improve_mobile_api_swagger Feature/Improving mobile API swagger annotations and serveral fixes in mobile API commit f595116c6f1221b7c6fafe5b9425b70239aa8d07 Author: Thomas Kalverda Date: Tue Jan 27 13:09:51 2026 +0100 Prefetch related badge instances to minimize queries commit 6f3616e38beee031c5b53d979d1842bb640bfbaa Author: Thomas Kalverda Date: Tue Jan 27 13:09:30 2026 +0100 Use slug related field instead of serializer method field commit ad7b5770e56c7a136359b54cf0de23edd6cadfe8 Author: Daniel Date: Fri Jan 23 17:42:21 2026 +0100 fix: have badge instance PUT method only allow acceptance and public field commit 4c5b6222aea458a4cc7d6f704312893a19602061 Author: Daniel Date: Fri Jan 23 17:12:31 2026 +0100 fix: use for badge-instances/entity_id path one view (BadgeInstanceDetail) and add logic to support PUT method in BadgeInstanceDetail commit 4600b6841c5d87b7fc4916af34a89ac9eb382ff7 Author: Daniel Date: Fri Jan 23 16:54:10 2026 +0100 chore: improved the swagger doc by adding full models of badge instances, direct award, and collections commit 1e178d389a7918a707363444460ce41755eb5b2f Author: Daniel Date: Fri Jan 23 16:52:30 2026 +0100 fix: return entity_id's instead of id's of badgeinstances within collections # Conflicts: # apps/mobile_api/serializers.py # Conflicts: # apps/mobile_api/serializers.py commit 7df7af910407a17098852ecb624abee06de1f942 Author: Daniel Date: Fri Jan 23 15:47:43 2026 +0100 fix: mobile API auth to return 401 instead of 403 commit 32d22a4943f849d4144923ce915660403ccce309 Author: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri Jan 16 16:17:25 2026 +0100 Adding .zed to gitignore commit ffe9846b02191fc4baac79611bb15af72f26e52b Author: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri Jan 16 16:17:15 2026 +0100 Feat: improve mobile api swagger, initial commit commit e8368058d21b2d11027b7cc9758eccfb15428b02 Author: Okke Harsta Date: Mon Jan 26 11:24:38 2026 +0100 Added badge_class_type in mobile API commit d0d686ee8fadbeb9202587324e738f597dbecc77 Merge: 489d8932 4318e30d Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Wed Jan 21 09:36:00 2026 +0100 Merge pull request #244 from edubadges/bugfix/fix-audittrail-errors Bugfix/fix audittrail errors commit 4318e30d5dbe8aa1c3b8fb74ef16533d2ad19da5 Author: Thomas Kalverda Date: Tue Jan 20 17:07:15 2026 +0100 Add a one-off management command to backfill badgeclass ids commit f0ff521c7a243041c93c9aa0b34093f30fd41c1d Author: Thomas Kalverda Date: Tue Jan 20 16:59:14 2026 +0100 Select related institution through issuer and faculty As the badgeclass model itself doesn't have a FK relationship, only a property commit e900377d4b8423c3dd757b185fc76f8c3531a3ad Author: Thomas Kalverda Date: Tue Jan 20 16:56:53 2026 +0100 Fix migration to filter on actual ids commit 489d8932f4ae5cbcf4ce9a284cbb87196a9a78d9 Merge: 14ce28a9 e7241929 Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Tue Jan 20 12:02:41 2026 +0100 Merge pull request #243 from edubadges/feature/improve-performance-of-direct-award-audit-trail-endpoint Feature/improve performance of direct award audit trail endpoint commit e72419298ba8377e047e50a35d247de40eb541dd Author: Thomas Kalverda Date: Mon Jan 19 13:35:19 2026 +0100 Update audit trail signal receiver to set fk relations properly commit ebee67db605334258e3a506df00a67b3ac72bc51 Author: Thomas Kalverda Date: Mon Jan 19 13:34:33 2026 +0100 Improve performance with select_related and extra filter commit 82503d7690677cdb4b521ee707af6569eba6719d Author: Thomas Kalverda Date: Mon Jan 19 13:33:59 2026 +0100 Refactor audit trail api view into a ListAPIView Refactor the audit trail API endpoint to a ListAPIView and improve the serializer using the new model relations. commit 001673fbac2519b479989e82981df8986ebe778d Author: Thomas Kalverda Date: Mon Jan 19 13:23:20 2026 +0100 Refactor charfields to foreign key relationships Refactor DirectAwardAuditTrail to use proper ForeignKey relations instead of CharField entity IDs. Includes a data migration to populate historical records and removes the legacy fields. The migration is also backwards compatible. commit 14ce28a98c237d96796a08dca817e58da40cedcf Author: Okke Harsta Date: Mon Jan 19 11:24:00 2026 +0100 Added stackable to the badgeclass serializer commit 7eb48344395dab93107c2fa63f2e8103b2f46089 Author: Okke Harsta Date: Mon Jan 19 10:27:01 2026 +0100 Added grade_achieved to mobile seerializer commit 411937b8addaac808c24184f21ddea1c5ab5fdb9 Author: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Fri Jan 16 10:25:13 2026 +0100 Updated CHANGELOG for release 8.4 commit 8347ae253f7771e315dc85f922b26ba436b0d750 Merge: d312ec40 001e3c0a Author: Dostkamp <4895210+Iso5786@users.noreply.github.com> Date: Thu Jan 15 16:43:17 2026 +0100 Merge pull request #242 from edubadges/feature/add-linkedin-url-to-mobile-badgeinstance-api-endpoint Add linkedin_url field to badge instance detail serializer commit 001e3c0ac7c46876c4549e7b8413e41876ac8994 Author: Thomas Kalverda Date: Thu Jan 15 14:02:44 2026 +0100 Retrieve faculty directly fro badgeclass issuer Should be always available commit bc979410e5c45831f67499335a8a8f9e0e0f35d9 Author: Thomas Kalverda Date: Wed Jan 14 15:08:15 2026 +0100 Add linkedin_url field to badge instance detail serializer commit 02ca798c506ca271619990b86b4176829573a647 Merge: b00d51fe d312ec40 Author: Daniel Ostkamp <4895210+Iso5786@users.noreply.github.com> Date: Wed Jan 14 10:05:29 2026 +0100 Merge branch 'develop' for release 8.4 commit d312ec40cb4582f5ec738f168c225fe108129699 Merge: b4997cbf 574daced Author: Thomas Kalverda Date: Wed Jan 14 08:30:19 2026 +0100 Merge pull request #239 from edubadges/dependabot/pip/urllib3-2.6.3 Bump urllib3 from 1.26.19 to 2.6.3 commit b4997cbf7dbecd2e92e3969c9f229de01fb183db Merge: fe331317 8bd3c8e6 Author: Okke Harsta Date: Tue Jan 13 19:30:34 2026 +0100 Merge pull request #241 from edubadges/chore/run-django-tests-in-ci-cd Add workflow to run django tests commit 574daced418f2cbe2066f4e24418a603b95347eb Author: Thomas Kalverda Date: Tue Jan 13 11:12:05 2026 +0100 Update import of urllib commit 310f47505592b1cf6267c973a0c9ad664df454e3 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue Jan 13 09:26:25 2026 +0000 Bump urllib3 from 1.26.19 to 2.6.3 Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.19 to 2.6.3. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.19...2.6.3) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.6.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] commit 8bd3c8e6093cefd8648defad807c6f7fae98a755 Author: Thomas Kalverda Date: Mon Jan 12 16:01:58 2026 +0100 Grant privileges to test db user commit 9e9e4a55fc3ce373d2f7646ae71afc1bce5f59c1 Author: Thomas Kalverda Date: Mon Jan 12 15:34:48 2026 +0100 Add workflow to run django tests commit fe33131739fae1d6709a7201f2e71ed74e724909 Merge: 503f94d1 4e46cf32 Author: Okke Harsta Date: Mon Jan 12 15:32:32 2026 +0100 Merge pull request #240 from edubadges/chore/fix-tests Chore/fix tests commit 4e46cf32bbf8e091e639b50814dddcf580d9c44a Author: Thomas Kalverda Date: Mon Jan 12 15:19:10 2026 +0100 Fix tests for removed constraint for badgeclass Badge names do not have to be unique anymore since dd765f3 commit 5138a0d3c7ba46ef857ad52e0e41b30be77d880b Author: Thomas Kalverda Date: Mon Jan 12 14:48:45 2026 +0100 Fix request data that was no valid json commit aa7b04b4e191fa0c1bc89cd2eb7ae35a24c2bdb4 Author: Thomas Kalverda Date: Mon Jan 12 14:48:29 2026 +0100 Add required badgeclass type to request data commit 3e6c9cdcc57a3e5dbd321d87a98e5483d65337be Author: Thomas Kalverda Date: Mon Jan 12 14:42:29 2026 +0100 Disable extension validation in tests This doesn't work because the @context is not available commit abf0655b6dbcbaedf97a037efd93609aafbdf4c3 Author: Thomas Kalverda Date: Mon Jan 12 11:51:33 2026 +0100 Fix assertion for showing archived badges in issuer response Archived badges should show now in the resolve_issuers. Change done in 49834567217f74bad9da418160f5e13f78891a2b by Okke, test was not updated commit 94790adf44b548bbf0f7517d9877dae5001d4e4b Author: Thomas Kalverda Date: Mon Jan 12 11:16:12 2026 +0100 Fix urls and expected response code in institution test commit 72c1e0e822ef9fb619f28d2c3154e82e51666b35 Author: Thomas Kalverda Date: Mon Jan 12 09:56:27 2026 +0100 Remove edit directaward functionality from tests Functionality itself was removed in 72d6783 commit 3a007339cd219547463dfb4dd62e1771680abb84 Author: Thomas Kalverda Date: Thu Jan 8 17:35:13 2026 +0100 Assert correct type commit f56e713e2d9c308dd354ae2013483eff23140638 Author: Thomas Kalverda Date: Thu Jan 8 16:59:03 2026 +0100 Fix staff permission in test to show issuers Apparently for institution staff the may_update permission is required to view issuers. commit f3c1f421c8242f9bb94fec7b7d9e5adc03d73100 Author: Thomas Kalverda Date: Thu Jan 8 15:21:56 2026 +0100 Fix broken test helpers for enrollment setup commit a53679be738d63bcea3d53f57a7565736ae258b6 Author: Thomas Kalverda Date: Thu Jan 8 13:11:09 2026 +0100 Disable auth signals and logging in tests Authentication login/logout signals produced noisy stdout output during tests. These are now disabled via a test-only settings override commit 4a405354b0fdce6739336c88fdd0f7c82da83fc5 Author: Thomas Kalverda Date: Thu Jan 8 13:09:12 2026 +0100 Add dedicated settings for testing commit 5acea261f115b5754de0147ebef95e46be4e170f Author: Thomas Kalverda Date: Thu Jan 8 12:23:06 2026 +0100 Suppress cssutils CSS validation errors in test environment cssutils logs ERROR-level messages for valid modern CSS due to CSS 2.1 validation limitations. Since these warnings do not indicate functional issues, we silence cssutils logging in BadgrRunner to reduce noise during tests. commit e357bea5d4505ec592a14ed80584549984b5363b Author: Thomas Kalverda Date: Thu Jan 8 11:52:28 2026 +0100 Fix naive datetime defaults in legacy migrations The previous defaults produced naive datetimes during test database creation while USE_TZ was enabled, causing runtime warnings before tests ran. This change fixes the issue at the migration level and eliminates test startup noise. commit 983442829ffe380097a4e3b7f24460e747cf86da Author: Thomas Kalverda Date: Thu Jan 8 10:29:37 2026 +0100 Remove setlocale usage and localize email dates in templates Removed locale.setlocale() from email utility functions and moved date localization into the email templates using Django’s {% language %} tag and date filter. This avoids reliance on system locales (which are missing in Docker) and eliminates unsafe global locale switching in threaded email sending. Localization is now explicit, thread-safe, and handled entirely by Django’s i18n system. commit 503f94d1940ffebbd5418b07b171633bd9d1fa99 Author: Okke Harsta Date: Fri Dec 19 16:19:22 2025 +0100 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 commit b00d51fe42004b3db6d311ebaa2adb0ce5412b7f Author: Daniel Date: Tue Dec 2 15:23:06 2025 +0100 Updated CHANGELOG for 8.3.3 release --- .github/workflows/tests.yml | 94 ++ .github/workflows/trivy.yml | 2 +- .gitignore | 5 + CHANGELOG.md | 200 ++- .../migrations/0068_auto_20200820_1138.py | 5 +- .../0079_delete_importbadgeallowedurl.py | 16 + .../0080_termsagreement_agreed_at.py | 18 + ...0081_populate_termsagreement_agreed_add.py | 23 + apps/badgeuser/models.py | 10 + apps/badgeuser/tests/test_badgeuser.py | 2 +- apps/badgrsocialauth/providers/eduid/views.py | 6 +- apps/directaward/api.py | 23 +- apps/directaward/api_urls.py | 4 +- apps/directaward/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../backfill_audittrail_badgeclass_ids.py | 34 + ..._and_directaward_relations_and_populate.py | 85 ++ ...rectaward_recipient_first_name_and_more.py | 23 + apps/directaward/models.py | 73 +- apps/directaward/serializer.py | 67 +- apps/directaward/signals.py | 16 +- apps/directaward/tests/test_direct_award.py | 45 +- ...0052_alter_institution_sis_default_user.py | 24 + .../migrations/0053_merge_20240226_1350.py | 13 + .../migrations/0058_merge_20240814_0929.py | 13 + .../migrations/0066_merge_20241113_1458.py | 13 + ...titution_email_0066_merge_20241113_1458.py | 13 + .../0068_populate_institution_email.py | 37 + .../0069_alter_institution_staff.py | 21 + apps/institution/tests/test_institution.py | 8 +- .../migrations/0027_auto_20170801_1636.py | 5 +- .../0118_badgeinstance_recipient_name.py | 18 + .../0119_populate_recipient_name.py | 50 + apps/issuer/models.py | 22 +- apps/issuer/serializers.py | 16 +- apps/issuer/tests/test_issuer.py | 169 ++- .../commands/reminders_direct_awards.py | 19 +- apps/mainsite/mobile_api_authentication.py | 60 +- apps/mainsite/settings.py | 9 + apps/mainsite/settings_tests.py | 11 +- .../static/sample_direct_award_email_only.csv | 10 +- .../email/earned_direct_award_new.html | 29 +- .../email/reminder_direct_award_new.html | 36 +- apps/mainsite/test_utils.py | 10 +- apps/mainsite/testrunner.py | 16 +- apps/mainsite/tests/base.py | 6 +- apps/mobile_api/api.py | 1082 +++++++++++++++-- apps/mobile_api/api_urls.py | 57 +- apps/mobile_api/apps.py | 9 + apps/mobile_api/checks.py | 26 + apps/mobile_api/filters.py | 24 + apps/mobile_api/pagination.py | 6 + apps/mobile_api/push_notifications.py | 43 + apps/mobile_api/serializers.py | 516 +++++++- apps/public/public_api.py | 6 +- apps/staff/models.py | 2 +- docker-compose.yml | 1 + docker/dev.Dockerfile | 1 + env_vars.sh.example | 1 + manage.py | 5 +- requirements.txt | 23 +- secrets/.keep | 0 62 files changed, 2797 insertions(+), 384 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 apps/badgeuser/migrations/0079_delete_importbadgeallowedurl.py create mode 100644 apps/badgeuser/migrations/0080_termsagreement_agreed_at.py create mode 100644 apps/badgeuser/migrations/0081_populate_termsagreement_agreed_add.py create mode 100644 apps/directaward/management/__init__.py create mode 100644 apps/directaward/management/commands/__init__.py create mode 100644 apps/directaward/management/commands/backfill_audittrail_badgeclass_ids.py create mode 100644 apps/directaward/migrations/0024_directawardaudittrail_refactor_badgeclass_and_directaward_relations_and_populate.py create mode 100644 apps/directaward/migrations/0025_directaward_recipient_first_name_and_more.py create mode 100644 apps/institution/migrations/0052_alter_institution_sis_default_user.py create mode 100644 apps/institution/migrations/0053_merge_20240226_1350.py create mode 100644 apps/institution/migrations/0058_merge_20240814_0929.py create mode 100644 apps/institution/migrations/0066_merge_20241113_1458.py create mode 100644 apps/institution/migrations/0067_merge_0066_institution_email_0066_merge_20241113_1458.py create mode 100644 apps/institution/migrations/0068_populate_institution_email.py create mode 100644 apps/institution/migrations/0069_alter_institution_staff.py create mode 100644 apps/issuer/migrations/0118_badgeinstance_recipient_name.py create mode 100644 apps/issuer/migrations/0119_populate_recipient_name.py create mode 100644 apps/mobile_api/apps.py create mode 100644 apps/mobile_api/checks.py create mode 100644 apps/mobile_api/filters.py create mode 100644 apps/mobile_api/pagination.py create mode 100644 apps/mobile_api/push_notifications.py create mode 100644 secrets/.keep 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