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