CLAUDE.md's canonical table names SQLAlchemy 2.0 select() and an inline datetime.now(timezone.utc), and both had drifted: five db.query(...) calls remained in routes/auth.py and routes/me.py (seven before the demo went), and four private now helpers stood in sync/videos.py, utils.py (imported as _now by explore and maintenance), scheduler.py (_now_iso) and the e2e seed. All are the canonical form now. Ruff cannot ban a method call on an instance or a function by name, so tests/test_canonical_patterns.py reads every app module's AST and fails on a .query(...) call or a now/_now/now_utc helper — seen red on a planted file.
100 lines
5.3 KiB
Python
100 lines
5.3 KiB
Python
"""Small cross-cutting helpers shared across modules (kept dependency-light on purpose)."""
|
||
|
||
import re
|
||
from datetime import date
|
||
|
||
|
||
def iso(dt: date | None) -> str | None:
|
||
"""Serialize a date/datetime for a JSON response: ISO-8601, or None when the column is
|
||
NULL. Single source of truth for the `x.isoformat() if x else None` idiom the routes had
|
||
hand-rolled ~17 times — so a future tz/format tweak lands in one place. Accepts `date`
|
||
too (datetime is a subclass) — e.g. MediaAsset.upload_date is a plain `date`."""
|
||
return dt.isoformat() if dt is not None else None
|
||
|
||
|
||
# Single source of truth for "looks like an email" — used by every endpoint that accepts an
|
||
# address (auth register/reset, admin invites, the setup wizard). Keep
|
||
# the strictness in one place so routes don't drift between this and a looser "@"-in check.
|
||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||
|
||
|
||
def valid_email(value: str | None) -> bool:
|
||
return bool(value and _EMAIL_RE.match(value))
|
||
|
||
|
||
# Gmail (and its googlemail.com alias) delivers to one mailbox regardless of dots in the local part
|
||
# and of any `+tag`; nobody else is guaranteed to. So the canonical form below strips those for
|
||
# Gmail only, and merely lowercases everything else.
|
||
_GMAIL_DOMAINS = {"gmail.com", "googlemail.com"}
|
||
|
||
|
||
def canonical_email(value: str | None) -> str:
|
||
"""The form used to decide whether two addresses are the SAME MAILBOX. Never used for sending —
|
||
mail always goes to the address as the user typed it.
|
||
|
||
Why this exists (observed on prod, 2026-09-03): someone registered as `npeter83.temu@gmail.com`
|
||
and was approved, but Google's OAuth `email` claim came back as the account's stored primary,
|
||
`npeter83temu@gmail.com`. The invite gate only lowercased, found no approved row for that literal
|
||
string, and turned an approved user's sign-in into a silent SECOND access request — plus a second
|
||
User row. The `+tag` case is worse and fails every time: a Google account cannot contain `+`, so
|
||
OAuth can only ever return the base address, meaning anyone who registers with
|
||
`user+siftlode@gmail.com` (a common throwaway habit) is guaranteed to be denied after approval.
|
||
|
||
Scope is deliberately narrow. The dot rule does NOT hold for Google Workspace custom domains
|
||
unless the admin turned it on, and dots are significant on most providers, so anything outside
|
||
gmail.com/googlemail.com is only lowercased. Approving one Gmail spelling always did mean
|
||
approving that mailbox, so collapsing them is the honest reading of an invite, not a widening
|
||
of it.
|
||
"""
|
||
email = (value or "").strip().lower()
|
||
local, sep, domain = email.partition("@")
|
||
if not sep or domain not in _GMAIL_DOMAINS:
|
||
return email
|
||
local = local.partition("+")[0].replace(".", "")
|
||
# Normalise the alias domain too, so googlemail and gmail spellings meet.
|
||
return f"{local}@gmail.com" if local else email
|
||
|
||
|
||
# The shortest last word that is prefix-matched. A 1–2 letter prefix matches a large share of the
|
||
# library and ts_rank then scores every match: "m" took ~5–8 s on prod against ~100 ms for a whole
|
||
# word (E31 S4e). A shorter last word is matched whole instead ("tv", "4k" still find themselves).
|
||
MIN_PREFIX_LEN = 3
|
||
|
||
|
||
def to_tsquery_str(q: str) -> str | None:
|
||
"""Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word
|
||
prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*" —
|
||
once it has MIN_PREFIX_LEN characters ("ti amo m" → "ti & amo & m").
|
||
Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in
|
||
(the string is built from sanitised tokens, never the raw query). Shared by the YouTube feed
|
||
search and the Plex library search, hence this neutral home rather than either domain module."""
|
||
tokens = [t for t in re.findall(r"\w+", q, flags=re.UNICODE) if any(c.isalnum() for c in t)]
|
||
if not tokens:
|
||
return None
|
||
if len(tokens[-1]) >= MIN_PREFIX_LEN:
|
||
tokens[-1] = tokens[-1] + ":*"
|
||
return " & ".join(tokens)
|
||
|
||
|
||
def like_escape(s: str) -> str:
|
||
"""`s` as a LITERAL inside a LIKE/ILIKE pattern: `%` and `_` are wildcards there, and `\\` is
|
||
Postgres's default escape character, so all three are escaped. Every pattern built from text a
|
||
person typed goes through this — unescaped, a search for `100%` or `my_channel` matches rows it
|
||
should not."""
|
||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||
|
||
|
||
def normalize_description(text: str | None) -> str | None:
|
||
"""Strip whitespace-only lines from a CHANNEL description before storing. YouTube channel
|
||
descriptions pad their contact/link blocks with runs of blank lines; keeping them wastes storage,
|
||
bloats the About popover, and adds nothing to the search document. Drops purely-empty lines and
|
||
right-strips each kept line; `str.splitlines()` also folds CR / CRLF and Unicode line separators
|
||
to "\\n" (a benign line-ending normalization). None stays None; all-blank collapses to "".
|
||
|
||
Deliberately CHANNEL-only — video descriptions are left with their original formatting: they are
|
||
user-facing prose where paragraph breaks carry meaning, unlike a channel's metadata block."""
|
||
if text is None:
|
||
return None
|
||
kept = [line.rstrip() for line in text.splitlines() if line.strip()]
|
||
return "\n".join(kept)
|