"""What a person has folded away in the navigation.
Two kinds of thing fold: a whole section (`sec:workspace`) and one category in
the tree (`cat:12`). Both are stored as one list of keys per organisation on the
user's own row, because a fold is *this person's* view of the navigation - unlike
the categories themselves, which are the organisation's and shared.
Kept on the user row rather than in the browser, because a fold is a preference
and not a property of a device: it should follow someone to a new laptop. Per
organisation, because the tree differs between them - a tenant with one category
and a tenant with forty do not want the same things collapsed.
The keys are validated against a shape rather than trusted, and the list is
capped. The column is JSON, which is otherwise an invitation to store anything on
a user row that no migration ever has to describe; `_KEY_RE` is what keeps it a
record of folded twisties.
Nothing folded is the default, so a new account - and every account that existed
before this shipped - opens the navigation showing everything.
"""
import re
from db.db_declarations import User
from .context import Invalid, NotFound, Scope
# Two kinds of foldable thing, and the pattern names both rather than accepting
# whatever arrives:
#
# sec:<name> a whole section of the navigation, declared in NAV
# cat:<id> one category in the tree
#
# Add a third kind by extending this, not by loosening it. The bound on each is
# deliberate: an unbounded key is a way to write arbitrary strings onto a user
# row that no migration ever has to describe.
_KEY_RE = re.compile(r"^(sec:[a-z][a-z0-9-]{0,19}|cat:[0-9]{1,10})$")
# A person cannot fold away more of a navigation than this without the list being
# about something other than a navigation.
MAX_KEYS = 500
def collapsed(session, principal) -> list[str]:
"""The keys this account has folded away in its current organisation."""
principal.require(Scope.READ)
stored = _user(session, principal).nav_collapsed or {}
keys = stored.get(str(principal.org_id), [])
# Filtered on the way out as well as on the way in. The column is JSON and
# this app is not the only thing that could ever have written to it; a key
# that no longer matches the shape should disappear, not reach a template.
return [k for k in keys if isinstance(k, str) and _KEY_RE.match(k)]
def set_collapsed(session, principal, keys) -> list[str]:
"""Replace what is folded in the current organisation.
Whole-list, not add/remove: the sidebar knows the complete state after every
toggle, so sending it is free, and a replace cannot drift out of step with
the screen the way two half-updates can.
Other organisations' entries are left alone, so a browser that only knows
about the tenant it is looking at cannot wipe the rest.
"""
principal.require(Scope.WRITE)
if not isinstance(keys, list):
raise Invalid("Expected a list of keys.")
if len(keys) > MAX_KEYS:
raise Invalid(f"At most {MAX_KEYS} folded items.")
clean = []
for key in keys:
if not isinstance(key, str) or not _KEY_RE.match(key):
raise Invalid(f"Not a nav key: {key!r}.")
if key not in clean:
clean.append(key)
user = _user(session, principal)
# Rebuilt rather than mutated in place: SQLAlchemy does not track writes
# *inside* a JSON value, so appending to the existing dict would commit
# nothing at all and the fold would come back on the next page load.
stored = dict(user.nav_collapsed or {})
stored[str(principal.org_id)] = clean
user.nav_collapsed = stored
session.commit()
return clean
def _user(session, principal) -> User:
user = session.get(User, principal.user_id) if principal.user_id else None
if user is None:
raise NotFound("No such account.")
return user