Tilføj produktions-installationsvejledning + bootstrap-script
INSTALL.md dækker hele vejen fra en frisk server til en kørende løsning: forudsætninger, database, .env, migrations, admin-UI-build, en systemd-unit til backend'en, domæne-/reverse proxy-krav (holdt værktøjs-uafhængigt, med en kort CloudPanel-note), DNS, Postal-webhook, rute-opsætning, verifikation og fremtidige opdateringer. scripts/create_admin_user.py lukker en reel mangel: der findes ingen selvbetjenings-registrering, så uden en bruger i databasen kan ingen logge ind på en frisk installation. Scriptet opretter (eller genbruger) en organisation + rute, samt en ny superadmin-bruger med korrekt hashet adgangskode — samme mønster som det eksisterende scripts/import_participants.py. Verificeret i en isoleret test (oprettet bruger kunne logge ind via POST /auth/login), testdata ryddet op igen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d360e704df
commit
e337c4a49e
3 changed files with 279 additions and 1 deletions
194
INSTALL.md
Normal file
194
INSTALL.md
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
# Installationsvejledning — produktionsopsætning
|
||||||
|
|
||||||
|
Denne vejledning dækker opsætning af Vinindkøb-backend'en (API +
|
||||||
|
offentlig side + admin-UI) på en rigtig server, fra en frisk
|
||||||
|
installation til en kørende løsning. Se [README.md](README.md) for
|
||||||
|
lokal udvikling.
|
||||||
|
|
||||||
|
Vejledningen forudsætter at der allerede findes en kørende
|
||||||
|
Postal-instans (selvhostet mailserver — se CLAUDE.md's "Tech stack")
|
||||||
|
med DKIM sat op for det domæne der sendes mails fra. Selve
|
||||||
|
Postal-opsætningen er uden for denne vejlednings omfang.
|
||||||
|
|
||||||
|
## 1. Forudsætninger på serveren
|
||||||
|
|
||||||
|
- Python 3.13 (se `.python-version`)
|
||||||
|
- [`uv`](https://docs.astral.sh/uv/) (Python-pakkehåndtering)
|
||||||
|
- Node.js + npm (til at bygge admin-UI'et, `admin-ui/`)
|
||||||
|
- PostgreSQL
|
||||||
|
- git
|
||||||
|
|
||||||
|
## 2. Hent koden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> /sti/til/vinindkoeb
|
||||||
|
cd /sti/til/vinindkoeb
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Database
|
||||||
|
|
||||||
|
Opret en Postgres-bruger og -database der matcher formatet i
|
||||||
|
`DATABASE_URL` (`postgresql+psycopg://<bruger>:<kodeord>@<host>:5432/<database>`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE USER vinindkoeb WITH PASSWORD '...';
|
||||||
|
CREATE DATABASE vinindkoeb OWNER vinindkoeb;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Miljøvariabler
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Udfyld hvert felt i `.env`:
|
||||||
|
|
||||||
|
| Variabel | Hvor den kommer fra |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_URL` | Fra trin 3 |
|
||||||
|
| `ENVIRONMENT` | `production` |
|
||||||
|
| `SECRET_KEY` | Generér med `openssl rand -hex 32` |
|
||||||
|
| `ACCESS_TOKEN_EXPIRE_MINUTES` / `ELEVATION_EXPIRE_MINUTES` | Standardværdierne er fine, med mindre andet ønskes |
|
||||||
|
| `POSTAL_BASE_URL` / `POSTAL_API_KEY` | Fra Postal-instansens admin-UI (opret en API-nøgle til denne server) |
|
||||||
|
| `POSTAL_WEBHOOK_PUBLIC_KEY_B64` | DKIM-nøglen for afsenderdomænet — find med `dig +short TXT <dkim-selector>._domainkey.<domæne>`, brug værdien af `p=` |
|
||||||
|
| `ADMIN_DOMAIN` | Domænet admin-UI'et skal serveres på, fx `admin.vinindkoeb.dk` |
|
||||||
|
|
||||||
|
## 5. Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
Dette opretter alle tabeller og seeder `WineCategory` automatisk
|
||||||
|
(vinkategorierne er globale, delt af alle organisationer/ruter).
|
||||||
|
|
||||||
|
## 6. Første organisation, rute og admin-bruger
|
||||||
|
|
||||||
|
Der findes ingen selvbetjenings-registrering — uden en bruger i
|
||||||
|
databasen kan ingen logge ind. Brug bootstrap-scriptet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts/create_admin_user.py \
|
||||||
|
--org "Din Organisation" \
|
||||||
|
--route "Din Rute" \
|
||||||
|
--email admin@example.com \
|
||||||
|
--name "Dit Navn" \
|
||||||
|
--password "et-sikkert-kodeord"
|
||||||
|
```
|
||||||
|
|
||||||
|
Scriptet genbruger en organisation/rute med samme navn, hvis den
|
||||||
|
allerede findes — sikkert at køre igen for at oprette endnu en
|
||||||
|
admin-bruger på en eksisterende organisation.
|
||||||
|
|
||||||
|
## 7. Byg admin-UI'et
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd admin-ui
|
||||||
|
npm install
|
||||||
|
npm run build # skriver til ../app/admin_dist
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Uden dette trin kører API'et og den offentlige side fint, men
|
||||||
|
`ADMIN_DOMAIN` viser ingenting (se `app/admin_site.py`s
|
||||||
|
sikkerhedsnet).
|
||||||
|
|
||||||
|
## 8. Kør backend'en som en vedvarende service
|
||||||
|
|
||||||
|
Eksempel på en `systemd`-unit (`/etc/systemd/system/vinindkoeb.service`),
|
||||||
|
uafhængig af hvilken reverse proxy der bruges foran den:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Vinindkøb backend
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=vinindkoeb
|
||||||
|
WorkingDirectory=/sti/til/vinindkoeb
|
||||||
|
ExecStart=/sti/til/vinindkoeb/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||||
|
Restart=always
|
||||||
|
EnvironmentFile=/sti/til/vinindkoeb/.env
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now vinindkoeb
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend'en lytter kun på `127.0.0.1` — den skal ikke være direkte
|
||||||
|
tilgængelig udefra, kun via reverse proxy'en i næste trin.
|
||||||
|
|
||||||
|
## 9. Domæner + reverse proxy
|
||||||
|
|
||||||
|
Løsningen afgør selv hvilken rute/hvilket indhold der skal vises ud
|
||||||
|
fra requestens `Host`-header (se CLAUDE.md's "Route" og
|
||||||
|
"Admin-UI-arkitektur") — det betyder at **alle** domæner nedenfor
|
||||||
|
bare skal pege på samme kørende backend (samme port, fra trin 8),
|
||||||
|
uanset hvilken reverse proxy-løsning der bruges:
|
||||||
|
|
||||||
|
- Organisationens/rutens `public_domain`-felt(er) i databasen, fx
|
||||||
|
`vinindkoeb.dk` og `test.vinindkoeb.dk`
|
||||||
|
- `ADMIN_DOMAIN` fra `.env`, fx `admin.vinindkoeb.dk`
|
||||||
|
|
||||||
|
**Med CloudPanel:** opret ét "Reverse Proxy"-site pr. domæne, der
|
||||||
|
peger på `127.0.0.1:8000` (porten fra trin 8), og aktivér
|
||||||
|
Let's Encrypt-certifikat for hvert site. Bruges en anden løsning
|
||||||
|
(nginx, Caddy, Traefik, ...), er princippet det samme: én
|
||||||
|
vhost/site pr. domæne, alle proxyet til samme port.
|
||||||
|
|
||||||
|
## 10. DNS
|
||||||
|
|
||||||
|
A-records (eller CNAME, alt efter opsætning) for hvert domæne fra
|
||||||
|
trin 9, der peger på serverens IP-adresse.
|
||||||
|
|
||||||
|
## 11. Postal-webhook
|
||||||
|
|
||||||
|
I Postal-instansens konfiguration: sæt webhook-URL'en til
|
||||||
|
`https://<det rigtige domæne>/webhooks/postal` (fx
|
||||||
|
`https://vinindkoeb.dk/webhooks/postal`) — det er sådan
|
||||||
|
leveringsstatus (sendt/leveret/afvist) opdateres asynkront på
|
||||||
|
`MailLog` (se opgave 7c).
|
||||||
|
|
||||||
|
## 12. Sæt rutens felter
|
||||||
|
|
||||||
|
For hver rute (den rigtige, og evt. en test-rute) skal disse felter
|
||||||
|
sættes direkte i databasen — der findes ingen rute-indstillings-skærm
|
||||||
|
i admin-UI'et endnu:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE route SET
|
||||||
|
public_domain = 'vinindkoeb.dk',
|
||||||
|
sender_name = 'Afsendernavn',
|
||||||
|
sender_email = 'afsender@vinindkoeb.dk'
|
||||||
|
WHERE id = <rute-id>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Verifikation
|
||||||
|
|
||||||
|
1. `curl https://<rigtigt domæne>/health` → `{"status":"ok","database":"ok"}`.
|
||||||
|
2. Besøg det rigtige domæne i en browser → bestillings-/tilmeldingssiden.
|
||||||
|
3. Besøg `ADMIN_DOMAIN` → login-siden, log ind med brugeren fra trin 6.
|
||||||
|
4. Afprøv en tilmelding/bestilling på test-ruten (hvis en sådan er
|
||||||
|
sat op) og bekræft at en rigtig mail bliver sendt og logget i
|
||||||
|
`MailLog`.
|
||||||
|
|
||||||
|
## 14. Fremtidige opdateringer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
uv sync
|
||||||
|
uv run alembic upgrade head
|
||||||
|
cd admin-ui && npm install && npm run build && cd ..
|
||||||
|
sudo systemctl restart vinindkoeb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
Ikke uddybet her, men en simpel `pg_dump`-baseret cron-opgave af
|
||||||
|
databasen anbefales som minimum.
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
# Vinindkøb v2
|
# Vinindkøb v2
|
||||||
|
|
||||||
Admin-backend til fælles vinindkøb (FastAPI + SQLModel + PostgreSQL +
|
Admin-backend til fælles vinindkøb (FastAPI + SQLModel + PostgreSQL +
|
||||||
Alembic). Se [CLAUDE.md](CLAUDE.md) for projektplan og datamodel.
|
Alembic). Se [CLAUDE.md](CLAUDE.md) for projektplan og datamodel, og
|
||||||
|
[INSTALL.md](INSTALL.md) for produktionsopsætning på en server.
|
||||||
|
|
||||||
## Kom i gang
|
## Kom i gang
|
||||||
|
|
||||||
|
|
|
||||||
83
scripts/create_admin_user.py
Normal file
83
scripts/create_admin_user.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""One-off bootstrap: opretter den første organisation + rute + superadmin-bruger.
|
||||||
|
|
||||||
|
Der findes ingen selvbetjenings-registrering i systemet — uden en
|
||||||
|
bruger i databasen kan ingen logge ind overhovedet. Dette script
|
||||||
|
dækker det på en frisk installation.
|
||||||
|
|
||||||
|
Genbruger en organisation/rute med samme navn, hvis de allerede
|
||||||
|
findes (sikkert at køre flere gange, fx for at oprette endnu en
|
||||||
|
admin-bruger på en eksisterende organisation). Fejler tydeligt hvis
|
||||||
|
en bruger med samme email allerede findes, i stedet for at duplikere.
|
||||||
|
|
||||||
|
Brug:
|
||||||
|
uv run python scripts/create_admin_user.py \
|
||||||
|
--org "Fælles Vinindkøb" --route "Fælles Vinindkøb" \
|
||||||
|
--email admin@example.com --name "Dit Navn" --password "..."
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.db import engine
|
||||||
|
from app.models.organization import Organization
|
||||||
|
from app.models.route import Route
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--org", required=True, help="Organisationens navn (genbruges hvis den allerede findes)")
|
||||||
|
parser.add_argument("--route", required=True, help="Rutens navn (genbruges hvis den allerede findes)")
|
||||||
|
parser.add_argument("--email", required=True, help="Email til den nye admin-bruger")
|
||||||
|
parser.add_argument("--name", required=True, help="Navn på den nye admin-bruger")
|
||||||
|
parser.add_argument("--password", required=True, help="Adgangskode til den nye admin-bruger")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
existing_user = session.exec(select(User).where(User.email == args.email)).first()
|
||||||
|
if existing_user is not None:
|
||||||
|
print(f"Fejl: en bruger med email {args.email!r} findes allerede (id={existing_user.id}).")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
organization = session.exec(select(Organization).where(Organization.name == args.org)).first()
|
||||||
|
if organization is None:
|
||||||
|
organization = Organization(name=args.org)
|
||||||
|
session.add(organization)
|
||||||
|
session.flush()
|
||||||
|
print(f"Oprettede organisation {args.org!r} (id={organization.id}).")
|
||||||
|
else:
|
||||||
|
print(f"Genbruger eksisterende organisation {args.org!r} (id={organization.id}).")
|
||||||
|
|
||||||
|
route = session.exec(
|
||||||
|
select(Route).where(Route.name == args.route, Route.organization_id == organization.id)
|
||||||
|
).first()
|
||||||
|
if route is None:
|
||||||
|
route = Route(name=args.route, organization_id=organization.id)
|
||||||
|
session.add(route)
|
||||||
|
session.flush()
|
||||||
|
print(f"Oprettede rute {args.route!r} (id={route.id}).")
|
||||||
|
else:
|
||||||
|
print(f"Genbruger eksisterende rute {args.route!r} (id={route.id}).")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=args.email,
|
||||||
|
name=args.name,
|
||||||
|
hashed_password=hash_password(args.password),
|
||||||
|
is_active=True,
|
||||||
|
is_superadmin=True,
|
||||||
|
organization_id=organization.id,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
print(f"Oprettede superadmin-bruger {args.email!r} (id={user.id}).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Reference in a new issue