Files
LLeMbas/src/lembas/api/reports.py
T
Jaroslav BenešandClaude Opus 5 1b8c9f948c Grants that outlive what they name, and a rule you can read
sharing.forget_principal has existed since shares did, documented as the thing
that stops a recycled id inheriting somebody's grant, and was called by nobody.
Deleting a group left every grant naming it; deleting an account left both the
grants to it and the grants of its own work -- that second half is the one
nothing else could catch, since their rows cascade and the shares of those rows
have nothing to cascade from. Both now run before the delete, while the rows are
still findable, and a deleted resource forgets its own.

library.share defaulted to False, which meant sharing shipped documented as done
and unreachable: the panel only renders for somebody holding it, so out of the
box nobody could share anything and nothing said why. It is on.

The panel itself was checkboxes inside the resource's *save form*, listing every
group and every account on the instance, unpaginated, on every detail page -- and
a tick only took effect if you also saved the resource. It is its own routes now:
search, one grant per POST, the panel re-rendered from what is stored. Anything
already shared stays listed whatever the search says, or removing a grant would
mean searching for the name it was given to.

Reports join the shareable set and memories still do not: a finished piece of
work is the thing somebody most wants to hand over, and a record about a person
is not content to pass round. reports.visible became sharing.visible_to, which is
the one line its own docstring predicted. Two things fell out: `owned` beside
`get`, because sharing grants reading and deleting is the owner's alone; and
reading somebody else's report no longer clears their unread dot.

Permissions gained the answer to "what can this person actually do?" --
explain() is resolve()'s working shown rather than thrown away, naming admin, the
baseline, or the groups that granted each one. That is the simulation the union
rule exists to make unnecessary, and until now the only way to get it was to open
every group and read the grids by eye. Users and groups are list-plus-detail, and
membership is edited from one side: it was on both, and a full-form POST from
either overwrote what the other had shown.

Read and write are split for notes, memory and skills -- checked on the tool's
declared risk, after the gate so it can only narrow, and defaulting on.

Quotas are the union rule applied to numbers, with the corner that makes it
interesting: zero means "no limit" and wins outright, or a group saying unlimited
would count for less than one saying a million. Absent means "no opinion".
_narrower folds a group's ceiling with the instance's and is deliberately not
min, for the same reason. Five axes, enforced where each is knowable -- before a
reply is built, before a second one starts, on an agent reply's clock, before a
minute of GPU, and beside the helper cap -- and usage is recorded even for a
reply that was stopped or errored, because an endpoint charges either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:48:14 +02:00

123 lines
4.6 KiB
Python

"""Reports: a feed of finished work, and one report on its own page.
List-plus-detail, the same shape as the library — and for the same reason, since
an instance running a daily schedule accumulates reports faster than anything
else here.
**There is no composer on either page, and no route below accepts a message.**
That is the whole character of the section rather than an omission: a report is
addressed to the reader and cannot be answered, and the way to be sure of that
is for the machinery that would answer to be absent. Nothing here renders
`chat/_message.html`, so there is no `sse-connect` anywhere on these pages and
nothing on them can start a generation.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response
from sqlalchemy import select
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.library import PAGE_SIZE, _page
from lembas.api.pages import sidebar_context
from lembas.db.models import Report
from lembas.security import permissions
from lembas.services import reports as reports_service
from lembas.services import sharing
from lembas.services.library import retrieval
from lembas.services.markdown import render_markdown
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_permission("reports.use"))], tags=["reports"])
@router.get("/reports")
async def reports_list(
request: Request,
db: Db,
user: RequiredUser,
q: str = "",
page: int = 1,
shared: bool = False,
):
"""`shared=1` narrows to reports other people have shared with this reader.
Reports became shareable at the same time as this filter appeared, and the
two arrived together on purpose: a feed that quietly grew somebody else's
work with no way to see only theirs is worse than one that never grew.
"""
if q.strip():
rows = reports_service.search(
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
)
pager = {"page": 1, "pages": 1, "total": len(rows)}
else:
rows, pager = _page(
db,
(
select(Report).where(sharing.only_shared(Report, user))
if shared
else reports_service.visible(user)
).order_by(Report.created_at.desc()),
page,
)
return render(
request,
"reports/index.html",
{
"section": "reports",
"reports": rows,
"q": q,
"shared": shared,
"pager": pager,
**sidebar_context(db, user),
},
)
@router.get("/reports/{report_id}")
async def report_detail(request: Request, db: Db, user: RequiredUser, report_id: str):
report = reports_service.get(db, report_id, user)
if report is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That report is not available.")
# Opening one is what reading it means. Done before rendering so the dot on
# the way in and the dot on the way back to the list agree -- the poller
# would otherwise re-announce a report the reader is looking at.
#
# Only the owner's own reading counts. `unread` is the owner's dot, and
# somebody a report was shared with opening it would otherwise clear a
# notification meant for a person who has not seen it.
if report.owner_id == user.id:
reports_service.mark_read(db, report)
return render(
request,
"reports/detail.html",
{
"section": "reports",
"report": report,
# Model output, through the one path allowed to emit HTML.
"body_html": render_markdown(report.body),
"can_share": permissions.has(db, user, "library.share"),
"is_owner": report.owner_id == user.id,
"share_kind": "report",
"share_id": report.id,
**sidebar_context(db, user),
},
)
@router.post("/api/reports/{report_id}/delete")
async def delete_report(db: Db, user: RequiredUser, report_id: str) -> Response:
# `owned`, not `get`: sharing grants reading, so being able to see a report
# is not being able to delete it out from under the person who filed it.
report = reports_service.owned(db, report_id, user)
if report is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That report is not available.")
reports_service.delete(db, report)
return RedirectResponse("/reports", status_code=status.HTTP_303_SEE_OTHER)