Add Stork family name board MVP with invite auth and votes.
Includes multilingual etymology fields, seed data for starter names, and CI-ready Python project layout.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Copy to .env on the deploy host (never commit real tokens).
|
||||
STORK_INVITE_TOKEN=change-me-long-random
|
||||
STORK_ADMIN_TOKEN=change-me-admin-random
|
||||
STORK_COOKIE_SECURE=false
|
||||
STORK_DATA=./data
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
# Homelab CI — Python lane + secret scan.
|
||||
# POLICY: lint and tests are HARD gates. Never add `|| true` to them.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
python-ci:
|
||||
runs-on: [homelab, self-hosted, linux, python]
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Python tooling
|
||||
run: |
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip python3-venv
|
||||
python3 -m pip install --upgrade pip --break-system-packages
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt --break-system-packages; fi
|
||||
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt --break-system-packages; fi
|
||||
pip install pytest ruff --break-system-packages
|
||||
|
||||
- name: Ruff lint (hard gate)
|
||||
run: ruff check .
|
||||
|
||||
- name: Pytest (hard gate)
|
||||
run: pytest -q
|
||||
|
||||
# Advisory-only scanners (allowed to be soft — they're noisy, not gates)
|
||||
- name: Bandit (advisory)
|
||||
run: pip install bandit --break-system-packages && bandit -r . -q || true
|
||||
|
||||
secret-scan:
|
||||
runs-on: [homelab, self-hosted, linux, heavy]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gitleaks
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
|
||||
detect --source /repo --config /repo/.gitleaks.toml --no-banner --redact
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# --- OS / editor ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# --- Secrets & env (real values never in git) ---
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
*password*
|
||||
*credential*
|
||||
!*example*
|
||||
|
||||
# --- Real data (commit *.example.* fixtures instead) ---
|
||||
data/
|
||||
!data/
|
||||
data/*
|
||||
!data/*.example.*
|
||||
*.sqlite3
|
||||
*.local.yml
|
||||
*.local.json
|
||||
|
||||
# --- Python ---
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# --- Node ---
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
npm-debug.log*
|
||||
|
||||
# --- Outputs / scratch ---
|
||||
out/
|
||||
tmp/
|
||||
.tmp/
|
||||
scratch/
|
||||
*.log
|
||||
|
||||
# --- Agent tooling (local, not for the repo) ---
|
||||
.cursor/
|
||||
.claude/
|
||||
.codex/
|
||||
.agents/
|
||||
.opencode/
|
||||
@@ -0,0 +1,36 @@
|
||||
# Repo gitleaks config — same rules as the global fallback
|
||||
# (~/.config/git/gitleaks.toml) so local hook and CI agree.
|
||||
|
||||
title = "repo gitleaks"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[rules]]
|
||||
id = "env-file-committed"
|
||||
description = "Raw .env file (or backup/variant) staged for commit"
|
||||
path = '''(^|/)\.env(\.(backup|bak|old|orig|save|local|prod|production|dev|development|staging))?$'''
|
||||
|
||||
[[rules]]
|
||||
id = "password-or-credential-file"
|
||||
description = "File name indicates stored password/credential material"
|
||||
path = '''(?i)(^|/)[^/]*(password|passwd|credential)[^/]*\.(txt|json|ya?ml|env|cfg|ini)$'''
|
||||
|
||||
[[rules]]
|
||||
id = "private-key-file"
|
||||
description = "Private key or keystore file staged for commit"
|
||||
path = '''(^|/)(id_rsa|id_ed25519|id_ecdsa)[^/]*$|\.(pem|p12|pfx|key)$'''
|
||||
|
||||
[[rules]]
|
||||
id = "dotfile-secret-token"
|
||||
description = "Hidden standalone secret/token file"
|
||||
path = '''(^|/)\.[^/]*(secret|token)[^/]*$'''
|
||||
|
||||
[allowlist]
|
||||
description = "Legitimate non-secret files matching the patterns above"
|
||||
paths = [
|
||||
'''(?i)\.(example|sample|template|dist)$''',
|
||||
'''(^|/)\.env\.example[^/]*$''',
|
||||
'''(^|/)[^/]*\.pub$''',
|
||||
'''(^|/)package(-lock)?\.json$''',
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# AGENTS.md — stork
|
||||
|
||||
Short orientation for Cursor agents.
|
||||
|
||||
## Defaults
|
||||
|
||||
- Family baby-name board (votes + en/ru/he notes). Run/install: `README.md`.
|
||||
- Lint/tests are CI hard gates: `make lint` / `make test` — never `|| true`.
|
||||
- Secrets: Infisical `/apps/stork` or gitignored `.env`. Never commit tokens.
|
||||
- Deploy/DNS/Caddy/Kuma live in `~/Documents/code/ansible` (`make deploy-stork`).
|
||||
|
||||
## Close-out
|
||||
|
||||
1. `make test` and `make lint` before claiming done.
|
||||
2. Do not put LAN IPs or real invites in this repo.
|
||||
3. Merge only when the user asks.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY stork /app/stork
|
||||
COPY static /app/static
|
||||
|
||||
ENV STORK_DATA=/data
|
||||
ENV STORK_COOKIE_SECURE=true
|
||||
EXPOSE 8094
|
||||
CMD ["uvicorn", "stork.app:app", "--host", "0.0.0.0", "--port", "8094"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ilia Dobkin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,28 @@
|
||||
.PHONY: help install test lint run docker-build seed
|
||||
|
||||
help: ## Show targets
|
||||
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-16s %s\n", $$1, $$2}'
|
||||
|
||||
install: ## Install runtime + dev deps into .venv (prefers python3.12)
|
||||
@PY=$$(command -v python3.12 || command -v python3); \
|
||||
$$PY -m venv .venv
|
||||
.venv/bin/pip install -U pip
|
||||
.venv/bin/pip install -r requirements-dev.txt
|
||||
|
||||
lint: ## Ruff lint (CI hard gate)
|
||||
ruff check .
|
||||
|
||||
test: ## Pytest (CI hard gate)
|
||||
pytest -q
|
||||
|
||||
run: ## Local uvicorn on :8094 (needs .env)
|
||||
@test -f .env || (echo "Copy .env.example to .env first" && exit 1)
|
||||
set -a && . ./.env && set +a && uvicorn stork.app:app --reload --host 127.0.0.1 --port 8094
|
||||
|
||||
seed: ## Seed example first names (usage: make seed STORK_INVITE=... [STORK_URL=...])
|
||||
@test -n "$(STORK_INVITE)" || (echo "Set STORK_INVITE=..." && exit 1)
|
||||
STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" STORK_INVITE="$(STORK_INVITE)" \
|
||||
.venv/bin/python scripts/seed_names.py
|
||||
|
||||
docker-build: ## Build local image
|
||||
docker build -t homelab-stork:latest .
|
||||
@@ -0,0 +1,42 @@
|
||||
# Stork
|
||||
|
||||
Private family board for baby **first** and **middle** name ideas: suggest
|
||||
names, vote, and keep origin / meaning / pronunciation in English, Russian,
|
||||
and Hebrew.
|
||||
|
||||
Homelab deploy lives in the ansible repo (`make deploy-stork`). This repo is
|
||||
the app only — no LAN IPs or production secrets here.
|
||||
|
||||
## Quick start (local)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set STORK_INVITE_TOKEN (and optional STORK_ADMIN_TOKEN)
|
||||
make install
|
||||
make test
|
||||
make run
|
||||
```
|
||||
|
||||
Open http://127.0.0.1:8094 — enter the invite code and your display name.
|
||||
Share `http://127.0.0.1:8094/?invite=YOUR_TOKEN` with family for one-tap entry.
|
||||
|
||||
## API sketch
|
||||
|
||||
| Method | Path | Notes |
|
||||
|--------|------|-------|
|
||||
| GET | `/api/health` | Liveness |
|
||||
| POST | `/api/session` | `{invite, display_name}` → cookies |
|
||||
| GET | `/api/names?kind=first\|middle` | Ranked list |
|
||||
| POST | `/api/names` | Add name + optional locales |
|
||||
| PATCH | `/api/names/{id}` | Update locale notes |
|
||||
| POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` |
|
||||
| DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` |
|
||||
|
||||
## Secrets
|
||||
|
||||
- Local: gitignored `.env`
|
||||
- Prod: Infisical `/apps/stork` (or host `.env` written by deploy) — never git
|
||||
|
||||
## Production shape
|
||||
|
||||
Same host pattern as Compare: Docker on automationlab LXC **225**, public
|
||||
HTTPS via Caddy (`stork.levkin.ca`). See ansible `docs/guides/stork-deploy.md`.
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create the Gitea repo for this project with gates preconfigured:
|
||||
# private repo, delete-branch-after-merge, branch protection on main
|
||||
# requiring the CI contexts, initial push.
|
||||
#
|
||||
# Usage: ./bootstrap.sh <repo-name> <python|node> [--public]
|
||||
set -euo pipefail
|
||||
|
||||
NAME="${1:?usage: bootstrap.sh <repo-name> <python|node> [--public]}"
|
||||
LANE="${2:?usage: bootstrap.sh <repo-name> <python|node> [--public]}"
|
||||
PRIVATE=true
|
||||
[[ "${3:-}" == "--public" ]] && PRIVATE=false
|
||||
|
||||
GITEA="https://git.levkin.ca"
|
||||
OWNER="ilia"
|
||||
# shellcheck disable=SC1090
|
||||
source "$HOME/.config/hermes/gitea.env" # provides GITEA_TOKEN
|
||||
|
||||
api() {
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
curl -sf -X "$method" -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" ${body:+-d "$body"} "${GITEA}/api/v1${path}"
|
||||
}
|
||||
|
||||
case "$LANE" in
|
||||
python) CONTEXTS='["CI / python-ci (pull_request)","CI / secret-scan (pull_request)"]' ;;
|
||||
node) CONTEXTS='["CI / node-ci (pull_request)","CI / secret-scan (pull_request)"]' ;;
|
||||
*) echo "lane must be python or node" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [[ ! -f ".gitea/workflows/ci.yml" ]]; then
|
||||
echo "ERROR: .gitea/workflows/ci.yml missing — copy one from ci-templates/ first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating ${OWNER}/${NAME} (private=${PRIVATE})..."
|
||||
api POST /user/repos "{\"name\":\"${NAME}\",\"private\":${PRIVATE},\"default_branch\":\"main\"}" >/dev/null
|
||||
|
||||
echo "Enabling delete-branch-after-merge..."
|
||||
api PATCH "/repos/${OWNER}/${NAME}" '{"default_delete_branch_after_merge": true}' >/dev/null
|
||||
|
||||
echo "Pushing main..."
|
||||
git remote add origin "${GITEA}/${OWNER}/${NAME}.git" 2>/dev/null || true
|
||||
git push "https://${OWNER}:${GITEA_TOKEN}@git.levkin.ca/${OWNER}/${NAME}.git" main
|
||||
|
||||
echo "Adding branch protection (required contexts: ${CONTEXTS})..."
|
||||
api POST "/repos/${OWNER}/${NAME}/branch_protections" \
|
||||
"{\"branch_name\":\"main\",\"rule_name\":\"main\",\"enable_push\":false,\"enable_status_check\":true,\"status_check_contexts\":${CONTEXTS},\"required_approvals\":0}" >/dev/null
|
||||
|
||||
echo "Done: ${GITEA}/${OWNER}/${NAME}"
|
||||
echo "Work via PRs from now on; merge with:"
|
||||
echo " GITEA_REPO=${NAME} bash ~/Documents/code/hermes/scripts/gitea-merge-when-green.sh <pr>"
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"names": [
|
||||
{
|
||||
"kind": "first",
|
||||
"spelling": "Shai",
|
||||
"locales": {
|
||||
"en": {
|
||||
"pronunciation": "SHY (rhymes with sky)",
|
||||
"origin": "Hebrew (שי)",
|
||||
"meaning": "Gift / present; sometimes a short form of Isaiah (Yeshayahu). Variants: Shay, Shai."
|
||||
},
|
||||
"ru": {
|
||||
"pronunciation": "Шай",
|
||||
"origin": "иврит",
|
||||
"meaning": "«дар», «подарок»; иногда уменьшительное от Исайи. Варианты: Shay, Shai."
|
||||
},
|
||||
"he": {
|
||||
"pronunciation": "שַׁי — shai",
|
||||
"origin": "עברית",
|
||||
"meaning": "שי — מתנה / מנחה. לפעמים קיצור של ישעיהו. כתיבים: שי, Shay."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "first",
|
||||
"spelling": "Roze",
|
||||
"locales": {
|
||||
"en": {
|
||||
"pronunciation": "ROHZ",
|
||||
"origin": "Latin rosa / flower name (also Germanic Rose lineage)",
|
||||
"meaning": "Rose (the flower); love and beauty. Spelling variant of Rose. Related: Rose, Rosa, Roza (RU/PL), Rosie, Rožė (LT), Roze (LV)."
|
||||
},
|
||||
"ru": {
|
||||
"pronunciation": "Ро́уз / Ро́за",
|
||||
"origin": "латинское rosa; славянская форма Роза",
|
||||
"meaning": "роза (цветок). Варианты: Rose, Rosa, Roza, Rosie."
|
||||
},
|
||||
"he": {
|
||||
"pronunciation": "רוֹז",
|
||||
"origin": "לטינית / שם פרח",
|
||||
"meaning": "ורד / רוזה. כתיבים קרובים: Rose, Rosa, Roza; בעברית גם ורד (Vered)."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "first",
|
||||
"spelling": "Odet",
|
||||
"locales": {
|
||||
"en": {
|
||||
"pronunciation": "oh-DET",
|
||||
"origin": "French diminutive (Odette) from Germanic od-/ot- “wealth”",
|
||||
"meaning": "Wealth / prosperity. Short form of Odette (Swan Lake). Variants: Odette, Odetta, Ode, Oda."
|
||||
},
|
||||
"ru": {
|
||||
"pronunciation": "Оде́т",
|
||||
"origin": "французское Odette ← германское «богатство»",
|
||||
"meaning": "богатство / достаток. Варианты: Odette, Одетта, Odetta."
|
||||
},
|
||||
"he": {
|
||||
"pronunciation": "אוֹדֶט",
|
||||
"origin": "צרפתית (Odette) משורש גרמאני",
|
||||
"meaning": "עושר / שפע. צורות: Odette, Odetta, Odet."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "first",
|
||||
"spelling": "Riva",
|
||||
"locales": {
|
||||
"en": {
|
||||
"pronunciation": "REE-vah",
|
||||
"origin": "Hebrew / Yiddish short form of Rivka (Rebecca)",
|
||||
"meaning": "Short for Rivka — “to bind / tie; captivating.” Related: Rivka, Rebecca, Rebekah, Rifka, Reba."
|
||||
},
|
||||
"ru": {
|
||||
"pronunciation": "Ри́ва",
|
||||
"origin": "уменьшительное от Ривка / Ревекка",
|
||||
"meaning": "краткое от Ривка («связывать», «пленить»). Варианты: Rivka, Rebecca, Rifka."
|
||||
},
|
||||
"he": {
|
||||
"pronunciation": "רִיבָה",
|
||||
"origin": "קיצור של רבקה",
|
||||
"meaning": "צורת חיבה של רבקה. קשור ל־Rivka / Rebecca."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "first",
|
||||
"spelling": "Rivka",
|
||||
"locales": {
|
||||
"en": {
|
||||
"pronunciation": "riv-KAH / REEV-kah",
|
||||
"origin": "Hebrew biblical (רִבְקָה) — wife of Isaac",
|
||||
"meaning": "From root “to bind / tie / join”; often glossed captivating. English: Rebecca, Rebekah. Short forms: Riva, Rifka, Becky."
|
||||
},
|
||||
"ru": {
|
||||
"pronunciation": "Ри́вка",
|
||||
"origin": "библейское еврейское имя (Ревекка)",
|
||||
"meaning": "«связывать», «соединять»; библейская Ревекка. Варианты: Rebecca, Rebekah, Riva, Rifka."
|
||||
},
|
||||
"he": {
|
||||
"pronunciation": "רִבְקָה — rivká",
|
||||
"origin": "תנ״ך — אשת יצחק",
|
||||
"meaning": "שורש ר־ב־ק (לקשור / לחבר). באנגלית Rebecca. קיצורים: Riva, Rifka."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Stork — family baby-name board (local / generic bind).
|
||||
# Production bind + Caddy live in ansible deploy/stork/.
|
||||
|
||||
services:
|
||||
stork:
|
||||
build: .
|
||||
image: homelab-stork:latest
|
||||
container_name: stork
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8094:8094"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
STORK_DATA: /data
|
||||
STORK_COOKIE_SECURE: "${STORK_COOKIE_SECURE:-false}"
|
||||
@@ -0,0 +1,138 @@
|
||||
# Writing READMEs and guides
|
||||
|
||||
House style for Markdown in Levkin repos. Goal: docs that read like a
|
||||
competent human wrote them for another engineer, not like a product page
|
||||
or a chatbot reply.
|
||||
|
||||
Agents: also follow `~/.cursor/rules/docs-writing.mdc` and the humanizer
|
||||
skill when rewriting prose.
|
||||
|
||||
## What belongs where
|
||||
|
||||
| Doc | Job |
|
||||
|-----|-----|
|
||||
| `README.md` | What it is, how to run it, where config and deeper docs live |
|
||||
| `docs/guides/*.md` | How-to for one task (deploy, upgrade, recover) |
|
||||
| `docs/reference/*.md` | Stable facts (URLs, ports, schema, CLI flags) |
|
||||
| `AGENTS.md` / `.cursorrules` | Short orientation for agents, not a second README |
|
||||
| `CHANGELOG.md` | What changed, by version |
|
||||
| Scratch (`*_PLAN`, `*_SUMMARY`, `FIX_*`, `HANDOFF`, `PROGRESS_*`) | Temporary. Archive or delete when done; do not polish as product docs |
|
||||
|
||||
Do not paste the same wall of text into README and three guides. Link once.
|
||||
|
||||
## README checklist
|
||||
|
||||
Every repo README should answer these, in roughly this order:
|
||||
|
||||
1. **What** — one or two plain sentences. Name the audience if it is not obvious.
|
||||
2. **Status** — active, beta, archived, research-only. Put archived status in a blockquote at the top.
|
||||
3. **Run it** — install or `make` entrypoint and one command that works.
|
||||
4. **Config** — `.env.example`, Infisical path (`/apps/<repo>`), or vault keys. Never real secrets.
|
||||
5. **Pointers** — links to guides; do not duplicate them.
|
||||
6. **License** — or say the repo is private / personal.
|
||||
|
||||
Add when useful: short architecture sketch, known limits, deploy note
|
||||
("ops runbooks live in the ansible repo").
|
||||
|
||||
Skip in READMEs: emoji decoration, marketing feature walls, welcome blurbs,
|
||||
kanban ceremony, and change-narration ("this was added to replace…").
|
||||
|
||||
## Guide checklist
|
||||
|
||||
A how-to guide should:
|
||||
|
||||
1. State the goal in the first sentence ("Deploy X on LXC Y").
|
||||
2. List prerequisites (access, packages, secrets location).
|
||||
3. Give ordered steps with copy-pasteable commands.
|
||||
4. End with a verify step and what "done" looks like.
|
||||
5. Link related runbooks instead of restating them.
|
||||
|
||||
One job per guide. Split if you need more than one primary verb.
|
||||
|
||||
## Voice and formatting
|
||||
|
||||
### Do
|
||||
|
||||
- Write in sentence case for headings: `## Getting started`, not `## Getting Started`.
|
||||
- Prefer short paragraphs and concrete nouns (paths, hosts, commands).
|
||||
- Use tables for env vars, ports, and host inventories.
|
||||
- Use fenced code blocks with a language tag when it helps.
|
||||
- Prefer `is` / `has` / `runs` over `serves as` / `boasts` / `features`.
|
||||
- Keep bold for rare emphasis (a warning, a path). Not for every label.
|
||||
|
||||
### Do not
|
||||
|
||||
- Decorate headings or bullets with emoji (`🚀`, `✅`, `💡`, …). If a CLI
|
||||
prints emoji, quote that output in a code block; do not restyle the doc around it.
|
||||
- Use bold-colon list items as fake headings:
|
||||
`- **Web-Based**: Modern React frontend…` → write a normal sentence or table.
|
||||
- Stuff feature lists with adjectives: *state-of-the-art*, *seamless*,
|
||||
*robust*, *powerful*, *comprehensive*, *cutting-edge*, *production-ready*,
|
||||
*unlock*, *leverage*, *delve*, *landscape*, *tapestry*, *testament*.
|
||||
- Open with chatbot filler: "Welcome to…", "Here's what you need to know",
|
||||
"Let's dive in", "It is important to note that…".
|
||||
- Overuse em dashes. Prefer a period, comma, colon, or parentheses.
|
||||
- Title-Case Every Heading Like A Brochure.
|
||||
- Invent facts, URLs, version numbers, or "experts say" attributions while editing.
|
||||
|
||||
### Examples
|
||||
|
||||
Bad:
|
||||
|
||||
```markdown
|
||||
# 🚀 PunimTag
|
||||
|
||||
**Modern Photo Management and Facial Recognition System**
|
||||
|
||||
A fast, simple, and modern web application using state-of-the-art DeepFace AI.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Web-Based**: Modern React frontend with FastAPI backend
|
||||
- **Privacy-First**: All data stored locally, no cloud dependencies
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```markdown
|
||||
# PunimTag
|
||||
|
||||
Local photo library with face recognition (DeepFace / ArcFace). Admin UI
|
||||
(React) and viewer (Next.js) share one monorepo and a PostgreSQL backend.
|
||||
|
||||
## Features
|
||||
|
||||
- Face detect and match with RetinaFace + ArcFace (or other listed models)
|
||||
- Search by person, date, tag, or folder
|
||||
- Runs on your machine; no cloud dependency for core data
|
||||
```
|
||||
|
||||
## Public vs private repos
|
||||
|
||||
Public READMEs and guides:
|
||||
|
||||
- No LAN IPs, no `10.0.10.x`, no root SSH one-liners.
|
||||
- No absolute home paths (`/Users/…`).
|
||||
- No other people's PII.
|
||||
|
||||
Private homelab docs (ansible, hermes, levkin) may name hosts and IPs.
|
||||
Still skip emoji and marketing tone.
|
||||
|
||||
Repos under `Gitilia/*` are push mirrors from Gitea. After merging a release or
|
||||
public README/docs change, sync and verify from the ansible repo:
|
||||
`make github-mirror-sync` then `make github-mirror-health` (see
|
||||
`ansible/docs/guides/github-mirrors.md`).
|
||||
## When rewriting existing docs
|
||||
|
||||
1. Preserve every real fact (commands, paths, versions, URLs).
|
||||
2. Cut fluff; keep the procedure.
|
||||
3. Fix formatting to match this guide in the same pass.
|
||||
4. Leave code blocks, frontmatter, and link targets intact unless the link is wrong.
|
||||
5. Scratch/status files: delete or move under `docs/archive/` rather than
|
||||
"humanizing" a dead plan.
|
||||
|
||||
## Related
|
||||
|
||||
- Project skeleton: this repo's root `README.md`
|
||||
- Hard gates (CI, secrets, LICENSE): `~/.cursor/rules/new-project-standards.mdc`
|
||||
- Tone scrub: Cursor skill `humanizer`
|
||||
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.5
|
||||
httpx==0.28.1
|
||||
ruff==0.11.5
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
pydantic==2.11.3
|
||||
@@ -0,0 +1,7 @@
|
||||
# Lint gate config — enforced in CI (no || true). Start pragmatic; tighten
|
||||
# per-project by extending `select`, never by muting the CI step.
|
||||
target-version = "py311"
|
||||
line-length = 120
|
||||
|
||||
[lint]
|
||||
select = ["E4", "E7", "E9", "F", "I"]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Seed starter first names into a local or remote Stork instance.
|
||||
|
||||
Usage:
|
||||
# against local make run (cookies via invite):
|
||||
STORK_URL=http://127.0.0.1:8094 STORK_INVITE=local-dev-invite \\
|
||||
.venv/bin/python scripts/seed_names.py
|
||||
|
||||
# against prod after deploy:
|
||||
STORK_URL=https://stork.levkin.ca STORK_INVITE="$(ssh root@10.0.10.45 grep STORK_INVITE_TOKEN /opt/stork/.env | cut -d= -f2-)" \\
|
||||
.venv/bin/python scripts/seed_names.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SEED = ROOT / "data" / "seed-names.example.json"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/")
|
||||
invite = os.environ.get("STORK_INVITE", "").strip()
|
||||
display = os.environ.get("STORK_DISPLAY_NAME", "Ilia").strip() or "Ilia"
|
||||
if not invite:
|
||||
print("Set STORK_INVITE", file=sys.stderr)
|
||||
return 1
|
||||
names = json.loads(SEED.read_text())["names"]
|
||||
with httpx.Client(base_url=base, timeout=30.0) as client:
|
||||
session = client.post("/api/session", json={"invite": invite, "display_name": display})
|
||||
session.raise_for_status()
|
||||
for item in names:
|
||||
res = client.post("/api/names", json=item)
|
||||
if res.status_code == 400 and "already exists" in res.text:
|
||||
print(f"skip {item['spelling']} (exists)")
|
||||
continue
|
||||
res.raise_for_status()
|
||||
print(f"added {item['kind']}: {item['spelling']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,323 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Stork — family name board</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3efe6;
|
||||
--ink: #1c2430;
|
||||
--muted: #5c6674;
|
||||
--card: #fffdf8;
|
||||
--line: #d9d0c0;
|
||||
--accent: #2f6f6a;
|
||||
--up: #2f6f6a;
|
||||
--down: #9a4a3a;
|
||||
--focus: #c9a227;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1200px 500px at 10% -10%, #e8dfc8 0%, transparent 55%),
|
||||
radial-gradient(900px 400px at 100% 0%, #d7e4df 0%, transparent 50%),
|
||||
var(--bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
main { max-width: 720px; margin: 0 auto; padding: 1.25rem 1rem 3rem; }
|
||||
h1 { font-size: 2rem; margin: 0 0 0.25rem; letter-spacing: 0.02em; }
|
||||
.sub { color: var(--muted); margin: 0 0 1.25rem; }
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
label { display: block; font-size: 0.9rem; color: var(--muted); margin-bottom: 0.35rem; }
|
||||
input, select, textarea, button {
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
textarea { min-height: 3.2rem; resize: vertical; }
|
||||
button {
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
button.secondary { background: transparent; color: var(--ink); border-color: var(--line); }
|
||||
button.vote { min-width: 2.5rem; padding: 0.4rem 0.55rem; }
|
||||
button.vote.active-up { background: var(--up); color: #fff; }
|
||||
button.vote.active-down { background: var(--down); color: #fff; }
|
||||
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
|
||||
.row > * { flex: 1 1 140px; }
|
||||
.tabs { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
.tabs button { flex: 1; }
|
||||
.tabs button.active { outline: 2px solid var(--focus); }
|
||||
.name {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
padding: 0.85rem 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.name:first-child { border-top: 0; }
|
||||
.score {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
.spelling { font-size: 1.25rem; margin: 0 0 0.2rem; }
|
||||
.meta { color: var(--muted); font-size: 0.9rem; margin: 0 0 0.4rem; }
|
||||
.langs details { margin-top: 0.35rem; }
|
||||
.langs summary { cursor: pointer; color: var(--accent); }
|
||||
.lang-block { margin: 0.4rem 0; padding-left: 0.2rem; }
|
||||
.lang-block strong { display: inline-block; min-width: 2rem; }
|
||||
.votes { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.error { color: var(--down); margin: 0.5rem 0 0; }
|
||||
.hidden { display: none !important; }
|
||||
.grid-3 { display: grid; gap: 0.5rem; }
|
||||
@media (min-width: 640px) {
|
||||
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Stork</h1>
|
||||
<p class="sub">Family name ideas — vote, add notes in English, Russian, and Hebrew.</p>
|
||||
|
||||
<section id="gate" class="panel">
|
||||
<label for="invite">Invite code</label>
|
||||
<input id="invite" autocomplete="off" />
|
||||
<label for="display" style="margin-top:0.75rem">Your name (shown with your ideas/votes)</label>
|
||||
<input id="display" autocomplete="nickname" />
|
||||
<p class="error hidden" id="gate-error"></p>
|
||||
<div class="row" style="margin-top:0.75rem">
|
||||
<button id="enter" type="button">Enter</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="board" class="hidden">
|
||||
<div class="panel">
|
||||
<p class="meta" id="who"></p>
|
||||
<div class="tabs">
|
||||
<button type="button" class="secondary active" data-kind="first">First names</button>
|
||||
<button type="button" class="secondary" data-kind="middle">Middle names</button>
|
||||
</div>
|
||||
<div id="list"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2 style="margin:0 0 0.75rem; font-size:1.15rem">Suggest a name</h2>
|
||||
<label for="spelling">Spelling</label>
|
||||
<input id="spelling" />
|
||||
<div class="grid-3" style="margin-top:0.75rem">
|
||||
<div>
|
||||
<label>English pronunciation</label>
|
||||
<input data-loc="en" data-field="pronunciation" />
|
||||
<label style="margin-top:0.5rem">Origin</label>
|
||||
<input data-loc="en" data-field="origin" />
|
||||
<label style="margin-top:0.5rem">Meaning</label>
|
||||
<textarea data-loc="en" data-field="meaning"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label>Russian pronunciation</label>
|
||||
<input data-loc="ru" data-field="pronunciation" />
|
||||
<label style="margin-top:0.5rem">Origin</label>
|
||||
<input data-loc="ru" data-field="origin" />
|
||||
<label style="margin-top:0.5rem">Meaning</label>
|
||||
<textarea data-loc="ru" data-field="meaning"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label>Hebrew pronunciation</label>
|
||||
<input data-loc="he" data-field="pronunciation" />
|
||||
<label style="margin-top:0.5rem">Origin</label>
|
||||
<input data-loc="he" data-field="origin" />
|
||||
<label style="margin-top:0.5rem">Meaning</label>
|
||||
<textarea data-loc="he" data-field="meaning"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="error hidden" id="add-error"></p>
|
||||
<div class="row" style="margin-top:0.75rem">
|
||||
<button id="add" type="button">Add to board</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const state = { kind: "first" };
|
||||
|
||||
function params() {
|
||||
return new URLSearchParams(location.search);
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||||
...opts,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
|
||||
if (!res.ok) {
|
||||
const detail = data && (data.detail || data.message) || res.statusText;
|
||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function localePayload() {
|
||||
const locales = { en: {}, ru: {}, he: {} };
|
||||
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
|
||||
locales[el.dataset.loc][el.dataset.field] = el.value.trim();
|
||||
});
|
||||
return locales;
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
document.getElementById("spelling").value = "";
|
||||
document.querySelectorAll("[data-loc][data-field]").forEach((el) => { el.value = ""; });
|
||||
}
|
||||
|
||||
function renderLangs(locales) {
|
||||
return ["en", "ru", "he"].map((lang) => {
|
||||
const L = (locales && locales[lang]) || {};
|
||||
const bits = [
|
||||
L.pronunciation && `say: ${L.pronunciation}`,
|
||||
L.origin && `origin: ${L.origin}`,
|
||||
L.meaning && L.meaning,
|
||||
].filter(Boolean);
|
||||
if (!bits.length) return "";
|
||||
return `<div class="lang-block"><strong>${lang}</strong> ${bits.join(" · ")}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderList(items) {
|
||||
const root = document.getElementById("list");
|
||||
if (!items.length) {
|
||||
root.innerHTML = `<p class="meta">No names yet — add the first idea below.</p>`;
|
||||
return;
|
||||
}
|
||||
root.innerHTML = items.map((n) => {
|
||||
const langs = renderLangs(n.locales);
|
||||
return `
|
||||
<article class="name" data-id="${n.id}">
|
||||
<div class="score">${n.score}</div>
|
||||
<div>
|
||||
<p class="spelling">${escapeHtml(n.spelling)}</p>
|
||||
<p class="meta">suggested by ${escapeHtml(n.created_by || "someone")} · ${n.vote_count} vote(s)</p>
|
||||
${langs ? `<div class="langs"><details><summary>Origin & pronunciation</summary>${langs}</details></div>` : `<p class="meta">No notes yet</p>`}
|
||||
</div>
|
||||
<div class="votes">
|
||||
<button type="button" class="vote secondary ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
|
||||
<button type="button" class="vote secondary ${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join("");
|
||||
root.querySelectorAll("button[data-vote]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const id = btn.closest(".name").dataset.id;
|
||||
let value = Number(btn.dataset.vote);
|
||||
const item = items.find((x) => String(x.id) === String(id));
|
||||
if (item && item.my_vote === value) value = 0;
|
||||
await api(`/api/names/${id}/vote`, { method: "POST", body: JSON.stringify({ value }) });
|
||||
await refresh();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
})[c]);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const data = await api(`/api/names?kind=${encodeURIComponent(state.kind)}`);
|
||||
renderList(data.items || []);
|
||||
}
|
||||
|
||||
async function showBoard(displayName) {
|
||||
document.getElementById("gate").classList.add("hidden");
|
||||
document.getElementById("board").classList.remove("hidden");
|
||||
document.getElementById("who").textContent = `Signed in as ${displayName}`;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
document.querySelectorAll(".tabs button").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
state.kind = btn.dataset.kind;
|
||||
document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("active", b === btn));
|
||||
await refresh();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("enter").addEventListener("click", async () => {
|
||||
const err = document.getElementById("gate-error");
|
||||
err.classList.add("hidden");
|
||||
try {
|
||||
const invite = document.getElementById("invite").value.trim();
|
||||
const display_name = document.getElementById("display").value.trim();
|
||||
const data = await api("/api/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ invite, display_name }),
|
||||
});
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.delete("invite");
|
||||
history.replaceState({}, "", url.pathname);
|
||||
await showBoard(data.display_name);
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
err.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("add").addEventListener("click", async () => {
|
||||
const err = document.getElementById("add-error");
|
||||
err.classList.add("hidden");
|
||||
try {
|
||||
await api("/api/names", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
kind: state.kind,
|
||||
spelling: document.getElementById("spelling").value.trim(),
|
||||
locales: localePayload(),
|
||||
}),
|
||||
});
|
||||
clearForm();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
err.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const invite = params().get("invite");
|
||||
if (invite) document.getElementById("invite").value = invite;
|
||||
try {
|
||||
const session = await api("/api/session");
|
||||
if (session.authenticated) {
|
||||
await showBoard(session.display_name);
|
||||
}
|
||||
} catch (_) { /* stay on gate */ }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Stork — family baby-name board with votes and multilingual notes."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
"""Stork HTTP API + invite-gated family UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from stork.db import KINDS, LANGS, Store
|
||||
|
||||
DATA_DIR = Path(os.environ.get("STORK_DATA", "./data"))
|
||||
INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip()
|
||||
ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip()
|
||||
COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
|
||||
STATIC = Path(__file__).resolve().parent.parent / "static"
|
||||
|
||||
app = FastAPI(title="Stork", docs_url=None, redoc_url=None)
|
||||
store = Store(DATA_DIR / "stork.sqlite3")
|
||||
|
||||
if STATIC.is_dir():
|
||||
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||
|
||||
|
||||
class LocaleIn(BaseModel):
|
||||
pronunciation: str = ""
|
||||
origin: str = ""
|
||||
meaning: str = ""
|
||||
|
||||
|
||||
class NameIn(BaseModel):
|
||||
kind: str = Field(pattern="^(first|middle)$")
|
||||
spelling: str = Field(min_length=1, max_length=80)
|
||||
locales: dict[str, LocaleIn] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class LocalesPatch(BaseModel):
|
||||
locales: dict[str, LocaleIn]
|
||||
|
||||
|
||||
class VoteIn(BaseModel):
|
||||
value: int = Field(ge=-1, le=1)
|
||||
|
||||
|
||||
class SessionIn(BaseModel):
|
||||
invite: str = Field(min_length=1, max_length=200)
|
||||
display_name: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
def _require_invite_configured() -> None:
|
||||
if not INVITE_TOKEN:
|
||||
raise HTTPException(503, "STORK_INVITE_TOKEN not configured")
|
||||
|
||||
|
||||
def _invite_ok(invite: str | None) -> bool:
|
||||
_require_invite_configured()
|
||||
return bool(invite) and secrets.compare_digest(invite, INVITE_TOKEN)
|
||||
|
||||
|
||||
def _session(
|
||||
stork_invite: str | None = Cookie(default=None),
|
||||
stork_voter: str | None = Cookie(default=None),
|
||||
stork_name: str | None = Cookie(default=None),
|
||||
x_stork_invite: str | None = Header(default=None),
|
||||
) -> dict[str, str]:
|
||||
invite = x_stork_invite or stork_invite
|
||||
if not _invite_ok(invite):
|
||||
raise HTTPException(401, "Invite required")
|
||||
voter = (stork_voter or "").strip()
|
||||
name = (stork_name or "").strip()
|
||||
if not voter or not name:
|
||||
raise HTTPException(401, "Session required — open the invite link and enter your name")
|
||||
return {"voter_key": voter, "display_name": name}
|
||||
|
||||
|
||||
def _admin(
|
||||
x_stork_admin: str | None = Header(default=None),
|
||||
) -> None:
|
||||
if not ADMIN_TOKEN:
|
||||
raise HTTPException(503, "Admin not configured")
|
||||
if not x_stork_admin or not secrets.compare_digest(x_stork_admin, ADMIN_TOKEN):
|
||||
raise HTTPException(403, "Admin token required")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, Any]:
|
||||
return {"ok": True, "service": "stork"}
|
||||
|
||||
|
||||
@app.get("/api/meta")
|
||||
def meta() -> dict[str, Any]:
|
||||
return {
|
||||
"langs": list(LANGS),
|
||||
"kinds": list(KINDS),
|
||||
"invite_required": True,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/session")
|
||||
def create_session(body: SessionIn, response: Response) -> dict[str, Any]:
|
||||
if not _invite_ok(body.invite.strip()):
|
||||
raise HTTPException(403, "Invalid invite")
|
||||
voter = secrets.token_urlsafe(16)
|
||||
display = body.display_name.strip()
|
||||
cookie_kwargs: dict[str, Any] = {
|
||||
"httponly": True,
|
||||
"samesite": "lax",
|
||||
"secure": COOKIE_SECURE,
|
||||
"max_age": 60 * 60 * 24 * 400,
|
||||
"path": "/",
|
||||
}
|
||||
response.set_cookie("stork_invite", INVITE_TOKEN, **cookie_kwargs)
|
||||
response.set_cookie("stork_voter", voter, **cookie_kwargs)
|
||||
response.set_cookie("stork_name", display, **{**cookie_kwargs, "httponly": False})
|
||||
return {"ok": True, "display_name": display}
|
||||
|
||||
|
||||
@app.get("/api/session")
|
||||
def get_session(
|
||||
stork_invite: str | None = Cookie(default=None),
|
||||
stork_voter: str | None = Cookie(default=None),
|
||||
stork_name: str | None = Cookie(default=None),
|
||||
x_stork_invite: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
invite = x_stork_invite or stork_invite
|
||||
authed = _invite_ok(invite) if INVITE_TOKEN else False
|
||||
return {
|
||||
"authenticated": authed and bool(stork_voter) and bool(stork_name),
|
||||
"display_name": stork_name or "",
|
||||
"has_invite": authed,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/names")
|
||||
def list_names(kind: str = "first", session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
|
||||
if kind not in KINDS:
|
||||
raise HTTPException(400, "kind must be first or middle")
|
||||
return {"items": store.list_names(kind, voter_key=session["voter_key"])}
|
||||
|
||||
|
||||
@app.post("/api/names")
|
||||
def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
|
||||
locales = {k: v.model_dump() for k, v in body.locales.items()}
|
||||
try:
|
||||
return store.add_name(
|
||||
kind=body.kind,
|
||||
spelling=body.spelling,
|
||||
created_by=session["display_name"],
|
||||
locales=locales,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@app.patch("/api/names/{name_id}")
|
||||
def patch_name(
|
||||
name_id: int,
|
||||
body: LocalesPatch,
|
||||
session: dict[str, str] = Depends(_session),
|
||||
) -> dict[str, Any]:
|
||||
_ = session
|
||||
locales = {k: v.model_dump() for k, v in body.locales.items()}
|
||||
updated = store.update_locales(name_id, locales)
|
||||
if not updated:
|
||||
raise HTTPException(404, "Name not found")
|
||||
return updated
|
||||
|
||||
|
||||
@app.post("/api/names/{name_id}/vote")
|
||||
def vote_name(
|
||||
name_id: int,
|
||||
body: VoteIn,
|
||||
session: dict[str, str] = Depends(_session),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
updated = store.vote(
|
||||
name_id=name_id,
|
||||
voter_key=session["voter_key"],
|
||||
voter_name=session["display_name"],
|
||||
value=body.value,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
if not updated:
|
||||
raise HTTPException(404, "Name not found")
|
||||
return updated
|
||||
|
||||
|
||||
@app.delete("/api/names/{name_id}")
|
||||
def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]:
|
||||
if not store.delete_name(name_id):
|
||||
raise HTTPException(404, "Name not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def home() -> FileResponse:
|
||||
index = STATIC / "index.html"
|
||||
if not index.is_file():
|
||||
raise HTTPException(404, "UI missing")
|
||||
return FileResponse(index)
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
"""SQLite persistence for names, locales, and votes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
LANGS = ("en", "ru", "he")
|
||||
KINDS = ("first", "middle")
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('first', 'middle')),
|
||||
spelling TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at REAL NOT NULL,
|
||||
UNIQUE (kind, spelling COLLATE NOCASE)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS locales (
|
||||
name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE,
|
||||
lang TEXT NOT NULL CHECK (lang IN ('en', 'ru', 'he')),
|
||||
pronunciation TEXT NOT NULL DEFAULT '',
|
||||
origin TEXT NOT NULL DEFAULT '',
|
||||
meaning TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (name_id, lang)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE,
|
||||
voter_key TEXT NOT NULL,
|
||||
voter_name TEXT NOT NULL DEFAULT '',
|
||||
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY (name_id, voter_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_names_kind ON names(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_votes_name ON votes(name_id);
|
||||
"""
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA foreign_keys = ON")
|
||||
with self._lock:
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.close()
|
||||
|
||||
def _empty_locales(self) -> dict[str, dict[str, str]]:
|
||||
return {lang: {"pronunciation": "", "origin": "", "meaning": ""} for lang in LANGS}
|
||||
|
||||
def _normalize_locales(self, locales: dict[str, Any] | None) -> dict[str, dict[str, str]]:
|
||||
out = self._empty_locales()
|
||||
if not locales:
|
||||
return out
|
||||
for lang in LANGS:
|
||||
raw = locales.get(lang) or {}
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
out[lang] = {
|
||||
"pronunciation": str(raw.get("pronunciation") or "").strip()[:200],
|
||||
"origin": str(raw.get("origin") or "").strip()[:200],
|
||||
"meaning": str(raw.get("meaning") or "").strip()[:500],
|
||||
}
|
||||
return out
|
||||
|
||||
def _row_to_name(self, row: sqlite3.Row, score: int, vote_count: int, my_vote: int | None) -> dict[str, Any]:
|
||||
locales = self._empty_locales()
|
||||
cur = self._conn.execute(
|
||||
"SELECT lang, pronunciation, origin, meaning FROM locales WHERE name_id = ?",
|
||||
(row["id"],),
|
||||
)
|
||||
for loc in cur.fetchall():
|
||||
locales[loc["lang"]] = {
|
||||
"pronunciation": loc["pronunciation"],
|
||||
"origin": loc["origin"],
|
||||
"meaning": loc["meaning"],
|
||||
}
|
||||
return {
|
||||
"id": row["id"],
|
||||
"kind": row["kind"],
|
||||
"spelling": row["spelling"],
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"locales": locales,
|
||||
"score": score,
|
||||
"vote_count": vote_count,
|
||||
"my_vote": my_vote,
|
||||
}
|
||||
|
||||
def list_names(self, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]:
|
||||
if kind not in KINDS:
|
||||
raise ValueError("invalid kind")
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM names WHERE kind = ? ORDER BY spelling COLLATE NOCASE",
|
||||
(kind,),
|
||||
).fetchall()
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
score_row = self._conn.execute(
|
||||
"SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?",
|
||||
(row["id"],),
|
||||
).fetchone()
|
||||
my_vote = None
|
||||
if voter_key:
|
||||
vote = self._conn.execute(
|
||||
"SELECT value FROM votes WHERE name_id = ? AND voter_key = ?",
|
||||
(row["id"], voter_key),
|
||||
).fetchone()
|
||||
if vote:
|
||||
my_vote = int(vote["value"])
|
||||
out.append(
|
||||
self._row_to_name(
|
||||
row,
|
||||
int(score_row["score"]),
|
||||
int(score_row["vote_count"]),
|
||||
my_vote,
|
||||
)
|
||||
)
|
||||
out.sort(key=lambda n: (-n["score"], n["spelling"].casefold()))
|
||||
return out
|
||||
|
||||
def get_name(self, name_id: int, voter_key: str | None = None) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
row = self._conn.execute("SELECT * FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
score_row = self._conn.execute(
|
||||
"SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?",
|
||||
(name_id,),
|
||||
).fetchone()
|
||||
my_vote = None
|
||||
if voter_key:
|
||||
vote = self._conn.execute(
|
||||
"SELECT value FROM votes WHERE name_id = ? AND voter_key = ?",
|
||||
(name_id, voter_key),
|
||||
).fetchone()
|
||||
if vote:
|
||||
my_vote = int(vote["value"])
|
||||
return self._row_to_name(
|
||||
row,
|
||||
int(score_row["score"]),
|
||||
int(score_row["vote_count"]),
|
||||
my_vote,
|
||||
)
|
||||
|
||||
def add_name(
|
||||
self,
|
||||
kind: str,
|
||||
spelling: str,
|
||||
created_by: str,
|
||||
locales: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if kind not in KINDS:
|
||||
raise ValueError("invalid kind")
|
||||
spelling = spelling.strip()
|
||||
if not spelling or len(spelling) > 80:
|
||||
raise ValueError("spelling required (1–80 chars)")
|
||||
created_by = (created_by or "").strip()[:80]
|
||||
locs = self._normalize_locales(locales)
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
try:
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO names (kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?)",
|
||||
(kind, spelling, created_by, now),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ValueError("name already exists for this kind") from exc
|
||||
name_id = int(cur.lastrowid)
|
||||
for lang, payload in locs.items():
|
||||
self._conn.execute(
|
||||
"INSERT INTO locales (name_id, lang, pronunciation, origin, meaning) VALUES (?, ?, ?, ?, ?)",
|
||||
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
|
||||
)
|
||||
self._conn.commit()
|
||||
result = self.get_name(name_id)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
def update_locales(self, name_id: int, locales: dict[str, Any]) -> dict[str, Any] | None:
|
||||
locs = self._normalize_locales(locales)
|
||||
with self._lock:
|
||||
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
for lang, payload in locs.items():
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO locales (name_id, lang, pronunciation, origin, meaning)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name_id, lang) DO UPDATE SET
|
||||
pronunciation = excluded.pronunciation,
|
||||
origin = excluded.origin,
|
||||
meaning = excluded.meaning
|
||||
""",
|
||||
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
|
||||
)
|
||||
self._conn.commit()
|
||||
return self.get_name(name_id)
|
||||
|
||||
def vote(
|
||||
self,
|
||||
name_id: int,
|
||||
voter_key: str,
|
||||
voter_name: str,
|
||||
value: int,
|
||||
) -> dict[str, Any] | None:
|
||||
if value not in (-1, 0, 1):
|
||||
raise ValueError("vote must be -1, 0, or 1")
|
||||
voter_key = voter_key.strip()
|
||||
if not voter_key or len(voter_key) > 64:
|
||||
raise ValueError("invalid voter")
|
||||
voter_name = (voter_name or "").strip()[:80]
|
||||
with self._lock:
|
||||
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if value == 0:
|
||||
self._conn.execute(
|
||||
"DELETE FROM votes WHERE name_id = ? AND voter_key = ?",
|
||||
(name_id, voter_key),
|
||||
)
|
||||
else:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO votes (name_id, voter_key, voter_name, value, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name_id, voter_key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
voter_name = excluded.voter_name,
|
||||
created_at = excluded.created_at
|
||||
""",
|
||||
(name_id, voter_key, voter_name, value, time.time()),
|
||||
)
|
||||
self._conn.commit()
|
||||
return self.get_name(name_id, voter_key=voter_key)
|
||||
|
||||
def delete_name(self, name_id: int) -> bool:
|
||||
with self._lock:
|
||||
cur = self._conn.execute("DELETE FROM names WHERE id = ?", (name_id,))
|
||||
self._conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
def export_snapshot(self) -> str:
|
||||
"""Debug helper for tests."""
|
||||
with self._lock:
|
||||
names = [dict(r) for r in self._conn.execute("SELECT * FROM names").fetchall()]
|
||||
return json.dumps(names)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Shared fixtures for Stork API tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
monkeypatch.setenv("STORK_DATA", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
|
||||
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
|
||||
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
|
||||
|
||||
import stork.app as app_mod
|
||||
|
||||
importlib.reload(app_mod)
|
||||
with TestClient(app_mod.app) as test_client:
|
||||
yield test_client
|
||||
app_mod.store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def authed(client: TestClient) -> TestClient:
|
||||
res = client.post(
|
||||
"/api/session",
|
||||
json={"invite": "test-invite-token", "display_name": "Aunt Mira"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
return client
|
||||
@@ -0,0 +1,105 @@
|
||||
"""API tests for Stork MVP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_health(client: TestClient) -> None:
|
||||
res = client.get("/api/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["ok"] is True
|
||||
|
||||
|
||||
def test_session_rejects_bad_invite(client: TestClient) -> None:
|
||||
res = client.post("/api/session", json={"invite": "nope", "display_name": "Ilia"})
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
def test_names_require_session(client: TestClient) -> None:
|
||||
res = client.get("/api/names")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
def test_add_vote_and_rank(authed: TestClient) -> None:
|
||||
a = authed.post(
|
||||
"/api/names",
|
||||
json={
|
||||
"kind": "first",
|
||||
"spelling": "Noa",
|
||||
"locales": {
|
||||
"he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion / movement"},
|
||||
"en": {"pronunciation": "NO-uh", "origin": "Hebrew", "meaning": "movement"},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert a.status_code == 200
|
||||
noa_id = a.json()["id"]
|
||||
|
||||
b = authed.post("/api/names", json={"kind": "first", "spelling": "Levi"})
|
||||
assert b.status_code == 200
|
||||
levi_id = b.json()["id"]
|
||||
|
||||
assert authed.post(f"/api/names/{noa_id}/vote", json={"value": 1}).status_code == 200
|
||||
assert authed.post(f"/api/names/{levi_id}/vote", json={"value": -1}).status_code == 200
|
||||
|
||||
listed = authed.get("/api/names?kind=first").json()["items"]
|
||||
assert [n["spelling"] for n in listed] == ["Noa", "Levi"]
|
||||
assert listed[0]["score"] == 1
|
||||
assert listed[0]["my_vote"] == 1
|
||||
assert listed[1]["score"] == -1
|
||||
|
||||
# clear vote
|
||||
cleared = authed.post(f"/api/names/{noa_id}/vote", json={"value": 0})
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.json()["my_vote"] is None
|
||||
assert cleared.json()["score"] == 0
|
||||
|
||||
|
||||
def test_duplicate_name_rejected(authed: TestClient) -> None:
|
||||
assert authed.post("/api/names", json={"kind": "first", "spelling": "Maya"}).status_code == 200
|
||||
dup = authed.post("/api/names", json={"kind": "first", "spelling": "maya"})
|
||||
assert dup.status_code == 400
|
||||
|
||||
|
||||
def test_middle_names_separate(authed: TestClient) -> None:
|
||||
assert authed.post("/api/names", json={"kind": "first", "spelling": "Ari"}).status_code == 200
|
||||
assert authed.post("/api/names", json={"kind": "middle", "spelling": "Ari"}).status_code == 200
|
||||
first = authed.get("/api/names?kind=first").json()["items"]
|
||||
middle = authed.get("/api/names?kind=middle").json()["items"]
|
||||
assert len(first) == 1
|
||||
assert len(middle) == 1
|
||||
|
||||
|
||||
def test_patch_locales(authed: TestClient) -> None:
|
||||
created = authed.post("/api/names", json={"kind": "first", "spelling": "Eden"}).json()
|
||||
patched = authed.patch(
|
||||
f"/api/names/{created['id']}",
|
||||
json={"locales": {"ru": {"pronunciation": "Э-ден", "origin": "иврит", "meaning": "рай"}}},
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["locales"]["ru"]["meaning"] == "рай"
|
||||
|
||||
|
||||
def test_admin_delete(authed: TestClient) -> None:
|
||||
created = authed.post("/api/names", json={"kind": "first", "spelling": "Temp"}).json()
|
||||
denied = authed.delete(f"/api/names/{created['id']}")
|
||||
assert denied.status_code == 403
|
||||
ok = authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"})
|
||||
assert ok.status_code == 200
|
||||
assert authed.get("/api/names?kind=first").json()["items"] == []
|
||||
|
||||
|
||||
def test_second_voter_ranking(client: TestClient, authed: TestClient) -> None:
|
||||
name = authed.post("/api/names", json={"kind": "first", "spelling": "Shai"}).json()
|
||||
authed.post(f"/api/names/{name['id']}/vote", json={"value": 1})
|
||||
|
||||
other = client.post(
|
||||
"/api/session",
|
||||
json={"invite": "test-invite-token", "display_name": "Uncle Dan"},
|
||||
)
|
||||
assert other.status_code == 200
|
||||
client.post(f"/api/names/{name['id']}/vote", json={"value": 1})
|
||||
listed = client.get("/api/names?kind=first").json()["items"]
|
||||
assert listed[0]["score"] == 2
|
||||
assert listed[0]["vote_count"] == 2
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Unit tests for Store ranking helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from stork.db import Store
|
||||
|
||||
|
||||
def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None:
|
||||
store = Store(tmp_path / "t.sqlite3")
|
||||
a = store.add_name("first", "Zed", "x")
|
||||
store.add_name("first", "Ann", "x")
|
||||
c = store.add_name("first", "Bo", "x")
|
||||
store.vote(a["id"], "v1", "A", 1)
|
||||
store.vote(c["id"], "v1", "A", 1)
|
||||
store.vote(c["id"], "v2", "B", 1)
|
||||
ranked = store.list_names("first")
|
||||
assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"]
|
||||
store.close()
|
||||
Reference in New Issue
Block a user