Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/backend-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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 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 }}
12 changes: 9 additions & 3 deletions memberportal/api_admin_tools/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,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:
Expand Down Expand Up @@ -97,13 +99,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)
Expand Down
104 changes: 17 additions & 87 deletions memberportal/api_billing/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -641,89 +636,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:
Expand Down
39 changes: 13 additions & 26 deletions memberportal/api_general/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
6 changes: 3 additions & 3 deletions memberportal/membermatters/constance_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions memberportal/profile/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)
Expand Down
71 changes: 71 additions & 0 deletions memberportal/profile/migrations/0029_induction_provider_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from django.db import migrations, models
import django.db.models.deletion


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)),
(
"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",
),
),
]
13 changes: 13 additions & 0 deletions memberportal/profile/migrations/0031_merge_20260916_1458.py
Original file line number Diff line number Diff line change
@@ -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 = []
Loading
Loading