feat(book): tenant boundary and per-tenant numbering on the Book (T-7)
005 has never run in production (still at 004), so it is amended in place rather than patched later by a 007: book_records gains owner_tenant (the tatlock tenant, Caller.tenant from T-4) and number, a short outward id allocated per tenant via a new book_tenant_counters row locked FOR UPDATE in the same transaction as the insert — INSERT ... ON CONFLICT DO NOTHING bootstraps the counter row so a tenant's very first allocation doesn't race its own creation. Unique on (owner_tenant, number); composite index on (owner_tenant, kind, status). 006 applies on top unchanged. Every Book route now requires a tenant (BookService._require_tenant, mirroring the Scribe's own pattern) and filters on it: a record in another tenant answers exactly like one that does not exist, in both directions. link and add's parent convenience both route through _get_or_404, so a link can never reach another tenant — which is also what keeps show's links/backlinks free of their own tenant filter, since every link touching a tenant's record is created tenant-safe already. Responses include number; list accepts number= to find one. Verified: core_api_test downgraded to 004 (with the original migration, to match its already-applied pre-amendment schema), amended, upgraded back to head — clean round trip. 508 tests pass (489 baseline + 19 new: 3 migration-shape, 16 tenant/numbering). Mutation checks confirmed and reverted: dropping list's tenant filter, allocating numbers from a global counter, and dropping link's tenant check on its target each fail the test built to catch it. Raw REST against a dev server on :8777 against core_api_test confirms the same behaviour over the wire: two tenants' first records both land on number 1, and a cross-tenant read answers 404 not_found. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,10 @@ Book Controller
|
||||
|
||||
The core Book verbs (D-70): add, show, list, find, change, close,
|
||||
link/unlink, tag/untag, history. Every route requires a caller (office or
|
||||
user); writes additionally require an identity (T-1).
|
||||
user); writes additionally require an identity (T-1). Every route also
|
||||
requires a tenant on the caller — enforced in the service layer
|
||||
(`BookService._require_tenant`) rather than here, so reads and writes share
|
||||
one check (core-api T-7).
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
@@ -105,14 +108,19 @@ class BookController(BaseController):
|
||||
tag: Optional[str] = Query(None),
|
||||
scope: Optional[str] = Query(None),
|
||||
parent: Optional[uuid.UUID] = Query(None),
|
||||
number: Optional[int] = Query(None, description="find the one record with this outward id"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
results, total = await service.list_records(
|
||||
session, view=view, tag=tag, scope=scope, parent=parent, limit=limit, offset=offset
|
||||
)
|
||||
try:
|
||||
results, total = await service.list_records(
|
||||
session, caller, view=view, tag=tag, scope=scope, parent=parent,
|
||||
number=number, limit=limit, offset=offset,
|
||||
)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return BookRecordListResponse(
|
||||
records=[_record_response(record, tags) for record, tags in results],
|
||||
total=total,
|
||||
@@ -136,7 +144,10 @@ class BookController(BaseController):
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
results, total = await service.find_records(session, q=q, limit=limit, offset=offset)
|
||||
try:
|
||||
results, total = await service.find_records(session, caller, q=q, limit=limit, offset=offset)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return BookRecordListResponse(
|
||||
records=[_record_response(record, tags) for record, tags in results],
|
||||
total=total,
|
||||
@@ -159,7 +170,7 @@ class BookController(BaseController):
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags, links, backlinks = await service.show_record(session, record_id)
|
||||
record, tags, links, backlinks = await service.show_record(session, record_id, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _detail_response(record, tags, links, backlinks)
|
||||
@@ -301,7 +312,7 @@ class BookController(BaseController):
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
events = await service.get_history(session, record_id)
|
||||
events = await service.get_history(session, record_id, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return BookHistoryResponse(
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import (
|
||||
Computed,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
@@ -62,6 +63,8 @@ class BookRecord(Base):
|
||||
__tablename__ = "book_records"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
|
||||
UniqueConstraint("owner_tenant", "number", name="uq_book_records_tenant_number"),
|
||||
Index("ix_book_records_tenant_kind_status", "owner_tenant", "kind", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
@@ -69,6 +72,16 @@ class BookRecord(Base):
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
# The tatlock tenant the record belongs to (Caller.tenant, T-4/D-73).
|
||||
# Every query in the service layer filters on it — there is no
|
||||
# database-level RLS here, so that filter is load-bearing in Python,
|
||||
# following src/domains/scribe/models.py's same note.
|
||||
owner_tenant: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
# A short outward id, numbered per tenant (never globally) so a tenant
|
||||
# can never infer another's activity from its numbers. Allocated by
|
||||
# BookService._allocate_number, which locks a per-tenant counter row
|
||||
# FOR UPDATE in the same transaction as this row's insert.
|
||||
number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
@@ -246,3 +259,26 @@ class BookEvent(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BookEvent {self.action} on {self.record_id} by {self.identity}>"
|
||||
|
||||
|
||||
class BookTenantCounter(Base):
|
||||
"""
|
||||
One row per tenant: the next `number` to allocate on `book_records`.
|
||||
|
||||
`BookService._allocate_number` upserts this row (`INSERT ... ON
|
||||
CONFLICT DO NOTHING`, so a tenant's first `add` never races its own
|
||||
counter row into existence) and then locks it `FOR UPDATE` in the same
|
||||
transaction as the record insert it numbers — a second concurrent `add`
|
||||
in the same tenant blocks on that lock until the first commits, so two
|
||||
concurrent creates never read the same `next_number`. A different
|
||||
tenant's counter row is untouched, so tenants never contend with each
|
||||
other.
|
||||
"""
|
||||
|
||||
__tablename__ = "book_tenant_counters"
|
||||
|
||||
owner_tenant: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||
next_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BookTenantCounter {self.owner_tenant} next={self.next_number}>"
|
||||
|
||||
@@ -126,9 +126,13 @@ class BookLinkResponse(BaseSchema):
|
||||
|
||||
|
||||
class BookRecordResponse(BaseSchema):
|
||||
"""Every write returns the stored record (D-70 rule 7)."""
|
||||
"""Every write returns the stored record (D-70 rule 7). `number` is the
|
||||
outward id, numbered per tenant (core-api T-7); `owner_tenant` is not
|
||||
echoed back — it is implicit in the caller, following
|
||||
src/domains/scribe/schemas.py's AddressResponse."""
|
||||
|
||||
id: uuid.UUID
|
||||
number: int
|
||||
kind: str
|
||||
title: Optional[str]
|
||||
body: str
|
||||
|
||||
+124
-24
@@ -6,6 +6,16 @@ Rejected options (D-68, D-70). Every mutating method stamps a `book_events`
|
||||
row via `_write_event`; the table itself is append-only (migration 005's
|
||||
trigger), so this module never updates or deletes an event.
|
||||
|
||||
Every method starts by requiring a tenant on the caller (`_require_tenant`,
|
||||
same pattern as `src/domains/scribe/service.py`) and ends by filtering its
|
||||
query on it: **a record in another tenant answers exactly like one that
|
||||
does not exist** (core-api T-7 / workspace D-73), so no method here ever
|
||||
confirms an id across that boundary. `link` and `add`'s `parent` convenience
|
||||
both route through `_get_or_404`, so neither can create a link into another
|
||||
tenant — which is also what keeps `show`'s links/backlinks free of a
|
||||
separate tenant filter: every link touching a tenant's record is created
|
||||
tenant-safe in the first place.
|
||||
|
||||
Methods take `session: AsyncSession` per call rather than storing it on
|
||||
`self`, following `src/domains/dashboard/service.py`.
|
||||
"""
|
||||
@@ -14,6 +24,7 @@ from typing import List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domains.auth.callers import Caller
|
||||
@@ -24,6 +35,7 @@ from src.domains.book.models import (
|
||||
BookRecord,
|
||||
BookRecordLink,
|
||||
BookTag,
|
||||
BookTenantCounter,
|
||||
)
|
||||
from src.domains.book.schemas import BookCloseRequest, BookRecordCreate, BookRecordUpdate
|
||||
from src.shared.config import get_settings
|
||||
@@ -48,12 +60,61 @@ class BookService:
|
||||
# Internal helpers
|
||||
# =========================================================================
|
||||
|
||||
async def _get_or_404(self, session: AsyncSession, record_id) -> BookRecord:
|
||||
record = await session.get(BookRecord, record_id)
|
||||
def _require_tenant(self, caller: Caller) -> str:
|
||||
"""Every Book operation needs a tenant; a caller without one gets a
|
||||
clear error rather than a query that silently matches nothing. An
|
||||
Office caller always has one (callers.py enforces it on every call);
|
||||
a user-kind caller without a `tatlock_user` claim does not."""
|
||||
if not caller.tenant:
|
||||
raise BookError("tenant_required", 400)
|
||||
return caller.tenant
|
||||
|
||||
async def _get_or_404(self, session: AsyncSession, record_id, tenant: str) -> BookRecord:
|
||||
"""A record outside `tenant` answers exactly like one that does not
|
||||
exist — `session.get()` alone can't express that filter, since the
|
||||
primary key is `id` only, so this is a `select` rather than a get."""
|
||||
stmt = select(BookRecord).where(BookRecord.id == record_id, BookRecord.owner_tenant == tenant)
|
||||
record = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise BookError("not_found", 404)
|
||||
return record
|
||||
|
||||
async def _allocate_number(self, session: AsyncSession, tenant: str) -> int:
|
||||
"""
|
||||
Allocate the next outward `number` for `tenant`, safe under
|
||||
concurrency.
|
||||
|
||||
`INSERT ... ON CONFLICT DO NOTHING` guarantees the tenant's counter
|
||||
row exists without racing its own creation (two simultaneous first
|
||||
`add`s in a brand-new tenant can't both try to INSERT the same row
|
||||
and fail), then `SELECT ... FOR UPDATE` locks that row for the rest
|
||||
of this transaction — a second concurrent `add` in the same tenant
|
||||
blocks on the lock until this one commits or rolls back, so two
|
||||
concurrent creates in one tenant can never read the same
|
||||
`next_number`. A different tenant's row is untouched, so tenants
|
||||
never contend with each other. This is the dossier's suggested
|
||||
shape (a per-tenant counter row taken FOR UPDATE in the same
|
||||
transaction as the insert); the ON CONFLICT DO NOTHING bootstrap is
|
||||
the one addition, needed because the plain shape alone still races
|
||||
on a tenant's very first allocation.
|
||||
"""
|
||||
upsert = (
|
||||
pg_insert(BookTenantCounter)
|
||||
.values(owner_tenant=tenant, next_number=1)
|
||||
.on_conflict_do_nothing(index_elements=["owner_tenant"])
|
||||
)
|
||||
await session.execute(upsert)
|
||||
|
||||
locked = (
|
||||
select(BookTenantCounter)
|
||||
.where(BookTenantCounter.owner_tenant == tenant)
|
||||
.with_for_update()
|
||||
)
|
||||
counter = (await session.execute(locked)).scalar_one()
|
||||
number = counter.next_number
|
||||
counter.next_number = number + 1
|
||||
return number
|
||||
|
||||
async def _tags_for(self, session: AsyncSession, record_id) -> List[str]:
|
||||
stmt = select(BookTag.tag).where(BookTag.record_id == record_id).order_by(BookTag.tag)
|
||||
result = await session.execute(stmt)
|
||||
@@ -97,10 +158,15 @@ class BookService:
|
||||
data: BookRecordCreate,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`add` — text-only input lands an unfiled Note (D-72)."""
|
||||
"""`add` — text-only input lands an unfiled Note (D-72). Sets
|
||||
`owner_tenant` from the caller and allocates `number` (core-api T-7)."""
|
||||
tenant = self._require_tenant(caller)
|
||||
status = self._validate_status(data.kind, data.status)
|
||||
number = await self._allocate_number(session, tenant)
|
||||
|
||||
record = BookRecord(
|
||||
owner_tenant=tenant,
|
||||
number=number,
|
||||
kind=data.kind,
|
||||
title=data.title,
|
||||
body=data.body,
|
||||
@@ -132,6 +198,11 @@ class BookService:
|
||||
session.add(BookTag(record_id=record.id, tag=tag))
|
||||
|
||||
if data.parent is not None:
|
||||
# The parent is a link target (`relation="parent"`), so it goes
|
||||
# through the same tenant-scoped lookup `link` uses — a parent
|
||||
# in another tenant (or no such record at all) answers 404
|
||||
# not_found, the same as an explicit `link` call would.
|
||||
await self._get_or_404(session, data.parent, tenant)
|
||||
session.add(BookRecordLink(
|
||||
from_id=record.id,
|
||||
relation="parent",
|
||||
@@ -143,16 +214,18 @@ class BookService:
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
logger.info(f"Book record added: kind={data.kind} id={record.id} by={caller.identity}")
|
||||
logger.info(f"Book record added: kind={data.kind} id={record.id} number={number} tenant={tenant} by={caller.identity}")
|
||||
return record, tag_names
|
||||
|
||||
async def show_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str], List[BookRecordLink], List[BookRecordLink]]:
|
||||
"""`show` — the record, its tags, outgoing links and backlinks."""
|
||||
record = await self._get_or_404(session, record_id)
|
||||
tenant = self._require_tenant(caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
tags = await self._tags_for(session, record_id)
|
||||
|
||||
links_stmt = select(BookRecordLink).where(BookRecordLink.from_id == record_id)
|
||||
@@ -166,21 +239,26 @@ class BookService:
|
||||
async def list_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
caller: Caller,
|
||||
view: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
parent: Optional[object] = None,
|
||||
number: Optional[int] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
|
||||
"""
|
||||
`list` — the nine views of D-70. Status-named views (`inbox`,
|
||||
`waiting`, `someday`, `done`) filter on the matching task status and
|
||||
simply return nothing for kinds that never carry it. `today` and
|
||||
`upcoming` bucket on `due_at` in `book_timezone`; richer due/overdue
|
||||
handling is `due`/`next`, both next round.
|
||||
`list` — the nine views of D-70, scoped to the caller's tenant.
|
||||
Status-named views (`inbox`, `waiting`, `someday`, `done`) filter on
|
||||
the matching task status and simply return nothing for kinds that
|
||||
never carry it. `today` and `upcoming` bucket on `due_at` in
|
||||
`book_timezone`; richer due/overdue handling is `due`/`next`, both
|
||||
next round. `number=` finds the one record with that outward id in
|
||||
the caller's tenant (core-api T-7).
|
||||
"""
|
||||
conditions = []
|
||||
tenant = self._require_tenant(caller)
|
||||
conditions = [BookRecord.owner_tenant == tenant]
|
||||
|
||||
if view in ("inbox", "waiting", "someday", "done"):
|
||||
conditions.append(BookRecord.status == view)
|
||||
@@ -220,6 +298,9 @@ class BookService:
|
||||
tag_subquery = select(BookTag.record_id).where(BookTag.tag == tag)
|
||||
conditions.append(BookRecord.id.in_(tag_subquery))
|
||||
|
||||
if number is not None:
|
||||
conditions.append(BookRecord.number == number)
|
||||
|
||||
base_query = select(BookRecord).where(*conditions)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
@@ -243,13 +324,18 @@ class BookService:
|
||||
async def find_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
caller: Caller,
|
||||
q: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
|
||||
"""`find` — full-text search over title and body."""
|
||||
"""`find` — full-text search over title and body, scoped to the
|
||||
caller's tenant."""
|
||||
tenant = self._require_tenant(caller)
|
||||
tsquery = func.websearch_to_tsquery("simple", q)
|
||||
base_query = select(BookRecord).where(BookRecord.search.op("@@")(tsquery))
|
||||
base_query = select(BookRecord).where(
|
||||
BookRecord.owner_tenant == tenant, BookRecord.search.op("@@")(tsquery)
|
||||
)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
@@ -276,7 +362,8 @@ class BookService:
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`change` — a partial update; only fields set on the request move."""
|
||||
record = await self._get_or_404(session, record_id)
|
||||
tenant = self._require_tenant(caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
diff: dict = {}
|
||||
@@ -321,8 +408,12 @@ class BookService:
|
||||
`close` — a Task is done or dropped; a Question is resolved into a
|
||||
Decision (`resolves` link, status `settled`); a Decision is
|
||||
superseded by a newer one (`supersedes` link, status `superseded`).
|
||||
`resolved_by`/`superseded_by` are link targets, so each is looked up
|
||||
in the caller's tenant the same as `link` — a target in another
|
||||
tenant answers 404 not_found.
|
||||
"""
|
||||
record = await self._get_or_404(session, record_id)
|
||||
tenant = self._require_tenant(caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
diff: dict = {}
|
||||
|
||||
if record.kind == "task":
|
||||
@@ -334,7 +425,7 @@ class BookService:
|
||||
elif record.kind == "question":
|
||||
if data.resolved_by is None:
|
||||
raise BookError("invalid_status", 400)
|
||||
decision = await self._get_or_404(session, data.resolved_by)
|
||||
decision = await self._get_or_404(session, data.resolved_by, tenant)
|
||||
if decision.kind != "decision":
|
||||
raise BookError("invalid_status", 400)
|
||||
session.add(BookRecordLink(
|
||||
@@ -349,7 +440,7 @@ class BookService:
|
||||
elif record.kind == "decision":
|
||||
if data.superseded_by is None:
|
||||
raise BookError("invalid_status", 400)
|
||||
new_decision = await self._get_or_404(session, data.superseded_by)
|
||||
new_decision = await self._get_or_404(session, data.superseded_by, tenant)
|
||||
if new_decision.kind != "decision":
|
||||
raise BookError("invalid_status", 400)
|
||||
session.add(BookRecordLink(
|
||||
@@ -386,8 +477,11 @@ class BookService:
|
||||
relation: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
record = await self._get_or_404(session, record_id)
|
||||
await self._get_or_404(session, to_id) # the target must exist
|
||||
"""`link` refuses a target in another tenant the same way a missing
|
||||
one is refused — both are 404 not_found (core-api T-7)."""
|
||||
tenant = self._require_tenant(caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
await self._get_or_404(session, to_id, tenant) # the target must exist in the same tenant
|
||||
|
||||
session.add(BookRecordLink(
|
||||
from_id=record_id,
|
||||
@@ -412,7 +506,8 @@ class BookService:
|
||||
relation: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
record = await self._get_or_404(session, record_id)
|
||||
tenant = self._require_tenant(caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
|
||||
stmt = delete(BookRecordLink).where(
|
||||
BookRecordLink.from_id == record_id,
|
||||
@@ -443,7 +538,8 @@ class BookService:
|
||||
tag: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
record = await self._get_or_404(session, record_id)
|
||||
tenant = self._require_tenant(caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
|
||||
existing = await session.get(BookTag, (record_id, tag))
|
||||
if existing is None:
|
||||
@@ -461,7 +557,8 @@ class BookService:
|
||||
tag: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
record = await self._get_or_404(session, record_id)
|
||||
tenant = self._require_tenant(caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
|
||||
stmt = delete(BookTag).where(BookTag.record_id == record_id, BookTag.tag == tag)
|
||||
result = await session.execute(stmt)
|
||||
@@ -476,8 +573,11 @@ class BookService:
|
||||
# history
|
||||
# =========================================================================
|
||||
|
||||
async def get_history(self, session: AsyncSession, record_id, limit: int = 100) -> List[BookEvent]:
|
||||
await self._get_or_404(session, record_id)
|
||||
async def get_history(
|
||||
self, session: AsyncSession, record_id, caller: Caller, limit: int = 100
|
||||
) -> List[BookEvent]:
|
||||
tenant = self._require_tenant(caller)
|
||||
await self._get_or_404(session, record_id, tenant)
|
||||
|
||||
stmt = (
|
||||
select(BookEvent)
|
||||
|
||||
Reference in New Issue
Block a user