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
6 changes: 6 additions & 0 deletions config/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ def setup_periodic_tasks(sender, **kwargs):
app.signature("asciidoctor_sandbox.tasks.cleanup_old_sandbox_documents"),
)

# Purge expired pending mailing list subscriptions. Executes daily at 3:45 AM.
sender.add_periodic_task(
crontab(hour=3, minute=45),
app.signature("mailing_list.tasks.purge_expired_pending_subscriptions"),
)

# Sync per-post page views from Plausible. Executes daily at 6:00 AM.
sender.add_periodic_task(
crontab(hour=6, minute=0),
Expand Down
2 changes: 2 additions & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.core.exceptions import ImproperlyConfigured
from pythonjsonlogger import jsonlogger


env = environs.Env()

READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False)
Expand Down Expand Up @@ -381,6 +382,7 @@

# Mailman API credentials
MAILMAN_REST_API_URL = env("MAILMAN_REST_API_URL", default="http://localhost:8001")
MAILMAN_REST_API_VERSION = env("MAILMAN_REST_API_VERSION", default="3.1")
MAILMAN_REST_API_USER = env("MAILMAN_REST_API_USER", default="restadmin")
MAILMAN_REST_API_PASS = env("MAILMAN_REST_API_PASS", default="restpass")

Expand Down
1 change: 1 addition & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@
LibraryDetail.as_view(redirect_to_docs=True),
name="library-docs-redirect",
),
path("mailing-list/", include("mailing_list.urls")),
path("news/", include("news.urls")),
path("v3/news/add/", V3AllTypesCreateView.as_view(), name="v3-news-create"),
path(
Expand Down
41 changes: 39 additions & 2 deletions core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,15 @@
set_selected_boost_version,
modernize_boost_slug,
)
from mailing_list import constants
from versions.models import Version, docs_path_to_boost_name

from . import context_processors
from .mixins import V3Mixin, iter_v3_views
from mailing_list.mixins import (
MailingListCardMixin,
get_subscription_state_count_and_email,
)
from .asciidoc import convert_adoc_to_html
from .boostrenderer import (
convert_img_paths,
Expand Down Expand Up @@ -129,7 +134,7 @@ class BoostDevelopmentView(CalendarView):
template_name = "boost_development.html"


class CommunityView(V3Mixin, TemplateView):
class CommunityView(MailingListCardMixin, V3Mixin, TemplateView):
template_name = "community.html"
v3_template_name = "v3/community.html"

Expand Down Expand Up @@ -498,7 +503,7 @@ def get_v3_context_data(self, **kwargs):
return {"last_updated": "2024-02-17"}


class LearnPageView(V3Mixin, TemplateView):
class LearnPageView(MailingListCardMixin, V3Mixin, TemplateView):
v3_template_name = "v3/learn_page.html"

def get_v3_context_data(self, **kwargs):
Expand Down Expand Up @@ -2237,6 +2242,38 @@ def _intro_card(library_name, authors):
)
context["demo_library_items"] = demo_library_items

# Mailing list subscribe demo
from mailing_list.models import UserMailingListSubscription

_demo_card_list_id = constants.MAILMAN_LISTS[0]
context["demo_mailman_lists"] = constants.MAILMAN_LISTS
Comment thread
herzog0 marked this conversation as resolved.
context["demo_subscribe_url"] = reverse("mailing-list-subscribe")
context["demo_quick_subscribe_url"] = reverse("mailing-list-quick-subscribe")

mailing_list_state = None

if self.request.user.is_authenticated:
mailing_list_state = get_subscription_state_count_and_email(
self.request.user, constants.MAILMAN_LISTS
)
context["demo_subscribed_lists"] = set(
UserMailingListSubscription.objects.filter(
user=self.request.user, list_id__in=constants.MAILMAN_LISTS
).values_list("list_id", flat=True)
)
else:
context["demo_subscribed_lists"] = set()

