diff --git a/CLAUDE.md b/CLAUDE.md index f1a6dca..cd2651d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ lembas info # paths + counts, useful when confused lembas secret-key # generate LEMBAS_SECRET_KEY lembas create-admin # create or promote an admin -pytest # 197 tests, ~7s +pytest # 212 tests, ~8s ruff check . # lint (line length 100) python scripts/build_artwork.py # regenerate all SVG artwork python scripts/fetch_vendor.py # verify vendored JS against the lockfile @@ -197,6 +197,12 @@ it; `POST /api/chats/start` writes the chat together with its first message. That is why an opened-and-abandoned chat never appears in the sidebar. Tests that just need a chat use the `make_chat` fixture rather than the HTTP flow. +**Admin lists are list-plus-detail, never a form per row.** `/admin/models` +renders compact rows with search, filter tabs and pagination; the full form +lives at `/admin/models/{id}/edit`. A connection can advertise a hundred models, +and a page that renders a form for each is unusable. Any future admin list +(tools, agents) should follow the same shape. + **Route order matters for static path segments.** FastAPI matches in registration order, so `/admin/models/bulk` must be registered *before* `/admin/models/{model_id}` or "bulk" is parsed as a model id and 404s. This has diff --git a/README.md b/README.md index 6cefbb5..95f2f92 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ runtime. Clone it, `pip install -e .`, run it. inside it - **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp, llama-swap, Ollama or OpenRouter; models are discovered and cached -- **Model settings** — ordering, pinned models, an instance default and a - per-user default, custom names and descriptions, uploaded model images +- **Model settings** — searchable, filterable list with a page per model: + ordering, pinned models, an instance default and a per-user default, custom + names, descriptions and images. Scales to hundreds of models - **Users, groups & permissions** — per-group grants that union rather than override, and model access restricted to chosen groups - **Accounts** — first account becomes the administrator, argon2 password diff --git a/src/lembas/api/admin_models.py b/src/lembas/api/admin_models.py index a73a75e..9814ba4 100644 --- a/src/lembas/api/admin_models.py +++ b/src/lembas/api/admin_models.py @@ -47,20 +47,104 @@ def _renumber(db: DBSession) -> None: db.commit() -# --- The admin page ---------------------------------------------------------- +# --- Listing ----------------------------------------------------------------- +PAGE_SIZE = 40 + +# Filters offered as tabs above the list. Each is a predicate over a Model. +FILTERS: dict[str, tuple[str, object]] = { + "all": ("All", lambda m: True), + "enabled": ("Enabled", lambda m: m.enabled), + "disabled": ("Disabled", lambda m: not m.enabled), + "pinned": ("Pinned", lambda m: m.pinned), + "restricted": ("Restricted", lambda m: not m.public), +} + + @router.get("/admin/models") -async def models_page(request: Request, db: Db, user: AdminUser, saved: str = ""): - models = _ordered(db) +async def models_page( + request: Request, + db: Db, + user: AdminUser, + saved: str = "", + q: str = "", + filter: str = "all", + connection: str = "", + page: int = 1, +): + """The model list. + + Compact rows only -- editing happens on a page of its own. A connection can + advertise a hundred models, and a list that renders a full form for each of + them is unusable at that size. + """ + everything = _ordered(db) + + predicate = FILTERS.get(filter, FILTERS["all"])[1] + needle = q.strip().lower() + + matching = [ + model + for model in everything + if predicate(model) + and (not connection or model.connection_id == connection) + and ( + not needle + or needle in model.model_id.lower() + or needle in (model.display_name or "").lower() + ) + ] + + pages = max(1, -(-len(matching) // PAGE_SIZE)) + page = max(1, min(page, pages)) + start = (page - 1) * PAGE_SIZE + visible = matching[start : start + PAGE_SIZE] + return render( request, "admin/models.html", { - "models": models, - "groups": list(db.scalars(select(Group).order_by(Group.name))), + "models": visible, + "total": len(everything), + "matched": len(matching), + "page": page, + "pages": pages, + "page_start": start, "connections": list(db.scalars(select(Connection).order_by(Connection.name))), "default_model": settings_store.get(db, "default_model") or "", - "instance_prompt": settings_store.get(db, "system_prompt") or "", + "counts": { + key: sum(1 for m in everything if test(m)) for key, (_, test) in FILTERS.items() + }, + "filters": {key: label for key, (label, _) in FILTERS.items()}, + "active_filter": filter if filter in FILTERS else "all", + "q": q, + "connection_id": connection, + "saved": saved, + }, + ) + + +@router.get("/admin/models/{model_id}/edit") +async def model_detail( + request: Request, db: Db, user: AdminUser, model_id: str, saved: str = "" +): + """Everything about one model, on its own page.""" + model = _model(db, model_id) + ordered = _ordered(db) + index = next((i for i, m in enumerate(ordered) if m.id == model.id), 0) + + return render( + request, + "admin/model_detail.html", + { + "model": model, + "groups": list(db.scalars(select(Group).order_by(Group.name))), "capabilities": CAPABILITIES, + "default_model": settings_store.get(db, "default_model") or "", + "instance_prompt": settings_store.get(db, "system_prompt") or "", + "position_of": index + 1, + "total": len(ordered), + "previous": ordered[index - 1] if index > 0 else None, + "next": ordered[index + 1] if index + 1 < len(ordered) else None, "saved": saved, }, ) @@ -107,6 +191,7 @@ async def update_model( enabled: bool = Form(False), pinned: bool = Form(False), public: bool = Form(False), + position: str = Form(""), group_ids: list[str] = Form(default=[]), capability: list[str] = Form(default=[]), ) -> Response: @@ -130,13 +215,34 @@ async def update_model( model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or [])))) db.commit() + + # Typing a position is the only workable way to reorder a long list; the + # up/down buttons are for nudging a model one place. + if position.strip(): + try: + wanted = max(1, int(position)) - 1 + except ValueError: + wanted = None + if wanted is not None: + ordered = [m for m in _ordered(db) if m.id != model.id] + ordered.insert(min(wanted, len(ordered)), model) + for index, item in enumerate(ordered): + item.position = index + db.commit() + log.info("model %s updated by %s", model.model_id, user.email) - return RedirectResponse("/admin/models?saved=Model+saved.", status_code=303) + return RedirectResponse( + f"/admin/models/{model.id}/edit?saved=Saved.", status_code=303 + ) @router.post("/admin/models/{model_id}/move") async def move_model( - db: Db, user: AdminUser, model_id: str, direction: str = Form(...) + db: Db, + user: AdminUser, + model_id: str, + direction: str = Form(...), + back: str = Form(""), ) -> Response: """Swap a model with its neighbour.""" model = _model(db, model_id) @@ -153,17 +259,21 @@ async def move_model( item.position = position db.commit() - return RedirectResponse("/admin/models", status_code=303) + # Back to whichever filtered, paginated view the button was pressed on. + return RedirectResponse(back or "/admin/models", status_code=303) @router.post("/admin/models/{model_id}/default") -async def set_default_model(db: Db, user: AdminUser, model_id: str) -> Response: +async def set_default_model( + db: Db, user: AdminUser, model_id: str, back: str = Form("") +) -> Response: """Make a model the instance default for new chats.""" model = _model(db, model_id) settings_store.update(db, {"default_model": model.model_id}) log.info("default model set to %s by %s", model.model_id, user.email) return RedirectResponse( - f"/admin/models?saved={model.label}+is+now+the+default.", status_code=303 + back or f"/admin/models/{model.id}/edit?saved=Now+the+default+model.", + status_code=303, ) @@ -177,7 +287,9 @@ async def upload_model_image( try: filename = uploads.save_model_image(payload, image.content_type or "") except uploads.UploadError as exc: - return RedirectResponse(f"/admin/models?saved={exc}", status_code=303) + return RedirectResponse( + f"/admin/models/{model.id}/edit?saved={exc}", status_code=303 + ) # Remove the old file rather than orphaning it in the uploads directory. if model.image_path: @@ -185,7 +297,9 @@ async def upload_model_image( model.image_path = filename db.commit() - return RedirectResponse("/admin/models?saved=Image+updated.", status_code=303) + return RedirectResponse( + f"/admin/models/{model.id}/edit?saved=Image+updated.", status_code=303 + ) @router.post("/admin/models/{model_id}/image/delete") @@ -195,7 +309,9 @@ async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response uploads.delete_model_image(model.image_path) model.image_path = "" db.commit() - return RedirectResponse("/admin/models?saved=Image+removed.", status_code=303) + return RedirectResponse( + f"/admin/models/{model.id}/edit?saved=Image+removed.", status_code=303 + ) # --- Serving model images ---------------------------------------------------- diff --git a/src/lembas/web/static/css/admin.css b/src/lembas/web/static/css/admin.css index c21fcd5..9b48b21 100644 --- a/src/lembas/web/static/css/admin.css +++ b/src/lembas/web/static/css/admin.css @@ -162,23 +162,70 @@ .status-dot.is-bad { background: var(--danger); } .status-dot.is-off { background: var(--ink-faint); } -/* --- Model list ------------------------------------------------------------ */ +/* --- Filter bar ------------------------------------------------------------ */ +.filter-bar { + display: flex; + flex-direction: column; + gap: var(--sp-3); + margin-bottom: var(--sp-4); +} + +.filter-tabs { + display: flex; + gap: var(--sp-1); + flex-wrap: wrap; + border-bottom: 1px solid var(--border); +} +.filter-tab { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + height: var(--control-h); + padding: 0 var(--sp-3); + border-bottom: 2px solid transparent; + color: var(--ink-muted); + font-size: var(--text-sm); + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} +.filter-tab:hover { color: var(--ink); } +.filter-tab.is-active { color: var(--ink); border-bottom-color: var(--accent); } +.filter-tab__count { + font-size: var(--text-xs); + color: var(--ink-faint); + background: var(--surface-active); + border-radius: var(--radius-full); + padding: 0 0.4rem; + min-width: 1.4rem; + text-align: center; +} +.filter-tab.is-active .filter-tab__count { background: var(--accent-soft); color: var(--accent); } + +.filter-form { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } +.filter-form .input { width: auto; flex: 1; min-width: 12rem; } +.filter-form .select { width: auto; min-width: 10rem; } + +/* --- Model list ------------------------------------------------------------ + Compact rows only. Editing is a page of its own -- a connection can advertise + a hundred models, and a list that renders a form for each is unusable. +*/ .bulk-bar { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; - padding: var(--sp-3); + padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--surface); - margin-bottom: var(--sp-4); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + border-bottom: 0; + background: var(--bg-sunken); } -.bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); margin-right: var(--sp-1); } +.bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); } .model-rows { border: 1px solid var(--border); - border-radius: var(--radius-lg); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); overflow: hidden; background: var(--surface); } @@ -186,21 +233,36 @@ display: flex; align-items: center; gap: var(--sp-3); - padding: var(--sp-3); + padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); } .model-row:last-child { border-bottom: 0; } -.model-row.is-off { opacity: 0.5; } +.model-row:hover { background: var(--surface-hover); } +.model-row.is-off { opacity: 0.55; } .model-row__check { accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; } -.model-row__avatar { flex: none; width: 2rem; height: 2rem; } +.model-row__pos { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--ink-faint); + min-width: 1.75rem; + text-align: right; + flex: none; +} +.model-row__avatar { flex: none; width: 1.75rem; height: 1.75rem; } .model-row__main { flex: 1; min-width: 0; } .model-row__title { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; - margin-bottom: 0.1rem; } +.model-row__name { + color: var(--ink); + font-weight: 500; + font-size: var(--text-sm); + text-decoration: none; +} +.model-row__name:hover { color: var(--accent); text-decoration: underline; } .model-row__id { font-family: var(--font-mono); font-size: var(--text-xs); @@ -212,6 +274,34 @@ } .model-row__actions { display: flex; align-items: center; gap: var(--sp-1); flex: none; } +.list-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + margin-top: var(--sp-3); + flex-wrap: wrap; +} + +/* --- Model detail ---------------------------------------------------------- */ +.crumbs { + display: flex; + align-items: center; + gap: var(--sp-2); + margin-bottom: var(--sp-4); + font-size: var(--text-sm); +} +.crumbs > a:first-child { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + color: var(--ink-muted); + text-decoration: none; +} +.crumbs > a:first-child:hover { color: var(--ink); } +.crumbs__back { transform: rotate(180deg); } +.model-detail__avatar { width: 3rem; height: 3rem; flex: none; } + .model-list { list-style: none; margin: 0; padding: 0; } .model-list__item { display: flex; diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js index 8e54109..1d620a1 100644 --- a/src/lembas/web/static/js/app.js +++ b/src/lembas/web/static/js/app.js @@ -232,6 +232,18 @@ if (event.target.matches("[data-autosize]")) autosize(event.target); }); + /* A "select all" box driving every checkbox inside a container. Scoped to a + selector rather than the whole page, so a list can carry more than one. */ + document.addEventListener("change", function (event) { + var master = event.target.closest("[data-select-all]"); + if (!master) return; + var scope = document.querySelector(master.dataset.selectAll); + if (!scope) return; + scope.querySelectorAll('input[type="checkbox"]').forEach(function (box) { + box.checked = master.checked; + }); + }); + /* Enter sends, Shift+Enter inserts a newline -- the convention every chat application uses. Left alone on touch devices, where there is no easy Shift and Enter should mean "new line". */ diff --git a/src/lembas/web/templates/admin/model_detail.html b/src/lembas/web/templates/admin/model_detail.html new file mode 100644 index 0000000..35660fd --- /dev/null +++ b/src/lembas/web/templates/admin/model_detail.html @@ -0,0 +1,196 @@ +{% extends "admin/_layout.html" %} +{% from "_macros.html" import icon, model_avatar %} +{% set section = "models" %} + +{% block title %}{{ model.label }} - Models - LLeMbas{% endblock %} +{% block heading %}{{ model.label }}{% endblock %} + +{% block admin_content %} + + +{% if saved %} +
{{ icon("check", "icon--sm") }} {{ saved }}
+{% endif %} + +
+
+
+ {{ model_avatar(model, cls="model-detail__avatar") }} +
+ {{ model.label }} +
{{ model.model_id }}
+
via {{ model.connection.name }}
+
+
+
+ {% if model.model_id == default_model %} + {{ icon("star", "icon--sm") }} default model + {% else %} +
+ +
+ {% endif %} +
+
+ +
+
+ + +
+ {% if model.image_path %} +
+ +
+ {% endif %} +
+

