"""The signed-in shell: a top bar, a foldable sidebar, and the region pages fill.
The sidebar has two halves, and they come from different places on purpose:
declared `NAV` below - static links that exist in every deployment, in
code, because they are the application's own structure
the tree categories and subcategories, rows the organisation created, from
app/core/categories.py
Both fold, and what each person has folded is remembered on their account, per
organisation, by app.core.nav_state. That is the one part of the sidebar that is
theirs alone: the tree itself is the organisation's, and everyone in a tenant
reads the same one.
`NAV` is deliberately a plain declaration rather than a registry with an API. A
navigation is one of the few things in an application that genuinely wants to be
read top to bottom in one place: what is in it, in what order, under which
heading. A `register_nav_item()` call scattered across twelve modules answers
"what is in the sidebar?" only by running the program.
Two rules for anything added to `NAV`:
* `show` decides whether the entry is drawn, and it is not a permission check.
A link the shell declines to draw is not a gate - the route behind it must
refuse the caller on its own, or hiding it is decoration. Use `show` to keep
a person from seeing a door that would only tell them to go away.
* keys match `sec:[a-z][a-z0-9-]*` (see nav_state._KEY_RE, which also allows
`cat:<id>` for the tree). Rename one and the fold state for the old key is
ignored rather than misapplied - somebody's sidebar opens up once, which is
the right failure.
"""
import logging
from flask import jsonify, render_template, request
from app.core import categories as categories_core
from app.core import nav_state as nav_core
from app.core import plans as plans_core
from app.core.context import Scope
from utils.db_wrapper import db_session
from .crud import endpoint, page, principal
logger = logging.getLogger(__name__)
# The sidebar, top to bottom. Replace it with your own; the shell needs nothing
# from these entries but what is here.
#
# key stable id for the fold state
# title the heading
# items {href, label, show?} - `show` takes a Principal and returns a bool
NAV = [
{
"key": "sec:workspace",
"title": "Workspace",
"items": [
{"href": "/", "label": "Home"},
],
},
{
"key": "sec:account",
"title": "Account",
"items": [
{"href": "/account", "label": "Security"},
# ADMIN rather than WRITE, matching what the buttons on that page
# require. The two are written twice on purpose and must agree; this
# one only decides whether the link is drawn, and the page itself
# still refuses a member who reaches it by typing the URL.
{"href": "/billing", "label": "Plan & billing",
"show": lambda p: Scope.ADMIN in p.scopes},
],
},
]
def sections_for(principal_obj, collapsed_keys) -> list[dict]:
"""`NAV` resolved for one caller: entries they may see, folds applied.
An empty section is dropped rather than drawn as a heading with nothing
under it - which is what happens to "Account" for a viewer once `show`
removes its only admin link.
"""
folded = set(collapsed_keys or ())
out = []
for section in NAV:
items = [i for i in section["items"]
if "show" not in i or i["show"](principal_obj)]
if not items:
continue
out.append({
"key": section["key"],
"title": section["title"],
"items": items,
"folded": section["key"] in folded,
})
return out
def shell_context():
"""Everything shell.html needs. Call it from any page that extends the
shell, so a new page inherits the sidebar instead of re-deriving it.
The sidebar has two halves and they come from different places. The declared
sections above are static and live in code; the tree below is the
organisation's own rows. Both carry this person's fold state, which is the
one part of the sidebar that is theirs alone.
"""
p = principal()
collapsed = nav_core.collapsed(db_session, p)
folded = set(collapsed)
tree = categories_core.tree(db_session, p)
# Fold state is applied here rather than in the template, so the template has
# nothing to decide and the key format lives next to the code that validates
# it.
for category in tree["categories"]:
category["folded"] = f"cat:{category['id']}" in folded
return {
"nav_sections": sections_for(p, collapsed),
"nav_tree": tree,
"principal": p,
}
def page_home():
"""The landing page for a signed-in caller.
Reached signed-in only: "/" is not in PUBLIC_PATHS, so require_session has
already redirected a stranger to /login. If this app should have a public
landing page, add "/" to that allowlist and decide here what a stranger sees.
"""
return render_template("home.html", **shell_context())
@endpoint
def api_nav_collapsed():
return jsonify({"collapsed": nav_core.collapsed(db_session, principal())})
@endpoint
def api_nav_collapsed_set():
"""Replace the whole folded set.
Whole-list rather than a toggle: the sidebar knows its complete state after
every click, so sending it costs nothing and cannot drift out of step with
the screen the way an add and a remove can.
"""
body = request.get_json(silent=True) or {}
keys = nav_core.set_collapsed(db_session, principal(), body.get("collapsed"))
return jsonify({"collapsed": keys})
def page_account():
"""Security settings: password, passkeys, API tokens.
One page for the three because they are one question - what can be used to
get into this account - and splitting them means somebody auditing their own
access has to remember to look in three places.
"""
return render_template("account.html", **shell_context())
@page
def page_subcategory(slug: str):
"""Whatever a leaf in the tree points at.
A placeholder, and the seam: the skeleton knows a subcategory has a name and
a slug, and nothing about what belongs on its page. Replace this with yours.
"""
row = categories_core.get_subcategory(db_session, principal(), slug)
return render_template("subcategory.html", item=row, **shell_context())
# --- arranging the tree -----------------------------------------------------
#
# Thin, as a façade should be: read the request, call one core function,
# serialise. Every rule about who may arrange what is in app/core/categories.py.
@endpoint
def api_tree():
p = principal()
tree = categories_core.tree(db_session, p)
return jsonify({
"categories": [{"id": c["id"], "name": c["name"],
"items": [i.to_dict() for i in c["items"]]}
for c in tree["categories"]],
"ungrouped": [i.to_dict() for i in tree["ungrouped"]],
"can_arrange": tree["can_arrange"],
})
@endpoint
def api_category_create():
body = request.get_json(silent=True) or {}
category = categories_core.create_category(db_session, principal(),
body.get("name", ""))
return jsonify(category.to_dict())
@endpoint
def api_category_update(category_id: int):
body = request.get_json(silent=True) or {}
category = categories_core.update_category(
db_session, principal(), category_id,
name=body.get("name"), rank=body.get("rank"))
return jsonify(category.to_dict())
@endpoint
def api_category_delete(category_id: int):
return jsonify(categories_core.delete_category(db_session, principal(),
category_id))
@endpoint
def api_subcategory_create():
body = request.get_json(silent=True) or {}
# The worked example of a plan limit, at the create boundary and before the
# row is added, so the check is on the count that would result. It sits in
# the façade rather than in categories.create_subcategory because the paywall
# is a property of this deployment, not of the tree: a CLI or a seed script
# calling the core directly should not be sold an upgrade.
plans_core.enforce_limit(db_session, principal().org_id, "subcategory")
row = categories_core.create_subcategory(
db_session, principal(), body.get("name", ""),
slug=body.get("slug"), category_id=body.get("category_id"))
return jsonify(row.to_dict())
@endpoint
def api_subcategory_update(slug: str):
body = request.get_json(silent=True) or {}
row = categories_core.update_subcategory(db_session, principal(), slug,
name=body.get("name"))
return jsonify(row.to_dict())
@endpoint
def api_subcategory_delete(slug: str):
return jsonify(categories_core.delete_subcategory(db_session, principal(), slug))
@endpoint
def api_subcategory_place(slug: str):
"""Move a leaf: which group, and where in it.
Only the keys present in the body are applied. `category_id: null` is a
value - it means ungrouped - which is why absence and null cannot be the
same thing here, and why the core takes a sentinel rather than None.
"""
body = request.get_json(silent=True) or {}
kwargs = {}
if "category_id" in body:
kwargs["category_id"] = body["category_id"]
if "nav_rank" in body:
kwargs["nav_rank"] = body["nav_rank"]
row = categories_core.place_subcategory(db_session, principal(), slug, **kwargs)
return jsonify(row.to_dict())