Split OAuth credentials for member center and send engine
This commit is contained in:
parent
a6249e8854
commit
555462c71a
@ -0,0 +1,62 @@
|
||||
# Generated by Codex on 2026-07-07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("base", "0012_remove_newsletter_template_settings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="member_center_oauth_client_id",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Member Center OAuth client_id for usage=tenant_api.",
|
||||
max_length=255,
|
||||
verbose_name="OAuth Client ID",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="member_center_oauth_client_secret",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Member Center OAuth client_secret for usage=tenant_api.",
|
||||
verbose_name="OAuth Client Secret",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="member_center_oauth_token_path",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="/oauth/token",
|
||||
help_text="Shared Member Center OAuth token endpoint path for tenant_api and send_api clients.",
|
||||
max_length=255,
|
||||
verbose_name="OAuth Token Path",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="send_engine_oauth_client_id",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Member Center OAuth client_id for usage=send_api. Uses the Member Center OAuth Token Path.",
|
||||
max_length=255,
|
||||
verbose_name="Send Engine OAuth Client ID",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="send_engine_oauth_client_secret",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Member Center OAuth client_secret for usage=send_api. Uses the Member Center OAuth Token Path.",
|
||||
verbose_name="Send Engine OAuth Client Secret",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -208,10 +208,20 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="/oauth/token",
|
||||
help_text=_("Shared Member Center OAuth token endpoint path for tenant_api and send_api clients."),
|
||||
verbose_name=_("OAuth Token Path"),
|
||||
)
|
||||
member_center_oauth_client_id = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Client ID"))
|
||||
member_center_oauth_client_secret = models.TextField(blank=True, verbose_name=_("OAuth Client Secret"))
|
||||
member_center_oauth_client_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text=_("Member Center OAuth client_id for usage=tenant_api."),
|
||||
verbose_name=_("OAuth Client ID"),
|
||||
)
|
||||
member_center_oauth_client_secret = models.TextField(
|
||||
blank=True,
|
||||
help_text=_("Member Center OAuth client_secret for usage=tenant_api."),
|
||||
verbose_name=_("OAuth Client Secret"),
|
||||
)
|
||||
member_center_oauth_scope = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
@ -236,6 +246,17 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
||||
default="/api/send-jobs",
|
||||
verbose_name=_("Send Jobs Path"),
|
||||
)
|
||||
send_engine_oauth_client_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text=_("Member Center OAuth client_id for usage=send_api. Uses the Member Center OAuth Token Path."),
|
||||
verbose_name=_("Send Engine OAuth Client ID"),
|
||||
)
|
||||
send_engine_oauth_client_secret = models.TextField(
|
||||
blank=True,
|
||||
help_text=_("Member Center OAuth client_secret for usage=send_api. Uses the Member Center OAuth Token Path."),
|
||||
verbose_name=_("Send Engine OAuth Client Secret"),
|
||||
)
|
||||
send_engine_oauth_scope = models.CharField(max_length=255, blank=True, verbose_name=_("Send Engine OAuth Scope"))
|
||||
send_engine_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Send Engine Timeout Seconds"))
|
||||
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300, verbose_name=_("Retry Interval Seconds"))
|
||||
@ -279,8 +300,6 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
||||
"member_center_oauth_client_secret",
|
||||
widget=django_forms.PasswordInput(render_value=False),
|
||||
),
|
||||
FieldPanel("member_center_oauth_scope"),
|
||||
FieldPanel("member_center_oauth_audience"),
|
||||
FieldPanel("member_center_one_click_unsubscribe_path"),
|
||||
FieldPanel("member_center_tenant_id"),
|
||||
FieldPanel("member_center_list_id"),
|
||||
@ -292,7 +311,11 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
||||
[
|
||||
FieldPanel("send_engine_base_url"),
|
||||
FieldPanel("send_engine_send_jobs_path"),
|
||||
FieldPanel("send_engine_oauth_scope"),
|
||||
FieldPanel("send_engine_oauth_client_id"),
|
||||
FieldPanel(
|
||||
"send_engine_oauth_client_secret",
|
||||
widget=django_forms.PasswordInput(render_value=False),
|
||||
),
|
||||
FieldPanel("send_engine_timeout_seconds"),
|
||||
FieldPanel("send_engine_retry_interval_seconds"),
|
||||
FieldPanel("send_engine_retry_max_attempts"),
|
||||
@ -323,12 +346,23 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
||||
verbose_name = _("Newsletter System Settings")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
previous = None
|
||||
if self.pk:
|
||||
previous = type(self).objects.filter(pk=self.pk).only(
|
||||
"member_center_oauth_client_secret",
|
||||
"send_engine_oauth_client_secret",
|
||||
).first()
|
||||
|
||||
if self.pk and not self.member_center_oauth_client_secret:
|
||||
previous = type(self).objects.filter(pk=self.pk).only("member_center_oauth_client_secret").first()
|
||||
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
|
||||
elif self.member_center_oauth_client_secret:
|
||||
self.member_center_oauth_client_secret = encrypt_text(self.member_center_oauth_client_secret)
|
||||
|
||||
if self.pk and not self.send_engine_oauth_client_secret:
|
||||
self.send_engine_oauth_client_secret = previous.send_engine_oauth_client_secret if previous else ""
|
||||
elif self.send_engine_oauth_client_secret:
|
||||
self.send_engine_oauth_client_secret = encrypt_text(self.send_engine_oauth_client_secret)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@ -41,25 +41,35 @@ class APIResult:
|
||||
error: str = ""
|
||||
|
||||
|
||||
def _decrypt_config_secret(value: str) -> str:
|
||||
encrypted_secret = (value or "").strip()
|
||||
if not encrypted_secret:
|
||||
return ""
|
||||
try:
|
||||
return decrypt_text(encrypted_secret)
|
||||
except Exception:
|
||||
return encrypted_secret
|
||||
|
||||
|
||||
class MemberCenterClient:
|
||||
def __init__(self, config: NewsletterSystemSettings):
|
||||
self.config = config
|
||||
self._oauth_token_cache: dict[str, tuple[str, int]] = {}
|
||||
|
||||
def subscribe(self, payload: dict) -> APIResult:
|
||||
return self._post(self.config.member_center_subscribe_path, payload)
|
||||
return self._post(self.config.member_center_subscribe_path, payload, use_oauth=True)
|
||||
|
||||
def confirm(self, token: str) -> APIResult:
|
||||
return self._get(self.config.member_center_confirm_path, {"token": token})
|
||||
|
||||
def request_unsubscribe_token(self, payload: dict) -> APIResult:
|
||||
return self._post(self.config.member_center_unsubscribe_token_path, payload)
|
||||
return self._post(self.config.member_center_unsubscribe_token_path, payload, use_oauth=True)
|
||||
|
||||
def unsubscribe(self, payload: dict) -> APIResult:
|
||||
return self._post(self.config.member_center_unsubscribe_path, payload)
|
||||
|
||||
def one_click_unsubscribe(self, payload: dict) -> APIResult:
|
||||
return self._post(self.config.member_center_one_click_unsubscribe_path, payload)
|
||||
return self._post(self.config.member_center_one_click_unsubscribe_path, payload, use_oauth=True)
|
||||
|
||||
def list_subscriptions(self, list_id: str) -> APIResult:
|
||||
auth_headers, auth_error = self._auth_headers()
|
||||
@ -71,50 +81,74 @@ class MemberCenterClient:
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
def _get(self, path: str, query: dict, headers: dict | None = None) -> APIResult:
|
||||
def _get(self, path: str, query: dict, headers: dict | None = None, use_oauth: bool = False) -> APIResult:
|
||||
base_url = (self.config.member_center_base_url or "").strip()
|
||||
if not base_url:
|
||||
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
||||
|
||||
merged_headers = dict(headers or {})
|
||||
if use_oauth:
|
||||
auth_headers, auth_error = self._optional_auth_headers()
|
||||
if auth_error:
|
||||
return APIResult(ok=False, status=0, data={}, error=auth_error)
|
||||
merged_headers.update(auth_headers)
|
||||
|
||||
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
||||
if query:
|
||||
endpoint = f"{endpoint}?{urlencode(query)}"
|
||||
|
||||
request = Request(
|
||||
endpoint,
|
||||
headers={"Accept": "application/json", **(headers or {})},
|
||||
headers={"Accept": "application/json", **merged_headers},
|
||||
method="GET",
|
||||
)
|
||||
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
||||
return self._send(request, timeout=timeout)
|
||||
|
||||
def _post(self, path: str, payload: dict, headers: dict | None = None) -> APIResult:
|
||||
def _post(self, path: str, payload: dict, headers: dict | None = None, use_oauth: bool = False) -> APIResult:
|
||||
base_url = (self.config.member_center_base_url or "").strip()
|
||||
if not base_url:
|
||||
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
||||
|
||||
merged_headers = dict(headers or {})
|
||||
if use_oauth:
|
||||
auth_headers, auth_error = self._optional_auth_headers()
|
||||
if auth_error:
|
||||
return APIResult(ok=False, status=0, data={}, error=auth_error)
|
||||
merged_headers.update(auth_headers)
|
||||
|
||||
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = Request(
|
||||
endpoint,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
headers={"Content-Type": "application/json", **merged_headers},
|
||||
method="POST",
|
||||
)
|
||||
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
||||
return self._send(request, timeout=timeout)
|
||||
|
||||
def _oauth_credentials_configured(self) -> bool:
|
||||
return bool(
|
||||
(self.config.member_center_oauth_client_id or "").strip()
|
||||
and (self.config.member_center_oauth_client_secret or "").strip()
|
||||
)
|
||||
|
||||
def _optional_auth_headers(self) -> tuple[dict, str]:
|
||||
if not self._oauth_credentials_configured():
|
||||
return {}, ""
|
||||
return self._auth_headers()
|
||||
|
||||
def _auth_headers(self) -> tuple[dict, str]:
|
||||
scope = (self.config.member_center_oauth_scope or "").strip() or "newsletter:list.read"
|
||||
token, error = self._get_oauth_access_token(required_scope=scope)
|
||||
token, error = self._get_oauth_access_token()
|
||||
if not token:
|
||||
return {}, error or "member center oauth token is empty"
|
||||
return {"Authorization": f"Bearer {token}"}, ""
|
||||
|
||||
def _get_oauth_access_token(self, required_scope: str) -> tuple[str, str]:
|
||||
scope = (required_scope or "").strip()
|
||||
def _get_oauth_access_token(self) -> tuple[str, str]:
|
||||
cache_key = "member_center"
|
||||
now = int(time.time())
|
||||
cached = self._oauth_token_cache.get(scope)
|
||||
cached = self._oauth_token_cache.get(cache_key)
|
||||
if cached and now < cached[1] - 30:
|
||||
return cached[0], ""
|
||||
|
||||
@ -125,27 +159,15 @@ class MemberCenterClient:
|
||||
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
||||
token_url = urljoin(f"{base_url.rstrip('/')}/", token_path.lstrip("/"))
|
||||
client_id = (self.config.member_center_oauth_client_id or "").strip()
|
||||
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
|
||||
client_secret = ""
|
||||
if encrypted_secret:
|
||||
try:
|
||||
client_secret = decrypt_text(encrypted_secret)
|
||||
except Exception:
|
||||
client_secret = encrypted_secret
|
||||
client_secret = _decrypt_config_secret(self.config.member_center_oauth_client_secret)
|
||||
if not client_id or not client_secret:
|
||||
return "", "member center oauth client_id/client_secret is empty"
|
||||
|
||||
body = urlencode(
|
||||
{
|
||||
k: v
|
||||
for k, v in {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": scope,
|
||||
"audience": (self.config.member_center_oauth_audience or "").strip(),
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = Request(
|
||||
@ -163,7 +185,7 @@ class MemberCenterClient:
|
||||
if not access_token:
|
||||
return "", "oauth token response missing access_token"
|
||||
expires_in = int(result.data.get("expires_in") or 300)
|
||||
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in))
|
||||
self._oauth_token_cache[cache_key] = (access_token, int(time.time()) + max(60, expires_in))
|
||||
return access_token, ""
|
||||
|
||||
def _send(self, request: Request, timeout: int) -> APIResult:
|
||||
@ -473,13 +495,18 @@ class SendEngineClient:
|
||||
return {"Authorization": f"Bearer {token}"}, ""
|
||||
|
||||
def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]:
|
||||
scope = (
|
||||
(required_scope or "").strip()
|
||||
or (self.config.send_engine_oauth_scope or "").strip()
|
||||
or "newsletter:send.write"
|
||||
client_id = (
|
||||
(getattr(self.config, "send_engine_oauth_client_id", "") or "").strip()
|
||||
or (self.config.member_center_oauth_client_id or "").strip()
|
||||
)
|
||||
encrypted_secret = (
|
||||
(getattr(self.config, "send_engine_oauth_client_secret", "") or "").strip()
|
||||
or (self.config.member_center_oauth_client_secret or "").strip()
|
||||
)
|
||||
client_secret = _decrypt_config_secret(encrypted_secret)
|
||||
cache_key = client_id or "send_engine"
|
||||
now = int(time.time())
|
||||
cached = self._oauth_token_cache.get(scope)
|
||||
cached = self._oauth_token_cache.get(cache_key)
|
||||
if cached and now < cached[1] - 30:
|
||||
return cached[0], ""
|
||||
|
||||
@ -489,27 +516,13 @@ class SendEngineClient:
|
||||
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
||||
token_url = urljoin(f"{member_center_base_url.rstrip('/')}/", token_path.lstrip("/"))
|
||||
|
||||
client_id = (self.config.member_center_oauth_client_id or "").strip()
|
||||
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
|
||||
client_secret = ""
|
||||
if encrypted_secret:
|
||||
try:
|
||||
client_secret = decrypt_text(encrypted_secret)
|
||||
except Exception:
|
||||
client_secret = encrypted_secret
|
||||
if not client_id or not client_secret:
|
||||
return ("", "member center oauth client_id/client_secret is empty")
|
||||
return ("", "send engine oauth client_id/client_secret is empty")
|
||||
body = urlencode(
|
||||
{
|
||||
k: v
|
||||
for k, v in {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": scope,
|
||||
"audience": (self.config.member_center_oauth_audience or "").strip(),
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = Request(
|
||||
@ -528,7 +541,7 @@ class SendEngineClient:
|
||||
return "", "oauth token response missing access_token"
|
||||
|
||||
expires_in = int(result.data.get("expires_in") or 300)
|
||||
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in))
|
||||
self._oauth_token_cache[cache_key] = (access_token, int(time.time()) + max(60, expires_in))
|
||||
return access_token, ""
|
||||
|
||||
def _send(self, request: Request, timeout: int) -> APIResult:
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from .newsletter import (
|
||||
APIResult,
|
||||
MemberCenterClient,
|
||||
SendEngineClient,
|
||||
extract_token,
|
||||
generate_one_click_token,
|
||||
build_weekly_news_body_html,
|
||||
@ -18,6 +22,107 @@ from .security import decrypt_text, encrypt_text
|
||||
|
||||
|
||||
class NewsletterTemplateTests(TestCase):
|
||||
def _member_center_config(self, **overrides):
|
||||
defaults = {
|
||||
"member_center_base_url": "https://member.example.com",
|
||||
"member_center_subscribe_path": "/newsletter/subscribe",
|
||||
"member_center_confirm_path": "/newsletter/confirm",
|
||||
"member_center_unsubscribe_token_path": "/newsletter/unsubscribe-token",
|
||||
"member_center_unsubscribe_path": "/newsletter/unsubscribe",
|
||||
"member_center_one_click_unsubscribe_path": "/api/subscriptions/unsubscribe",
|
||||
"member_center_oauth_client_id": "",
|
||||
"member_center_oauth_client_secret": "",
|
||||
"member_center_oauth_token_path": "/oauth/token",
|
||||
"member_center_oauth_scope": "newsletter:list.read",
|
||||
"member_center_oauth_audience": "",
|
||||
"send_engine_oauth_client_id": "",
|
||||
"send_engine_oauth_client_secret": "",
|
||||
"send_engine_timeout_seconds": 10,
|
||||
"member_center_timeout_seconds": 10,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
def test_member_center_write_calls_include_bearer_when_oauth_is_configured(self):
|
||||
client = MemberCenterClient(
|
||||
self._member_center_config(
|
||||
member_center_oauth_client_id="cms-client",
|
||||
member_center_oauth_client_secret="cms-secret",
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(client, "_get_oauth_access_token", return_value=("access-token", "")) as token_call,
|
||||
patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={})) as send,
|
||||
):
|
||||
result = client.subscribe({"email": "demo@example.com"})
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
token_call.assert_called_once_with()
|
||||
request = send.call_args.args[0]
|
||||
self.assertEqual(request.get_header("Authorization"), "Bearer access-token")
|
||||
|
||||
def test_member_center_token_request_uses_client_credentials_without_scope_or_audience(self):
|
||||
client = MemberCenterClient(
|
||||
self._member_center_config(
|
||||
member_center_oauth_client_id="tenant-client",
|
||||
member_center_oauth_client_secret="tenant-secret",
|
||||
member_center_oauth_scope="newsletter:subscriptions.write",
|
||||
member_center_oauth_audience="member_center_api",
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={"access_token": "token"})) as send:
|
||||
token, error = client._get_oauth_access_token()
|
||||
|
||||
self.assertEqual(token, "token")
|
||||
self.assertEqual(error, "")
|
||||
request = send.call_args.args[0]
|
||||
body = parse_qs(request.data.decode("utf-8"))
|
||||
self.assertEqual(body["grant_type"], ["client_credentials"])
|
||||
self.assertEqual(body["client_id"], ["tenant-client"])
|
||||
self.assertEqual(body["client_secret"], ["tenant-secret"])
|
||||
self.assertNotIn("scope", body)
|
||||
self.assertNotIn("audience", body)
|
||||
|
||||
def test_send_engine_token_request_uses_send_engine_oauth_client_without_scope_or_audience(self):
|
||||
client = SendEngineClient(
|
||||
self._member_center_config(
|
||||
member_center_oauth_client_id="tenant-client",
|
||||
member_center_oauth_client_secret="tenant-secret",
|
||||
send_engine_oauth_client_id="send-client",
|
||||
send_engine_oauth_client_secret="send-secret",
|
||||
member_center_oauth_audience="send_engine_api",
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={"access_token": "token"})) as send:
|
||||
token, error = client._get_member_center_access_token(required_scope="newsletter:send.write")
|
||||
|
||||
self.assertEqual(token, "token")
|
||||
self.assertEqual(error, "")
|
||||
request = send.call_args.args[0]
|
||||
body = parse_qs(request.data.decode("utf-8"))
|
||||
self.assertEqual(body["grant_type"], ["client_credentials"])
|
||||
self.assertEqual(body["client_id"], ["send-client"])
|
||||
self.assertEqual(body["client_secret"], ["send-secret"])
|
||||
self.assertNotIn("scope", body)
|
||||
self.assertNotIn("audience", body)
|
||||
|
||||
def test_member_center_write_calls_remain_unauthenticated_without_oauth_config(self):
|
||||
client = MemberCenterClient(self._member_center_config())
|
||||
|
||||
with (
|
||||
patch.object(client, "_get_oauth_access_token") as token_call,
|
||||
patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={})) as send,
|
||||
):
|
||||
result = client.unsubscribe({"token": "unsubscribe-token"})
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
token_call.assert_not_called()
|
||||
request = send.call_args.args[0]
|
||||
self.assertIsNone(request.get_header("Authorization"))
|
||||
|
||||
def test_render_placeholders_replaces_known_keys(self):
|
||||
template = "confirm={{confirm_url}} email={{email}} token={{token}}"
|
||||
rendered = render_placeholders(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user