---
title: crumbcore_v1
slug: crumbcore_v1
lang: de
summary: Kurzbeschreibung hier.
tags: [Crumbforest]
---
# crumbcore_v1

Kurzer Einstiegstext.

## Notizen
- Punkt 1
- Punkt 2

## FAQ
Q: Worum geht es?
A: …

mega—großer krümel geschafft 🍰🧩
Hier ist das **finale `README.md`** für den Stand „Login → Admin → Posts CRUD → i18n → Flash/Templating → DB/Qdrant Compose“. Es fasst die Reise (gestern→heute) zusammen, dokumentiert die wichtigen Entscheidungen, und enthält alle Befehle & Fixes, die wir unterwegs gebraucht haben.

---

# CrumbCRM – Minimal vNext (FastAPI + MariaDB + Qdrant)

Server-rendered, mehrsprachig, mit Login/Session, Admin-Bereich und einfachem Blog-CRUD.
Ziel: **schnell, stabil, kein Frontend-Build-Ballast**, dafür klare HTML-Templates (Jinja2), Forms, Flash-Messages und später andockbare Vektor-Suche (Qdrant).

## Was läuft aktuell?

* 🌐 **i18n** Pfade: `/de/…` und `/en/…` (Root `/` → 307 auf bevorzugte Sprache)
* 🔐 **Login** (Sessions + bcrypt) und Logout
* 👤 Rollen: `admin` vs `user` (403, wenn kein Admin)
* 🧰 **Admin Dashboard**: `/admin`
* ✍️ **Posts CRUD** für Admin:

  * `GET /admin/posts` – Liste
  * `GET /admin/posts/new` – Formular
  * `POST /admin/posts/new` – Erstellen
  * `GET /admin/posts/{id}/edit` – Bearbeiten
  * `POST /admin/posts/{id}/edit` – Speichern
* ✨ **Flash-Nachrichten** (einmalige Anzeige nach Redirect)
* 🧪 **API Demo**: `GET /api/hello?lang=de|en`
* 🩺 **Health**: `GET /health`
* 🧠 **Qdrant** ist per Compose angebunden (noch ohne Ingest), UI unter `http://localhost:6333/dashboard`

## Stack

* **FastAPI**, **Jinja2**, **Starlette Sessions**
* **MariaDB** (PyMySQL)
* **passlib\[bcrypt]** (Password-Hashing)
* **python-multipart** (Form-POSTs)
* **Qdrant** (Vektoren; später Indexing/Embedding)

Empfohlene Pins (stehen in `app/requirements.txt`):

```
fastapi==0.115.0
uvicorn[standard]==0.30.6
jinja2==3.1.4
passlib[bcrypt]==1.7.4
bcrypt==4.1.3
python-multipart==0.0.9
PyMySQL==1.1.1
```

## Projektstruktur

```
app/
  main.py                 # App, Routing, Session, Render-Helper (state.render)
  requirements.txt
  routers/
    admin_post.py         # Admin-CRUD für Posts
  templates/
    base.html
    pages/
      home.html
      login.html
      admin.html
    posts/
      index.html
      new.html
      edit.html
      _edit_row.htm
compose/
  docker-compose.yml
  init/
    01_schema.sql         # users Tabelle
    02_posts.sql          # posts Tabelle
  reset_admin_demo.sh     # pass-hash Seeds (admin/demo)
data/
  mysql/                  # MariaDB Daten
  qdrant/                 # Qdrant Storage
```

## Start (Docker Compose)

```bash
cd compose
docker compose up --build
```

