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>
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "0.9.5"
|
||||
__version__ = "0.9.6"
|
||||
|
||||
+138
-12
@@ -10,11 +10,21 @@ from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import ROLE_ADMIN, ROLE_PENDING, ROLE_USER, Group, Model, User
|
||||
from lembas.db.models import (
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
ROLE_ADMIN,
|
||||
ROLE_PENDING,
|
||||
ROLE_USER,
|
||||
Group,
|
||||
Model,
|
||||
User,
|
||||
)
|
||||
from lembas.security import permissions
|
||||
from lembas.security.passwords import hash_password, validate_password
|
||||
from lembas.security.sessions import revoke_all_for_user
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import settings_store, sharing
|
||||
from lembas.services import usage as usage_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -54,22 +64,71 @@ def _would_orphan_the_instance(db: DBSession, user: User) -> bool:
|
||||
|
||||
|
||||
# --- Users -------------------------------------------------------------------
|
||||
# List plus detail, which is the shape this codebase already mandates for admin
|
||||
# lists and the one `/admin/models` follows. The single page it replaces
|
||||
# rendered a full form per account *and* a membership grid, and edited that
|
||||
# membership from the opposite side to `/admin/groups` -- so a full-form POST
|
||||
# from either overwrote what the other had just shown.
|
||||
#
|
||||
# Membership is now edited from **one** side, the group's. A user's page links
|
||||
# to their groups and does not offer to change them, because two controls
|
||||
# writing one value is how each becomes the answer to "why did my change not
|
||||
# stick?".
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def users_page(request: Request, db: Db, user: AdminUser, q: str = "", saved: str = ""):
|
||||
async def users_page(
|
||||
request: Request, db: Db, user: AdminUser, q: str = "", saved: str = "", page: int = 1
|
||||
):
|
||||
query = select(User).order_by(User.created_at)
|
||||
if q.strip():
|
||||
pattern = f"%{q.strip()}%"
|
||||
query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern)))
|
||||
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = min(max(1, page), pages)
|
||||
rows = list(db.scalars(query.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE)))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/users.html",
|
||||
{
|
||||
"users": list(db.scalars(query)),
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"users": rows,
|
||||
"usage": {row.id: usage_service.summary(db, row) for row in rows},
|
||||
"roles": ROLES,
|
||||
"q": q,
|
||||
"saved": saved,
|
||||
"pager": {"page": page, "pages": pages, "total": total},
|
||||
"admin_count": _admin_count(db),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def user_detail(request: Request, db: Db, user: AdminUser, user_id: str, saved: str = ""):
|
||||
"""One account, and the answer to "what can this person actually do?".
|
||||
|
||||
That answer is `permissions.explain`, which is `resolve`'s working shown
|
||||
rather than thrown away. Read-only on purpose: every one of those switches
|
||||
is set somewhere else -- the baseline, or a named group -- and a control here
|
||||
would be a third place to change one thing.
|
||||
"""
|
||||
target = _user(db, user_id)
|
||||
return render(
|
||||
request,
|
||||
"admin/user_detail.html",
|
||||
{
|
||||
"target": target,
|
||||
"roles": ROLES,
|
||||
"explained": permissions.explain(db, target),
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"limits": permissions.limits_for(db, target),
|
||||
"limit_defs": permissions.LIMIT_DEFS,
|
||||
"usage": usage_service.summary(db, target),
|
||||
"models": permissions.models_visible_to(db, target),
|
||||
"saved": saved,
|
||||
"admin_count": _admin_count(db),
|
||||
},
|
||||
)
|
||||
@@ -114,8 +173,13 @@ async def update_user(
|
||||
name: str = Form(...),
|
||||
role: str = Form(ROLE_USER),
|
||||
active: bool = Form(False),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
"""Name, role and whether the account is active. **Not membership.**
|
||||
|
||||
That moved to the group's page. It used to be here as well, and a full-form
|
||||
POST from either side overwrote whatever the other had -- two controls, one
|
||||
value, and no answer to which one wins.
|
||||
"""
|
||||
target = _user(db, user_id)
|
||||
|
||||
losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active)
|
||||
@@ -128,7 +192,6 @@ async def update_user(
|
||||
target.name = name.strip()[:120] or target.name
|
||||
target.role = role if role in ROLES else target.role
|
||||
target.active = active
|
||||
target.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
|
||||
|
||||
# A deactivated or demoted user must lose their live sessions immediately,
|
||||
# otherwise the change only takes effect when their cookie happens to expire.
|
||||
@@ -137,7 +200,9 @@ async def update_user(
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active)
|
||||
return RedirectResponse(f"/admin/users?saved=Saved+{target.email}.", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{target.id}?saved=Saved+{target.email}.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
@@ -146,7 +211,7 @@ async def reset_password(
|
||||
) -> Response:
|
||||
target = _user(db, user_id)
|
||||
if (problem := validate_password(password)) is not None:
|
||||
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
|
||||
return RedirectResponse(f"/admin/users/{user_id}?saved={problem}", status_code=303)
|
||||
|
||||
target.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
@@ -155,7 +220,7 @@ async def reset_password(
|
||||
revoke_all_for_user(db, target)
|
||||
log.info("%s reset the password for %s", user.email, target.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/users?saved=Password+reset+for+{target.email}.+Sessions+revoked.",
|
||||
f"/admin/users/{target.id}?saved=Password+reset.+Sessions+revoked.",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
@@ -175,6 +240,15 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
|
||||
|
||||
email = target.email
|
||||
# Chats and folders cascade; that is the point of deleting an account.
|
||||
#
|
||||
# Shares do not, and never did. `Share.principal_id` and
|
||||
# `Share.resource_id` both point at one of several tables depending on a
|
||||
# sibling column, which SQLite cannot express as a foreign key -- so a
|
||||
# deleted account left behind every grant *to* it and every grant *of* its
|
||||
# own work. Both halves, and both before the delete, while the rows are
|
||||
# still there to be found.
|
||||
sharing.forget_owner(db, target.id)
|
||||
sharing.forget_principal(db, PRINCIPAL_USER, target.id)
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
log.info("%s deleted account %s", user.email, email)
|
||||
@@ -182,17 +256,42 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
|
||||
|
||||
|
||||
# --- Groups ------------------------------------------------------------------
|
||||
# The same list-plus-detail shape. The old page rendered every group's full
|
||||
# permission grid, every member and every model on one screen, which is fine for
|
||||
# two groups and unreadable at ten.
|
||||
@router.get("/groups")
|
||||
async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
groups = list(db.scalars(select(Group).order_by(Group.name)))
|
||||
return render(
|
||||
request,
|
||||
"admin/groups.html",
|
||||
{
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"groups": groups,
|
||||
"granted": {
|
||||
group.id: sum(1 for on in (group.permissions_json or {}).values() if on)
|
||||
for group in groups
|
||||
},
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"baseline": permissions.baseline_permissions(db),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}")
|
||||
async def group_detail(request: Request, db: Db, user: AdminUser, group_id: str, saved: str = ""):
|
||||
group = _group(db, group_id)
|
||||
return render(
|
||||
request,
|
||||
"admin/group_detail.html",
|
||||
{
|
||||
"group": group,
|
||||
"users": list(db.scalars(select(User).order_by(User.name))),
|
||||
"models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))),
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"baseline": permissions.baseline_permissions(db),
|
||||
"limit_defs": permissions.LIMIT_DEFS,
|
||||
"limits": group.limits_json or {},
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
@@ -216,6 +315,7 @@ async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Respon
|
||||
|
||||
@router.post("/groups/{group_id}")
|
||||
async def update_group(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
group_id: str,
|
||||
@@ -226,6 +326,7 @@ async def update_group(
|
||||
model_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
group = _group(db, group_id)
|
||||
form = await request.form()
|
||||
|
||||
group.name = name.strip()[:120] or group.name
|
||||
group.description = description.strip()[:1000]
|
||||
@@ -235,9 +336,26 @@ async def update_group(
|
||||
group.users = list(db.scalars(select(User).where(User.id.in_(user_ids or []))))
|
||||
group.models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
|
||||
|
||||
# Quotas. Only what was submitted and could be read as a number is stored, so
|
||||
# a blank box means "this group has no opinion" and contributes nothing to
|
||||
# the resolution -- which is what `limits_for` needs in order to tell it
|
||||
# apart from a deliberate zero, and zero here means *no limit*.
|
||||
wanted: dict[str, int] = {}
|
||||
for key in permissions.LIMIT_KEYS:
|
||||
raw = str(form.get(f"limit_{key}") or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
wanted[key] = max(0, int(raw))
|
||||
except ValueError:
|
||||
continue
|
||||
group.limits_json = wanted
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated group %s", user.email, group.name)
|
||||
return RedirectResponse(f"/admin/groups?saved=Saved+{group.name}.", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/admin/groups/{group.id}?saved=Saved+{group.name}.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/delete")
|
||||
@@ -245,8 +363,16 @@ async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response:
|
||||
group = _group(db, group_id)
|
||||
name = group.name
|
||||
# Members and model links go with it; the users themselves are untouched.
|
||||
#
|
||||
# Every share naming this group goes too. Nothing cascades -- see
|
||||
# `sharing.forget_principal` -- so a deleted group left its grants behind,
|
||||
# and a group id is a random hex string that nothing reissues today and
|
||||
# nothing promises not to reissue tomorrow.
|
||||
dropped = sharing.forget_principal(db, PRINCIPAL_GROUP, group.id)
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
if dropped:
|
||||
log.info("dropped %d share(s) naming group %s", dropped, name)
|
||||
log.info("%s deleted group %s", user.email, name)
|
||||
return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303)
|
||||
|
||||
|
||||
@@ -975,6 +975,36 @@ def _note_rewind(chat: Chat) -> None:
|
||||
chat.rewound_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
|
||||
"""Why this account may not start another reply right now, or "".
|
||||
|
||||
In-process, and that is exact rather than approximate only because this
|
||||
application runs one worker -- see the first known limit in PLAN.md. With
|
||||
several, this becomes a guess, and a quota that is a guess should be a
|
||||
number in the database instead. Stated here rather than discovered.
|
||||
"""
|
||||
from lembas.security import permissions
|
||||
|
||||
ceiling = permissions.limit(db, user, "concurrent_replies")
|
||||
if ceiling <= 0:
|
||||
return ""
|
||||
mine = {
|
||||
row[0]
|
||||
for row in db.execute(select(Chat.id).where(Chat.user_id == user.id)).all()
|
||||
}
|
||||
running = sum(
|
||||
1
|
||||
for chat_id in mine
|
||||
if chat_id != chat.id and generation_service.running_for(chat_id) is not None
|
||||
)
|
||||
if running < ceiling:
|
||||
return ""
|
||||
return (
|
||||
f"You already have {running} repl{'y' if running == 1 else 'ies'} being "
|
||||
f"written, which is this account's limit. Wait for one to finish."
|
||||
)
|
||||
|
||||
|
||||
def _send(
|
||||
request: Request,
|
||||
db: Db,
|
||||
@@ -997,6 +1027,18 @@ def _send(
|
||||
prefixes of it, with Stop pointing at whichever bubble came first in the
|
||||
document.
|
||||
"""
|
||||
# How many of *this account's* chats are already writing. Checked here and
|
||||
# not inside `generation`, because this is where there is somebody to tell:
|
||||
# a schedule firing or a finished job waking a chat has nobody at the
|
||||
# keyboard, and refusing those would be a quota silently eating work an
|
||||
# administrator set up on purpose.
|
||||
#
|
||||
# This chat's own reply does not count against it -- a second message here
|
||||
# is queued rather than sent, a few lines down, and that path is what the
|
||||
# queue is for.
|
||||
if busy := _too_many_replies(db, chat, user):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, busy)
|
||||
|
||||
if queued := _reply_in_flight(db, chat):
|
||||
waiting = db.scalar(
|
||||
select(func.count())
|
||||
|
||||
+73
-37
@@ -23,10 +23,7 @@ from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import (
|
||||
AUTHOR_USER,
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
Document,
|
||||
Group,
|
||||
KnowledgeBase,
|
||||
Note,
|
||||
Skill,
|
||||
@@ -61,32 +58,21 @@ def _page(db: DBSession, query, page: int):
|
||||
return rows, {"page": page, "pages": pages, "total": total}
|
||||
|
||||
|
||||
def _shared_context(db: DBSession, user: User, resource) -> dict:
|
||||
"""Everything the share panel on a detail page needs."""
|
||||
grants = sharing.grants_for(db, resource)
|
||||
def _shared_context(db: DBSession, user: User, resource, kind: str) -> dict:
|
||||
"""What the share placeholder needs, which is now three facts.
|
||||
|
||||
The panel itself is fetched from `api/sharing.py`, so the names, the search
|
||||
and the grants are no longer built here -- and neither is a query for every
|
||||
account on the instance on every detail page.
|
||||
"""
|
||||
return {
|
||||
"can_share": permissions.has(db, user, "library.share"),
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"people": list(
|
||||
db.scalars(select(User).where(User.id != user.id).order_by(User.name))
|
||||
),
|
||||
"shared_users": [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER],
|
||||
"shared_groups": [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP],
|
||||
"is_owner": resource.owner_id == user.id,
|
||||
"share_kind": kind,
|
||||
"share_id": resource.id,
|
||||
}
|
||||
|
||||
|
||||
def _apply_shares(db: DBSession, user: User, resource, form) -> None:
|
||||
if not permissions.has(db, user, "library.share") or resource.owner_id != user.id:
|
||||
return
|
||||
sharing.set_grants(
|
||||
db,
|
||||
resource,
|
||||
user_ids=form.getlist("share_user"),
|
||||
group_ids=form.getlist("share_group"),
|
||||
)
|
||||
|
||||
|
||||
# --- Shell -------------------------------------------------------------------
|
||||
@router.get("/library")
|
||||
async def library_home(user: RequiredUser):
|
||||
@@ -98,9 +84,21 @@ async def library_home(user: RequiredUser):
|
||||
# before /library/knowledge/{base_id}, or "document" is parsed as a base id.
|
||||
# FastAPI matches in registration order and this has bitten before.
|
||||
@router.get("/library/knowledge")
|
||||
async def knowledge_list(request: Request, db: Db, user: RequiredUser, error: str = ""):
|
||||
"""The bases, not the documents. A library is a set of places first."""
|
||||
bases = list(db.scalars(documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)))
|
||||
async def knowledge_list(
|
||||
request: Request, db: Db, user: RequiredUser, error: str = "", shared: bool = False
|
||||
):
|
||||
"""The bases, not the documents. A library is a set of places first.
|
||||
|
||||
`shared=1` narrows to bases other people have given this reader — the same
|
||||
filter the notes and skills lists carry, and the one that makes "what have
|
||||
people shared with me?" a question with an answer.
|
||||
"""
|
||||
query = (
|
||||
select(KnowledgeBase).where(sharing.only_shared(KnowledgeBase, user))
|
||||
if shared
|
||||
else documents_service.visible_bases(db, user)
|
||||
)
|
||||
bases = list(db.scalars(query.order_by(KnowledgeBase.name)))
|
||||
counts = {
|
||||
base.id: db.scalar(
|
||||
select(func.count()).select_from(Document).where(Document.base_id == base.id)
|
||||
@@ -115,6 +113,7 @@ async def knowledge_list(request: Request, db: Db, user: RequiredUser, error: st
|
||||
"section": "knowledge",
|
||||
"bases": bases,
|
||||
"counts": counts,
|
||||
"shared": shared,
|
||||
"error": error,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -200,7 +199,7 @@ async def base_detail(
|
||||
"documents": rows,
|
||||
"q": q,
|
||||
"pager": pager,
|
||||
**_shared_context(db, user, base),
|
||||
**_shared_context(db, user, base, "base"),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -220,7 +219,6 @@ async def update_base(request: Request, db: Db, user: RequiredUser, base_id: str
|
||||
base.name = name
|
||||
base.description = str(form.get("description", "")).strip()[:2000]
|
||||
db.commit()
|
||||
_apply_shares(db, user, base, form)
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
@@ -345,16 +343,34 @@ async def document_content(db: Db, user: RequiredUser, document_id: str) -> Resp
|
||||
|
||||
# --- Notes -------------------------------------------------------------------
|
||||
@router.get("/library/notes")
|
||||
async def notes_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
async def notes_list(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
shared: bool = False,
|
||||
):
|
||||
"""`shared=1` narrows to what other people have given this reader.
|
||||
|
||||
A separate view rather than a badge in the mixed list. A badge answers "is
|
||||
this mine?" for a row already on screen; the question somebody has is "what
|
||||
have people given me?", which a mixed list of two hundred cannot answer.
|
||||
Searching inside it is deliberately left out -- the search path returns
|
||||
ranked ids and re-filtering them by owner would silently shorten the page.
|
||||
"""
|
||||
if q.strip():
|
||||
rows = notes_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, notes_service.visible(db, user).order_by(Note.updated_at.desc()), page
|
||||
query = (
|
||||
select(Note).where(sharing.only_shared(Note, user))
|
||||
if shared
|
||||
else notes_service.visible(db, user)
|
||||
)
|
||||
rows, pager = _page(db, query.order_by(Note.updated_at.desc()), page)
|
||||
return render(
|
||||
request,
|
||||
"library/notes.html",
|
||||
@@ -362,6 +378,7 @@ async def notes_list(request: Request, db: Db, user: RequiredUser, q: str = "",
|
||||
"section": "notes",
|
||||
"notes": rows,
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -389,7 +406,7 @@ async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str
|
||||
"section": "notes",
|
||||
"note": note,
|
||||
"body_html": render_markdown(note.body),
|
||||
**_shared_context(db, user, note),
|
||||
**_shared_context(db, user, note, "note"),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -413,7 +430,6 @@ async def update_note(request: Request, db: Db, user: RequiredUser, note_id: str
|
||||
|
||||
form = await request.form()
|
||||
notes_service.update(db, note, title=str(form.get("title", "")), body=str(form.get("body", "")))
|
||||
_apply_shares(db, user, note, form)
|
||||
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@@ -428,14 +444,34 @@ async def delete_note(db: Db, user: RequiredUser, note_id: str) -> Response:
|
||||
|
||||
# --- Skills ------------------------------------------------------------------
|
||||
@router.get("/library/skills")
|
||||
async def skills_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
async def skills_list(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
shared: bool = False,
|
||||
):
|
||||
"""`shared=1` narrows to what other people have given this reader.
|
||||
|
||||
A separate view rather than a badge in the mixed list. A badge answers "is
|
||||
this mine?" for a row already on screen; the question somebody has is "what
|
||||
have people given me?", which a mixed list of two hundred cannot answer.
|
||||
Searching inside it is deliberately left out -- the search path returns
|
||||
ranked ids and re-filtering them by owner would silently shorten the page.
|
||||
"""
|
||||
if q.strip():
|
||||
rows = skills_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, skills_service.visible(db, user).order_by(Skill.name), page)
|
||||
query = (
|
||||
select(Skill).where(sharing.only_shared(Skill, user))
|
||||
if shared
|
||||
else skills_service.visible(db, user)
|
||||
)
|
||||
rows, pager = _page(db, query.order_by(Skill.name), page)
|
||||
return render(
|
||||
request,
|
||||
"library/skills.html",
|
||||
@@ -443,6 +479,7 @@ async def skills_list(request: Request, db: Db, user: RequiredUser, q: str = "",
|
||||
"section": "skills",
|
||||
"skills": rows,
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -470,7 +507,7 @@ async def skill_detail(request: Request, db: Db, user: RequiredUser, skill_id: s
|
||||
"section": "skills",
|
||||
"skill": skill,
|
||||
"revisions": skill.revisions,
|
||||
**_shared_context(db, user, skill),
|
||||
**_shared_context(db, user, skill, "skill"),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -511,7 +548,6 @@ async def update_skill(request: Request, db: Db, user: RequiredUser, skill_id: s
|
||||
author=AUTHOR_USER,
|
||||
note="edited by hand",
|
||||
)
|
||||
_apply_shares(db, user, skill, form)
|
||||
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
|
||||
@@ -18,12 +18,15 @@ 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
|
||||
@@ -34,7 +37,20 @@ router = APIRouter(dependencies=[Depends(require_permission("reports.use"))], ta
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
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)
|
||||
@@ -42,7 +58,13 @@ async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = ""
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
rows, pager = _page(
|
||||
db, reports_service.visible(user).order_by(Report.created_at.desc()), 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,
|
||||
@@ -51,6 +73,7 @@ async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = ""
|
||||
"section": "reports",
|
||||
"reports": rows,
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -65,7 +88,12 @@ async def report_detail(request: Request, db: Db, user: RequiredUser, report_id:
|
||||
# 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.
|
||||
reports_service.mark_read(db, report)
|
||||
#
|
||||
# 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",
|
||||
@@ -74,6 +102,10 @@ async def report_detail(request: Request, db: Db, user: RequiredUser, report_id:
|
||||
"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),
|
||||
},
|
||||
)
|
||||
@@ -81,7 +113,9 @@ async def report_detail(request: Request, db: Db, user: RequiredUser, report_id:
|
||||
|
||||
@router.post("/api/reports/{report_id}/delete")
|
||||
async def delete_report(db: Db, user: RequiredUser, report_id: str) -> Response:
|
||||
report = reports_service.get(db, report_id, user)
|
||||
# `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)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Giving somebody else access to one thing.
|
||||
|
||||
Its own routes and its own fragment, rather than a block of checkboxes riding
|
||||
along with the resource's save form. Three reasons, in the order they bite:
|
||||
|
||||
- **It rendered every group and every person on the instance, unpaginated, on
|
||||
every detail page.** That is fine for a household and unusable for anything
|
||||
else, and the page it is on has nothing to do with how many accounts exist.
|
||||
- **A share was only stored if the resource was saved.** Ticking a box and
|
||||
navigating away did nothing, silently, which is the shape of failure this
|
||||
codebase keeps cataloguing.
|
||||
- Sharing a *report* has no save form to ride along with at all.
|
||||
|
||||
So: search, and each grant is its own POST. The fragment re-renders itself after
|
||||
every change, which is what keeps "who can see this" a thing you read rather
|
||||
than a thing you reconstruct from checkboxes.
|
||||
|
||||
**Only the owner may reach any of it.** Somebody a thing was shared with cannot
|
||||
share it on -- that is what keeps "who can see this?" answerable by asking one
|
||||
person -- and the check is `sharing.can_write`, which is ownership and nothing
|
||||
else.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import (
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
Group,
|
||||
KnowledgeBase,
|
||||
Note,
|
||||
Report,
|
||||
Skill,
|
||||
User,
|
||||
)
|
||||
from lembas.security import permissions
|
||||
from lembas.services import sharing
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/library/share", tags=["sharing"])
|
||||
|
||||
# What a URL may name, and what it resolves to. A fixed table rather than a
|
||||
# lookup by string on `sharing.RESOURCE_TYPES`, because that one maps class to
|
||||
# string and this needs the other direction -- and because a route segment is
|
||||
# request input, so the set of things it may name belongs written down.
|
||||
KINDS: dict[str, type] = {
|
||||
"base": KnowledgeBase,
|
||||
"note": Note,
|
||||
"skill": Skill,
|
||||
"report": Report,
|
||||
}
|
||||
|
||||
# Candidates offered at once. Enough that a small instance never has to type
|
||||
# anything, few enough that a large one is not a page of names.
|
||||
MAX_CANDIDATES = 12
|
||||
|
||||
|
||||
def _resource(db: Db, kind: str, resource_id: str, user: User):
|
||||
model = KINDS.get(kind)
|
||||
if model is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Not a shareable kind.")
|
||||
resource = db.get(model, resource_id)
|
||||
# Ownership, not readability. Being able to see a thing is not being able to
|
||||
# give it away, and the 404 rather than a 403 is deliberate: somebody who
|
||||
# cannot share it has no business learning whether it exists.
|
||||
if resource is None or not sharing.can_write(resource, user):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That is not yours to share.")
|
||||
return resource
|
||||
|
||||
|
||||
def _panel(request: Request, db: Db, user: User, kind: str, resource, q: str = "") -> Response:
|
||||
grants = sharing.grants_for(db, resource)
|
||||
shared_users = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER]
|
||||
shared_groups = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP]
|
||||
|
||||
needle = q.strip()
|
||||
pattern = f"%{needle}%"
|
||||
group_query = select(Group).order_by(Group.name)
|
||||
people_query = select(User).where(User.id != user.id).order_by(User.name)
|
||||
if needle:
|
||||
group_query = group_query.where(Group.name.ilike(pattern))
|
||||
people_query = people_query.where(
|
||||
or_(User.name.ilike(pattern), User.email.ilike(pattern))
|
||||
)
|
||||
|
||||
# Anything already shared is shown whatever the search says, or the only way
|
||||
# to remove a grant would be to search for the name it was given to.
|
||||
groups = list(db.scalars(group_query.limit(MAX_CANDIDATES)))
|
||||
people = list(db.scalars(people_query.limit(MAX_CANDIDATES)))
|
||||
for existing in db.scalars(select(Group).where(Group.id.in_(shared_groups or [""]))):
|
||||
if existing.id not in {g.id for g in groups}:
|
||||
groups.insert(0, existing)
|
||||
for existing in db.scalars(select(User).where(User.id.in_(shared_users or [""]))):
|
||||
if existing.id not in {p.id for p in people}:
|
||||
people.insert(0, existing)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"library/_share_panel.html",
|
||||
{
|
||||
"kind": kind,
|
||||
"resource": resource,
|
||||
"q": needle,
|
||||
"groups": groups,
|
||||
"people": people,
|
||||
"shared_users": shared_users,
|
||||
"shared_groups": shared_groups,
|
||||
"share_count": len(grants),
|
||||
# Whether the lists were cut, so the panel can say "search for
|
||||
# somebody" rather than implying these are all the names there are.
|
||||
"truncated": len(people) >= MAX_CANDIDATES or len(groups) >= MAX_CANDIDATES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{kind}/{resource_id}")
|
||||
async def share_panel(
|
||||
request: Request, db: Db, user: RequiredUser, kind: str, resource_id: str, q: str = ""
|
||||
) -> Response:
|
||||
resource = _resource(db, kind, resource_id, user)
|
||||
if not permissions.has(db, user, "library.share"):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not share things.")
|
||||
return _panel(request, db, user, kind, resource, q)
|
||||
|
||||
|
||||
@router.post("/{kind}/{resource_id}")
|
||||
async def set_share(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
kind: str,
|
||||
resource_id: str,
|
||||
principal_type: str = Form(""),
|
||||
principal_id: str = Form(""),
|
||||
on: bool = Form(False),
|
||||
q: str = Form(""),
|
||||
) -> Response:
|
||||
"""Add or remove one grant, and answer with the panel.
|
||||
|
||||
One grant per request rather than a submitted set, because the set is what
|
||||
made the old panel need every name on the instance in front of you before
|
||||
you could change one of them.
|
||||
"""
|
||||
resource = _resource(db, kind, resource_id, user)
|
||||
if not permissions.has(db, user, "library.share"):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not share things.")
|
||||
if principal_type not in (PRINCIPAL_USER, PRINCIPAL_GROUP):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unknown principal.")
|
||||
|
||||
grants = sharing.grants_for(db, resource)
|
||||
users = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER]
|
||||
groups = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP]
|
||||
target = users if principal_type == PRINCIPAL_USER else groups
|
||||
|
||||
# Validated against what exists, so a crafted id cannot write a grant naming
|
||||
# nothing -- which would be invisible in the panel and unremovable from it.
|
||||
exists = db.get(User if principal_type == PRINCIPAL_USER else Group, principal_id)
|
||||
if on and exists is not None and principal_id not in target:
|
||||
target.append(principal_id)
|
||||
elif not on and principal_id in target:
|
||||
target.remove(principal_id)
|
||||
|
||||
sharing.set_grants(db, resource, user_ids=users, group_ids=groups)
|
||||
log.info(
|
||||
"%s %s %s %s with %s", user.email, "shared" if on else "unshared", kind,
|
||||
resource_id, principal_id,
|
||||
)
|
||||
return _panel(request, db, user, kind, resource, q)
|
||||
@@ -48,6 +48,7 @@ from lembas.db.models.library import (
|
||||
PRINCIPAL_USER,
|
||||
RESOURCE_BASE,
|
||||
RESOURCE_NOTE,
|
||||
RESOURCE_REPORT,
|
||||
RESOURCE_SKILL,
|
||||
SOURCE_LINK,
|
||||
SOURCE_UPLOAD,
|
||||
@@ -101,6 +102,7 @@ from lembas.db.models.user import (
|
||||
Group,
|
||||
PushSubscription,
|
||||
Session,
|
||||
Usage,
|
||||
User,
|
||||
user_groups,
|
||||
)
|
||||
@@ -108,6 +110,7 @@ from lembas.db.models.user import (
|
||||
__all__ = [
|
||||
"AUTHOR_MODEL",
|
||||
"PushSubscription",
|
||||
"Usage",
|
||||
"AUTH_KEY",
|
||||
"AUTH_METHODS",
|
||||
"AUTH_PASSWORD",
|
||||
@@ -126,6 +129,7 @@ __all__ = [
|
||||
"PRINCIPAL_USER",
|
||||
"RESOURCE_BASE",
|
||||
"RESOURCE_NOTE",
|
||||
"RESOURCE_REPORT",
|
||||
"RESOURCE_SKILL",
|
||||
"RESPONSE_JSON",
|
||||
"RESPONSE_MODES",
|
||||
|
||||
@@ -53,6 +53,13 @@ SOURCE_LINK = "link"
|
||||
RESOURCE_BASE = "base"
|
||||
RESOURCE_NOTE = "note"
|
||||
RESOURCE_SKILL = "skill"
|
||||
# A report is shareable and a memory is not, and the line between them is the
|
||||
# one already drawn elsewhere: a finished piece of work is exactly the thing
|
||||
# somebody wants to hand over, and a record *about a person* is not content to
|
||||
# pass round. The constant lives here beside the other three even though Report
|
||||
# is not a library model, because `Share.resource_type` is one column and its
|
||||
# vocabulary belongs in one place.
|
||||
RESOURCE_REPORT = "report"
|
||||
|
||||
PRINCIPAL_USER = "user"
|
||||
PRINCIPAL_GROUP = "group"
|
||||
|
||||
@@ -5,7 +5,18 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
@@ -69,6 +80,16 @@ class Group(UUIDPrimaryKey, Timestamps, Base):
|
||||
# lembas.security.permissions.
|
||||
permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# What members of this group may spend. Resolved across a user's groups by
|
||||
# **maximum**, which is the union rule applied to numbers: being in a second
|
||||
# group can only ever grant more. Zero means "no limit" and therefore wins
|
||||
# outright, because a group that says "unlimited" saying less than one that
|
||||
# says "a million" would be the union rule inverted for one value.
|
||||
#
|
||||
# Absent keys mean the group has no opinion and contribute nothing. See
|
||||
# security/permissions.py:limits_for.
|
||||
limits_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups")
|
||||
models: Mapped[list[Model]] = relationship(
|
||||
"Model", secondary="model_groups", back_populates="groups"
|
||||
@@ -149,3 +170,43 @@ class PushSubscription(UUIDPrimaryKey, Timestamps, Base):
|
||||
|
||||
|
||||
Index("ix_push_subscriptions_user_id", PushSubscription.user_id)
|
||||
|
||||
|
||||
class Usage(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""What one account spent in one period.
|
||||
|
||||
A row per user per period rather than a row per reply. A per-reply ledger is
|
||||
what somebody eventually wants for a bill; this exists to answer one
|
||||
question on the request path -- "has this account used its month?" -- and
|
||||
that question wants one indexed lookup, not a sum over ten thousand rows.
|
||||
|
||||
`period` is a plain "YYYY-MM" string in **UTC**. Not the reader's timezone:
|
||||
a quota that resets at a different instant for each member of a group is a
|
||||
quota nobody can reason about, and the month boundary is not something
|
||||
anybody experiences to the hour.
|
||||
|
||||
Written by `generation._persist`, which is the single writer for everything
|
||||
a reply produced, so a reply that is stopped or errors still records what it
|
||||
spent -- an endpoint charges for tokens it generated whether or not the
|
||||
reply was wanted.
|
||||
"""
|
||||
|
||||
__tablename__ = "usage"
|
||||
__table_args__ = (UniqueConstraint("user_id", "period", name="uq_usage_user_period"),)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
period: Mapped[str] = mapped_column(String(7), nullable=False)
|
||||
|
||||
prompt_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
completion_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
replies: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
# Counted separately because it is its own quota: one picture is a minute of
|
||||
# somebody's GPU and no tokens at all, so a token budget says nothing about
|
||||
# it. `images_today` on the resolved limits is the daily half; this is the
|
||||
# month's running total, for the admin screen.
|
||||
images: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Usage {self.user_id} {self.period}>"
|
||||
|
||||
@@ -41,6 +41,7 @@ from lembas.api import (
|
||||
push,
|
||||
reports,
|
||||
schedules,
|
||||
sharing,
|
||||
terminal,
|
||||
)
|
||||
from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
|
||||
@@ -194,6 +195,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(reports.router)
|
||||
app.include_router(schedules.router)
|
||||
app.include_router(agents.router)
|
||||
app.include_router(sharing.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(admin_models.router)
|
||||
|
||||
@@ -225,9 +225,15 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
PermissionDef(
|
||||
"library.share",
|
||||
"Share library items",
|
||||
"Give other people, or a group, access to their documents, notes and "
|
||||
"skills. Sharing grants reading only.",
|
||||
False,
|
||||
"Give other people, or a group, access to their knowledge bases, notes, "
|
||||
"skills and reports. Sharing grants reading only — never changing, and "
|
||||
"never sharing on.",
|
||||
# On. It was off, which meant sharing shipped documented as done and
|
||||
# unreachable: the panel is only rendered for somebody who holds this,
|
||||
# so out of the box nobody could share anything and nothing said why.
|
||||
# An instance that wants it off can say so; one that never looked should
|
||||
# get the feature it was told it had.
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
@@ -260,8 +266,53 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
# --- Reading and writing, split where the difference matters ------------
|
||||
# Three gates cover both, and for these three the two halves are genuinely
|
||||
# different decisions: a model that may *read* somebody's notes and not add
|
||||
# to them is a reasonable thing to want, and until now `tools.notes` was one
|
||||
# switch over five tools.
|
||||
#
|
||||
# Not split for every gate. `tools.web_search` has no write half; `report`
|
||||
# is a write with no read worth withholding; `agent` has modes, which are a
|
||||
# finer instrument than a permission and are per chat. A permission that
|
||||
# answers "the same as that one" is a permission nobody should be asked
|
||||
# about -- the reasoning `schedule.use` already carries.
|
||||
#
|
||||
# **All three default on**, so an instance that never looks behaves exactly
|
||||
# as it did: `_family_allowed` reads them only to *narrow* what the gate
|
||||
# already allowed.
|
||||
PermissionDef(
|
||||
"tools.notes.write",
|
||||
"Write notes",
|
||||
"Let a model create, change and delete notes. Without it, it can still "
|
||||
"search and read the ones that are there.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.memory.write",
|
||||
"Record memories",
|
||||
"Let a model add and forget short facts about this person. Without it, "
|
||||
"the memories it already has are still shown to it every turn.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.skills.write",
|
||||
"Write skills",
|
||||
"Let a model write new skills and change existing ones. Without it, it "
|
||||
"follows the skills that are there and cannot add to them — which is "
|
||||
"the setting for an instance whose skills are curated by hand.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
)
|
||||
|
||||
# Gates whose read and write halves are separate permissions. Keyed on the gate,
|
||||
# with the permission derived as `tools.<gate>.write`, so adding a fourth is one
|
||||
# entry here and one PermissionDef above.
|
||||
SPLIT_GATES = ("notes", "memory", "skills")
|
||||
|
||||
PERMISSION_KEYS = tuple(d.key for d in PERMISSION_DEFS)
|
||||
DEFAULT_PERMISSIONS = {d.key: d.default for d in PERMISSION_DEFS}
|
||||
|
||||
@@ -304,6 +355,128 @@ def has(db: DBSession, user: User | None, key: str) -> bool:
|
||||
return resolve(db, user).get(key, False)
|
||||
|
||||
|
||||
def explain(db: DBSession, user: User | None) -> dict[str, dict]:
|
||||
"""Every permission, whether this user has it, and **where it came from**.
|
||||
|
||||
The question the admin screens could not answer. `resolve` has always
|
||||
computed the union and thrown the working away, so "why can this person do
|
||||
X?" meant opening every group they belong to and reading the grids by eye --
|
||||
which is exactly the simulation the union rule exists to avoid needing.
|
||||
|
||||
`source` is "admin" (bypassing everything), "baseline", or the names of the
|
||||
groups that granted it. A permission that is off has no source, because
|
||||
nothing granted it -- there is no such thing as a deny here to point at.
|
||||
"""
|
||||
keys = PERMISSION_KEYS
|
||||
if user is None:
|
||||
return {key: {"on": False, "source": []} for key in keys}
|
||||
if user.is_admin:
|
||||
return {key: {"on": True, "source": ["admin"]} for key in keys}
|
||||
|
||||
baseline = baseline_permissions(db)
|
||||
out: dict[str, dict] = {}
|
||||
for key in keys:
|
||||
sources = ["baseline"] if baseline.get(key) else []
|
||||
sources += [
|
||||
group.name for group in user.groups if (group.permissions_json or {}).get(key)
|
||||
]
|
||||
out[key] = {"on": bool(sources), "source": sources}
|
||||
return out
|
||||
|
||||
|
||||
# --- Quotas -------------------------------------------------------------------
|
||||
# What a group may raise, and what each number means. Every one of them is
|
||||
# **zero for no limit**, which is the convention `max_completion_tokens` and
|
||||
# `index_chars` already use here, and it is what makes "unlimited" sayable at all.
|
||||
#
|
||||
# Five axes rather than one, because they fail differently and a single "budget"
|
||||
# would have to pick an exchange rate between a token and a minute of somebody's
|
||||
# GPU. There isn't one.
|
||||
LIMIT_DEFS: tuple[tuple[str, str, str], ...] = (
|
||||
(
|
||||
"monthly_tokens",
|
||||
"Tokens a month",
|
||||
"Prompt and completion together, across every chat, reset on the first "
|
||||
"of the month. Reached, a reply says so before it spends anything "
|
||||
"rather than stopping half way through.",
|
||||
),
|
||||
(
|
||||
"concurrent_replies",
|
||||
"Replies at once",
|
||||
"How many of their chats may be writing at the same time. This is the "
|
||||
"one that stops one person queueing every other person's work behind "
|
||||
"them on a single endpoint.",
|
||||
),
|
||||
(
|
||||
"agent_seconds",
|
||||
"Longest agent reply",
|
||||
"Seconds of wall clock for one reply in an agent chat, if lower than "
|
||||
"the instance's own. Waiting for somebody to approve something does "
|
||||
"not count.",
|
||||
),
|
||||
(
|
||||
"images_per_day",
|
||||
"Images a day",
|
||||
"Each one is a minute of somebody's GPU and no tokens at all, so a "
|
||||
"token budget says nothing about it.",
|
||||
),
|
||||
(
|
||||
"helpers_per_reply",
|
||||
"Helpers per reply",
|
||||
"How many subagents one reply may send, if lower than the instance's "
|
||||
"own.",
|
||||
),
|
||||
)
|
||||
|
||||
LIMIT_KEYS = tuple(key for key, _, _ in LIMIT_DEFS)
|
||||
|
||||
# Nobody is limited until somebody says so. A quota that arrived with an upgrade
|
||||
# and started refusing replies would be the worst possible way to introduce one.
|
||||
NO_LIMITS: dict[str, int] = dict.fromkeys(LIMIT_KEYS, 0)
|
||||
|
||||
|
||||
def limits_for(db: DBSession, user: User | None) -> dict[str, int]:
|
||||
"""What this user may spend, resolved across their groups.
|
||||
|
||||
**By maximum**, which is the union rule applied to numbers: being in a second
|
||||
group can only ever grant more, never less. That is the same promise the
|
||||
permissions make, and having one of the two work the other way round is how
|
||||
"why can this person not do X" stops being answerable.
|
||||
|
||||
**Zero wins outright**, because zero means "no limit". Taking the plain
|
||||
maximum would make a group saying "unlimited" count for less than one saying
|
||||
"a million", which is the union rule inverted for exactly one value -- and it
|
||||
is the value somebody sets when they mean *stop limiting this person*.
|
||||
|
||||
An administrator is unlimited, for the reason `resolve` gives them every
|
||||
permission: they can raise their own quota in two clicks, and pretending
|
||||
otherwise is theatre.
|
||||
"""
|
||||
if user is None or user.is_admin:
|
||||
return dict(NO_LIMITS)
|
||||
|
||||
resolved = dict(NO_LIMITS)
|
||||
for key in LIMIT_KEYS:
|
||||
values = []
|
||||
for group in user.groups:
|
||||
raw = (group.limits_json or {}).get(key)
|
||||
if raw is None:
|
||||
continue # no opinion, contributes nothing
|
||||
try:
|
||||
values.append(max(0, int(raw)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not values or 0 in values:
|
||||
resolved[key] = 0
|
||||
else:
|
||||
resolved[key] = max(values)
|
||||
return resolved
|
||||
|
||||
|
||||
def limit(db: DBSession, user: User | None, key: str) -> int:
|
||||
return limits_for(db, user).get(key, 0)
|
||||
|
||||
|
||||
def models_visible_to(db: DBSession, user: User | None) -> list[Model]:
|
||||
"""Models a user may start a chat with, in display order.
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import KIND_AGENT, ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.services import canvas as canvas_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
@@ -36,6 +37,7 @@ from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import push as push_service
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services import usage as usage_service
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
from lembas.services.agent import session as agent_session
|
||||
from lembas.services.agent import tools as agent_tools
|
||||
@@ -437,6 +439,21 @@ async def shutdown() -> None:
|
||||
await task
|
||||
|
||||
|
||||
def _narrower(instance: float, quota: int) -> float:
|
||||
"""The tighter of two ceilings, where **zero means no limit**.
|
||||
|
||||
Not `min`: a zero on either side would win and turn "no opinion" into "no
|
||||
time at all". Written once and used wherever a group's number meets the
|
||||
instance's, because getting it wrong in one of those places is a limit that
|
||||
silently stops working.
|
||||
"""
|
||||
if instance <= 0:
|
||||
return float(quota)
|
||||
if quota <= 0:
|
||||
return float(instance)
|
||||
return float(min(instance, quota))
|
||||
|
||||
|
||||
async def _run(generation: Generation) -> None:
|
||||
"""Produce one reply, then persist it. Never raises into the task.
|
||||
|
||||
@@ -483,6 +500,16 @@ async def _run(generation: Generation) -> None:
|
||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
||||
owner = db.get(User, chat.user_id)
|
||||
|
||||
# Before the request is built, not while it streams. Every other
|
||||
# budget here can only be noticed part way through and so ends with
|
||||
# `_wrap_up` asking for a final answer; this one is knowable in
|
||||
# advance, and a reply that trails off because a month ran out mid
|
||||
# sentence would be the failure `_wrap_up` exists to prevent.
|
||||
over = usage_service.over_token_budget(db, owner)
|
||||
if over:
|
||||
generation.error = over
|
||||
return
|
||||
|
||||
# Read while the session is open: everything below outlives it.
|
||||
# Resolved once, so that what the loop is allowed to *run* is the
|
||||
# same set the endpoint was *offered* -- not whatever happens to
|
||||
@@ -520,6 +547,10 @@ async def _run(generation: Generation) -> None:
|
||||
# vision model, a plain string to anything else, or the endpoint
|
||||
# rejects the whole request.
|
||||
vision = chat_service.model_supports(db, chat, "vision")
|
||||
# Resolved while the session is open, like everything else here.
|
||||
# Empty for an admin and for a user in no group, which is every
|
||||
# instance that has not set one -- see permissions.limits_for.
|
||||
quota = permissions.limits_for(db, owner)
|
||||
chat_rounds = settings_store.chat_rounds(db)
|
||||
# A helper's chat is bounded by its own number, not the instance's.
|
||||
# Only reached in an *ordinary* helper chat -- an agent one is sized
|
||||
@@ -532,6 +563,15 @@ async def _run(generation: Generation) -> None:
|
||||
nudge_enabled = bool(settings_store.agents(db).get("nudge_unfinished"))
|
||||
|
||||
limits = tool_context.agent.limits if tool_context.agent else None
|
||||
# A group's ceiling narrows the instance's, never widens it. `min` of
|
||||
# two numbers where zero means "no limit" cannot be written as `min`:
|
||||
# the zero would win and turn a group with no opinion into an unlimited
|
||||
# one, so the two are folded by `_narrower`.
|
||||
if limits is not None and quota.get("agent_seconds"):
|
||||
limits = replace(
|
||||
limits,
|
||||
wall_seconds=_narrower(limits.wall_seconds, quota["agent_seconds"]),
|
||||
)
|
||||
# A ceiling, not a schedule -- the loop below ends the moment a round
|
||||
# produces no tool calls, which is the model saying it is done. Zero
|
||||
# means an ordinary chat has no ceiling either; `steps` is already a
|
||||
@@ -2083,6 +2123,23 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
|
||||
message.stopped = generation.stopped
|
||||
message.complete = True
|
||||
|
||||
# What this reply cost, against the account's month. Here because
|
||||
# this is the single writer and it runs for a reply that finished, a
|
||||
# reply that was stopped and a reply that errored alike -- an
|
||||
# endpoint charges for tokens it generated whether or not anybody
|
||||
# wanted them, and a quota that only counted happy paths is one a
|
||||
# Stop button can walk past. `metrics.from_generation` is the one
|
||||
# place the three figures are worked out, so this is the same
|
||||
# arithmetic the bubble shows.
|
||||
spent = metrics_service.from_generation(generation)
|
||||
usage_service.record(
|
||||
db,
|
||||
chat.user_id,
|
||||
prompt_tokens=spent.prompt_tokens,
|
||||
completion_tokens=spent.completion_tokens,
|
||||
images=len(generation.attachment_ids),
|
||||
)
|
||||
|
||||
if title and not chat.title_generated:
|
||||
chat.title = title
|
||||
chat.title_generated = True
|
||||
|
||||
@@ -403,6 +403,23 @@ async def _review(
|
||||
|
||||
|
||||
# --- The runner ----------------------------------------------------------------
|
||||
def _over_quota(context: ToolContext) -> str:
|
||||
"""Why this account may not draw another picture today, or "".
|
||||
|
||||
Its own session, opened and closed before anything else: this runs before a
|
||||
request that takes a minute, and holding a session across one is the trade
|
||||
every long call in this codebase already refuses.
|
||||
"""
|
||||
from lembas.db.models import User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import usage as usage_service
|
||||
|
||||
if not context.owner_id:
|
||||
return ""
|
||||
with session_scope() as db:
|
||||
return usage_service.over_image_budget(db, db.get(User, context.owner_id))
|
||||
|
||||
|
||||
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Generate one image, review it if there is anybody to ask, and keep one."""
|
||||
from lembas.db.session import session_scope
|
||||
@@ -427,6 +444,13 @@ async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
{**event, "status": "error", "error": "No chat."},
|
||||
)
|
||||
|
||||
# Before a minute of somebody's GPU is spent. Its own quota because it is
|
||||
# its own cost: a picture is no tokens at all, so a token budget says
|
||||
# nothing about how many of them one account may make.
|
||||
over = _over_quota(context)
|
||||
if over:
|
||||
return ToolOutcome(over, {**event, "status": "error", "error": over})
|
||||
|
||||
values = context.image_config or {}
|
||||
config = config_of(context)
|
||||
if not config.configured:
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import CHUNK_REPORT, SOURCE_MANUAL, SOURCES, Report, User
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -33,21 +34,35 @@ SNIPPET_CHARS = 400
|
||||
|
||||
|
||||
def visible(user: User | None):
|
||||
"""Every report this person owns.
|
||||
"""Every report this person owns or has been shared.
|
||||
|
||||
Takes no session because it builds a query rather than running one, and
|
||||
takes `None` to mean nobody so an unauthenticated caller gets an empty
|
||||
result instead of an exception -- the same shape `sharing.visible_to` has,
|
||||
so a later move to shared reports is a change of one line here.
|
||||
result instead of an exception.
|
||||
|
||||
It said "a later move to shared reports is a change of one line here", and
|
||||
it was: `sharing.visible_to` is that line. Every listing, search and detail
|
||||
page went through this already, which is what made the move safe.
|
||||
"""
|
||||
if user is None:
|
||||
return select(Report).where(Report.id.is_(None))
|
||||
return select(Report).where(Report.owner_id == user.id)
|
||||
return select(Report).where(sharing.visible_to(Report, user))
|
||||
|
||||
|
||||
def get(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
report = db.get(Report, report_id)
|
||||
if report is None or user is None or report.owner_id != user.id:
|
||||
if report is None or not sharing.can_read(db, report, user):
|
||||
return None
|
||||
return report
|
||||
|
||||
|
||||
def owned(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
"""The same, but only when they own it.
|
||||
|
||||
Sharing grants **reading**, so deleting and marking-as-read are the owner's
|
||||
alone. Two functions rather than a flag, because a route that wants one and
|
||||
calls the other is a bug you can see in the name.
|
||||
"""
|
||||
report = db.get(Report, report_id)
|
||||
if report is None or not sharing.can_write(report, user):
|
||||
return None
|
||||
return report
|
||||
|
||||
@@ -202,6 +217,10 @@ def mark_read(db: DBSession, report: Report) -> Report:
|
||||
|
||||
|
||||
def delete(db: DBSession, report: Report) -> None:
|
||||
# Shares carry no foreign key to their resource, so nothing cascades and
|
||||
# this has to be said. A grant left behind names a report that has gone --
|
||||
# harmless now and a grant to whoever next holds that id later.
|
||||
sharing.forget_resource(db, report)
|
||||
db.delete(report)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Who may see a document, a note or a skill.
|
||||
"""Who may see a knowledge base, a note, a skill or a report.
|
||||
|
||||
One rule, in one place, for all three: you can see a resource if you own it, if
|
||||
One rule, in one place, for all four: you can see a resource if you own it, if
|
||||
it was shared with you by name, or if it was shared with a group you are in.
|
||||
|
||||
Documents are deliberately absent from that list. They are shared through the
|
||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ColumnElement, delete, or_, select
|
||||
from sqlalchemy import ColumnElement, and_, delete, or_, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
@@ -33,9 +33,11 @@ from lembas.db.models import (
|
||||
PRINCIPAL_USER,
|
||||
RESOURCE_BASE,
|
||||
RESOURCE_NOTE,
|
||||
RESOURCE_REPORT,
|
||||
RESOURCE_SKILL,
|
||||
KnowledgeBase,
|
||||
Note,
|
||||
Report,
|
||||
Share,
|
||||
Skill,
|
||||
User,
|
||||
@@ -49,6 +51,12 @@ RESOURCE_TYPES: dict[Any, str] = {
|
||||
KnowledgeBase: RESOURCE_BASE,
|
||||
Note: RESOURCE_NOTE,
|
||||
Skill: RESOURCE_SKILL,
|
||||
# A report joins the list and a memory still does not. A finished piece of
|
||||
# work is the thing somebody most wants to hand over -- "here is what the
|
||||
# Monday run found" -- and a report is read once and never answered, so
|
||||
# sharing it has none of the two-editors problem that keeps writing off the
|
||||
# table everywhere else here.
|
||||
Report: RESOURCE_REPORT,
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +96,19 @@ def visible_to(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
return or_(model.owner_id == user.id, model.id.in_(shared))
|
||||
|
||||
|
||||
def only_shared(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
"""Rows this user may see and does **not** own.
|
||||
|
||||
The "Shared with me" filter. Worth having as its own listing rather than a
|
||||
badge in the mixed one: a badge answers "is this mine?" for a row already on
|
||||
screen, and the question somebody actually has is "what have people given
|
||||
me?", which a mixed list of two hundred cannot answer at all.
|
||||
"""
|
||||
if user is None:
|
||||
return model.id.is_(None)
|
||||
return and_(visible_to(model, user), model.owner_id != user.id)
|
||||
|
||||
|
||||
def owned_by(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
"""Rows this user may *change*.
|
||||
|
||||
@@ -197,12 +218,39 @@ def forget_principal(db: DBSession, principal_type: str, principal_id: str) -> i
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def forget_owner(db: DBSession, owner_id: str) -> int:
|
||||
"""Drop every share of everything a departing account owned.
|
||||
|
||||
Their rows cascade when the account goes; the shares of those rows do not,
|
||||
because `Share.resource_id` has no foreign key to point at. Left behind,
|
||||
they are grants naming resources that no longer exist -- harmless today,
|
||||
and a grant to whoever next receives one of those ids if a future store
|
||||
ever reuses them.
|
||||
|
||||
Called *before* the delete, while the rows are still there to be found.
|
||||
`forget_principal` is the other half and covers shares pointing *at* them.
|
||||
"""
|
||||
removed = 0
|
||||
for model in RESOURCE_TYPES:
|
||||
owned = select(model.id).where(model.owner_id == owner_id)
|
||||
result = db.execute(
|
||||
delete(Share).where(
|
||||
Share.resource_type == RESOURCE_TYPES[model],
|
||||
Share.resource_id.in_(owned),
|
||||
)
|
||||
)
|
||||
removed += result.rowcount or 0
|
||||
return removed
|
||||
|
||||
|
||||
__all__ = [
|
||||
"can_read",
|
||||
"can_write",
|
||||
"forget_owner",
|
||||
"forget_principal",
|
||||
"forget_resource",
|
||||
"grants_for",
|
||||
"only_shared",
|
||||
"owned_by",
|
||||
"resource_type",
|
||||
"set_grants",
|
||||
|
||||
@@ -76,6 +76,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lembas.db.models import KIND_AGENT, Chat, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
|
||||
@@ -443,6 +444,12 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
|
||||
# rather than told it has run out of helpers, and the counter should
|
||||
# only move for a call that is about to spend one.
|
||||
values = settings_store.subagents(db)
|
||||
# A group's ceiling narrows the instance's, never widens it. Zero on
|
||||
# either side means "no opinion", so the two cannot be folded with
|
||||
# `min` -- see generation._narrower for the same arithmetic.
|
||||
allowance = permissions.limit(db, owner, "helpers_per_reply")
|
||||
if allowance:
|
||||
values = {**values, "max_per_reply": min(int(values["max_per_reply"]), allowance)}
|
||||
refusal = _budget(generation_service.running_for(parent_id), values)
|
||||
if refusal:
|
||||
return _error(refusal, task=task)
|
||||
|
||||
@@ -1604,6 +1604,21 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
# instrument -- `git log` is a read whatever its risk class says.
|
||||
writes_off = scoped_writes_off(chat)
|
||||
|
||||
# Reading and writing, split for the three gates where the two are genuinely
|
||||
# different decisions. A second check keyed on the tool's **risk**, applied
|
||||
# after the gate rather than instead of it -- so it can only ever narrow
|
||||
# what `_family_allowed` already allowed, and an instance that has never
|
||||
# looked at it behaves exactly as it did, all three defaulting on.
|
||||
#
|
||||
# Here rather than in `_family_allowed` because that one is given a family
|
||||
# and this needs the tool: the whole point is that two tools in one family
|
||||
# get different answers.
|
||||
def may_write(tool: ToolDef) -> bool:
|
||||
gate = gate_of(tool.family)
|
||||
if tool.risk != RISK_WRITE or gate not in permissions.SPLIT_GATES:
|
||||
return True
|
||||
return bool(allowed.get(f"tools.{gate}.write", True))
|
||||
|
||||
return ToolSet(
|
||||
tuple(
|
||||
tool
|
||||
@@ -1619,6 +1634,7 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
)
|
||||
and gate_of(tool.family) not in off
|
||||
and not (writes_off and tool.risk == RISK_WRITE)
|
||||
and may_write(tool)
|
||||
# Nothing to read and nothing to improve. Offering `skill_get` with
|
||||
# no skills is what makes a model spend a round looking one up and
|
||||
# being told it does not exist -- and `context.skills` already
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""What an account has spent, and whether it may spend more.
|
||||
|
||||
A row per user per period, not per reply. A per-reply ledger is what somebody
|
||||
eventually wants for a bill; this exists to answer one question on the request
|
||||
path — "has this account used its month?" — and that wants one indexed lookup
|
||||
rather than a sum over ten thousand rows.
|
||||
|
||||
**Recorded even when the reply failed.** `generation._persist` is the single
|
||||
writer for everything a reply produced, and it calls this whether the reply
|
||||
finished, was stopped or errored: an endpoint charges for tokens it generated
|
||||
regardless of whether anybody wanted them, and a quota that only counted happy
|
||||
paths would be one somebody could avoid by pressing Stop.
|
||||
|
||||
**Never raises.** A quota that broke a reply because its own bookkeeping failed
|
||||
would be worse than no quota. Everything here is best-effort and logs.
|
||||
|
||||
The period is UTC and the boundary is not the reader's midnight. A quota that
|
||||
reset at a different instant for each member of a group is one nobody can reason
|
||||
about, and nobody experiences a monthly allowance to the hour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import Attachment, Usage, User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def period_of(moment: datetime | None = None) -> str:
|
||||
return (moment or datetime.now(tz=UTC)).astimezone(UTC).strftime("%Y-%m")
|
||||
|
||||
|
||||
def row_for(db: DBSession, user_id: str, *, period: str = "") -> Usage:
|
||||
"""This account's row for a period, made if it is not there yet."""
|
||||
period = period or period_of()
|
||||
row = db.scalar(select(Usage).where(Usage.user_id == user_id, Usage.period == period))
|
||||
if row is None:
|
||||
row = Usage(user_id=user_id, period=period)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def record(
|
||||
db: DBSession,
|
||||
user_id: str,
|
||||
*,
|
||||
prompt_tokens: int = 0,
|
||||
completion_tokens: int = 0,
|
||||
images: int = 0,
|
||||
replies: int = 1,
|
||||
) -> None:
|
||||
"""Add what one reply spent. Best-effort, and never raises."""
|
||||
if not user_id:
|
||||
return
|
||||
try:
|
||||
row = row_for(db, user_id)
|
||||
row.prompt_tokens += max(0, int(prompt_tokens))
|
||||
row.completion_tokens += max(0, int(completion_tokens))
|
||||
row.images += max(0, int(images))
|
||||
row.replies += max(0, int(replies))
|
||||
except Exception: # noqa: BLE001 - bookkeeping must never break a reply
|
||||
log.debug("could not record usage for %s", user_id, exc_info=True)
|
||||
|
||||
|
||||
def month_tokens(db: DBSession, user_id: str) -> int:
|
||||
row = db.scalar(select(Usage).where(Usage.user_id == user_id, Usage.period == period_of()))
|
||||
return int((row.prompt_tokens if row else 0) + (row.completion_tokens if row else 0))
|
||||
|
||||
|
||||
def images_today(db: DBSession, user_id: str) -> int:
|
||||
"""Pictures this account has made since midnight UTC.
|
||||
|
||||
Counted off `Attachment` rather than kept as a counter, because there is a
|
||||
natural source of truth and a *daily* counter would need a second row shape
|
||||
and a second reset. The month's total on `Usage.images` is for the admin
|
||||
screen, where a number that is a day stale costs nothing.
|
||||
"""
|
||||
start = datetime.combine(date.today(), datetime.min.time(), tzinfo=UTC) # noqa: DTZ011
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Attachment)
|
||||
.where(
|
||||
Attachment.user_id == user_id,
|
||||
Attachment.kind == "image",
|
||||
Attachment.created_at >= start,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
# --- Asking permission ----------------------------------------------------------
|
||||
def over_token_budget(db: DBSession, user: User | None) -> str:
|
||||
"""Why this account may not start another reply, or "".
|
||||
|
||||
Checked **before** a reply is built rather than while it streams. A quota
|
||||
that stopped a reply half way through would leave the reader with an answer
|
||||
that trails off, which is exactly what `_wrap_up` exists to prevent for every
|
||||
other budget here -- and unlike those, this one is knowable in advance.
|
||||
"""
|
||||
from lembas.security import permissions
|
||||
|
||||
if user is None:
|
||||
return ""
|
||||
ceiling = permissions.limit(db, user, "monthly_tokens")
|
||||
if ceiling <= 0:
|
||||
return ""
|
||||
spent = month_tokens(db, user.id)
|
||||
if spent < ceiling:
|
||||
return ""
|
||||
return (
|
||||
f"This account has used its {ceiling:,} tokens for the month "
|
||||
f"({spent:,} so far). It will reset on the first."
|
||||
)
|
||||
|
||||
|
||||
def over_image_budget(db: DBSession, user: User | None) -> str:
|
||||
from lembas.security import permissions
|
||||
|
||||
if user is None:
|
||||
return ""
|
||||
ceiling = permissions.limit(db, user, "images_per_day")
|
||||
if ceiling <= 0:
|
||||
return ""
|
||||
made = images_today(db, user.id)
|
||||
if made < ceiling:
|
||||
return ""
|
||||
return f"This account has made its {ceiling} image(s) for today."
|
||||
|
||||
|
||||
def summary(db: DBSession, user: User) -> dict[str, int]:
|
||||
"""This month's figures, for the admin screen."""
|
||||
row = db.scalar(select(Usage).where(Usage.user_id == user.id, Usage.period == period_of()))
|
||||
return {
|
||||
"prompt_tokens": int(row.prompt_tokens if row else 0),
|
||||
"completion_tokens": int(row.completion_tokens if row else 0),
|
||||
"tokens": month_tokens(db, user.id),
|
||||
"replies": int(row.replies if row else 0),
|
||||
"images": int(row.images if row else 0),
|
||||
"images_today": images_today(db, user.id),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"images_today",
|
||||
"month_tokens",
|
||||
"over_image_budget",
|
||||
"over_token_budget",
|
||||
"period_of",
|
||||
"record",
|
||||
"row_for",
|
||||
"summary",
|
||||
]
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon, model_avatar %}
|
||||
{% set section = "groups" %}
|
||||
|
||||
{% block title %}{{ group.name }} - Groups - {{ brand.name }}{% endblock %}
|
||||
{% block heading %}{{ group.name }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
<a href="/admin/groups">{{ icon("chevron-left", "icon--sm") }} All groups</a>
|
||||
· A group only ever <em>adds</em>. Anything already in the baseline is shown
|
||||
below as such, so a tick here that changes nothing looks like one.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/groups/{{ group.id }}" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Name</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<input class="input" id="name" name="name" value="{{ group.name }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="description">What it is for</label>
|
||||
<input class="input" id="description" name="description"
|
||||
value="{{ group.description }}" maxlength="1000">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Permissions this group adds</h2>
|
||||
{% for section_name, defs in permission_groups.items() %}
|
||||
<div class="field">
|
||||
<span class="field__label">{{ section_name }}</span>
|
||||
{% for definition in defs %}
|
||||
<label class="checkbox perm-row">
|
||||
<input type="checkbox" name="permission" value="{{ definition.key }}"
|
||||
{{ 'checked' if (group.permissions_json or {}).get(definition.key) }}>
|
||||
<span>
|
||||
<strong>{{ definition.label }}</strong>
|
||||
{% if baseline[definition.key] %}
|
||||
<span class="badge">already in the baseline</span>
|
||||
{% endif %}
|
||||
<span class="perm-row__desc">{{ definition.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{#
|
||||
Quotas. Every one is zero-for-no-limit, and an *empty* box is different from
|
||||
a zero: empty is "this group has no opinion" and contributes nothing to the
|
||||
resolution, zero is "unlimited" and wins outright. Saying that here is the
|
||||
only place somebody will read it.
|
||||
#}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Quotas</h2>
|
||||
<p class="card__lede">
|
||||
Resolved across a person's groups by <strong>maximum</strong> — the union
|
||||
rule applied to numbers, so a second group can only grant more.
|
||||
<strong>Leave a box empty</strong> for “no opinion”, and use
|
||||
<strong>0</strong> for “no limit”, which beats any number another group
|
||||
sets. Administrators are unlimited whatever is here.
|
||||
</p>
|
||||
<div class="field-row">
|
||||
{% for key, label, description in limit_defs %}
|
||||
<div class="field">
|
||||
<label class="field__label" for="limit-{{ key }}">{{ label }}</label>
|
||||
<input class="input" id="limit-{{ key }}" name="limit_{{ key }}"
|
||||
type="number" min="0" step="1"
|
||||
value="{{ limits.get(key, '') }}" placeholder="no opinion">
|
||||
<p class="field__hint">{{ description }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#
|
||||
Membership lives here and only here. It used to be on the user page as well,
|
||||
and a full-form POST from either side overwrote what the other had shown.
|
||||
#}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Members</h2>
|
||||
<p class="card__lede">
|
||||
The one place membership is edited. A user's own page links here rather
|
||||
than offering a second control for the same value.
|
||||
</p>
|
||||
<div class="checkbox-row">
|
||||
{% for person in users %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="user_ids" value="{{ person.id }}"
|
||||
{{ 'checked' if person in group.users }}>
|
||||
<span>{{ person.name }} <span class="faint text-xs">{{ person.email }}</span></span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Models this group unlocks</h2>
|
||||
<p class="card__lede">
|
||||
A model marked public is available to everyone; one that is not is
|
||||
available to the groups named here. Model access is separate from
|
||||
permissions — one says what somebody may do, the other what with.
|
||||
</p>
|
||||
<div class="checkbox-row">
|
||||
{% for model in models %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="model_ids" value="{{ model.id }}"
|
||||
{{ 'checked' if model in group.models }}>
|
||||
<span>{{ model_avatar(model, "model-avatar model-avatar--sm") }} {{ model.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save group</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Remove</h2>
|
||||
<form method="post" action="/admin/groups/{{ group.id }}/delete"
|
||||
data-confirm="Delete {{ group.name }}? Its members keep their accounts.">
|
||||
<button class="btn btn--danger btn--sm" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete this group
|
||||
</button>
|
||||
</form>
|
||||
<p class="field__hint">
|
||||
Members keep their accounts and lose whatever this group granted them. Every
|
||||
share naming this group goes too — nothing cascades to those, so they are
|
||||
deleted explicitly.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon, model_avatar %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "groups" %}
|
||||
|
||||
{% block title %}Groups & permissions - {{ brand.name }}{% endblock %}
|
||||
@@ -9,8 +9,9 @@
|
||||
<p class="admin-lede">
|
||||
Permissions are a <strong>union</strong>: everyone starts with the baseline
|
||||
below, and each group they belong to can add more. A group never takes
|
||||
something away, so being in a second group can only widen what someone can do.
|
||||
Administrators bypass all of it.
|
||||
something away, so being in a second group can only widen what someone can do —
|
||||
which is what keeps “why can this person not do X?” answerable without
|
||||
simulating every group they are in. Administrators bypass all of it.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
@@ -19,7 +20,7 @@
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Baseline permissions</h2>
|
||||
<p class="text-sm muted" style="margin-bottom: var(--sp-4)">
|
||||
<p class="card__lede">
|
||||
What every signed-in user can do before any group is considered. Turn
|
||||
something off here and grant it through a group to make it opt-in.
|
||||
</p>
|
||||
@@ -48,7 +49,39 @@
|
||||
Groups <span class="badge">{{ groups|length }}</span>
|
||||
</h2>
|
||||
|
||||
<section class="card">
|
||||
{#
|
||||
Rows, not a form each. The old page rendered every group's full permission
|
||||
grid, every member and every model on one screen -- fine for two groups and
|
||||
unreadable at ten, which is the list-plus-detail rule the model admin already
|
||||
follows.
|
||||
#}
|
||||
<div class="model-rows">
|
||||
{% for group in groups %}
|
||||
<a class="model-row" href="/admin/groups/{{ group.id }}">
|
||||
<div class="model-row__main">
|
||||
<span class="model-row__name">{{ group.name }}</span>
|
||||
{% if group.description %}
|
||||
<span class="model-row__id">{{ group.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="model-row__meta">
|
||||
<span class="badge">{{ group.users | length }} member{{ '' if group.users|length == 1 else 's' }}</span>
|
||||
{% if granted[group.id] %}
|
||||
<span class="badge badge--leaf">+{{ granted[group.id] }} permission{{ '' if granted[group.id] == 1 else 's' }}</span>
|
||||
{% endif %}
|
||||
{% if group.limits_json %}<span class="badge">quotas</span>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
<p class="empty__text">
|
||||
No groups yet. Everybody gets the baseline above and nothing more.
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-top: var(--sp-6)">
|
||||
<form method="post" action="/admin/groups" class="btn-row">
|
||||
<input class="input" name="name" placeholder="New group name" required
|
||||
aria-label="New group name">
|
||||
@@ -57,114 +90,4 @@
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if not groups %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
{{ icon("users", "empty__mark") }}
|
||||
<p class="empty__text">
|
||||
No groups yet. Create one to grant extra permissions, or to restrict a model
|
||||
to a subset of users.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for group in groups %}
|
||||
<section class="card">
|
||||
<form method="post" action="/admin/groups/{{ group.id }}">
|
||||
<div class="card__header">
|
||||
<strong>{{ group.name }}</strong>
|
||||
<span class="text-xs faint">
|
||||
{{ group.users|length }} member{{ '' if group.users|length == 1 else 's' }},
|
||||
{{ group.models|length }} model{{ '' if group.models|length == 1 else 's' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="gn-{{ group.id }}">Name</label>
|
||||
<input class="input" id="gn-{{ group.id }}" name="name" value="{{ group.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="gd-{{ group.id }}">Description</label>
|
||||
<input class="input" id="gd-{{ group.id }}" name="description"
|
||||
value="{{ group.description }}" placeholder="What is this group for?">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Grants</span>
|
||||
<p class="field__hint" style="margin-bottom: var(--sp-2)">
|
||||
Anything already in the baseline stays on regardless — these only add.
|
||||
</p>
|
||||
{% for section_name, defs in permission_groups.items() %}
|
||||
{% for definition in defs %}
|
||||
<label class="checkbox perm-row">
|
||||
<input type="checkbox" name="permission" value="{{ definition.key }}"
|
||||
{{ 'checked' if (group.permissions_json or {}).get(definition.key) }}>
|
||||
<span>
|
||||
<strong>{{ definition.label }}</strong>
|
||||
<span class="perm-row__desc">
|
||||
{{ definition.description }}
|
||||
{% if baseline[definition.key] %}<em>(already in the baseline)</em>{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Members</span>
|
||||
{% if users %}
|
||||
<div class="checkbox-row">
|
||||
{% for account in users %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="user_ids" value="{{ account.id }}"
|
||||
{{ 'checked' if account in group.users }}>
|
||||
<span>{{ account.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="field__hint">No users yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Model access</span>
|
||||
<p class="field__hint" style="margin-bottom: var(--sp-2)">
|
||||
Models marked “available to everyone” are reachable regardless. These
|
||||
grant access to the restricted ones.
|
||||
</p>
|
||||
{% if models %}
|
||||
<div class="checkbox-row">
|
||||
{% for model in models %}
|
||||
<label class="checkbox {{ 'is-muted' if model.public }}">
|
||||
<input type="checkbox" name="model_ids" value="{{ model.id }}"
|
||||
{{ 'checked' if model in group.models }}>
|
||||
<span>{{ model.label }}{% if model.public %} <em>(public)</em>{% endif %}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="field__hint">No models yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save {{ group.name }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card__footer">
|
||||
<span class="text-xs faint">Deleting a group leaves its members alone.</span>
|
||||
<form method="post" action="/admin/groups/{{ group.id }}/delete"
|
||||
data-confirm="Delete the group “{{ group.name }}”? Its members keep their accounts."
|
||||
data-confirm-title="Delete group">
|
||||
<button class="btn btn--sm btn--danger" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "users" %}
|
||||
|
||||
{% block title %}{{ target.name }} - Users - {{ brand.name }}{% endblock %}
|
||||
{% block heading %}{{ target.name }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
<a href="/admin/users">{{ icon("chevron-left", "icon--sm") }} All users</a>
|
||||
· {{ target.email }}
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/users/{{ target.id }}" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Account</h2>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<input class="input" id="name" name="name" value="{{ target.name }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="role">Role</label>
|
||||
<select class="input" id="role" name="role">
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {{ 'selected' if target.role == role }}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
An administrator bypasses every permission and every quota below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="active" value="true" {{ 'checked' if target.active }}>
|
||||
<span>Active</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Turning this off signs them out everywhere at once, rather than waiting
|
||||
for a cookie to expire.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# --- What they can actually do -------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">What this account can do</h2>
|
||||
<p class="card__lede">
|
||||
Read-only, and deliberately: every switch here is set somewhere else — in the
|
||||
<a href="/admin/groups">baseline</a> or in a named group — and a control on
|
||||
this page would be a third place to change one thing. What it adds is the
|
||||
<em>source</em>, which is the question the grids could not answer without
|
||||
opening every group by eye.
|
||||
</p>
|
||||
|
||||
{% for section_name, defs in permission_groups.items() %}
|
||||
<div class="field">
|
||||
<span class="field__label">{{ section_name }}</span>
|
||||
{% for definition in defs %}
|
||||
{% set state = explained[definition.key] %}
|
||||
<div class="perm-row" style="display: flex; gap: var(--sp-3); align-items: baseline">
|
||||
{{ icon("check" if state.on else "x", "icon--sm") }}
|
||||
<span>
|
||||
<strong>{{ definition.label }}</strong>
|
||||
{% if state.on %}
|
||||
<span class="perm-row__desc">
|
||||
from {{ state.source | join(", ") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="perm-row__desc faint">not granted</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{# --- Membership ----------------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Groups</h2>
|
||||
<p class="card__lede">
|
||||
Edited from the group's own page. One control per value, so a save here
|
||||
cannot undo a save there.
|
||||
</p>
|
||||
{% if target.groups %}
|
||||
<div class="btn-row">
|
||||
{% for group in target.groups %}
|
||||
<a class="btn btn--sm" href="/admin/groups/{{ group.id }}">{{ group.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted text-sm">In no group. They get the baseline and nothing more.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# --- Quotas and usage ----------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">This month</h2>
|
||||
<p class="card__lede">
|
||||
Counted from the first of the month, UTC. Recorded for every reply including
|
||||
one that was stopped or failed — an endpoint charges for tokens it generated
|
||||
whether or not anybody wanted them.
|
||||
</p>
|
||||
<dl class="mode-list">
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Tokens</strong></dt>
|
||||
<dd>
|
||||
{{ "{:,}".format(usage.tokens) }}
|
||||
{% if limits.monthly_tokens %} of {{ "{:,}".format(limits.monthly_tokens) }}{% endif %}
|
||||
<span class="faint text-xs">
|
||||
({{ "{:,}".format(usage.prompt_tokens) }} prompt,
|
||||
{{ "{:,}".format(usage.completion_tokens) }} written)
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Replies</strong></dt>
|
||||
<dd>{{ usage.replies }}</dd>
|
||||
</div>
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Images</strong></dt>
|
||||
<dd>
|
||||
{{ usage.images }} this month, {{ usage.images_today }} today
|
||||
{% if limits.images_per_day %} (limit {{ limits.images_per_day }} a day){% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h3 class="section-title">Limits in force</h3>
|
||||
<p class="field__hint">
|
||||
Resolved across their groups by <strong>maximum</strong> — the union rule
|
||||
applied to numbers, so a second group can only ever grant more. Zero means no
|
||||
limit and wins outright, because a group saying “unlimited” must not count
|
||||
for less than one saying “a million”.
|
||||
</p>
|
||||
<dl class="mode-list">
|
||||
{% for key, label, description in limit_defs %}
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>{{ label }}</strong></dt>
|
||||
<dd>
|
||||
{% if limits[key] %}{{ "{:,}".format(limits[key]) }}{% else %}no limit{% endif %}
|
||||
<span class="perm-row__desc">{{ description }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{# --- Models --------------------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Models they can use</h2>
|
||||
<p class="card__lede">
|
||||
Model access is separate from permissions: a permission says what somebody
|
||||
may do, this says what they may do it with.
|
||||
</p>
|
||||
{% if models %}
|
||||
<div class="btn-row">
|
||||
{% for model in models %}
|
||||
<a class="btn btn--sm" href="/admin/models/{{ model.id }}/edit">{{ model.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted text-sm">None. They cannot start a chat at all.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# --- Dangerous ------------------------------------------------------------ #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Password and removal</h2>
|
||||
<form method="post" action="/admin/users/{{ target.id }}/password" class="btn-row">
|
||||
<input class="input" name="password" type="password" required
|
||||
placeholder="New password" aria-label="New password" style="flex: 1">
|
||||
<button class="btn" type="submit">Reset password</button>
|
||||
</form>
|
||||
<p class="field__hint">
|
||||
Signs them out everywhere. An administrator resetting a password usually
|
||||
means the account is compromised or the person has gone.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/admin/users/{{ target.id }}/delete"
|
||||
data-confirm="Delete {{ target.email }}? Their chats, folders and library go with them."
|
||||
style="margin-top: var(--sp-4)">
|
||||
<button class="btn btn--danger btn--sm" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete this account
|
||||
</button>
|
||||
</form>
|
||||
<p class="field__hint">
|
||||
Their chats, folders and library go too, and every share naming them or
|
||||
naming anything of theirs.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -7,157 +7,103 @@
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Everyone with an account on this instance. Administrators bypass every
|
||||
permission; ordinary users get the baseline permissions plus whatever their
|
||||
groups add.
|
||||
Every account on this instance. Open one to see what it can actually do and
|
||||
where each of those permissions came from. Group membership is edited from the
|
||||
<a href="/admin/groups">group's</a> own page — one control per value, so a save
|
||||
on one screen cannot undo a save on another.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" action="/admin/users" class="btn-row" style="margin-bottom: var(--sp-5)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Search by name or email" aria-label="Search users">
|
||||
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Search</button>
|
||||
{% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %}
|
||||
</form>
|
||||
<div class="filter-bar">
|
||||
<form class="filter-form" method="get" action="/admin/users">
|
||||
<input class="input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Search by name or email…" aria-label="Search users">
|
||||
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
|
||||
{% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<details class="card">
|
||||
<summary class="card__title" style="cursor: pointer">Add a user</summary>
|
||||
<form method="post" action="/admin/users" style="margin-top: var(--sp-4)">
|
||||
<div class="field">
|
||||
<label class="field__label" for="nu-name">Name</label>
|
||||
<input class="input" id="nu-name" name="name" required>
|
||||
<div class="model-rows">
|
||||
{% for person in users %}
|
||||
<a class="model-row" href="/admin/users/{{ person.id }}">
|
||||
<div class="model-row__main">
|
||||
<span class="model-row__name">
|
||||
{{ person.name }}
|
||||
{% if person.role == "admin" %}<span class="badge badge--leaf">admin</span>{% endif %}
|
||||
{% if person.role == "pending" %}<span class="badge">pending</span>{% endif %}
|
||||
{% if not person.active %}<span class="badge">disabled</span>{% endif %}
|
||||
</span>
|
||||
<span class="model-row__id">{{ person.email }}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="nu-email">Email</label>
|
||||
<input class="input" id="nu-email" name="email" type="email" required>
|
||||
<div class="model-row__meta">
|
||||
{% if person.groups %}
|
||||
<span class="text-xs faint">
|
||||
{{ person.groups | map(attribute="name") | join(", ") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% set spent = usage[person.id] %}
|
||||
{% if spent.tokens %}
|
||||
<span class="badge" title="Tokens this month">{{ "{:,}".format(spent.tokens) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="nu-password">Password</label>
|
||||
<input class="input" id="nu-password" name="password" type="password"
|
||||
required minlength="8" autocomplete="new-password">
|
||||
<p class="field__hint">
|
||||
At least 8 characters. Tell them to change it — you will know it otherwise.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="nu-role">Role</label>
|
||||
<select class="select" id="nu-role" name="role">
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {{ 'selected' if role == 'user' }}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
<p class="empty__text">{{ "Nobody matches that." if q else "No accounts yet." }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if pager.pages > 1 %}
|
||||
<div class="btn-row" style="margin-top: var(--sp-5)">
|
||||
{% if pager.page > 1 %}
|
||||
<a class="btn btn--sm" href="/admin/users?page={{ pager.page - 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">
|
||||
{{ icon("chevron-left", "icon--sm") }} Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
<span class="text-sm faint">Page {{ pager.page }} of {{ pager.pages }} · {{ pager.total }} accounts</span>
|
||||
{% if pager.page < pager.pages %}
|
||||
<a class="btn btn--sm" href="/admin/users?page={{ pager.page + 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">
|
||||
Next {{ icon("chevron-right", "icon--sm") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="card" style="margin-top: var(--sp-8)">
|
||||
<h2 class="card__title">Add an account</h2>
|
||||
<p class="card__lede">
|
||||
Without going through registration — useful when sign-up is closed.
|
||||
</p>
|
||||
<form method="post" action="/admin/users" class="form-grid">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-name">Name</label>
|
||||
<input class="input" id="new-name" name="name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-email">Email</label>
|
||||
<input class="input" id="new-email" name="email" type="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-password">Password</label>
|
||||
<input class="input" id="new-password" name="password" type="password" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-role">Role</label>
|
||||
<select class="input" id="new-role" name="role">
|
||||
{% for role in roles %}<option value="{{ role }}">{{ role }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<h2 class="admin-section-title">
|
||||
Accounts <span class="badge">{{ users|length }}</span>
|
||||
</h2>
|
||||
|
||||
{% for account in users %}
|
||||
<section class="card">
|
||||
<form method="post" action="/admin/users/{{ account.id }}">
|
||||
<div class="card__header">
|
||||
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
||||
<span class="status-dot {{ 'is-ok' if account.active else 'is-off' }}"></span>
|
||||
<strong class="truncate">{{ account.name }}</strong>
|
||||
<code class="text-xs faint">{{ account.email }}</code>
|
||||
{% if account.is_admin %}<span class="badge badge--leaf">admin</span>{% endif %}
|
||||
{% if not account.active %}<span class="badge badge--danger">deactivated</span>{% endif %}
|
||||
{% if account.id == user.id %}<span class="badge">you</span>{% endif %}
|
||||
</div>
|
||||
<span class="text-xs faint">
|
||||
{% if account.last_login_at %}
|
||||
last seen {{ account.last_login_at.strftime("%Y-%m-%d %H:%M") }}
|
||||
{% else %}
|
||||
never signed in
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="un-{{ account.id }}">Name</label>
|
||||
<input class="input" id="un-{{ account.id }}" name="name" value="{{ account.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="ur-{{ account.id }}">Role</label>
|
||||
<select class="select" id="ur-{{ account.id }}" name="role">
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {{ 'selected' if role == account.role }}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
<strong>admin</strong> can do everything, including this page.
|
||||
<strong>user</strong> is an ordinary account.
|
||||
<strong>pending</strong> cannot sign in until promoted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="active" value="true" {{ 'checked' if account.active }}>
|
||||
<span>Active — may sign in</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Deactivating signs them out everywhere immediately, rather than waiting
|
||||
for their session to expire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if groups %}
|
||||
<div class="field">
|
||||
<span class="field__label">Groups</span>
|
||||
<div class="checkbox-row">
|
||||
{% for group in groups %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="group_ids" value="{{ group.id }}"
|
||||
{{ 'checked' if group in account.groups }}>
|
||||
<span>{{ group.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if account.is_admin and admin_count <= 1 %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon("warning", "alert__icon") }}
|
||||
<span>
|
||||
The only administrator. Promote someone else before demoting or
|
||||
deactivating this account — an instance with no admin can only be
|
||||
recovered with <code>lembas create-admin</code>.
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-row"><button class="btn btn--primary" type="submit">Save</button></div>
|
||||
</form>
|
||||
|
||||
<div class="card__footer">
|
||||
<form method="post" action="/admin/users/{{ account.id }}/password" class="btn-row">
|
||||
<input class="input" type="password" name="password" minlength="8"
|
||||
placeholder="Set a new password" autocomplete="new-password" required
|
||||
aria-label="New password for {{ account.email }}">
|
||||
<button class="btn btn--sm" type="submit">{{ icon("key", "icon--sm") }} Reset</button>
|
||||
</form>
|
||||
|
||||
{% if account.id != user.id %}
|
||||
<form method="post" action="/admin/users/{{ account.id }}/delete"
|
||||
data-confirm="Delete {{ account.email }} and all their chats? This cannot be undone."
|
||||
data-confirm-title="Delete account">
|
||||
<button class="btn btn--sm btn--danger" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("plus", "icon--sm") }} Create account
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,63 +1,30 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The share panel on a detail page.
|
||||
The share panel's placeholder on a detail page.
|
||||
|
||||
Sharing grants *reading*. Two people editing one note with no history and no
|
||||
merge is worse than the inconvenience of copying it, so there is no "can edit"
|
||||
here and the copy is deliberate rather than missing.
|
||||
It fetches `_share_panel.html` on load rather than being rendered inline, and
|
||||
that is the whole change: the panel used to be checkboxes inside the
|
||||
resource's *save form*, so a share only happened if you also saved the
|
||||
resource, and the list of candidates was every group and every account on the
|
||||
instance, unpaginated, on every detail page.
|
||||
|
||||
Only the owner sees this at all: someone a thing was shared with cannot share
|
||||
it onward, which keeps "who can see this" answerable by asking one person.
|
||||
Sharing still grants *reading*. Two people editing one note with no history and
|
||||
no merge is worse than the inconvenience of copying it, so there is no "can
|
||||
edit" and its absence is deliberate rather than missing.
|
||||
|
||||
Only the owner sees it at all — someone a thing was shared with cannot share it
|
||||
onward — and the route enforces that as well, because a template is not a
|
||||
permission check.
|
||||
#}
|
||||
{% if is_owner and can_share %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
Shared with
|
||||
{% if shared_users or shared_groups %}
|
||||
<span class="badge badge--leaf">{{ shared_users|length + shared_groups|length }}</span>
|
||||
{% else %}
|
||||
<span class="badge">nobody</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="card__lede">
|
||||
They will be able to read this, and their models will find it. They cannot
|
||||
change it or share it on.
|
||||
</p>
|
||||
|
||||
{% if groups %}
|
||||
<div class="field">
|
||||
<label class="field__label">Groups</label>
|
||||
<div class="checkbox-row">
|
||||
{% for group in groups %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="share_group" value="{{ group.id }}"
|
||||
{{ 'checked' if group.id in shared_groups }}>
|
||||
<span>{{ group.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if people %}
|
||||
<div class="field">
|
||||
<label class="field__label">People</label>
|
||||
<div class="checkbox-row">
|
||||
{% for person in people %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="share_user" value="{{ person.id }}"
|
||||
{{ 'checked' if person.id in shared_users }}>
|
||||
<span>{{ person.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not groups and not people %}
|
||||
<p class="muted text-sm">There is nobody else on this instance yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
<div hx-get="/api/library/share/{{ share_kind }}/{{ share_id }}"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Shared with <span class="badge">…</span></h2>
|
||||
</section>
|
||||
</div>
|
||||
{% elif not is_owner %}
|
||||
<div class="alert">
|
||||
{{ icon("users", "alert__icon") }}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
Who can see one thing, and the search that changes it.
|
||||
|
||||
Swapped into itself after every change, so what is on screen is always what is
|
||||
stored -- the old panel was a set of checkboxes that only took effect if the
|
||||
resource happened to be saved afterwards, which is a control that silently
|
||||
does nothing.
|
||||
|
||||
Every fetch in here names its own `hx-target`. This fragment is included on
|
||||
pages whose forms carry an inherited target, and an element that fetches
|
||||
without one aims at whatever an ancestor said -- the bug the jobs chip had, and
|
||||
the reason `tests/test_chat.py` walks the composer for it.
|
||||
|
||||
The switches are `<label>`s carrying no `role="menuitem"`, for the reason the
|
||||
scope menu's are: `ui.js` closes a picker when a menuitem is clicked, which is
|
||||
right for an action and wrong for a list you set several of.
|
||||
#}
|
||||
<section class="card" id="share-panel">
|
||||
<h2 class="card__title">
|
||||
Shared with
|
||||
{% if share_count %}
|
||||
<span class="badge badge--leaf">{{ share_count }}</span>
|
||||
{% else %}
|
||||
<span class="badge">nobody</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="card__lede">
|
||||
They will be able to read this, and their models will find it. They cannot
|
||||
change it, delete it, or share it on — so “who can see this?” stays a
|
||||
question you can answer.
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="share-search">Find somebody</label>
|
||||
<input class="input" id="share-search" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Name, email or group…"
|
||||
hx-get="/api/library/share/{{ kind }}/{{ resource.id }}"
|
||||
hx-trigger="input changed delay:250ms, search"
|
||||
hx-target="#share-panel"
|
||||
hx-swap="outerHTML">
|
||||
{% if truncated %}
|
||||
<p class="field__hint">
|
||||
Showing the first few. Type to narrow it — anything already shared stays
|
||||
listed whatever you search for.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if groups %}
|
||||
<div class="field">
|
||||
<label class="field__label">Groups</label>
|
||||
<div class="checkbox-row">
|
||||
{% for group in groups %}
|
||||
{% set on = group.id in shared_groups %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" {{ 'checked' if on }}
|
||||
hx-post="/api/library/share/{{ kind }}/{{ resource.id }}"
|
||||
hx-vals='{"principal_type": "group", "principal_id": "{{ group.id }}",
|
||||
"on": "{{ 'false' if on else 'true' }}", "q": "{{ q }}"}'
|
||||
hx-target="#share-panel"
|
||||
hx-swap="outerHTML">
|
||||
<span>{{ group.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if people %}
|
||||
<div class="field">
|
||||
<label class="field__label">People</label>
|
||||
<div class="checkbox-row">
|
||||
{% for person in people %}
|
||||
{% set on = person.id in shared_users %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" {{ 'checked' if on }}
|
||||
hx-post="/api/library/share/{{ kind }}/{{ resource.id }}"
|
||||
hx-vals='{"principal_type": "user", "principal_id": "{{ person.id }}",
|
||||
"on": "{{ 'false' if on else 'true' }}", "q": "{{ q }}"}'
|
||||
hx-target="#share-panel"
|
||||
hx-swap="outerHTML">
|
||||
<span>{{ person.name }} <span class="faint text-xs">{{ person.email }}</span></span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not groups and not people %}
|
||||
<p class="muted text-sm">
|
||||
{% if q %}Nobody matches “{{ q }}”.{% else %}There is nobody else here yet.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -13,6 +13,13 @@
|
||||
everything in it comes with it.
|
||||
</p>
|
||||
|
||||
{# Links rather than a form, so a filtered view is a URL you can keep. #}
|
||||
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
|
||||
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/knowledge">All bases</a>
|
||||
<a class="filter-tab {{ 'is-active' if shared }}"
|
||||
href="/library/knowledge?shared=1">Shared with me</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
yours, not its.
|
||||
</p>
|
||||
|
||||
{#
|
||||
Two views, as links, so a filtered list is a real URL you can keep -- the same
|
||||
shape the admin lists use. A badge on a row answers "is this mine?"; the question somebody has is
|
||||
"what have people given me?", which a mixed list of two hundred cannot answer.
|
||||
#}
|
||||
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
|
||||
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/notes">All notes</a>
|
||||
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/notes?shared=1">Shared with me</a>
|
||||
</div>
|
||||
<form method="get" action="/library/notes" class="btn-row" style="margin-bottom: var(--sp-5)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||
placeholder="Search notes…">
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
change can be read and undone.
|
||||
</p>
|
||||
|
||||
{#
|
||||
Two views, as links, so a filtered list is a real URL you can keep -- the same
|
||||
shape the admin lists use. Same as the notes list.
|
||||
#}
|
||||
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
|
||||
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/skills">All skills</a>
|
||||
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/skills?shared=1">Shared with me</a>
|
||||
</div>
|
||||
<form method="get" action="/library/skills" class="btn-row" style="margin-bottom: var(--sp-5)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||
placeholder="Search skills…">
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
{{ body_html | safe }}
|
||||
</article>
|
||||
|
||||
{# The same panel every library store uses. A finished piece of work is the
|
||||
thing somebody most wants to hand over. #}
|
||||
<div style="margin-top: var(--sp-6)">
|
||||
{% include "library/_share.html" %}
|
||||
</div>
|
||||
|
||||
{# Only the owner may delete. Sharing grants reading, so somebody a report was
|
||||
shared with sees the panel above saying so and no button here. #}
|
||||
{% if report.owner_id == user.id %}
|
||||
<div class="btn-row" style="margin-top: var(--sp-6)">
|
||||
<form method="post" action="/api/reports/{{ report.id }}/delete"
|
||||
data-confirm="Delete this report? It cannot be brought back.">
|
||||
@@ -44,4 +53,5 @@
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
schedule leaves its result here. Nothing on this page can be replied to.
|
||||
</p>
|
||||
|
||||
{# Reports became shareable and this filter arrived with them: a feed that
|
||||
quietly grew somebody else's work with no way to see only theirs would be
|
||||
worse than one that never grew. #}
|
||||
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
|
||||
<a class="filter-tab {{ 'is-active' if not shared }}" href="/reports">All reports</a>
|
||||
<a class="filter-tab {{ 'is-active' if shared }}" href="/reports?shared=1">Shared with me</a>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/reports" class="btn-row" style="margin-bottom: var(--sp-5)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||
placeholder="Search reports…">
|
||||
|
||||
Reference in New Issue
Block a user