context["demo_mailing_list_card_list_id"] = _demo_card_list_id
context["demo_mailing_list_card_state"] = (
mailing_list_state.state if mailing_list_state else None
)
context["demo_subscription_count"] = (
mailing_list_state.count if mailing_list_state else 0
)
context["demo_subscribed_lists_email"] = (
mailing_list_state.email if mailing_list_state else ""
)
# V3 paths registry
v3_paths = [
{
Expand Down
72 changes: 36 additions & 36 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,43 +49,43 @@ services:
- ../website2022/:/website
stop_signal: SIGKILL

# mailman-core:
# image: maxking/mailman-core
# stop_grace_period: 5s
# ports:
# - "8001:8001" # API
# - "8024:8024" # LMTP - incoming emails
# volumes:
# - ./mailman/core:/opt/mailman/
# networks:
# - backend
# env_file:
# - .env
# depends_on:
# - db
mailman-core:
image: maxking/mailman-core
stop_grace_period: 5s
ports:
- "8001:8001" # API
- "8024:8024" # LMTP - incoming emails
volumes:
- ./mailman/core:/opt/mailman/
networks:
- backend
env_file:
- .env
depends_on:
- db

# mailman-web:
# image: maxking/mailman-web
# entrypoint: /opt/mailman-docker/compose-start.sh
# env_file:
# - .env
# environment:
# - "DOCKER_DIR=/opt/mailman-docker"
# - "PYTHON=python3"
# - "WEB_PORT=8008"
# depends_on:
# - redis
# - db
# stop_signal: SIGKILL
# ports:
# - "8008:8008" # HTTP
# - "8080:8080" # uwsgi
# volumes:
# - .:/code
# - ./mailman/web:/opt/mailman-web-data
# - ./docker:/opt/mailman-docker
# networks:
# - backend
mailman-web:
image: maxking/mailman-web
entrypoint: /opt/mailman-docker/compose-start.sh
env_file:
- .env
environment:
- "DOCKER_DIR=/opt/mailman-docker"
- "PYTHON=python3"
- "WEB_PORT=8008"
depends_on:
- redis
- db
stop_signal: SIGKILL
ports:
- "8008:8008" # HTTP
- "8080:8080" # uwsgi
volumes:
- .:/code
- ./mailman/web:/opt/mailman-web-data
- ./docker:/opt/mailman-docker
networks:
- backend

celery-worker:
build:
Expand Down
6 changes: 6 additions & 0 deletions env.template
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,9 @@ ALGOLIA_APP_ID=
ALGOLIA_ANALYTICS_API_KEY=

OPENROUTER_API_KEY=

# Used by Django inside Docker — hostname resolves within the container network
MAILMAN_REST_API_URL=http://mailman-core:8001
# Used by scripts/dev-mailman-helpers on the host — maps to the published port
MAILMAN_DEV_API_URL=http://localhost:8001
MAILMAN_LISTS=boost-users.lists.boost.org,boost-announce.lists.boost.org,boost.lists.boost.org
8 changes: 7 additions & 1 deletion libraries/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from core.constants import SLACK_URL
from core.githubhelper import GithubAPIClient
from core.mixins import V3Mixin
from mailing_list.mixins import MailingListCardMixin
from core.mock_data import SharedResources
from news.models import Entry
from versions.exceptions import BoostImportedDataException
Expand Down Expand Up @@ -454,7 +455,12 @@ def get_results_by_tier(self):

@method_decorator(csrf_exempt, name="dispatch")
class LibraryDetail(
V3Mixin, VersionAlertMixin, BoostVersionMixin, ContributorMixin, DetailView
MailingListCardMixin,
V3Mixin,
VersionAlertMixin,
BoostVersionMixin,
ContributorMixin,
DetailView,
):
"""Display a single Library in insolation"""

Expand Down
159 changes: 159 additions & 0 deletions mailing_list/client.py
Comment thread
herzog0 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import logging

import requests
from django.conf import settings

logger = logging.getLogger(__name__)


class MailmanAPIError(Exception):
pass


class MailmanClient:
"""Thin wrapper around the Mailman REST API.

Instantiate with defaults (reads from settings) or pass explicit credentials
to talk to a different Mailman instance:

client = MailmanClient()
client = MailmanClient(base_url="http://other:8001", user="u", password="p")
Comment thread
herzog0 marked this conversation as resolved.
"""

def __init__(self, base_url=None, api_version=None, user=None, password=None):
url = (base_url or settings.MAILMAN_REST_API_URL).rstrip("/")
version = api_version or settings.MAILMAN_REST_API_VERSION
self._base = (
f"{url}/api-proxy/{version}"
if not settings.LOCAL_DEVELOPMENT
else f"{url}/{version}"
)
self._credentials = (
user or settings.MAILMAN_REST_API_USER,
password or settings.MAILMAN_REST_API_PASS,
)

def subscribe(self, email: str, list_id: str) -> None:
"""POST /<version>/members — subscribe an email to a list.

All pre_* flags are True because Django owns the confirmation flow. This is
only called after the user has clicked the Django confirmation link.
"""
url = f"{self._base}/members"
payload = {
"list_id": list_id,
"subscriber": email,
"pre_verified": True,
"pre_confirmed": True,
"pre_approved": True,
}
try:
response = requests.post(
url, data=payload, auth=self._credentials, timeout=10
)
except requests.RequestException as exc:
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc

if response.status_code == 409:
# Already a member — treat as a no-op.
return
if not response.ok:
raise MailmanAPIError(
f"subscribe failed [{response.status_code}]: {response.text}"
)

def is_confirmed(self, email: str, list_id: str) -> bool:
"""Return True if the email is a confirmed (active) member of the list."""
url = f"{self._base}/lists/{list_id}/member/{email}"
try:
response = requests.get(url, auth=self._credentials, timeout=10)
except requests.RequestException as exc:
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc

if response.status_code == 404:
return False
if response.ok:
return True
raise MailmanAPIError(
f"member lookup failed [{response.status_code}]: {response.text}"
)

def _discard_pending(self, email: str, list_id: str) -> None:
"""Discard any pending (unconfirmed) subscription request for email on list_id."""
url = f"{self._base}/lists/{list_id}/requests"
try:
response = requests.get(url, auth=self._credentials, timeout=10)
except requests.RequestException as exc:
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc

if not response.ok:
raise MailmanAPIError(
f"pending requests lookup failed [{response.status_code}]: {response.text}"
)

entries = response.json().get("entries", [])
token = next(
(
e["token"]
for e in entries
if e.get("email") == email and e.get("type") == "subscription"
),
None,
)
if token is None:
return

discard_url = f"{self._base}/lists/{list_id}/requests/{token}"
try:
discard_response = requests.post(
discard_url,
data={"action": "discard"},
auth=self._credentials,
timeout=10,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
except requests.RequestException as exc:
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc

if discard_response.status_code == 404:
return
if not discard_response.ok:
raise MailmanAPIError(
f"discard pending failed [{discard_response.status_code}]"
)

def unsubscribe(self, email: str, list_id: str) -> None:
"""DELETE /<version>/members/<id> — remove a subscription."""
url = f"{self._base}/lists/{list_id}/member/{email}"
try:
response = requests.get(url, auth=self._credentials, timeout=10)
except requests.RequestException as exc:
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc

if response.status_code == 404:
# Not a confirmed member — discard any pending subscription request so
# the user can subscribe again cleanly.
self._discard_pending(email, list_id)
return
if not response.ok:
raise MailmanAPIError(
f"member lookup failed [{response.status_code}]: {response.text}"
)

member_id = response.json().get("member_id")
if not member_id:
raise MailmanAPIError("member lookup returned no member_id")

delete_url = f"{self._base}/members/{member_id}"
try:
del_response = requests.delete(
delete_url, auth=self._credentials, timeout=10
)
except requests.RequestException as exc:
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc

if del_response.status_code == 404:
return
if not del_response.ok:
raise MailmanAPIError(
f"unsubscribe failed [{del_response.status_code}]: {del_response.text}"
)
Loading
Loading