+ PNG, JPEG, WEBP or GIF, under 2 MB. Without one, the model gets a generated + badge whose colour is derived from its id. +

+
+ +
+
+

Presentation

+ +
+
+ + +

Shown instead of the raw id. Empty uses the id.

+
+ +
+ + +

+ Place in the picker, 1–{{ total }}. Typing a number is the workable way + to move a model a long distance. +

+
+
+ +
+ + +

Shown in the chat settings panel and your users' settings.

+
+
+ +
+

System prompt

+

+ Used by chats on this model that have no prompt of their own. A chat's own + prompt overrides this; this overrides the instance prompt. +

+
+ + +

+ {% if instance_prompt %} + Leave empty to fall back to the instance prompt shown above. + {% else %} + Leave empty to send no system prompt. + {% endif %} +

+
+
+ +
+

Capabilities

+

+ Endpoints rarely advertise these reliably, so they are your call. + reasoning shows the thinking block, + vision lets images be sent to this model, and + tools is reserved for a feature not built yet. +

+
+ {% for name in capabilities %} + + {% endfor %} +
+
+ +
+

Availability

+ +
+
+ + +
+
+ +
+ +

+ Uncheck to restrict this model to chosen groups. Administrators always + have access. +

+
+ +
+ Groups with access + {% if groups %} +
+ {% for group in groups %} + + {% endfor %} +
+

