From b18700b828a7260a67e8a0621312ff1ecd199e89 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:28:28 -0700 Subject: [PATCH 1/5] feat(induction): Refactor induction system with provider-based architecture - Introduce induction provider state tracking via new Profile model relation - Create centralized induction service (services/induction.py) to handle provider refresh and status aggregation - Replace hardcoded Moodle/Canvas/DocuSeal logic with unified provider-based workflow - Add induction status to member profile responses for better signup visibility --- memberportal/api_admin_tools/views.py | 12 +- memberportal/api_billing/views.py | 104 ++----- memberportal/api_general/views.py | 39 +-- .../membermatters/constance_config.py | 6 +- memberportal/profile/admin.py | 13 + .../0029_induction_provider_state.py | 138 +++++++++ .../migrations/0031_merge_20260916_1458.py | 13 + memberportal/profile/models.py | 91 ++++-- memberportal/services/docuseal.py | 11 +- memberportal/services/induction.py | 271 ++++++++++++++++++ .../ManageMember/MemberProfileTab.vue | 9 +- .../Billing/SignupRequiredSteps.vue | 133 +++++---- src-frontend/src/i18n/en-AU/index.ts | 17 ++ src-frontend/src/i18n/sv-SE/index.ts | 17 ++ src-frontend/src/pages/Dashboard.vue | 6 +- src-frontend/src/pages/Profile.vue | 5 +- src-frontend/src/types/member.ts | 17 ++ 17 files changed, 686 insertions(+), 216 deletions(-) create mode 100644 memberportal/profile/migrations/0029_induction_provider_state.py create mode 100644 memberportal/profile/migrations/0031_merge_20260916_1458.py create mode 100644 memberportal/services/induction.py diff --git a/memberportal/api_admin_tools/views.py b/memberportal/api_admin_tools/views.py index bd98a837..155b81d5 100644 --- a/memberportal/api_admin_tools/views.py +++ b/memberportal/api_admin_tools/views.py @@ -68,7 +68,9 @@ class GetMembers(APIView): def get(self, request): filtered = [] - members_queryset = User.objects.select_related("profile") + members_queryset = User.objects.select_related("profile").prefetch_related( + "profile__induction_provider_states" + ) screenName = request.GET.get("screenName") if screenName is not None: @@ -96,13 +98,17 @@ def get(self, request): profiles = ( Profile.objects.filter(state__in=["noob", "inactive"]) .select_related("user") + .prefetch_related("induction_provider_states") .all() ) result = [] for p in profiles: - data = p.get_basic_profile() - data["requiredSteps"] = p.can_signup()["requiredSteps"] + induction = p.get_induction_status() + data = p.get_basic_profile(induction=induction) + signup = p.can_signup(induction=induction) + data["requiredSteps"] = signup["requiredSteps"] + data["requirements"] = signup["requirements"] result.append(data) return Response(result) diff --git a/memberportal/api_billing/views.py b/memberportal/api_billing/views.py index 99de1798..ed670035 100644 --- a/memberportal/api_billing/views.py +++ b/memberportal/api_billing/views.py @@ -18,12 +18,7 @@ import stripe import logging import uuid -from services.canvas import Canvas -from services.moodle_integration import ( - moodle_get_course_activity_completion_status, - moodle_get_user_from_email, -) -from services.docuseal import get_docuseal_submission, submission_is_complete +from services.induction import refresh as refresh_induction from services.emails import send_email_to_admin from constance import config from django.db import transaction, IntegrityError @@ -633,89 +628,24 @@ def post(self, request): class CheckInductionStatus(APIView): - """ - post: checks if the member has completed the induction (via the canvas/moodle API). - """ + """Refresh every enabled induction provider and return its local status.""" def post(self, request): - if "induction" not in request.user.profile.can_signup()["requiredSteps"]: - return Response({"success": True, "score": 0, "notRequired": True}) - - score = 0 - - if config.MOODLE_INDUCTION_ENABLED: - try: - moodle_user = moodle_get_user_from_email(request.user.email) - activities = moodle_get_course_activity_completion_status( - config.MOODLE_INDUCTION_COURSE_ID, moodle_user["id"] - ) - score = activities["percentage_completed"] - except RuntimeError as e: - # Helper raises RuntimeError when 0 or >1 Moodle users match - # the member's email. Most common case: member hasn't set up - # their Moodle account yet — return a friendly response so - # the frontend can prompt them, instead of 500-ing. - logger.info("Moodle lookup for %s: %s", request.user.email, e) - return Response( - { - "success": False, - "score": 0, - "message": "signup.noMoodleAccount", - } - ) - except Exception as e: - # Network / JSON / unexpected Moodle response — log and - # surface a generic error rather than leaking the trace. - capture_exception(e) - return Response( - { - "success": False, - "score": 0, - "message": "signup.moodleUnavailable", - } - ) - - elif config.CANVAS_INDUCTION_ENABLED: - try: - canvas_api = Canvas() - except OperationalError as error: - capture_exception(error) - logger.error(error) - return Response({"success": False, "score": 0}) - - score = ( - canvas_api.get_student_score_for_course( - config.CANVAS_INDUCTION_COURSE_ID, request.user.email - ) - or 0 - ) - - try: - if score or config.MIN_INDUCTION_SCORE == 0: - induction_passed = score >= config.MIN_INDUCTION_SCORE - - # if member doc is on, but the document has not been completed prevent setting the user's induction date - if config.ENABLE_DOCUSEAL_INTEGRATION: - submission = get_docuseal_submission(request.user.profile) - if not submission_is_complete(submission): - return Response( - { - "success": False, - "score": score, - "error": "User has passed induction but has NOT completed membership agreement docs", - } - ) - - if induction_passed: - request.user.profile.update_last_induction() - - return Response({"success": True, "score": score}) - return Response({"success": False, "score": score}) - - except Exception as e: - capture_exception(e) - logger.error(e) - return Response({"success": False, "score": 0, "error": str(e)}) + induction = refresh_induction(request.user.profile) + scores = [ + provider["score"] + for provider in induction["providers"] + if provider["score"] is not None + ] + return Response( + { + "success": induction["complete"], + # Retained for older clients while they move to induction.providers. + "score": max(scores, default=0), + "notRequired": not induction["providers"], + "induction": induction, + } + ) def _serialize_complete_signup(result: CompleteSignupResult) -> Response: diff --git a/memberportal/api_general/views.py b/memberportal/api_general/views.py index 44c04f62..cf1b6197 100644 --- a/memberportal/api_general/views.py +++ b/memberportal/api_general/views.py @@ -25,8 +25,6 @@ from services.docuseal import ( create_submission_for_subscription, get_docuseal_submission, - submission_is_complete, - submission_is_declined, ) import base64 from urllib.parse import parse_qs, urlencode @@ -70,7 +68,8 @@ def get(self, request): "signup": { "inductionLink": config.INDUCTION_ENROL_LINK, "enableInduction": config.MOODLE_INDUCTION_ENABLED - or config.CANVAS_INDUCTION_ENABLED, + or config.CANVAS_INDUCTION_ENABLED + or config.ENABLE_DOCUSEAL_INTEGRATION, "requireAccessCard": config.REQUIRE_ACCESS_CARD, "memberCanEnterAccessCard": config.MEMBER_CAN_ENTER_ACCESS_CARD, "postInductionUrl": config.POST_INDUCTION_URL, @@ -522,29 +521,17 @@ def get(self, request): except: pass - # append induction link(s) if user has not been inducted - response["inductionLink"] = [] - if p.last_induction is None: - if ( - config.MOODLE_INDUCTION_ENABLED or config.CANVAS_INDUCTION_ENABLED - ) and config.INDUCTION_ENROL_LINK: - response["inductionLink"].append(config.INDUCTION_ENROL_LINK) - - if config.ENABLE_DOCUSEAL_INTEGRATION: - # TODO the following removed with a webhook callback from DocuSeal on submission signing - submission = get_docuseal_submission(p) - if submission is not None: - if submission_is_complete(submission): - # in the event our induction process is *just* DocuSeal and the doc is signed, update unduction status - if not ( - config.MOODLE_INDUCTION_ENABLED - or config.CANVAS_INDUCTION_ENABLED - ): - p.update_last_induction() - response["lastInduction"] = p.last_induction - elif not submission_is_declined(submission) and p.memberdoc_url: - response["inductionLink"].append(p.memberdoc_url) - # remainder state is "declined" + # Induction links and banner state now derive from independent local + # provider checks. Profile reads never query external providers or + # mutate authorization state. + induction = p.get_induction_status() + response["induction"] = induction + response["inductionLink"] = [ + provider["actionUrl"] + for provider in induction["providers"] + if not provider["complete"] and provider["actionUrl"] + ] + # remainder state is "declined" return Response(response) diff --git a/memberportal/membermatters/constance_config.py b/memberportal/membermatters/constance_config.py index bde6b258..dc4016e4 100644 --- a/memberportal/membermatters/constance_config.py +++ b/memberportal/membermatters/constance_config.py @@ -323,7 +323,7 @@ # Induction "MOODLE_INDUCTION_ENABLED": ( False, - "Whether induction is performed via the Moodle platform or not. This setting overrides the Canvas settings below. If both are enabled, Moodle will be used.", + "Require Moodle course completion as one induction provider. When multiple induction providers are enabled, members must complete each enabled provider.", ), "MOODLE_API_BASE_URL": ( "PLEASE_CHANGE_ME", @@ -339,7 +339,7 @@ ), "CANVAS_INDUCTION_ENABLED": ( False, - "Whether induction is performed via the Canvas platform or not. This setting is overriden by the Moodle settings above. If both are enabled, Moodle will be used.", + "Require Canvas course completion as one induction provider. When multiple induction providers are enabled, members must complete each enabled provider.", ), "CANVAS_API_TOKEN": ( "PLEASE_CHANGE_ME", @@ -355,7 +355,7 @@ ), "MAX_INDUCTION_DAYS": ( 180, - "The maximum amount of days since a member was last inducted before they have to complete another induction (0 disables re-induction; first-time induction is still required).", + "Maximum age of a verified induction-provider completion before it must be checked again (0 disables the recurring requirement; first-time completion remains required).", ), "MIN_INDUCTION_SCORE": ( 99, diff --git a/memberportal/profile/admin.py b/memberportal/profile/admin.py index d912f61e..5d8b5c6e 100644 --- a/memberportal/profile/admin.py +++ b/memberportal/profile/admin.py @@ -112,6 +112,19 @@ class ProfileAdmin(admin.ModelAdmin): pass +@admin.register(InductionProviderState) +class InductionProviderStateAdmin(admin.ModelAdmin): + list_display = ( + "profile", + "provider", + "requirement_key", + "status", + "completed_at", + "checked_at", + ) + readonly_fields = ("completed_at", "checked_at") + + @admin.register(UserEventLog) class UserEventLogAdmin(admin.ModelAdmin): readonly_fields = ("date",) diff --git a/memberportal/profile/migrations/0029_induction_provider_state.py b/memberportal/profile/migrations/0029_induction_provider_state.py new file mode 100644 index 00000000..e79f9bfe --- /dev/null +++ b/memberportal/profile/migrations/0029_induction_provider_state.py @@ -0,0 +1,138 @@ +from datetime import timedelta + +from django.db import migrations, models +import django.db.models.deletion +from django.utils import timezone + + +def backfill_valid_legacy_induction(apps, schema_editor): + """Preserve completion proven by the former aggregate induction field.""" + from constance import config + + Profile = apps.get_model("profile", "Profile") + InductionProviderState = apps.get_model("profile", "InductionProviderState") + now = timezone.now() + + providers = [] + # Match the old provider-selection behavior: Moodle overrode Canvas, and + # DocuSeal was an additional requirement when enabled. + if config.MOODLE_INDUCTION_ENABLED: + providers.append( + ( + "moodle", + f"moodle:course:{config.MOODLE_INDUCTION_COURSE_ID}:" + f"minimum-score:{config.MIN_INDUCTION_SCORE}", + ) + ) + elif config.CANVAS_INDUCTION_ENABLED: + providers.append( + ( + "canvas", + f"canvas:course:{config.CANVAS_INDUCTION_COURSE_ID}:" + f"minimum-score:{config.MIN_INDUCTION_SCORE}", + ) + ) + if config.ENABLE_DOCUSEAL_INTEGRATION: + providers.append( + ("docuseal", f"docuseal:template:{config.DOCUSEAL_TEMPLATE_ID}") + ) + + for profile in Profile.objects.exclude(last_induction__isnull=True): + if config.MAX_INDUCTION_DAYS > 0 and profile.last_induction < now - timedelta( + days=config.MAX_INDUCTION_DAYS + ): + continue + for provider, requirement_key in providers: + # `last_induction` can prove the legacy LMS path, but it never + # proves that a DocuSeal agreement was signed. Preserve an + # existing DocuSeal submission reference as pending so refresh() + # verifies it; otherwise it will issue a new agreement. + is_docuseal = provider == "docuseal" + InductionProviderState.objects.get_or_create( + profile_id=profile.pk, + provider=provider, + requirement_key=requirement_key, + defaults={ + "status": "pending" if is_docuseal else "complete", + "completed_at": None if is_docuseal else profile.last_induction, + "checked_at": None if is_docuseal else profile.last_induction, + "external_reference": ( + str(profile.memberdoc_id) + if is_docuseal and profile.memberdoc_id + else "" + ), + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("profile", "0028_profile_memberdoc_url"), + ] + + operations = [ + migrations.CreateModel( + name="InductionProviderState", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "provider", + models.CharField( + choices=[ + ("canvas", "Canvas"), + ("moodle", "Moodle"), + ("docuseal", "DocuSeal"), + ], + max_length=20, + ), + ), + ("requirement_key", models.CharField(max_length=255)), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("complete", "Complete"), + ("declined", "Declined"), + ("unavailable", "Unavailable"), + ("invalid_configuration", "Invalid configuration"), + ], + default="pending", + max_length=32, + ), + ), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("checked_at", models.DateTimeField(blank=True, null=True)), + ("score", models.PositiveSmallIntegerField(blank=True, null=True)), + ("error_code", models.CharField(blank=True, max_length=64)), + ("external_reference", models.CharField(blank=True, max_length=255)), + ( + "profile", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="induction_provider_states", + to="profile.profile", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="inductionproviderstate", + constraint=models.UniqueConstraint( + fields=("profile", "provider", "requirement_key"), + name="unique_profile_induction_requirement", + ), + ), + migrations.RunPython( + backfill_valid_legacy_induction, migrations.RunPython.noop + ), + ] diff --git a/memberportal/profile/migrations/0031_merge_20260916_1458.py b/memberportal/profile/migrations/0031_merge_20260916_1458.py new file mode 100644 index 00000000..cb572339 --- /dev/null +++ b/memberportal/profile/migrations/0031_merge_20260916_1458.py @@ -0,0 +1,13 @@ +# Generated by Django 6.0.8 on 2026-09-16 04:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("profile", "0029_induction_provider_state"), + ("profile", "0030_listmonk_member_sync_outbox"), + ] + + operations = [] diff --git a/memberportal/profile/models.py b/memberportal/profile/models.py index 6ebfee4b..23ecfc1b 100644 --- a/memberportal/profile/models.py +++ b/memberportal/profile/models.py @@ -360,6 +360,50 @@ class CompleteCancelResult: previous_state: str = "" +class InductionProviderState(models.Model): + """The latest verification of one provider's versioned induction requirement.""" + + class Provider(models.TextChoices): + CANVAS = "canvas", "Canvas" + MOODLE = "moodle", "Moodle" + DOCUSEAL = "docuseal", "DocuSeal" + + class Status(models.TextChoices): + PENDING = "pending", "Pending" + COMPLETE = "complete", "Complete" + DECLINED = "declined", "Declined" + UNAVAILABLE = "unavailable", "Unavailable" + INVALID_CONFIGURATION = "invalid_configuration", "Invalid configuration" + + profile = models.ForeignKey( + "Profile", on_delete=models.CASCADE, related_name="induction_provider_states" + ) + provider = models.CharField(max_length=20, choices=Provider.choices) + # Identifies the current course/template and pass policy. A configuration + # change creates a distinct requirement instead of reusing stale proof. + requirement_key = models.CharField(max_length=255) + status = models.CharField( + max_length=32, choices=Status.choices, default=Status.PENDING + ) + # A verification may be safely reused only briefly. MAX_INDUCTION_DAYS + # separately controls the configured re-induction deadline. + completed_at = models.DateTimeField(null=True, blank=True) + checked_at = models.DateTimeField(null=True, blank=True) + score = models.PositiveSmallIntegerField(null=True, blank=True) + error_code = models.CharField(max_length=64, blank=True) + # The DocuSeal submission issued for this requirement. Keeping it on the + # requirement row prevents a template change from losing its audit trail. + external_reference = models.CharField(max_length=255, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["profile", "provider", "requirement_key"], + name="unique_profile_induction_requirement", + ) + ] + + class Profile(ExportModelOperationsMixin("profile"), models.Model): STATES = ( ("noob", "Needs Induction"), @@ -1014,7 +1058,7 @@ def signup_stage(self): return "awaiting_payment" return "needs_requirements" - def get_basic_profile(self): + def get_basic_profile(self, induction=None): """ Returns a user's profile with a basic amount of info. :return: {} @@ -1055,6 +1099,7 @@ def get_basic_profile(self): if self.last_induction else None ), + "induction": induction or self.get_induction_status(), "termsAcceptedAt": ( self.terms_accepted_at.strftime("%m/%d/%Y, %H:%M:%S") if self.terms_accepted_at @@ -1143,8 +1188,14 @@ def get_access_permissions(self, ignore_user_state=False): return {"doors": doors, "interlocks": interlocks} - def can_signup(self): - """Checks if a member can signup. Returns {"success": True/False, "reasons": [String]}""" + def get_induction_status(self, states=None): + """Return the local status of every currently enabled induction provider.""" + from services.induction import get_status + + return get_status(self, states=states) + + def can_signup(self, induction=None): + """Checks whether the member meets every currently configured signup gate.""" required_steps = [] try: @@ -1161,35 +1212,23 @@ def can_signup(self): ): required_steps.append("subscription") - # First-time induction is always required when an induction - # provider is enabled. MAX_INDUCTION_DAYS only controls *re*- - # induction: 0 disables the recurring requirement but does not - # let first-timers skip. - induction_enabled = ( - config.CANVAS_INDUCTION_ENABLED - or config.MOODLE_INDUCTION_ENABLED - or config.ENABLE_DOCUSEAL_INTEGRATION - ) - last_inducted = self.last_induction - - if induction_enabled and last_inducted is None: + # Every enabled provider has an independent, versioned completion + # record. Removing a provider from configuration excludes it + # immediately; enabling/changing one creates a new pending requirement. + induction = induction or self.get_induction_status() + if not induction["complete"]: required_steps.append("induction") - elif induction_enabled and config.MAX_INDUCTION_DAYS > 0: - furthest_previous_date = timezone.now() - timedelta( - days=config.MAX_INDUCTION_DAYS - ) - if last_inducted < furthest_previous_date: - required_steps.append("induction") # check if they have an RFID card assigned (only if required by config) if config.REQUIRE_ACCESS_CARD and not self.rfid: required_steps.append("accessCard") - if len(required_steps): - return {"success": False, "requiredSteps": required_steps} - - else: - return {"success": True, "requiredSteps": []} + result = { + "success": not required_steps, + "requiredSteps": required_steps, + "requirements": {"induction": induction}, + } + return result def save(self, *args, **kwargs): """On save, update timestamps""" diff --git a/memberportal/services/docuseal.py b/memberportal/services/docuseal.py index 5e88460d..cfc63518 100644 --- a/memberportal/services/docuseal.py +++ b/memberportal/services/docuseal.py @@ -102,16 +102,15 @@ def create_submission_for_subscription(profile): profile.save() -def get_docuseal_submission(profile): - if profile.memberdoc_id is None: +def get_docuseal_submission(profile, submission_id=None): + submission_id = submission_id or profile.memberdoc_id + if submission_id is None: return None - logger.debug( - "Requesting {}".format("/api/submissions/" + str(profile.memberdoc_id)) - ) + logger.debug("Requesting {}".format("/api/submissions/" + str(submission_id))) try: response = requests.get( - url=config.DOCUSEAL_URL + "/api/submissions/" + str(profile.memberdoc_id), + url=config.DOCUSEAL_URL + "/api/submissions/" + str(submission_id), headers={"X-Auth-Token": config.DOCUSEAL_API_KEY}, ) logger.debug("Got response:\n{}".format(response.json())) diff --git a/memberportal/services/induction.py b/memberportal/services/induction.py new file mode 100644 index 00000000..a6ae7ea0 --- /dev/null +++ b/memberportal/services/induction.py @@ -0,0 +1,271 @@ +"""Provider-aware induction requirement evaluation and verification.""" + +from dataclasses import dataclass +from datetime import timedelta + +from constance import config +from django.db import transaction +from django.utils import timezone +from sentry_sdk import capture_exception + +from services.canvas import Canvas +from services.docuseal import ( + create_submission_for_subscription, + get_docuseal_submission, + submission_is_complete, + submission_is_declined, +) +from services.moodle_integration import ( + moodle_get_course_activity_completion_status, + moodle_get_user_from_email, +) + + +@dataclass(frozen=True) +class InductionRequirement: + provider: str + requirement_key: str + action_url: str + + +def enabled_requirements(): + """Return every induction provider enabled by the current configuration.""" + requirements = [] + if config.CANVAS_INDUCTION_ENABLED: + requirements.append( + InductionRequirement( + provider="canvas", + requirement_key=( + f"canvas:course:{config.CANVAS_INDUCTION_COURSE_ID}:" + f"minimum-score:{config.MIN_INDUCTION_SCORE}" + ), + action_url=config.INDUCTION_ENROL_LINK, + ) + ) + if config.MOODLE_INDUCTION_ENABLED: + requirements.append( + InductionRequirement( + provider="moodle", + requirement_key=( + f"moodle:course:{config.MOODLE_INDUCTION_COURSE_ID}:" + f"minimum-score:{config.MIN_INDUCTION_SCORE}" + ), + action_url=config.INDUCTION_ENROL_LINK, + ) + ) + if config.ENABLE_DOCUSEAL_INTEGRATION: + requirements.append( + InductionRequirement( + provider="docuseal", + requirement_key=f"docuseal:template:{config.DOCUSEAL_TEMPLATE_ID}", + action_url="", + ) + ) + return requirements + + +def _is_current_completion(state, now): + if not (state and state.status == "complete" and state.completed_at): + return False + if config.MAX_INDUCTION_DAYS <= 0: + return True + return state.completed_at >= now - timedelta(days=config.MAX_INDUCTION_DAYS) + + +def get_status(profile, states=None): + """Return local, authorization-safe status for each enabled provider.""" + now = timezone.now() + if states is None: + prefetched = getattr(profile, "_prefetched_objects_cache", {}).get( + "induction_provider_states" + ) + states = ( + prefetched + if prefetched is not None + else list(profile.induction_provider_states.all()) + ) + state_by_requirement = { + (state.provider, state.requirement_key): state for state in states + } + providers = [] + + for requirement in enabled_requirements(): + state = state_by_requirement.get( + (requirement.provider, requirement.requirement_key) + ) + is_complete = _is_current_completion(state, now) + status = "complete" if is_complete else (state.status if state else "pending") + error_code = "" if is_complete or not state else state.error_code + if state and state.status == "complete" and not is_complete: + status = "pending" + error_code = "reverificationRequired" + + action_url = ( + profile.memberdoc_url + if requirement.provider == "docuseal" + else requirement.action_url + ) + providers.append( + { + "provider": requirement.provider, + "status": status, + "complete": is_complete, + "score": state.score if state else None, + "errorCode": error_code, + "actionUrl": action_url, + "completedAt": state.completed_at if is_complete else None, + "checkedAt": state.checked_at if state else None, + } + ) + + return { + "complete": all(item["complete"] for item in providers), + "providers": providers, + } + + +def _passes_score(score): + return score is not None and score >= config.MIN_INDUCTION_SCORE + + +def _verify_canvas(profile): + try: + score = Canvas().get_student_score_for_course( + config.CANVAS_INDUCTION_COURSE_ID, profile.user.email + ) + score = 0 if score is None else int(score) + if _passes_score(score) or config.MIN_INDUCTION_SCORE == 0: + return "complete", score, "" + return "pending", score, "canvasPending" + except Exception as error: + capture_exception(error) + return "unavailable", None, "canvasUnavailable" + + +def _verify_moodle(profile): + try: + moodle_user = moodle_get_user_from_email(profile.user.email) + activities = moodle_get_course_activity_completion_status( + config.MOODLE_INDUCTION_COURSE_ID, moodle_user["id"] + ) + score = activities["percentage_completed"] + if _passes_score(score) or config.MIN_INDUCTION_SCORE == 0: + return "complete", score, "" + return "pending", score, "moodlePending" + except RuntimeError: + # The Moodle adapter uses RuntimeError for no/ambiguous account matches. + return "pending", None, "noMoodleAccount" + except Exception as error: + capture_exception(error) + return "unavailable", None, "moodleUnavailable" + + +def _reserve_docuseal_submission(profile_id, requirement): + """Return one durable DocuSeal submission reservation for a requirement. + + The profile lock intentionally covers submission creation. It prevents two + browser tabs from issuing duplicate agreements before either can persist + the external submission id for this requirement. + """ + from profile.models import InductionProviderState, Profile + + with transaction.atomic(): + profile = Profile.objects.select_for_update().get(pk=profile_id) + state, _ = InductionProviderState.objects.get_or_create( + profile=profile, + provider="docuseal", + requirement_key=requirement.requirement_key, + defaults={"status": "pending"}, + ) + if state.external_reference: + return profile, state + + prior_requirement_exists = ( + InductionProviderState.objects.filter(profile=profile, provider="docuseal") + .exclude(requirement_key=requirement.requirement_key) + .exists() + ) + if profile.memberdoc_id and not prior_requirement_exists: + # A submission created before provider state was introduced belongs + # to the first requirement seen after rollout. + state.external_reference = str(profile.memberdoc_id) + else: + create_submission_for_subscription(profile) + if not profile.memberdoc_id: + raise RuntimeError("DocuSeal did not return a submission id") + state.external_reference = str(profile.memberdoc_id) + state.save(update_fields=["external_reference"]) + return profile, state + + +def _verify_docuseal(profile, state): + if not state.external_reference: + return "pending", None, "docusealPending" + try: + submission = get_docuseal_submission(profile, state.external_reference) + if submission_is_complete(submission): + return "complete", None, "" + if submission_is_declined(submission): + return "declined", None, "docusealDeclined" + return "pending", None, "docusealPending" + except Exception as error: + capture_exception(error) + return "unavailable", None, "docusealUnavailable" + + +def _verify(profile, provider): + if provider == "canvas": + return _verify_canvas(profile) + if provider == "moodle": + return _verify_moodle(profile) + raise ValueError(f"Unsupported induction provider: {provider}") + + +def _save_verification(state, provider_status, score, error_code, now): + state.status = provider_status + state.checked_at = now + state.score = score + state.error_code = error_code + state.completed_at = now if provider_status == "complete" else None + state.save( + update_fields=["status", "checked_at", "score", "error_code", "completed_at"] + ) + + +def refresh(profile): + """Verify and persist the state of every provider enabled right now.""" + from profile.models import InductionProviderState, Profile + + now = timezone.now() + for requirement in enabled_requirements(): + if requirement.provider == "docuseal": + try: + verification_profile, state = _reserve_docuseal_submission( + profile.pk, requirement + ) + provider_status, score, error_code = _verify_docuseal( + verification_profile, state + ) + except Exception as error: + capture_exception(error) + state, _ = InductionProviderState.objects.get_or_create( + profile_id=profile.pk, + provider="docuseal", + requirement_key=requirement.requirement_key, + defaults={"external_reference": ""}, + ) + provider_status, score, error_code = ( + "unavailable", + None, + "docusealUnavailable", + ) + else: + provider_status, score, error_code = _verify(profile, requirement.provider) + state, _ = InductionProviderState.objects.get_or_create( + profile_id=profile.pk, + provider=requirement.provider, + requirement_key=requirement.requirement_key, + ) + _save_verification(state, provider_status, score, error_code, now) + + return get_status(Profile.objects.get(pk=profile.pk)) diff --git a/src-frontend/src/components/AdminTools/ManageMember/MemberProfileTab.vue b/src-frontend/src/components/AdminTools/ManageMember/MemberProfileTab.vue index b4fec0c3..e3b4f5ca 100644 --- a/src-frontend/src/components/AdminTools/ManageMember/MemberProfileTab.vue +++ b/src-frontend/src/components/AdminTools/ManageMember/MemberProfileTab.vue @@ -129,7 +129,7 @@ -
Complete
+
Complete
Incomplete
@@ -959,6 +959,13 @@ export default defineComponent({ selectedMember(): MemberProfile { return this.member as MemberProfile; }, + docusealComplete(): boolean { + return Boolean( + this.selectedMember.induction?.providers.find( + (provider) => provider.provider === 'docuseal' + )?.complete + ); + }, // A settled non-member: not active and without a live subscription. // Button 2 reads "Make Member" for these; the Lock button is enabled. isSettledNonMember(): boolean { diff --git a/src-frontend/src/components/Billing/SignupRequiredSteps.vue b/src-frontend/src/components/Billing/SignupRequiredSteps.vue index 9a3fdd5a..6f24c168 100644 --- a/src-frontend/src/components/Billing/SignupRequiredSteps.vue +++ b/src-frontend/src/components/Billing/SignupRequiredSteps.vue @@ -58,64 +58,54 @@
-

- {{ $t('signup.completeInductionDescription') }} -

- -

- - {{ $t('signup.canvasEmailWarning', { email: profile.email }) }} - -

+

{{ $t('signup.completeInductionDescription') }}

- + + + - +
+

+ {{ $t('signup.waitingCompletion') }}
+ {{ $t('progress', { percent: inductionScore }) }} +

+ +
+ +
+

{{ $t('signup.completedInduction') }}

+ +
@@ -311,6 +301,14 @@ export default defineComponent({ signupErrorItems: [], awaitingPayment: false, inductionScore: 0, + inductionProviders: [] as Array<{ + provider: string; + status: string; + complete: boolean; + score: number | null; + errorCode: string; + actionUrl: string; + }>, acceptedFlags: new Array(cards.length).fill(false) as boolean[], termsSubmitting: false, termsAccepted: false, @@ -402,13 +400,38 @@ export default defineComponent({ } }, async updateInductionStatus() { - let result = await api.post('/api/billing/check-induction/'); - this.inductionComplete = result.data.success; - this.inductionScore = Math.floor(result.data.score); + try { + const result = await api.post('/api/billing/check-induction/'); + this.inductionComplete = result.data.success; + this.inductionScore = Math.floor(result.data.score || 0); + this.inductionProviders = result.data.induction?.providers || []; - if (this.inductionComplete || result.data.notRequired) { - this.inductionCompleted(); + if (this.inductionComplete || result.data.notRequired) { + this.inductionCompleted(); + } + } catch { + this.inductionComplete = false; + } + }, + providerLabel(provider: string) { + return this.$t(`signup.inductionProvider.${provider}`); + }, + providerStatus(provider: { + status: string; + complete: boolean; + errorCode: string; + score: number | null; + }) { + if (provider.complete) { + return this.$t('signup.inductionProviderComplete'); + } + if (provider.errorCode) { + return this.$t(`signup.${provider.errorCode}`); + } + if (provider.score !== null) { + return this.$t('progress', { percent: provider.score }); } + return this.$t('signup.inductionProviderPending'); }, inductionCompleted() { // Guard against a late poll firing after can-signup already diff --git a/src-frontend/src/i18n/en-AU/index.ts b/src-frontend/src/i18n/en-AU/index.ts index 8202e165..96e9c1f8 100644 --- a/src-frontend/src/i18n/en-AU/index.ts +++ b/src-frontend/src/i18n/en-AU/index.ts @@ -687,6 +687,23 @@ export default { canvasEmailWarning: "Please use the same email address you used during signup ({email}) or your progress won't sync. This is a limitation of the Canvas platform.", waitingCompletion: 'Waiting for completion...', + inductionProvider: { + canvas: 'Canvas induction', + moodle: 'Moodle induction', + docuseal: 'Membership agreement', + }, + inductionProviderComplete: 'Completed', + inductionProviderPending: 'Waiting for completion...', + canvasPending: 'Complete the configured Canvas course to continue.', + canvasUnavailable: + 'We could not reach Canvas to check your induction. Please try again shortly.', + moodlePending: 'Complete the configured Moodle course to continue.', + docusealPending: 'Sign the membership agreement to continue.', + docusealDeclined: + 'The membership agreement was declined. Contact an administrator to continue.', + docusealUnavailable: + 'We could not reach DocuSeal to check your agreement. Please try again shortly.', + reverificationRequired: 'Your induction needs to be checked again.', accessCard: 'Access Card', accessCardNumber: 'Access Card Number', assignAccessCard: 'Access Card', diff --git a/src-frontend/src/i18n/sv-SE/index.ts b/src-frontend/src/i18n/sv-SE/index.ts index 35ca679a..d0445c1d 100644 --- a/src-frontend/src/i18n/sv-SE/index.ts +++ b/src-frontend/src/i18n/sv-SE/index.ts @@ -666,6 +666,23 @@ export default { canvasEmailWarning: 'Använd samma email-adress som du hade vid registreringen ({email}) för att dina framsteg ska synkroniseras. Detta är en begränsning i Canvas-plattformen.', waitingCompletion: 'Väntar på färdigställande...', + inductionProvider: { + canvas: 'Canvas-introduktion', + moodle: 'Moodle-introduktion', + docuseal: 'Medlemsavtal', + }, + inductionProviderComplete: 'Genomförd', + inductionProviderPending: 'Väntar på färdigställande...', + canvasPending: 'Slutför den konfigurerade Canvas-kursen för att fortsätta.', + canvasUnavailable: + 'Vi kunde inte nå Canvas för att kontrollera din introduktion. Försök igen snart.', + moodlePending: 'Slutför den konfigurerade Moodle-kursen för att fortsätta.', + docusealPending: 'Signera medlemsavtalet för att fortsätta.', + docusealDeclined: + 'Medlemsavtalet avvisades. Kontakta en administratör för att fortsätta.', + docusealUnavailable: + 'Vi kunde inte nå DocuSeal för att kontrollera ditt avtal. Försök igen snart.', + reverificationRequired: 'Din introduktion måste kontrolleras igen.', accessCard: 'Nyckelkort', accessCardNumber: 'Nummer Nyckelkort', assignAccessCard: 'Nyckelkort', diff --git a/src-frontend/src/pages/Dashboard.vue b/src-frontend/src/pages/Dashboard.vue index 7d4a10a5..649769a0 100644 --- a/src-frontend/src/pages/Dashboard.vue +++ b/src-frontend/src/pages/Dashboard.vue @@ -3,11 +3,7 @@
Date: Wed, 16 Sep 2026 14:05:27 -0700 Subject: [PATCH 2/5] Pre-commit lint --- .../components/AdminTools/ManageMember/MemberProfileTab.vue | 4 ++-- src-frontend/src/types/member.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-frontend/src/components/AdminTools/ManageMember/MemberProfileTab.vue b/src-frontend/src/components/AdminTools/ManageMember/MemberProfileTab.vue index e3b4f5ca..79cfc6dc 100644 --- a/src-frontend/src/components/AdminTools/ManageMember/MemberProfileTab.vue +++ b/src-frontend/src/components/AdminTools/ManageMember/MemberProfileTab.vue @@ -962,8 +962,8 @@ export default defineComponent({ docusealComplete(): boolean { return Boolean( this.selectedMember.induction?.providers.find( - (provider) => provider.provider === 'docuseal' - )?.complete + (provider) => provider.provider === 'docuseal', + )?.complete, ); }, // A settled non-member: not active and without a live subscription. diff --git a/src-frontend/src/types/member.ts b/src-frontend/src/types/member.ts index b7baeffa..751946a3 100644 --- a/src-frontend/src/types/member.ts +++ b/src-frontend/src/types/member.ts @@ -49,7 +49,7 @@ export const MemberProfileSchema = z.object({ actionUrl: z.string(), completedAt: z.string().nullable(), checkedAt: z.string().nullable(), - }) + }), ), }) .optional(), From fef564e860349251abbca09b03ad985f6a5fdf02 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:27:26 -0700 Subject: [PATCH 3/5] feat(induction): Refactor induction state migration, add tests for new approach - Renumber migration import to merge with dev - Add external_reference field provider-specific identifiers - Create legacy provider type to preserve induction timestamps - Add test coverage for induction provider and migration --- .../0029_induction_provider_state.py | 67 ----- ...2_induction_provider_external_reference.py | 65 +++++ ...3_alter_inductionproviderstate_provider.py | 26 ++ memberportal/profile/models.py | 1 + memberportal/profile/tests/test_induction.py | 240 ++++++++++++++++++ .../test_migrate_induction_provider_state.py | 87 +++++++ memberportal/services/induction.py | 13 + 7 files changed, 432 insertions(+), 67 deletions(-) create mode 100644 memberportal/profile/migrations/0032_induction_provider_external_reference.py create mode 100644 memberportal/profile/migrations/0033_alter_inductionproviderstate_provider.py create mode 100644 memberportal/profile/tests/test_induction.py create mode 100644 memberportal/profile/tests/test_migrate_induction_provider_state.py diff --git a/memberportal/profile/migrations/0029_induction_provider_state.py b/memberportal/profile/migrations/0029_induction_provider_state.py index e79f9bfe..8d550287 100644 --- a/memberportal/profile/migrations/0029_induction_provider_state.py +++ b/memberportal/profile/migrations/0029_induction_provider_state.py @@ -1,68 +1,5 @@ -from datetime import timedelta - from django.db import migrations, models import django.db.models.deletion -from django.utils import timezone - - -def backfill_valid_legacy_induction(apps, schema_editor): - """Preserve completion proven by the former aggregate induction field.""" - from constance import config - - Profile = apps.get_model("profile", "Profile") - InductionProviderState = apps.get_model("profile", "InductionProviderState") - now = timezone.now() - - providers = [] - # Match the old provider-selection behavior: Moodle overrode Canvas, and - # DocuSeal was an additional requirement when enabled. - if config.MOODLE_INDUCTION_ENABLED: - providers.append( - ( - "moodle", - f"moodle:course:{config.MOODLE_INDUCTION_COURSE_ID}:" - f"minimum-score:{config.MIN_INDUCTION_SCORE}", - ) - ) - elif config.CANVAS_INDUCTION_ENABLED: - providers.append( - ( - "canvas", - f"canvas:course:{config.CANVAS_INDUCTION_COURSE_ID}:" - f"minimum-score:{config.MIN_INDUCTION_SCORE}", - ) - ) - if config.ENABLE_DOCUSEAL_INTEGRATION: - providers.append( - ("docuseal", f"docuseal:template:{config.DOCUSEAL_TEMPLATE_ID}") - ) - - for profile in Profile.objects.exclude(last_induction__isnull=True): - if config.MAX_INDUCTION_DAYS > 0 and profile.last_induction < now - timedelta( - days=config.MAX_INDUCTION_DAYS - ): - continue - for provider, requirement_key in providers: - # `last_induction` can prove the legacy LMS path, but it never - # proves that a DocuSeal agreement was signed. Preserve an - # existing DocuSeal submission reference as pending so refresh() - # verifies it; otherwise it will issue a new agreement. - is_docuseal = provider == "docuseal" - InductionProviderState.objects.get_or_create( - profile_id=profile.pk, - provider=provider, - requirement_key=requirement_key, - defaults={ - "status": "pending" if is_docuseal else "complete", - "completed_at": None if is_docuseal else profile.last_induction, - "checked_at": None if is_docuseal else profile.last_induction, - "external_reference": ( - str(profile.memberdoc_id) - if is_docuseal and profile.memberdoc_id - else "" - ), - }, - ) class Migration(migrations.Migration): @@ -114,7 +51,6 @@ class Migration(migrations.Migration): ("checked_at", models.DateTimeField(blank=True, null=True)), ("score", models.PositiveSmallIntegerField(blank=True, null=True)), ("error_code", models.CharField(blank=True, max_length=64)), - ("external_reference", models.CharField(blank=True, max_length=255)), ( "profile", models.ForeignKey( @@ -132,7 +68,4 @@ class Migration(migrations.Migration): name="unique_profile_induction_requirement", ), ), - migrations.RunPython( - backfill_valid_legacy_induction, migrations.RunPython.noop - ), ] diff --git a/memberportal/profile/migrations/0032_induction_provider_external_reference.py b/memberportal/profile/migrations/0032_induction_provider_external_reference.py new file mode 100644 index 00000000..4f9700e3 --- /dev/null +++ b/memberportal/profile/migrations/0032_induction_provider_external_reference.py @@ -0,0 +1,65 @@ +from django.db import migrations, models +from django.utils import timezone + + +class AddFieldIfMissing(migrations.AddField): + """Advance model state while tolerating the two released 0029 schemas.""" + + def database_forwards(self, app_label, schema_editor, from_state, to_state): + to_model = to_state.apps.get_model(app_label, self.model_name) + if not self.allow_migrate_model(schema_editor.connection.alias, to_model): + return + + field = to_model._meta.get_field(self.name) + with schema_editor.connection.cursor() as cursor: + columns = { + description.name + for description in schema_editor.connection.introspection.get_table_description( + cursor, to_model._meta.db_table + ) + } + if field.column in columns: + return + + super().database_forwards(app_label, schema_editor, from_state, to_state) + + +def backfill_valid_legacy_induction(apps, schema_editor): + """Store the lossy legacy timestamp as explicit compatibility evidence.""" + Profile = apps.get_model("profile", "Profile") + InductionProviderState = apps.get_model("profile", "InductionProviderState") + + for profile in Profile.objects.exclude(last_induction__isnull=True): + # The timestamp contains no provider, course, score, or document + # provenance. Runtime code consumes it only for the LMS selected by + # the old Moodle-over-Canvas flow; it never satisfies DocuSeal. + InductionProviderState.objects.get_or_create( + profile_id=profile.pk, + provider="legacy", + requirement_key="legacy:last-induction", + defaults={ + "status": "complete", + "completed_at": profile.last_induction, + "checked_at": profile.last_induction, + "external_reference": "", + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("profile", "0031_merge_20260916_1458"), + ] + + operations = [ + AddFieldIfMissing( + model_name="inductionproviderstate", + name="external_reference", + field=models.CharField(blank=True, default="", max_length=255), + preserve_default=False, + ), + migrations.RunPython( + backfill_valid_legacy_induction, migrations.RunPython.noop + ), + ] diff --git a/memberportal/profile/migrations/0033_alter_inductionproviderstate_provider.py b/memberportal/profile/migrations/0033_alter_inductionproviderstate_provider.py new file mode 100644 index 00000000..8852295a --- /dev/null +++ b/memberportal/profile/migrations/0033_alter_inductionproviderstate_provider.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.8 on 2026-09-17 05:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("profile", "0032_induction_provider_external_reference"), + ] + + operations = [ + migrations.AlterField( + model_name="inductionproviderstate", + name="provider", + field=models.CharField( + choices=[ + ("canvas", "Canvas"), + ("moodle", "Moodle"), + ("docuseal", "DocuSeal"), + ("legacy", "Legacy aggregate induction"), + ], + max_length=20, + ), + ), + ] diff --git a/memberportal/profile/models.py b/memberportal/profile/models.py index 23ecfc1b..6d1dcf1c 100644 --- a/memberportal/profile/models.py +++ b/memberportal/profile/models.py @@ -367,6 +367,7 @@ class Provider(models.TextChoices): CANVAS = "canvas", "Canvas" MOODLE = "moodle", "Moodle" DOCUSEAL = "docuseal", "DocuSeal" + LEGACY = "legacy", "Legacy aggregate induction" class Status(models.TextChoices): PENDING = "pending", "Pending" diff --git a/memberportal/profile/tests/test_induction.py b/memberportal/profile/tests/test_induction.py new file mode 100644 index 00000000..b850f371 --- /dev/null +++ b/memberportal/profile/tests/test_induction.py @@ -0,0 +1,240 @@ +from datetime import timedelta +from unittest.mock import patch + +from constance.test.unittest import override_config +from django.test import TestCase +from django.utils import timezone + +from profile.models import InductionProviderState, Profile, User +from services import induction + + +class ProviderAwareInductionTests(TestCase): + base_config = { + "ENABLE_STRIPE_MEMBERSHIP_PAYMENTS": False, + "TERMS_ACCEPTANCE_CARDS": "[]", + "REQUIRE_ACCESS_CARD": False, + "CANVAS_INDUCTION_ENABLED": False, + "CANVAS_INDUCTION_COURSE_ID": "canvas-1", + "MOODLE_INDUCTION_ENABLED": False, + "MOODLE_INDUCTION_COURSE_ID": "moodle-1", + "ENABLE_DOCUSEAL_INTEGRATION": False, + "DOCUSEAL_TEMPLATE_ID": 1, + "MIN_INDUCTION_SCORE": 99, + "MAX_INDUCTION_DAYS": 180, + } + + def make_profile(self): + suffix = Profile.objects.count() + 1 + user = User.objects.create_user( + f"induction-{suffix}@example.test", + password="test-password", + ) + return Profile.objects.create( + user=user, + first_name="Induction", + last_name=str(suffix), + ) + + def make_state( + self, + profile, + requirement, + status="complete", + completed_at=None, + **kwargs, + ): + return InductionProviderState.objects.create( + profile=profile, + provider=requirement.provider, + requirement_key=requirement.requirement_key, + status=status, + completed_at=completed_at or timezone.now(), + checked_at=timezone.now(), + **kwargs, + ) + + def test_zero_max_induction_days_accepts_old_complete_state(self): + profile = self.make_profile() + config = { + **self.base_config, + "CANVAS_INDUCTION_ENABLED": True, + "MAX_INDUCTION_DAYS": 0, + } + + with override_config(**config): + requirement = induction.enabled_requirements()[0] + completed_at = timezone.now() - timedelta(days=3650) + self.make_state(profile, requirement, completed_at=completed_at) + + status = induction.get_status(profile) + self.assertTrue(status["complete"]) + self.assertEqual(status["providers"][0]["status"], "complete") + self.assertEqual(status["providers"][0]["completedAt"], completed_at) + self.assertTrue(profile.can_signup()["success"]) + + def test_legacy_proof_supports_later_lms_enablement_with_old_precedence(self): + profile = self.make_profile() + completed_at = timezone.now() - timedelta(days=1) + InductionProviderState.objects.create( + profile=profile, + provider="legacy", + requirement_key="legacy:last-induction", + status="complete", + completed_at=completed_at, + checked_at=completed_at, + ) + config = { + **self.base_config, + "CANVAS_INDUCTION_ENABLED": True, + "MOODLE_INDUCTION_ENABLED": True, + } + + with override_config(**config): + status = induction.get_status(profile) + providers = {item["provider"]: item for item in status["providers"]} + self.assertTrue(providers["moodle"]["complete"]) + self.assertFalse(providers["canvas"]["complete"]) + self.assertFalse(status["complete"]) + + with override_config(**{**config, "CANVAS_INDUCTION_ENABLED": False}): + self.assertTrue(profile.can_signup()["success"]) + + with override_config(**{**config, "MOODLE_INDUCTION_ENABLED": False}): + self.assertTrue(profile.can_signup()["success"]) + + def test_docuseal_never_uses_legacy_proof(self): + profile = self.make_profile() + completed_at = timezone.now() - timedelta(days=1) + InductionProviderState.objects.create( + profile=profile, + provider="legacy", + requirement_key="legacy:last-induction", + status="complete", + completed_at=completed_at, + checked_at=completed_at, + ) + + with override_config( + **{**self.base_config, "ENABLE_DOCUSEAL_INTEGRATION": True} + ): + status = induction.get_status(profile) + self.assertFalse(status["complete"]) + self.assertEqual(status["providers"][0]["provider"], "docuseal") + self.assertFalse(status["providers"][0]["complete"]) + + def test_each_enabled_provider_is_an_independent_signup_gate(self): + profile = self.make_profile() + config = { + **self.base_config, + "CANVAS_INDUCTION_ENABLED": True, + "MOODLE_INDUCTION_ENABLED": True, + } + + with override_config(**config): + requirements = { + requirement.provider: requirement + for requirement in induction.enabled_requirements() + } + self.make_state(profile, requirements["moodle"]) + self.make_state(profile, requirements["canvas"], status="pending") + + self.assertFalse(induction.get_status(profile)["complete"]) + self.assertIn("induction", profile.can_signup()["requiredSteps"]) + + with override_config(**{**config, "CANVAS_INDUCTION_ENABLED": False}): + self.assertTrue(profile.can_signup()["success"]) + + second_profile = self.make_profile() + with override_config(**config): + requirements = { + requirement.provider: requirement + for requirement in induction.enabled_requirements() + } + self.make_state(second_profile, requirements["canvas"]) + self.make_state(second_profile, requirements["moodle"], status="pending") + + with override_config(**{**config, "MOODLE_INDUCTION_ENABLED": False}): + self.assertTrue(second_profile.can_signup()["success"]) + + def test_docuseal_reservation_creates_once_and_reissues_on_template_change(self): + profile = self.make_profile() + config = { + **self.base_config, + "ENABLE_DOCUSEAL_INTEGRATION": True, + "DOCUSEAL_TEMPLATE_ID": 101, + } + issued_ids = iter((501, 502)) + + def create_submission(target): + submission_id = next(issued_ids) + target.memberdoc_id = submission_id + target.memberdoc_url = f"https://docs.example/{submission_id}" + target.save(update_fields=["memberdoc_id", "memberdoc_url"]) + + with override_config(**config): + requirement_101 = induction.enabled_requirements()[0] + with patch( + "services.induction.create_submission_for_subscription", + side_effect=create_submission, + ) as create: + _, first = induction._reserve_docuseal_submission( + profile.pk, requirement_101 + ) + _, repeated = induction._reserve_docuseal_submission( + profile.pk, requirement_101 + ) + self.assertEqual(create.call_count, 1) + self.assertEqual(first.external_reference, "501") + self.assertEqual(repeated.external_reference, "501") + + with override_config(**{**config, "DOCUSEAL_TEMPLATE_ID": 202}): + requirement_202 = induction.enabled_requirements()[0] + _, reissued = induction._reserve_docuseal_submission( + profile.pk, requirement_202 + ) + _, repeated_reissue = induction._reserve_docuseal_submission( + profile.pk, requirement_202 + ) + + self.assertEqual(create.call_count, 2) + self.assertEqual(reissued.external_reference, "502") + self.assertEqual(repeated_reissue.external_reference, "502") + + self.assertEqual( + InductionProviderState.objects.filter( + profile=profile, provider="docuseal" + ).count(), + 2, + ) + + def test_status_and_can_signup_do_not_verify_external_providers(self): + profile = self.make_profile() + config = { + **self.base_config, + "CANVAS_INDUCTION_ENABLED": True, + "MOODLE_INDUCTION_ENABLED": True, + "ENABLE_DOCUSEAL_INTEGRATION": True, + } + + with override_config(**config): + for requirement in induction.enabled_requirements(): + self.make_state(profile, requirement) + + with patch("services.induction.Canvas") as canvas, patch( + "services.induction.moodle_get_user_from_email" + ) as moodle_user, patch( + "services.induction.moodle_get_course_activity_completion_status" + ) as moodle_completion, patch( + "services.induction.create_submission_for_subscription" + ) as create_docuseal, patch( + "services.induction.get_docuseal_submission" + ) as get_docuseal: + self.assertTrue(induction.get_status(profile)["complete"]) + self.assertTrue(profile.can_signup()["success"]) + + canvas.assert_not_called() + moodle_user.assert_not_called() + moodle_completion.assert_not_called() + create_docuseal.assert_not_called() + get_docuseal.assert_not_called() diff --git a/memberportal/profile/tests/test_migrate_induction_provider_state.py b/memberportal/profile/tests/test_migrate_induction_provider_state.py new file mode 100644 index 00000000..d1ce6458 --- /dev/null +++ b/memberportal/profile/tests/test_migrate_induction_provider_state.py @@ -0,0 +1,87 @@ +import importlib +from datetime import timedelta + +from constance.test.unittest import override_config +from django.apps import apps +from django.test import TestCase +from django.utils import timezone + +from profile.models import InductionProviderState, Profile, User + +induction_migration = importlib.import_module( + "profile.migrations.0032_induction_provider_external_reference" +) + + +class InductionProviderStateMigrationTests(TestCase): + base_config = { + "CANVAS_INDUCTION_ENABLED": False, + "MOODLE_INDUCTION_ENABLED": False, + "ENABLE_DOCUSEAL_INTEGRATION": False, + "MAX_INDUCTION_DAYS": 180, + } + + def make_profile(self, last_induction=None, memberdoc_id=None): + suffix = Profile.objects.count() + 1 + user = User.objects.create_user( + f"migration-induction-{suffix}@example.test", + password="test-password", + ) + return Profile.objects.create( + user=user, + first_name="Migration", + last_name=str(suffix), + last_induction=last_induction, + memberdoc_id=memberdoc_id, + ) + + def run_backfill(self): + induction_migration.backfill_valid_legacy_induction(apps, None) + + def test_backfill_creates_legacy_proof_when_all_providers_are_disabled(self): + completed_at = timezone.now() - timedelta(days=1) + profile = self.make_profile(last_induction=completed_at) + + with override_config(**self.base_config): + self.run_backfill() + + state = InductionProviderState.objects.get(profile=profile, provider="legacy") + self.assertEqual(state.requirement_key, "legacy:last-induction") + self.assertEqual(state.status, "complete") + self.assertEqual(state.completed_at, completed_at) + self.assertEqual(state.checked_at, completed_at) + self.assertEqual(state.external_reference, "") + self.assertFalse( + InductionProviderState.objects.filter( + profile=profile, provider__in=["canvas", "moodle", "docuseal"] + ).exists() + ) + + def test_backfill_creates_legacy_proof_without_docuseal_completion(self): + completed_at = timezone.now() - timedelta(days=1) + profile = self.make_profile(last_induction=completed_at, memberdoc_id=712) + + with override_config( + **{**self.base_config, "ENABLE_DOCUSEAL_INTEGRATION": True} + ): + self.run_backfill() + + state = InductionProviderState.objects.get(profile=profile, provider="legacy") + self.assertEqual(state.status, "complete") + self.assertEqual(state.completed_at, completed_at) + self.assertEqual(state.external_reference, "") + self.assertFalse( + InductionProviderState.objects.filter( + profile=profile, provider="docuseal" + ).exists() + ) + + def test_backfill_keeps_expired_proof_for_runtime_retention_evaluation(self): + completed_at = timezone.now() - timedelta(days=365) + profile = self.make_profile(last_induction=completed_at) + + with override_config(**self.base_config): + self.run_backfill() + + state = InductionProviderState.objects.get(profile=profile, provider="legacy") + self.assertEqual(state.completed_at, completed_at) diff --git a/memberportal/services/induction.py b/memberportal/services/induction.py index a6ae7ea0..2642f541 100644 --- a/memberportal/services/induction.py +++ b/memberportal/services/induction.py @@ -87,12 +87,25 @@ def get_status(profile, states=None): state_by_requirement = { (state.provider, state.requirement_key): state for state in states } + legacy_state = state_by_requirement.get(("legacy", "legacy:last-induction")) + moodle_enabled = any( + requirement.provider == "moodle" for requirement in enabled_requirements() + ) providers = [] for requirement in enabled_requirements(): state = state_by_requirement.get( (requirement.provider, requirement.requirement_key) ) + # The former aggregate timestamp had no course or provider identity. + # It can satisfy the provider the old flow would have checked: Moodle + # when enabled, otherwise Canvas. It never proves Docuseal signing and + # never overrides a concrete requirement-local state. + if state is None and legacy_state: + if requirement.provider == "moodle" or ( + requirement.provider == "canvas" and not moodle_enabled + ): + state = legacy_state is_complete = _is_current_completion(state, now) status = "complete" if is_complete else (state.status if state else "pending") error_code = "" if is_complete or not state else state.error_code From b51f0f7227171b08f89c8ad1fdc949dc51f2f4fc Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:45:19 -0700 Subject: [PATCH 4/5] ci(workflows): Add Django backend tests workflow --- .github/workflows/backend-tests.yml | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/backend-tests.yml diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml new file mode 100644 index 00000000..25421c87 --- /dev/null +++ b/.github/workflows/backend-tests.yml @@ -0,0 +1,42 @@ +name: Django Backend Tests + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + - dev + - staging + +permissions: {} + +jobs: + backend-tests: + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: memberportal + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Set up uv + uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0 + + - name: Install backend dependencies + run: uv sync --frozen + + - name: Run backend tests + run: uv run python manage.py test + env: + MM_ENV: Development + MM_DB_LOCATION: ${{ runner.temp }}/membermatters.sqlite3 + MM_LOG_LOCATION: ${{ runner.temp }}/django.log From 6c2597d084a1864e246bb6ec1c4aa1777d06c4b5 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:19:25 -0700 Subject: [PATCH 5/5] ci(backend): Add coverage tracking; induction notice detail - Submit coverage to Coveralls - Fix induction flash showing "Item 1, Item 2..." --- .github/workflows/backend-tests.yml | 12 +++++++- memberportal/pyproject.toml | 1 + memberportal/uv.lock | 45 +++++++++++++++++++++++++++- src-frontend/src/pages/Dashboard.vue | 13 ++++++-- src-frontend/src/pages/Profile.vue | 13 ++++++-- 5 files changed, 76 insertions(+), 8 deletions(-) diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 25421c87..ace553cd 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -35,8 +35,18 @@ jobs: run: uv sync --frozen - name: Run backend tests - run: uv run python manage.py test + run: | + uv run coverage run --source=. manage.py test + uv run coverage xml -o coverage.xml env: MM_ENV: Development MM_DB_LOCATION: ${{ runner.temp }}/membermatters.sqlite3 MM_LOG_LOCATION: ${{ runner.temp }}/django.log + + - name: Upload backend coverage to Coveralls + uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8 + with: + file: memberportal/coverage.xml + format: cobertura + base-path: memberportal + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/memberportal/pyproject.toml b/memberportal/pyproject.toml index 3433da80..081998b7 100644 --- a/memberportal/pyproject.toml +++ b/memberportal/pyproject.toml @@ -61,6 +61,7 @@ dependencies = [ [dependency-groups] dev = [ + "coverage==7.16.1", "pre-commit==4.6.2", ] diff --git a/memberportal/uv.lock b/memberportal/uv.lock index 932674f4..30e3bf80 100644 --- a/memberportal/uv.lock +++ b/memberportal/uv.lock @@ -462,6 +462,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" }, ] +[[package]] +name = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" }, + { url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" }, + { url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" }, + { url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" }, + { url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" }, + { url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" }, + { url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" }, + { url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + [[package]] name = "cron-descriptor" version = "1.4.5" @@ -981,6 +1020,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "coverage" }, { name = "pre-commit" }, ] @@ -1027,7 +1067,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pre-commit", specifier = "==4.6.2" }] +dev = [ + { name = "coverage", specifier = "==7.16.1" }, + { name = "pre-commit", specifier = "==4.6.2" }, +] [[package]] name = "msgpack" diff --git a/src-frontend/src/pages/Dashboard.vue b/src-frontend/src/pages/Dashboard.vue index 649769a0..bf878dc0 100644 --- a/src-frontend/src/pages/Dashboard.vue +++ b/src-frontend/src/pages/Dashboard.vue @@ -13,9 +13,16 @@
{{ $t('access.inductionIncompleteTasks') }} -
  • - Task {{ index + 1 }} -
  • +
    {{ $t('access.inductionIncompleteNoTasks') }}
    diff --git a/src-frontend/src/pages/Profile.vue b/src-frontend/src/pages/Profile.vue index b7bac348..e330f63d 100644 --- a/src-frontend/src/pages/Profile.vue +++ b/src-frontend/src/pages/Profile.vue @@ -12,9 +12,16 @@
    {{ $t('access.inductionIncompleteTasks') }} -
  • - Task {{ index + 1 }} -
  • +
    {{ $t('access.inductionIncompleteNoTasks') }}