* App: [http://localhost:8000](http://localhost:8000)
* Qdrant UI: [http://localhost:6333/dashboard](http://localhost:6333/dashboard)

### Admin/Demo Benutzer anlegen (Seeds)

Wenn die Container laufen, einmalig die Benutzer mit bekannten Hashes setzen:

```bash
# Benutzer in DB einspielen (verwende -T, damit keine TTY-Probleme)
docker compose exec -T db sh -lc '
mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE" -e "
INSERT INTO users (email, pass_hash, role, locale, display_name)
VALUES
 (\"admin@crumb.local\", \"\$2b\$12\$H1V2q0iY8mqlz1xUbx7m5u4T0cVJH0hQk0q9o5aNq5Pjv1e0q9lby\", \"admin\", \"de\", \"Admin\"),
 (\"demo@crumb.local\",  \"\$2b\$12\$pI6cJ7gq4qkqF3gL2xjY4u9v9s1yN6wZQn9I7gG7xv7Cw0m3t8yVSe\", \"user\",  \"de\", \"Demo\")
ON DUPLICATE KEY UPDATE pass_hash=VALUES(pass_hash), role=VALUES(role), locale=VALUES(locale);
"'
```

> **Hinweis:** `admin@crumb.local` hat Adminrechte. `demo@crumb.local` **nicht** – 403 auf `/admin` ist korrekt.

## Wichtige Routen (Stand heute)

* `GET /` → 307 auf `/de/`
* `GET /{lang}/` → Home
* `GET /{lang}/login` + `POST /{lang}/login` → Login
* `POST /logout` → Logout
* `GET /admin` → Admin-Start (nur Admin)
* `GET /admin/posts` → Post-Liste (nur Admin)
* `GET|POST /admin/posts/new` → Neu anlegen
* `GET|POST /admin/posts/{id}/edit` → Bearbeiten
* `GET /api/hello?lang=de|en` → einfache JSON-API
* `GET /health` → Healthcheck

*(Optional)* Öffentliche Post-Liste wäre `/de/posts` bzw. `/en/posts` – Route/Template ist noch nicht aktiv. Snippet unten.

## Flash-Nachrichten

* Wir speichern Flashs als Liste in `req.session["_flashes"]`.
* **Einmalige Anzeige**: `render()` poppt sie und setzt sie leer zurück.
* Wenn du direkt nach dem Redirect noch mal „hart“ navigierst, ist die Flash weg – das ist Absicht.

## Security Basics

* Session-Cookie: HttpOnly, `SameSite=Lax`
* CSRF: Forms sind serverseitig – bei Bedarf später Token ergänzen
* Rollenprüfung: `admin_required` schützt Admin-Routen

## Troubleshooting (die Fallen heute)

* **`python-multipart` fehlt** → in `requirements.txt` **mit** eintragen (ist drin).
* **`(trapped) error reading bcrypt version`**
  → sichere Kombi pinnen: `passlib[bcrypt]==1.7.4` **und** `bcrypt==4.1.3`.
* **`ImportError: circular import`**
  → Admin-Router importiert nur **Funktionen** aus einer kleinen `deps.py` (DB/ACL), nicht `main`.
* **`State has no attribute render`**
  → `state.render(req, tpl, **ctx)` wird **in `main.py`** beim Startup gesetzt. Router nutzt **genau** diese Funktion.
* **MariaDB Warnung „Aborted connection … Got an error reading communication packets“**
  → Fix: überall `with db() as conn:` verwenden (Kontextmanager schließt sauber).
* **`Field 'body_md' doesn't have a default value`**
  → Beim Insert/Edit `body_md` **immer** mitsenden (Form Feld ist vorhanden).

## Logs

```bash
docker compose logs -f app
docker compose logs -f db
```

## Optional: Öffentliche Posts-Liste

Wenn gewünscht, Route in `main.py` und Template ergänzen:

```python
# in main.py
from deps import db
from fastapi import Request
from fastapi.responses import HTMLResponse

@app.get("/{lang}/posts", response_class=HTMLResponse)
def public_posts(req: Request, lang: str):
    with db() as conn:
        with conn.cursor() as cur:
            cur.execute("""
                SELECT id, title, slug, locale, updated_at
                FROM posts
                WHERE is_published=1 AND locale=%s
                ORDER BY IFNULL(updated_at, created_at) DESC, id DESC
            """, (lang,))
            rows = cur.fetchall()
    return req.app.state.render(req, "pages/posts_public.html",
                                posts=rows, seo={"title": "Posts"})
```

```html
<!-- templates/pages/posts_public.html -->
{% extends 'base.html' %}
{% block content %}
<h1>Posts</h1>
<ul class="list">
  {% for p in posts %}
    <li><strong>{{ p.title }}</strong> <small>({{ p.locale }})</small>
      {% if p.updated_at %} – <em>{{ p.updated_at }}</em>{% endif %}
    </li>
  {% else %}
    <li>Keine veröffentlichten Beiträge.</li>
  {% endfor %}
</ul>
{% endblock %}
```

## DB-Schema (Minimal)

**users**

```
id, email (unique), pass_hash, role('admin'|'user'), locale, created_at
```

**posts**

```
id, title, slug, locale, is_published TINYINT, body_md MEDIUMTEXT,
created_at, updated_at
```

→ angelegt über `compose/init/01_schema.sql` und `02_posts.sql`.

## Befehls-Snippets (nützlich)

```bash
# DB-Ping
docker compose exec -T db sh -lc \
  'mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE" -e "SELECT 1;"'

# User-Check
docker compose exec -T db sh -lc \
  'mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE" \
  -e "SELECT id,email,role,locale,created_at FROM users;"'
```

## Nächste Schritte (wenn du wieder Luft hast)

* Öffentliche Post-Ansicht `/de/posts` aktivieren
* CSRF-Token für Form-POSTs (kleiner Middleware-Helfer)
* Qdrant-Ingest-Worker (Markdown → Chunks → Embeddings → Upsert)
* Settings/ACL feiner (Rollenmatrix)
* Passwort-Reset/Mail (lokal via Mailpit/SMTP-Mock)

---

**Danke für den Ritt durch Pepper 🐍, Dumbo 🐘 & den Krümelwald 🌲.**
Von „nur Kuchen“ zu „Tortenbit“: Login-Loop steht, Admin schreibt, Flash funkt, i18n schaltet. Der Rest wird Feinschliff—aber die Basis trägt. Wuuuuhuuu! 🎉