Ignored while the model is available to everyone.

+ {% else %} +

+ No groups yet — create one to restrict access. +

+ {% endif %} +
+
+ +
+ + Back to all models +
+
+{% endblock %} diff --git a/src/lembas/web/templates/admin/models.html b/src/lembas/web/templates/admin/models.html index 4b4234c..5dccc8d 100644 --- a/src/lembas/web/templates/admin/models.html +++ b/src/lembas/web/templates/admin/models.html @@ -17,7 +17,7 @@
{{ icon("check", "icon--sm") }} {{ saved }}
{% endif %} -{% if not models %} +{% if not total %}
{{ icon("server", "empty__mark") }}

@@ -27,8 +27,50 @@

{% else %} +{# Filters are links, so a filtered view is a real URL you can keep or share. #} +
+
+ {% for key, label in filters.items() %} + + {{ label }} {{ counts[key] }} + + {% endfor %} +
+ +
+ + + + + {% if q or connection_id or active_filter != "all" %} + Clear + {% endif %} +
+
+ +{% if not models %} +
+

Nothing matches that filter.

+
+{% else %} +
+ + With selected: @@ -36,174 +78,74 @@
-
+
{% for model in models %}
+ {{ page_start + loop.index }} + {{ model_avatar(model, cls="model-row__avatar") }}
- {{ model.label }} + {{ model.label }} {% if model.model_id == default_model %} - {{ icon("star", "icon--sm") }} default + default {% endif %} {% if model.pinned %}pinned{% endif %} {% if not model.enabled %}disabled{% endif %} - {% if not model.public %} - {{ model.groups|length }} group{{ '' if model.groups|length == 1 else 's' }} - {% endif %} + {% if not model.public %}restricted{% endif %} {% for name, on in (model.capabilities_json or {}).items() %} {% if on %}{{ name }}{% endif %} {% endfor %}
- {{ model.model_id }} - via {{ model.connection.name }} + {{ model.model_id }} · {{ model.connection.name }}
+ {# formaction lets these post elsewhere without nesting a second form. #} - Edit + Edit
{% endfor %}
-

Model settings

+ +{% endif %} {% endif %} {% endblock %} diff --git a/tests/test_permissions.py b/tests/test_permissions.py index aa54f47..fa4e844 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -477,3 +477,186 @@ def test_bulk_with_nothing_selected_is_harmless(client: TestClient, db, register assert client.post( "/admin/models/bulk", data={"action": "disable"}, follow_redirects=False ).status_code == 303 + + +# --- The model admin list ---------------------------------------------------- +def _many_models(db, count: int) -> None: + connection = _connection(db) + db.add_all( + [ + Model(connection_id=connection.id, model_id=f"model-{i:03d}", position=i) + for i in range(count) + ] + ) + db.commit() + + +def test_the_list_paginates_rather_than_rendering_everything( + client: TestClient, db, registered +): + """A hundred models must not become a hundred forms on one page.""" + from lembas.api.admin_models import PAGE_SIZE + + _many_models(db, PAGE_SIZE + 15) + page = client.get("/admin/models").text + + assert page.count('name="model_ids"') == PAGE_SIZE + assert f"of {PAGE_SIZE + 15}" in page + assert "Page 1 of 2" in page + + +def test_the_second_page_shows_the_remainder(client: TestClient, db, registered): + from lembas.api.admin_models import PAGE_SIZE + + _many_models(db, PAGE_SIZE + 15) + page = client.get("/admin/models?page=2").text + assert page.count('name="model_ids"') == 15 + + +def test_an_out_of_range_page_is_clamped(client: TestClient, db, registered): + _many_models(db, 5) + assert "Page 1 of 1" not in client.get("/admin/models?page=99").text + assert client.get("/admin/models?page=99").status_code == 200 + + +def test_the_list_does_not_render_edit_forms(client: TestClient, db, registered): + """The whole point of the split: rows link to a page, they are not forms.""" + _many_models(db, 3) + page = client.get("/admin/models").text + assert 'name="system_prompt"' not in page + assert 'name="display_name"' not in page + assert page.count("/edit") >= 3 + + +def test_search_narrows_the_list(client: TestClient, db, registered): + connection = _connection(db) + db.add_all( + [ + Model(connection_id=connection.id, model_id="llama-large", position=0), + Model(connection_id=connection.id, model_id="qwen-small", position=1), + ] + ) + db.commit() + + page = client.get("/admin/models?q=qwen").text + assert "qwen-small" in page + assert "llama-large" not in page + + +def test_search_matches_the_display_name_too(client: TestClient, db, registered): + connection = _connection(db) + db.add( + Model(connection_id=connection.id, model_id="abc-123", display_name="Friendly Name") + ) + db.commit() + assert "abc-123" in client.get("/admin/models?q=friendly").text + + +def test_filter_tabs_narrow_the_list(client: TestClient, db, registered): + connection = _connection(db) + db.add_all( + [ + Model(connection_id=connection.id, model_id="on-model", position=0), + Model(connection_id=connection.id, model_id="off-model", position=1, enabled=False), + ] + ) + db.commit() + + disabled = client.get("/admin/models?filter=disabled").text + assert "off-model" in disabled + assert ">on-model" not in disabled + + +def test_filtering_by_connection(client: TestClient, db, registered): + first = _connection(db) + second = _connection(db) + db.add_all( + [ + Model(connection_id=first.id, model_id="from-first", position=0), + Model(connection_id=second.id, model_id="from-second", position=1), + ] + ) + db.commit() + + page = client.get(f"/admin/models?connection={second.id}").text + assert "from-second" in page + assert "from-first" not in page + + +# --- The model detail page --------------------------------------------------- +def test_the_detail_page_carries_the_full_form(client: TestClient, db, registered): + _many_models(db, 3) + db.add(Group(name="Insiders")) + db.commit() + model = db.scalar(select(Model).where(Model.model_id == "model-001")) + + page = client.get(f"/admin/models/{model.id}/edit").text + for field in ('name="display_name"', 'name="system_prompt"', 'name="capability"', + 'name="group_ids"', 'name="position"'): + assert field in page, field + + +def test_the_detail_page_links_to_its_neighbours(client: TestClient, db, registered): + _many_models(db, 3) + first, middle, last = db.scalars(select(Model).order_by(Model.position)).all() + + page = client.get(f"/admin/models/{middle.id}/edit").text + assert f"/admin/models/{first.id}/edit" in page + assert f"/admin/models/{last.id}/edit" in page + assert "2 of 3" in page + + +def test_an_unknown_model_detail_is_404(client: TestClient, db, registered): + assert client.get("/admin/models/nope/edit").status_code == 404 + + +def test_saving_from_the_detail_page_returns_to_it(client: TestClient, db, registered): + _many_models(db, 2) + model = db.scalar(select(Model).where(Model.model_id == "model-000")) + + response = client.post( + f"/admin/models/{model.id}", + data={"display_name": "Renamed", "enabled": "true", "public": "true"}, + follow_redirects=False, + ) + assert response.headers["location"].startswith(f"/admin/models/{model.id}/edit") + + db.refresh(model) + assert model.display_name == "Renamed" + + +def test_typing_a_position_moves_the_model(client: TestClient, db, registered): + """Up/down is unusable for moving a model 60 places.""" + _many_models(db, 5) + last = db.scalar(select(Model).where(Model.model_id == "model-004")) + + client.post( + f"/admin/models/{last.id}", + data={"display_name": "", "enabled": "true", "public": "true", "position": "1"}, + ) + order = [m.model_id for m in db.scalars(select(Model).order_by(Model.position))] + assert order[0] == "model-004" + + +def test_a_nonsense_position_is_ignored(client: TestClient, db, registered): + _many_models(db, 3) + model = db.scalar(select(Model).where(Model.model_id == "model-000")) + + client.post( + f"/admin/models/{model.id}", + data={"display_name": "", "enabled": "true", "public": "true", "position": "abc"}, + ) + db.refresh(model) + assert model.position == 0 + + +def test_moving_returns_to_the_filtered_view(client: TestClient, db, registered): + _many_models(db, 3) + model = db.scalar(select(Model).where(Model.model_id == "model-001")) + + response = client.post( + f"/admin/models/{model.id}/move", + data={"direction": "up", "back": "/admin/models?filter=enabled&page=2"}, + follow_redirects=False, + ) + assert response.headers["location"] == "/admin/models?filter=enabled&page=2"