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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ jobs:
INDEXING_API_USERNAME: mitodl
MITOL_COOKIE_DOMAIN: localhost
MITOL_COOKIE_NAME: cookie_monster
UNSUBSCRIBE_SECRET_KEY: fake_unsubscribe_secret

- name: Upload coverage to CodeCov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
Expand Down
5 changes: 5 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Release Notes
=============

Version 0.75.2
--------------

- Add unsubscribe API (#3636)

Version 0.75.1 (Released July 22, 2026)
--------------

Expand Down
8 changes: 4 additions & 4 deletions drf_lint_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"channels/serializers.py:132:24:ORM002",
"channels/serializers.py:134:24:ORM002",
"channels/serializers.py:136:24:ORM002",
"profiles/serializers.py:132:31:ORM002",
"profiles/serializers.py:169:16:ORM002",
"profiles/serializers.py:391:15:ORM001",
"profiles/serializers.py:392:27:ORM001"
"profiles/serializers.py:136:31:ORM002",
"profiles/serializers.py:196:16:ORM002",
"profiles/serializers.py:418:15:ORM001",
"profiles/serializers.py:419:27:ORM001"
]
3 changes: 3 additions & 0 deletions env/backend.env
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ MITOL_COOKIE_NAME=mitlearn
MAILGUN_KEY=fake
MAILGUN_SENDER_DOMAIN=open.odl.local

# Unsubscribe token signing (dev-only placeholder)
UNSUBSCRIBE_SECRET_KEY=insecure_secret_key # pragma: allowlist-secret

DEBUG=True
DJANGO_LOG_LEVEL=INFO
LOG_LEVEL=info
Expand Down
175 changes: 175 additions & 0 deletions frontends/api/src/generated/v1/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions main/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
Factory for Users
"""

import uuid

import ulid
from django.conf import settings
from factory import (
Expand Down Expand Up @@ -32,6 +34,8 @@ class UserFactory(DjangoModelFactory):

global_id = SelfAttribute("scim_external_id")

unsubscribe_uuid = LazyFunction(uuid.uuid4)

class Meta:
model = settings.AUTH_USER_MODEL
skip_postgeneration_save = True
Expand Down
13 changes: 12 additions & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import dj_database_url
from django.core.exceptions import ImproperlyConfigured
from mitol.keycloak.settings.keycloak import * # noqa: F403
from mitol.scim.settings.scim import * # noqa: F403

from main.envs import (
Expand All @@ -35,7 +36,7 @@
from main.settings_pluggy import * # noqa: F403
from openapi.settings_spectacular import open_spectacular_settings

VERSION = "0.75.1"
VERSION = "0.75.2"

log = logging.getLogger()

Expand Down Expand Up @@ -140,6 +141,7 @@
"vector_search",
"ol_hubspot",
"mitol.scim.apps.ScimApp",
"mitol.keycloak.apps.KeycloakApp",
"health_check",
)

Expand Down Expand Up @@ -889,3 +891,12 @@ def get_all_config_keys():
CREATE_HIDDEN_OCW_LEARNING_MATERIALS = get_bool(
"CREATE_HIDDEN_OCW_LEARNING_MATERIALS", default=False
)

# separate secret keys for unsubscribe
UNSUBSCRIBE_SECRET_KEY = get_string("UNSUBSCRIBE_SECRET_KEY", None)
if not UNSUBSCRIBE_SECRET_KEY:
msg = "UNSUBSCRIBE_SECRET_KEY is not set"
raise ImproperlyConfigured(msg)
UNSUBSCRIBE_SECRET_KEY_FALLBACKS = get_list_of_str(
"UNSUBSCRIBE_SECRET_KEY_FALLBACKS", []
)
1 change: 1 addition & 0 deletions main/settings_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"MITOL_COOKIE_NAME": "cookie_monster",
"MITOL_COOKIE_DOMAIN": "od.fake.domain",
"MITOL_APP_BASE_URL": "http:localhost:8063/",
"UNSUBSCRIBE_SECRET_KEY": "fake_unsubscribe_secret_key", # pragma: allowlist-secret
}


Expand Down
1 change: 1 addition & 0 deletions main/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
[ # noqa: RUF005
re_path(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
re_path(r"^admin/", admin.site.urls),
re_path(r"", include("users.urls", namespace="users")),
re_path(r"", include("authentication.urls")),
re_path(r"", include("channels.urls")),
re_path(r"", include("profiles.urls")),
Expand Down
31 changes: 31 additions & 0 deletions openapi/specs/v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8591,6 +8591,37 @@ paths:
schema:
$ref: '#/components/schemas/LearningResourceTopic'
description: ''
/api/v1/unsubscribe/{token}/:
post:
operationId: unsubscribe_create
description: Handle email unsubscribe requests via signed token.
summary: One-click unsubscribe (RFC 8058)
parameters:
- in: path
name: token
schema:
type: string
pattern: ^[^/]+$
required: true
tags:
- unsubscribe
responses:
'200':
content:
application/json:
schema:
type: object
properties: {}
description: Successfully unsubscribed
'400':
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: Invalid or expired token
/api/v1/upload-media/:
post:
operationId: media_upload
Expand Down
22 changes: 22 additions & 0 deletions profiles/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Profile API"""

import logging

import tldextract
from mitol.keycloak import api as keycloak_api
from mitol.keycloak.data_models import UserAttributes

from profiles.models import (
PERSONAL_SITE_TYPE,
Expand All @@ -9,6 +13,8 @@
filter_profile_props,
)

log = logging.getLogger(__name__)


def ensure_profile(user, profile_data=None):
"""
Expand Down Expand Up @@ -44,3 +50,19 @@ def get_site_type_from_url(url):
if domain in SITE_TYPE_OPTIONS:
return domain
return PERSONAL_SITE_TYPE


def sync_email_optin_to_keycloak(user, *, email_optin):
"""Push the user's email opt-in preference to their Keycloak account"""
if not keycloak_api.is_admin_client_configured():
return

if not user.global_id:
log.warning(
"Cannot sync email_optin to Keycloak for user %s: no global_id", user.id
)
return

keycloak_api.update_user(
user.global_id, attributes=UserAttributes(email_optin=1 if email_optin else 0)
)
Loading
Loading