Prompt suggestions on the new-chat screen

A blank composer is the least helpful thing a chat client can show someone
who has just installed one. Three cards now sit under the empty state, and
an administrator manages them at /admin/suggestions.

Clicking a card fills the composer and stops there. It deliberately does not
send: every default ends mid-sentence, because a card is a starting point
rather than a question somebody already asked, and the caret lands where the
person has to start typing.

Seeding is guarded by a settings flag, not by "is the table empty" --
otherwise an administrator who decided against them would get all three back
on every restart. Capped at twelve, six shown: past a dozen this is a menu,
and a menu on the empty screen is a worse blank page than a blank page.

The cards are gated on there being no chat at all, not on the thread being
empty. An empty chat someone opened on purpose already has a model and a
prompt chosen.

Also fixes a pre-existing bug the position test caught. Both this and
_refresh_models wrote `coalesce(max(position), -1) or -1`, and position 0 is
falsy -- so the second row landed back on 0 on top of the first. The
coalesce was already doing that job; the `or` was undoing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 00:49:12 +02:00
parent 248dec2961
commit 09cfde4de8
13 changed files with 650 additions and 1 deletions
+4 -1
View File
@@ -192,7 +192,10 @@ async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, s
# New models land after everything already ordered, rather than all at
# position 0 where they would sort by id and shuffle the existing list.
next_position = (db.scalar(select(func.coalesce(func.max(Model.position), -1))) or -1) + 1
# No `or -1` after the coalesce: position 0 is falsy, so that idiom sent the
# second discovered model back to 0 on top of the first.
highest = db.scalar(select(func.coalesce(func.max(Model.position), -1)))
next_position = int(highest if highest is not None else -1) + 1
for entry in discovered:
model_id = str(entry["id"])[:300]
+113
View File
@@ -0,0 +1,113 @@
"""Administration for the cards offered on the new-chat screen."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.db.models import Suggestion
from lembas.services import suggestions as suggestions_service
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/suggestions", tags=["admin-suggestions"])
def _suggestion(db: Db, suggestion_id: str) -> Suggestion:
suggestion = db.get(Suggestion, suggestion_id)
if suggestion is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That suggestion no longer exists.")
return suggestion
def _back(message: str = "") -> Response:
target = f"/admin/suggestions?saved={message}" if message else "/admin/suggestions"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
@router.get("")
async def suggestions_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
rows = suggestions_service.all_of_them(db)
return render(
request,
"admin/suggestions.html",
{
"suggestions": rows,
"at_limit": len(rows) >= suggestions_service.MAX_SUGGESTIONS,
"max_suggestions": suggestions_service.MAX_SUGGESTIONS,
"max_shown": suggestions_service.MAX_SHOWN,
"saved": saved,
},
)
@router.post("")
async def create_suggestion(
db: Db,
user: AdminUser,
name: str = Form(""),
description: str = Form(""),
prompt: str = Form(""),
) -> Response:
name = name.strip()
if not name:
return _back("A suggestion needs a name.")
if len(suggestions_service.all_of_them(db)) >= suggestions_service.MAX_SUGGESTIONS:
return _back(f"That is already {suggestions_service.MAX_SUGGESTIONS}, which is plenty.")
suggestions_service.create(db, name=name, description=description, prompt=prompt)
log.info("%s added suggestion %s", user.email, name)
return _back(f"Added {name}.")
# Registered before /{suggestion_id}: FastAPI matches in registration order, so
# with the parameterised route first any literal segment added later would be
# captured as an id. That has already been a bug once, in /admin/models.
@router.post("/{suggestion_id}/delete")
async def delete_suggestion(db: Db, user: AdminUser, suggestion_id: str) -> Response:
suggestion = _suggestion(db, suggestion_id)
name = suggestion.name
db.delete(suggestion)
db.commit()
log.info("%s deleted suggestion %s", user.email, name)
return _back(f"Deleted {name}.")
@router.post("/{suggestion_id}")
async def update_suggestion(
request: Request,
db: Db,
user: AdminUser,
suggestion_id: str,
) -> Response:
"""Save one row.
The raw form is read rather than declared parameters because `enabled` is a
checkbox: FastAPI cannot tell an unticked box from an absent field, and an
absent one is exactly what an unticked box sends.
"""
suggestion = _suggestion(db, suggestion_id)
form = await request.form()
suggestion.name = (
str(form.get("name") or "").strip()[: suggestions_service.MAX_NAME] or suggestion.name
)
suggestion.description = str(form.get("description") or "").strip()[
: suggestions_service.MAX_DESCRIPTION
]
suggestion.prompt = str(form.get("prompt") or "").replace("\r\n", "\n")[
: suggestions_service.MAX_PROMPT
]
suggestion.enabled = "enabled" in form
position = str(form.get("position") or "").strip()
if position.isdigit():
suggestion.position = min(max(int(position) - 1, 0), 999)
db.commit()
log.info("%s updated suggestion %s", user.email, suggestion.name)
return _back(f"Saved {suggestion.name}.")
+2
View File
@@ -13,6 +13,7 @@ from lembas.security import permissions
from lembas.services import audio as audio_service
from lembas.services import chat as chat_service
from lembas.services import settings_store
from lembas.services import suggestions as suggestions_service
from lembas.services.library import documents as documents_service
from lembas.services.markdown import render_markdown
from lembas.web.templating import STATIC_DIR, render
@@ -201,6 +202,7 @@ async def chat_index(
**context,
"current_model": preselected,
"starting_temporary": temporary,
"suggestions": suggestions_service.visible(db),
**sidebar_context(db, user),
},
)
+2
View File
@@ -41,6 +41,7 @@ from lembas.db.models.library import (
chat_knowledge_bases,
)
from lembas.db.models.setting import Setting
from lembas.db.models.suggestion import Suggestion
from lembas.db.models.user import (
ROLE_ADMIN,
ROLE_PENDING,
@@ -85,6 +86,7 @@ __all__ = [
"Share",
"Skill",
"SkillRevision",
"Suggestion",
"User",
"chat_knowledge_bases",
"model_groups",
+32
View File
@@ -0,0 +1,32 @@
"""Starting points offered on the new-chat screen."""
from __future__ import annotations
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
class Suggestion(UUIDPrimaryKey, Timestamps, Base):
"""One card on the empty chat screen.
Instance-wide rather than per-user: these are what an administrator wants
people to start with, the same way the instance system prompt is. There is
no owner_id and therefore nothing for `sharing` to decide.
"""
__tablename__ = "suggestions"
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(String(300), default="")
# What lands in the composer. Deliberately not sent on its own: it usually
# ends mid-sentence, because a card is a starting point rather than a
# question somebody already asked.
prompt: Mapped[str] = mapped_column(Text, default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
def __repr__(self) -> str:
return f"<Suggestion {self.name}>"
+5
View File
@@ -18,6 +18,7 @@ from lembas.api import (
admin_models,
admin_prompts,
admin_search,
admin_suggestions,
admin_users,
audio,
auth,
@@ -65,6 +66,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
from lembas.services.chat import sweep_temporary
from lembas.services.files import sweep_orphans
from lembas.services.library.documents import sweep_unfiled
from lembas.services.suggestions import seed_defaults as seed_suggestions
with session_scope() as db:
sweep_orphans(db)
@@ -74,6 +76,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Temporary chats older than a day. Startup only, like the sweeps
# above it -- see services/chat.py:sweep_temporary.
sweep_temporary(db)
# Three starting points on the empty screen, written once ever.
seed_suggestions(db)
except Exception: # noqa: BLE001 - housekeeping must never block startup
log.exception("orphaned upload sweep failed")
@@ -115,6 +119,7 @@ def create_app() -> FastAPI:
app.include_router(admin_audio.router)
app.include_router(admin_search.router)
app.include_router(admin_prompts.router)
app.include_router(admin_suggestions.router)
register_error_handlers(app)
return app
+118
View File
@@ -0,0 +1,118 @@
"""The cards offered on the new-chat screen.
A blank composer is the least helpful thing a chat client can show someone who
has just installed one. These are three starting points an administrator can
replace with their own.
"""
from __future__ import annotations
import logging
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import Suggestion
from lembas.services import settings_store
log = logging.getLogger(__name__)
# A short list stays a short list. Past a dozen it is a menu, and a menu on the
# empty screen is a worse blank page than a blank page.
MAX_SUGGESTIONS = 12
MAX_SHOWN = 6
MAX_NAME = 120
MAX_DESCRIPTION = 300
MAX_PROMPT = 4000
# Each ends mid-sentence, so the caret lands exactly where the person has to
# start typing. No Middle-earth flavour: this is functional UI.
DEFAULTS: tuple[tuple[str, str, str], ...] = (
(
"Explain this",
"Paste something confusing and get it back in plain language.",
"Explain the following in plain language. Start with one sentence "
"summarising it, then the details that actually matter, then anything I "
"should watch out for. If I have not pasted anything yet, ask me for it "
"rather than guessing.\n\n",
),
(
"Draft a reply",
"Turn a message you have received into an answer you can send.",
"Help me reply to the message below. If the tone I want and the outcome "
"I am after are not obvious from it, ask me before writing. Then give me "
"a draft I could send as it stands.\n\n",
),
(
"Find the flaw",
"Have a plan argued with before you commit to it.",
"I am going to describe a plan. Argue against it: what is most likely to "
"go wrong, what am I assuming without evidence, and what would change "
"your mind. Do not soften it, and do not agree just because I sound "
"confident.\n\nMy plan: ",
),
)
# Guards the seed. Not "is the table empty", because an administrator who
# deletes all three would get them back on every restart.
SEEDED_KEY = "suggestions_seeded"
def visible(db: DBSession) -> list[Suggestion]:
"""What the new-chat screen shows, in order."""
return list(
db.scalars(
select(Suggestion)
.where(Suggestion.enabled.is_(True))
.order_by(Suggestion.position, Suggestion.name)
.limit(MAX_SHOWN)
)
)
def all_of_them(db: DBSession) -> list[Suggestion]:
"""Every suggestion, enabled or not, for the admin page."""
return list(
db.scalars(select(Suggestion).order_by(Suggestion.position, Suggestion.name))
)
def next_position(db: DBSession) -> int:
"""New rows land at the end rather than at 0, where they would shuffle.
No `or -1` after the coalesce: position 0 is falsy, so that idiom sends the
second row back to 0 on top of the first.
"""
highest = db.scalar(select(func.coalesce(func.max(Suggestion.position), -1)))
return int(highest if highest is not None else -1) + 1
def create(db: DBSession, *, name: str, description: str = "", prompt: str = "") -> Suggestion:
suggestion = Suggestion(
name=name.strip()[:MAX_NAME],
description=description.strip()[:MAX_DESCRIPTION],
prompt=prompt[:MAX_PROMPT],
position=next_position(db),
)
db.add(suggestion)
db.commit()
return suggestion
def seed_defaults(db: DBSession) -> int:
"""Write the built-in suggestions, once ever.
Runs from the startup housekeeping block. The flag is what makes it once:
checking whether the table is empty would restore all three every restart
for anyone who decided they did not want them.
"""
if settings_store.get(db, SEEDED_KEY):
return 0
for name, description, prompt in DEFAULTS:
create(db, name=name, description=description, prompt=prompt)
settings_store.update(db, {SEEDED_KEY: True})
log.info("seeded %d default suggestion(s)", len(DEFAULTS))
return len(DEFAULTS)
+37
View File
@@ -167,6 +167,43 @@
.reasoning__summary::-webkit-details-marker { display: none; }
.reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); }
/* --- Suggestions -----------------------------------------------------------
Starting points on the empty screen. Cards rather than a list, because they
are things to press.
*/
.suggestions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: var(--sp-3);
width: 100%;
max-width: 40rem;
margin-top: var(--sp-6);
text-align: left;
}
.suggestion {
display: flex;
flex-direction: column;
gap: var(--sp-1);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
color: var(--ink);
cursor: pointer;
text-align: left;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.suggestion:hover { background: var(--surface-hover); border-color: var(--border-strong); }
.suggestion:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.suggestion__name { font-weight: 600; font-size: var(--text-sm); }
.suggestion__note {
font-size: var(--text-xs);
color: var(--ink-muted);
line-height: var(--leading-normal);
}
/* --- Metrics ---------------------------------------------------------------
What a reply cost, under the bubble. Quiet by default: it is reference, not
something to read every time.
+15
View File
@@ -384,6 +384,21 @@
return;
}
/* A suggestion card fills the composer and stops there. It deliberately
does not submit: the prompts end mid-sentence, because a card is a
starting point rather than a question somebody already asked. */
var suggestion = event.target.closest("[data-suggestion]");
if (suggestion) {
event.preventDefault();
var input = document.querySelector("[data-composer-input]");
if (!input) return;
input.value = suggestion.dataset.suggestion;
autosize(input);
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
return;
}
/* Show/hide a panel by selector, so templates do not each carry their own
inline toggle script. */
var toggle = event.target.closest("[data-toggle]");
@@ -47,6 +47,11 @@
{{ icon("sparkle", "icon--sm") }}
<span class="nav-item__label">Prompts</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'suggestions' }}"
href="/admin/suggestions">
{{ icon("star", "icon--sm") }}
<span class="nav-item__label">Suggestions</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'users' }}" href="/admin/users">
{{ icon("user", "icon--sm") }}
<span class="nav-item__label">Users</span>
@@ -0,0 +1,120 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "suggestions" %}
{% block title %}Suggestions - LLeMbas{% endblock %}
{% block heading %}Suggestions{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Cards on the new-chat screen. Clicking one puts its prompt in the composer
without sending it — the built-in ones deliberately end mid-sentence, so the
caret lands where the person has to start typing. The first
{{ max_shown }} enabled ones are shown, in this order.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
{% for suggestion in suggestions %}
<form class="card" method="post" action="/admin/suggestions/{{ suggestion.id }}">
<div class="card__header">
<h2 class="card__title">
{{ suggestion.name }}
{% if not suggestion.enabled %}<span class="badge">hidden</span>{% endif %}
{% if loop.index > max_shown and suggestion.enabled %}
<span class="badge badge--warning" title="Only the first {{ max_shown }} are shown">
below the cut
</span>
{% endif %}
</h2>
<button class="btn btn--sm btn--danger" type="submit"
formaction="/admin/suggestions/{{ suggestion.id }}/delete"
data-confirm-button="Delete the suggestion “{{ suggestion.name }}”?"
data-confirm-title="Delete suggestion">
{{ icon("trash", "icon--sm") }}
</button>
</div>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="name-{{ suggestion.id }}">Name</label>
<input class="input" id="name-{{ suggestion.id }}" name="name"
value="{{ suggestion.name }}" maxlength="120">
<p class="field__hint">The heading on the card.</p>
</div>
<div class="field">
<label class="field__label" for="position-{{ suggestion.id }}">Position</label>
<input class="input" id="position-{{ suggestion.id }}" name="position" type="number"
min="1" value="{{ suggestion.position + 1 }}">
<p class="field__hint">Order on the screen, lowest first.</p>
</div>
</div>
<div class="field">
<label class="field__label" for="description-{{ suggestion.id }}">Description</label>
<input class="input" id="description-{{ suggestion.id }}" name="description"
value="{{ suggestion.description }}" maxlength="300">
<p class="field__hint">One line under the name, saying what it is for.</p>
</div>
<div class="field">
<label class="field__label" for="prompt-{{ suggestion.id }}">Prompt</label>
<textarea class="textarea" id="prompt-{{ suggestion.id }}" name="prompt"
rows="4">{{ suggestion.prompt }}</textarea>
<p class="field__hint">
Put in the composer, not sent. Ending it mid-sentence is usually right:
the person still has to say what they are asking about.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if suggestion.enabled }}>
<span>Show this one</span>
</label>
</div>
<div class="card__footer">
<button class="btn btn--primary btn--sm" type="submit">Save</button>
</div>
</form>
{% else %}
<div class="empty">
{{ icon("sparkle", "empty__mark") }}
<h2 class="empty__title">No suggestions</h2>
<p class="empty__text">The new-chat screen shows its empty state instead.</p>
</div>
{% endfor %}
{% if not at_limit %}
<form class="card" method="post" action="/admin/suggestions">
<h2 class="card__title">Add a suggestion</h2>
<div class="field">
<label class="field__label" for="new-name">Name</label>
<input class="input" id="new-name" name="name" maxlength="120"
placeholder="Summarise a document" required>
</div>
<div class="field">
<label class="field__label" for="new-description">Description</label>
<input class="input" id="new-description" name="description" maxlength="300"
placeholder="What it is for, in one line.">
</div>
<div class="field">
<label class="field__label" for="new-prompt">Prompt</label>
<textarea class="textarea" id="new-prompt" name="prompt" rows="4"
placeholder="What lands in the composer when the card is clicked."></textarea>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Add</button>
</div>
</form>
{% else %}
<p class="admin-lede">
{{ max_suggestions }} is the limit. Delete one to add another — past a dozen
this is a menu, and a menu on the empty screen is a worse blank page than a
blank page.
</p>
{% endif %}
{% endblock %}
+16
View File
@@ -184,6 +184,22 @@
{{ mark(cls="empty__mark", uid="intro") }}
<h2 class="empty__title">What would you ask?</h2>
<p class="empty__text">Speak, friend, and enter.</p>
{# Only on a chat that does not exist yet. An empty chat someone
opened on purpose already has a model and a prompt chosen. #}
{% if not chat and suggestions %}
<div class="suggestions">
{% for suggestion in suggestions %}
<button class="suggestion" type="button"
data-suggestion="{{ suggestion.prompt }}">
<span class="suggestion__name">{{ suggestion.name }}</span>
{% if suggestion.description %}
<span class="suggestion__note">{{ suggestion.description }}</span>
{% endif %}
</button>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
+181
View File
@@ -0,0 +1,181 @@
"""Prompt suggestions: seeding, administration, and the cards."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Connection, Model, Suggestion, User
from lembas.services import settings_store
from lembas.services import suggestions as suggestions_service
from lembas.services.crypto import encrypt
@pytest.fixture
def clean(client: TestClient, db, registered):
"""Start from no suggestions.
The app's lifespan seeds the three defaults, so any test that goes through
`client` already has them. Tests about seeding want that; tests about the
admin screen want to count their own rows.
"""
for suggestion in suggestions_service.all_of_them(db):
db.delete(suggestion)
db.commit()
@pytest.fixture
def plain_user(client: TestClient, db, registered) -> User:
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
return db.scalar(select(User).where(User.email == "sam@shire.test"))
def _connection(db) -> None:
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model"))
db.commit()
# --- Seeding ------------------------------------------------------------------
def test_seeding_writes_the_defaults_once(db):
assert suggestions_service.seed_defaults(db) == len(suggestions_service.DEFAULTS)
assert suggestions_service.seed_defaults(db) == 0
assert len(suggestions_service.all_of_them(db)) == len(suggestions_service.DEFAULTS)
def test_deleting_every_suggestion_does_not_bring_them_back(db):
"""The guard is a flag, not "is the table empty". Otherwise an administrator
who decided against them gets them back on every restart."""
suggestions_service.seed_defaults(db)
for suggestion in suggestions_service.all_of_them(db):
db.delete(suggestion)
db.commit()
assert suggestions_service.seed_defaults(db) == 0
assert suggestions_service.all_of_them(db) == []
def test_the_seed_flag_lives_in_settings(db):
suggestions_service.seed_defaults(db)
assert settings_store.get(db, suggestions_service.SEEDED_KEY) is True
# --- What is shown ------------------------------------------------------------
def test_only_enabled_ones_are_shown(db):
suggestions_service.create(db, name="Shown", prompt="a")
hidden = suggestions_service.create(db, name="Hidden", prompt="b")
hidden.enabled = False
db.commit()
assert [s.name for s in suggestions_service.visible(db)] == ["Shown"]
def test_position_decides_the_order(db):
first = suggestions_service.create(db, name="Zebra", prompt="a")
second = suggestions_service.create(db, name="Antelope", prompt="b")
first.position, second.position = 5, 1
db.commit()
assert [s.name for s in suggestions_service.visible(db)] == ["Antelope", "Zebra"]
def test_no_more_than_the_cap_is_shown(db):
for index in range(suggestions_service.MAX_SHOWN + 3):
suggestions_service.create(db, name=f"One {index}", prompt="x")
assert len(suggestions_service.visible(db)) == suggestions_service.MAX_SHOWN
def test_new_rows_land_at_the_end(db):
suggestions_service.create(db, name="First", prompt="a")
assert suggestions_service.create(db, name="Second", prompt="b").position == 1
# --- The cards ----------------------------------------------------------------
def test_the_cards_appear_on_the_new_chat_screen(client: TestClient, db, registered):
_connection(db)
suggestions_service.create(db, name="Explain this", description="Plain language.", prompt="Go ")
page = client.get("/chat").text
assert "Explain this" in page
assert "Plain language." in page
assert 'data-suggestion="Go "' in page
def test_the_cards_do_not_appear_in_an_existing_empty_chat(
client: TestClient, db, registered, make_chat
):
"""A chat someone opened on purpose already has a model and a prompt
chosen; the cards are for the screen where nothing has been decided."""
_connection(db)
suggestions_service.create(db, name="Explain this", prompt="Go ")
chat_id = make_chat()
assert "data-suggestion" not in client.get(f"/chat/{chat_id}").text
def test_a_prompt_with_quotes_is_escaped_in_the_attribute(client: TestClient, db, registered):
_connection(db)
suggestions_service.create(db, name="Tricky", prompt='say "<script>alert(1)</script>"')
page = client.get("/chat").text
assert "<script>alert(1)</script>" not in page
assert "&lt;script&gt;" in page
# --- Administration -----------------------------------------------------------
def test_the_admin_page_is_refused_to_a_plain_user(client: TestClient, plain_user):
assert client.get("/admin/suggestions").status_code == 403
assert client.post("/admin/suggestions", data={"name": "x"}).status_code == 403
def test_an_admin_can_create_edit_and_delete(client: TestClient, db, registered, clean):
client.post(
"/admin/suggestions",
data={"name": "Explain this", "description": "Plain language.", "prompt": "Go "},
follow_redirects=False,
)
suggestion = db.scalar(select(Suggestion))
assert suggestion.name == "Explain this"
assert suggestion.enabled is True
client.post(
f"/admin/suggestions/{suggestion.id}",
data={"name": "Renamed", "description": "", "prompt": "New ", "position": "3"},
follow_redirects=False,
)
db.refresh(suggestion)
assert suggestion.name == "Renamed"
assert suggestion.prompt == "New "
# An unticked checkbox is simply absent from the post, which is the signal.
assert suggestion.enabled is False
assert suggestion.position == 2
client.post(f"/admin/suggestions/{suggestion.id}/delete", follow_redirects=False)
assert db.scalar(select(Suggestion)) is None
def test_a_nameless_suggestion_is_refused(client: TestClient, db, registered, clean):
client.post("/admin/suggestions", data={"name": " "}, follow_redirects=False)
assert db.scalar(select(Suggestion)) is None
def test_the_limit_is_enforced(client: TestClient, db, registered, clean):
for index in range(suggestions_service.MAX_SUGGESTIONS):
suggestions_service.create(db, name=f"One {index}", prompt="x")
client.post("/admin/suggestions", data={"name": "One too many"}, follow_redirects=False)
assert len(suggestions_service.all_of_them(db)) == suggestions_service.MAX_SUGGESTIONS
def test_editing_something_that_is_gone_is_a_404(client: TestClient, registered):
assert client.post("/admin/suggestions/nope", data={"name": "x"}).status_code == 404