Populate the client website template with catalog feature flags.

Extract always-on public/portal/UTM plus optional email_sms, directmail, blog, payments, social, and social_ai so new client sites can be bootstrapped from this seed.

Refs #1
Refs #2

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 07:55:26 -05:00
co-authored by Cursor
parent 45d0888d33
commit 787f0e48fb
297 changed files with 32534 additions and 3 deletions
+188
View File
@@ -0,0 +1,188 @@
"""LinkedIn 3-legged OAuth helpers (Authorization Code flow)."""
from __future__ import annotations
import logging
from datetime import timedelta
from urllib.parse import urlencode
import requests
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from social.crypto import decrypt_tokens
from social.models import Platform, SocialAppCredentials
logger = logging.getLogger(__name__)
AUTHORIZE_URL = "https://www.linkedin.com/oauth/v2/authorization"
TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
USERINFO_URL = "https://api.linkedin.com/v2/userinfo"
ME_URL = "https://api.linkedin.com/v2/me"
# openid/profile/email: Sign In with LinkedIn (OpenID Connect)
# w_member_social: Share on LinkedIn product (required to post as the member)
DEFAULT_SCOPES = ("openid", "profile", "email", "w_member_social")
class LinkedInOAuthError(RuntimeError):
"""Raised when LinkedIn OAuth or profile lookup fails."""
def get_app_credentials() -> SocialAppCredentials | None:
return SocialAppCredentials.objects.filter(platform=Platform.LINKEDIN).first()
def load_client_credentials() -> tuple[str, str]:
"""Return (client_id, client_secret) from portal-saved credentials."""
app = get_app_credentials()
if not app or not app.client_id or not app.encrypted_client_secret:
raise LinkedInOAuthError(
"LinkedIn app credentials are not saved yet. "
"Enter Client ID and Client Secret on the Connect LinkedIn form."
)
try:
secret = decrypt_tokens(app.encrypted_client_secret)
except ValueError as exc:
raise LinkedInOAuthError(
"Could not decrypt LinkedIn client secret. "
"Re-enter the Client Secret on the Connect LinkedIn form."
) from exc
if not secret:
raise LinkedInOAuthError("LinkedIn client secret is empty — save it again.")
return app.client_id.strip(), secret.strip()
def credentials_configured() -> bool:
app = get_app_credentials()
return bool(app and app.is_configured)
def redirect_uri() -> str:
"""Absolute callback URL — must match Authorized redirect URLs in LinkedIn Auth tab."""
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if not base:
raise LinkedInOAuthError(
"PUBLIC_SITE_URL is not set; cannot build LinkedIn OAuth redirect URI."
)
return f"{base}{reverse('social:linkedin_oauth_callback')}"
def authorization_url(*, state: str, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> str:
client_id, _secret = load_client_credentials()
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri(),
"state": state,
"scope": " ".join(scopes),
}
return f"{AUTHORIZE_URL}?{urlencode(params)}"
def exchange_code(code: str) -> dict:
"""Exchange authorization code for access token payload."""
client_id, client_secret = load_client_credentials()
response = requests.post(
TOKEN_URL,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri(),
"client_id": client_id,
"client_secret": client_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30,
)
if response.status_code >= 400:
logger.warning(
"LinkedIn token exchange failed: %s %s",
response.status_code,
response.text[:500],
)
raise LinkedInOAuthError(
f"LinkedIn token exchange failed ({response.status_code}). "
"Check client ID/secret, redirect URI, and that Share on LinkedIn is enabled."
)
payload = response.json()
if not payload.get("access_token"):
raise LinkedInOAuthError("LinkedIn token response missing access_token.")
return payload
def fetch_member_profile(access_token: str) -> dict:
"""
Resolve member display name + person URN.
Prefers OpenID userinfo (`sub`); falls back to legacy /v2/me.
"""
headers = {"Authorization": f"Bearer {access_token}"}
userinfo = requests.get(USERINFO_URL, headers=headers, timeout=30)
if userinfo.status_code == 200:
data = userinfo.json()
sub = (data.get("sub") or "").strip()
if sub:
name = (
(data.get("name") or "").strip()
or " ".join(
p
for p in (
(data.get("given_name") or "").strip(),
(data.get("family_name") or "").strip(),
)
if p
)
or "LinkedIn member"
)
return {
"author_urn": f"urn:li:person:{sub}",
"label": name,
"email": (data.get("email") or "").strip(),
}
me = requests.get(
ME_URL,
headers={
**headers,
"X-Restli-Protocol-Version": "2.0.0",
},
timeout=30,
)
if me.status_code >= 400:
logger.warning(
"LinkedIn profile lookup failed: userinfo=%s me=%s %s",
userinfo.status_code,
me.status_code,
me.text[:500],
)
raise LinkedInOAuthError(
"Could not load LinkedIn profile. Ensure Sign In with LinkedIn "
"(openid/profile) is approved on the app."
)
data = me.json()
person_id = (data.get("id") or "").strip()
if not person_id:
raise LinkedInOAuthError("LinkedIn /v2/me response missing id.")
localized = data.get("localizedFirstName") or ""
last = data.get("localizedLastName") or ""
label = f"{localized} {last}".strip() or "LinkedIn member"
return {"author_urn": f"urn:li:person:{person_id}", "label": label, "email": ""}
def token_blob_from_oauth(token_payload: dict, *, author_urn: str) -> dict:
expires_in = int(token_payload.get("expires_in") or 0)
blob: dict = {
"access_token": token_payload["access_token"],
"author_urn": author_urn,
"token_type": token_payload.get("token_type") or "Bearer",
"scope": token_payload.get("scope") or "",
}
if token_payload.get("refresh_token"):
blob["refresh_token"] = token_payload["refresh_token"]
if expires_in:
blob["expires_at"] = (
timezone.now() + timedelta(seconds=expires_in)
).isoformat()
return blob