agilentics / boiler
"""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