refactor(capture): the HTTP layer reads through queries and writes through the service
Capture's routes (downloads, files, public watch links) and the token list no longer build SQL, call the session or set a model's fields: reads are downloads/queries, writes the unit's service (service, folders, links, quota), and each write flushes for the handler to commit. Whose a row is goes through userscope (owned_by, owned_row), which owned_or_404 now uses too. The fence holds Capture below its routes as well: it reads only Catalog's and Plex's tables, writes no other unit's, filters owners through userscope and commits only in its jobs and the upload ingest. A test per service use case, among them that the tray lists only the caller's files.
This commit is contained in:
@@ -1,13 +1,54 @@
|
||||
"""Shared "who is an active, non-suspended account" predicate.
|
||||
"""Whose a row is, and who an account is to the others — one place for both.
|
||||
|
||||
Used wherever a user must be filtered out of a recipient list or rejected as a target: the download
|
||||
share-by-email and recipient-picker endpoints. Kept in one place so the two forms (SQL clauses for a
|
||||
query, Python check for a loaded row) can't drift apart, and so a route module doesn't have to import
|
||||
another route module just to reuse the rule.
|
||||
**Ownership.** A user-owned table says whose a row is in one column (`user_id`, or `to_user_id` for
|
||||
something handed to them). A unit's reads and writes name that column through `owned_by` and fetch a
|
||||
row by id through `owned_row`, never with an inline `Model.user_id == …`: the rule "a row owned by
|
||||
someone else is answered like a missing one" then lives here, and a contract test
|
||||
(`test_unit_fences.py`) holds a switched-on unit to it.
|
||||
|
||||
**Recipients.** "An active, non-suspended account" — the download share-by-email and the recipient
|
||||
picker filter on it. Kept here so the two forms (SQL clauses for a query, a Python check for a loaded
|
||||
row) can't drift apart.
|
||||
"""
|
||||
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from sqlalchemy import ColumnElement, func, select
|
||||
from sqlalchemy.orm import InstrumentedAttribute, Session
|
||||
|
||||
from app.collation import collated
|
||||
from app.db import Base
|
||||
from app.models import User
|
||||
|
||||
M = TypeVar("M", bound=Base)
|
||||
|
||||
|
||||
def _owner_id(owner: User | int) -> int:
|
||||
return owner if isinstance(owner, int) else owner.id
|
||||
|
||||
|
||||
def owned_by(column: InstrumentedAttribute, owner: User | int) -> ColumnElement[bool]:
|
||||
"""`column` — a table's owner column — names `owner`."""
|
||||
return column == _owner_id(owner)
|
||||
|
||||
|
||||
def owned_row(
|
||||
db: Session,
|
||||
model: type[M],
|
||||
id_: Any,
|
||||
owner: User | int,
|
||||
*,
|
||||
owner_attr: str = "user_id",
|
||||
lock: bool = False,
|
||||
) -> M | None:
|
||||
"""The row of `model` with this primary key when `owner` owns it, else None — a row of someone
|
||||
else's is the same answer as a missing one, so a caller can never tell the two apart. `lock`
|
||||
takes it FOR UPDATE (past the identity map)."""
|
||||
row = db.get(model, id_, with_for_update=True if lock else None)
|
||||
if row is None or getattr(row, owner_attr) != _owner_id(owner):
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def messageable_clauses() -> list:
|
||||
"""SQL clauses for "an active, non-suspended account" — splat into a recipient query."""
|
||||
@@ -20,3 +61,30 @@ def messageable_clauses() -> list:
|
||||
def is_messageable_user(u: User | None) -> bool:
|
||||
"""Python mirror of `messageable_clauses()` for an already-loaded row (a share recipient)."""
|
||||
return bool(u and u.is_active and not u.is_suspended)
|
||||
|
||||
|
||||
def user_by_email(db: Session, email: str) -> User | None:
|
||||
"""The account with this address, matched without regard to case."""
|
||||
return db.execute(
|
||||
select(User).where(func.lower(User.email) == email.strip().lower())
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def messageable_others(db: Session, user: User, lang: str) -> list[User]:
|
||||
"""Every account `user` may hand something to: active, not suspended, not themselves; in the
|
||||
reader's alphabet by address."""
|
||||
return list(
|
||||
db.execute(
|
||||
select(User)
|
||||
.where(User.id != user.id, *messageable_clauses())
|
||||
.order_by(collated(User.email, lang))
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def emails_of(db: Session, ids: set[int] | None = None) -> dict[int, str]:
|
||||
"""Each account's address by id, in address order: these ids, or every account."""
|
||||
stmt = select(User.id, User.email).order_by(User.email)
|
||||
if ids is not None:
|
||||
stmt = stmt.where(User.id.in_(ids))
|
||||
return dict(db.execute(stmt).tuples().all())
|
||||
|
||||
Reference in New Issue
Block a user