chore: remove remaining legacy files from main

- app.py, Dockerfile, requirements.txt, .coverage removed
- specs/, cache dirs removed
- Main is now clean: only docs/ + requirements artifacts
- Old code preserved on archive/legacy-v0 + feat/T1-auth
This commit is contained in:
leocrm-bot
2026-06-28 12:36:10 +02:00
parent 0d4cbe24dd
commit 2e6c2c5c17
6 changed files with 0 additions and 815 deletions
BIN
View File
Binary file not shown.
-256
View File
@@ -1,256 +0,0 @@
"""LeoCRM - Minimal CRM with auth, companies, contacts."""
import os
import sqlite3
from datetime import datetime
from functools import wraps
from flask import Flask, render_template, request, redirect, url_for, flash, session, g
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
app.secret_key = os.urandom(24).hex()
DATABASE = os.path.join(os.path.dirname(__file__), 'leocrm.db')
def get_db():
if 'db' not in g:
g.db = sqlite3.connect(DATABASE)
g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA foreign_keys = ON")
return g.db
@app.teardown_appcontext
def close_db(exception):
db = g.pop('db', None)
if db is not None:
db.close()
def init_db():
db = sqlite3.connect(DATABASE)
db.executescript('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address TEXT,
phone TEXT,
email TEXT,
website TEXT,
notes TEXT,
user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT,
phone TEXT,
position TEXT,
notes TEXT,
company_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
''')
db.commit()
db.close()
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if 'user_id' not in session:
flash('Bitte melde dich an.', 'warning')
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated
@app.route('/')
def index():
if 'user_id' in session:
return redirect(url_for('dashboard'))
return redirect(url_for('login'))
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username'].strip()
password = request.form['password']
if not username or not password:
flash('Benutzername und Passwort sind erforderlich.', 'danger')
return render_template('register.html')
db = get_db()
existing = db.execute('SELECT id FROM users WHERE username = ?', (username,)).fetchone()
if existing:
flash('Benutzername bereits vergeben.', 'danger')
return render_template('register.html')
db.execute('INSERT INTO users (username, password_hash) VALUES (?, ?)',
(username, generate_password_hash(password)))
db.commit()
flash('Registrierung erfolgreich! Bitte melde dich an.', 'success')
return redirect(url_for('login'))
return render_template('register.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username'].strip()
password = request.form['password']
db = get_db()
user = db.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
if user and check_password_hash(user['password_hash'], password):
session['user_id'] = user['id']
session['username'] = user['username']
flash(f'Willkommen, {username}!', 'success')
return redirect(url_for('dashboard'))
flash('Ungültige Anmeldedaten.', 'danger')
return render_template('login.html')
@app.route('/logout')
def logout():
session.clear()
flash('Du wurdest abgemeldet.', 'info')
return redirect(url_for('login'))
@app.route('/dashboard')
@login_required
def dashboard():
db = get_db()
companies = db.execute(
'SELECT * FROM companies WHERE user_id = ? ORDER BY name',
(session['user_id'],)
).fetchall()
return render_template('dashboard.html', companies=companies)
@app.route('/companies/create', methods=['GET', 'POST'])
@login_required
def company_create():
if request.method == 'POST':
db = get_db()
db.execute('INSERT INTO companies (name, address, phone, email, website, notes, user_id) VALUES (?,?,?,?,?,?,?)',
(request.form['name'], request.form.get('address',''), request.form.get('phone',''),
request.form.get('email',''), request.form.get('website',''), request.form.get('notes',''),
session['user_id']))
db.commit()
flash('Firma erstellt.', 'success')
return redirect(url_for('dashboard'))
return render_template('company_form.html', company=None)
@app.route('/companies/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def company_edit(id):
db = get_db()
company = db.execute('SELECT * FROM companies WHERE id = ? AND user_id = ?',
(id, session['user_id'])).fetchone()
if not company:
flash('Firma nicht gefunden.', 'danger')
return redirect(url_for('dashboard'))
if request.method == 'POST':
db.execute('UPDATE companies SET name=?, address=?, phone=?, email=?, website=?, notes=? WHERE id=?',
(request.form['name'], request.form.get('address',''), request.form.get('phone',''),
request.form.get('email',''), request.form.get('website',''), request.form.get('notes',''), id))
db.commit()
flash('Firma aktualisiert.', 'success')
return redirect(url_for('dashboard'))
return render_template('company_form.html', company=company)
@app.route('/companies/<int:id>/delete', methods=['POST'])
@login_required
def company_delete(id):
db = get_db()
db.execute('DELETE FROM companies WHERE id = ? AND user_id = ?', (id, session['user_id']))
db.commit()
flash('Firma gelöscht.', 'info')
return redirect(url_for('dashboard'))
@app.route('/companies/<int:company_id>/contacts')
@login_required
def contact_list(company_id):
db = get_db()
company = db.execute('SELECT * FROM companies WHERE id = ? AND user_id = ?',
(company_id, session['user_id'])).fetchone()
if not company:
flash('Firma nicht gefunden.', 'danger')
return redirect(url_for('dashboard'))
contacts = db.execute('SELECT * FROM contacts WHERE company_id = ? ORDER BY last_name, first_name',
(company_id,)).fetchall()
return render_template('contact_list.html', company=company, contacts=contacts)
@app.route('/companies/<int:company_id>/contacts/create', methods=['GET', 'POST'])
@login_required
def contact_create(company_id):
db = get_db()
company = db.execute('SELECT * FROM companies WHERE id = ? AND user_id = ?',
(company_id, session['user_id'])).fetchone()
if not company:
flash('Firma nicht gefunden.', 'danger')
return redirect(url_for('dashboard'))
if request.method == 'POST':
db.execute('INSERT INTO contacts (first_name, last_name, email, phone, position, notes, company_id) VALUES (?,?,?,?,?,?,?)',
(request.form['first_name'], request.form['last_name'], request.form.get('email',''),
request.form.get('phone',''), request.form.get('position',''), request.form.get('notes',''), company_id))
db.commit()
flash('Kontaktperson erstellt.', 'success')
return redirect(url_for('contact_list', company_id=company_id))
return render_template('contact_form.html', company=company, contact=None)
@app.route('/contacts/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def contact_edit(id):
db = get_db()
contact = db.execute('''SELECT c.*, co.name as company_name, co.user_id
FROM contacts c JOIN companies co ON c.company_id = co.id
WHERE c.id = ? AND co.user_id = ?''',
(id, session['user_id'])).fetchone()
if not contact:
flash('Kontakt nicht gefunden.', 'danger')
return redirect(url_for('dashboard'))
if request.method == 'POST':
db.execute('UPDATE contacts SET first_name=?, last_name=?, email=?, phone=?, position=?, notes=? WHERE id=?',
(request.form['first_name'], request.form['last_name'], request.form.get('email',''),
request.form.get('phone',''), request.form.get('position',''), request.form.get('notes',''), id))
db.commit()
flash('Kontakt aktualisiert.', 'success')
return redirect(url_for('contact_list', company_id=contact['company_id']))
return render_template('contact_form.html', company={'id': contact['company_id'], 'name': contact['company_name']}, contact=contact)
@app.route('/contacts/<int:id>/delete', methods=['POST'])
@login_required
def contact_delete(id):
db = get_db()
contact = db.execute('''SELECT c.company_id, co.user_id
FROM contacts c JOIN companies co ON c.company_id = co.id
WHERE c.id = ?''', (id,)).fetchone()
if contact and contact['user_id'] == session['user_id']:
db.execute('DELETE FROM contacts WHERE id = ?', (id,))
db.commit()
flash('Kontakt gelöscht.', 'info')
return redirect(url_for('contact_list', company_id=contact['company_id']))
flash('Kontakt nicht gefunden.', 'danger')
return redirect(url_for('dashboard'))
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000, debug=True)
-116
View File
@@ -1,116 +0,0 @@
# leocrm — Architektur (v0.1)
## 1. Überblick
`leocrm` ist eine monolithische FastAPI-Webanwendung, die HTML-Views (Jinja2) **und** eine JSON-API parallel anbietet. Beide Schichten teilen sich dieselben Service-Layer-Funktionen, dadurch sind Headless-Tests ohne Browser möglich.
```
┌─────────────────────────────────────────────────┐
│ Browser (HTML/Jinja2) │
│ ↓ 302-Login-Redirect bei fehlender Session │
├─────────────────────────────────────────────────┤
│ FastAPI App (app/main.py) │
│ ├── / /login /logout /companies/* │
│ │ /contacts/* → HTML-Routes (Jinja2) │
│ │ │
│ └── /api/health /api/auth/* /api/companies/* │
│ /api/contacts/* → JSON-Routes │
├─────────────────────────────────────────────────┤
│ Service Layer (app/services/) │
│ ├── auth_service.py │
│ ├── company_service.py │
│ └── contact_service.py │
├─────────────────────────────────────────────────┤
│ Persistence (SQLAlchemy 2.0, app/db/) │
│ ├── models.py (User, Company, Contact) │
│ ├── session.py (engine, SessionLocal) │
│ └── init_db.py (create_all + seed) │
├─────────────────────────────────────────────────┤
│ SQLite (/data/leocrm.db) │
└─────────────────────────────────────────────────┘
```
## 2. Tech-Stack
- **Python 3.11**
- **FastAPI 0.115** — Web-Framework
- **Uvicorn 0.30** — ASGI-Server
- **SQLAlchemy 2.0** — ORM
- **Jinja2 3.1** — Templates
- **bcrypt 4.1** — Passwort-Hashing (über `passlib[bcrypt]`)
- **itsdangerous 2.2** — Session-Cookie-Signatur (über Starlette `SessionMiddleware`)
- **pytest 8.3** + **httpx 0.27** — Tests
- **SQLite 3** (in-Container, Volume `/data`)
- **Docker** (Multi-Stage optional; Single-Stage reicht für v0.1)
## 3. Schichten
### 3.1 Routes (app/routes/)
- `html_routes.py` — HTML-Endpoints, rendert Jinja2
- `api_routes.py` — JSON-Endpoints, Pydantic-Schemas
### 3.2 Services (app/services/)
- `auth_service.py``verify_password`, `authenticate_user`, `get_current_user_optional`
- `company_service.py``list_companies(q, limit, offset)`, `get_company(id)`, `create_company(data)`, `update_company(id, data)`, `delete_company(id)`
- `contact_service.py` — analog für Contacts, inkl. Cascade-Delete-Logik
### 3.3 DB (app/db/)
- `models.py` — SQLAlchemy-Modelle
- `session.py``engine = create_engine(...)`, `SessionLocal = sessionmaker(...)`, `get_db()` als FastAPI-Dependency
- `init_db.py``init_db()` erstellt Tabellen + Seed-Daten, idempotent
### 3.4 Schemas (app/schemas/)
- Pydantic-v2-Schemas für Input/Output (CompanyIn, CompanyOut, ContactIn, ContactOut, LoginIn)
## 4. Auth-Flow
1. Browser ruft `/login` → Formular
2. `POST /login` mit `username`+`password``auth_service.authenticate_user()` → bcrypt-Vergleich
3. Bei Erfolg: `request.session['user_id'] = user.id` (signed Cookie via Starlette-SessionMiddleware)
4. Redirect auf `/`
5. Alle anderen Routes prüfen `request.session.get('user_id')`; wenn None → 302 → `/login`
6. API macht es analog: `POST /api/auth/login` setzt Cookie, alle anderen API-Routes prüfen Session
`SECRET_KEY` wird aus ENV `LEOCRM_SECRET_KEY` gelesen; Fallback-Default nur für lokales Dev, in Coolify **Pflicht-ENV**.
## 5. Datenpersistenz
- SQLite-Datei: `/data/leocrm.db` (in Coolify als Volume gemountet)
- Schema-Migration: `Base.metadata.create_all(engine)` beim App-Start; Alembic-Migrationen sind v0.2
- Cascade: Contact.company_id → Company.id mit `ondelete='CASCADE'`; beim Company-Delete verschwinden alle Contacts
## 6. Deployment
```
Container: leocrm-app
Image: python:3.11-slim + pip install + copy app
Port: 8000
Volume: /data (persistent, SQLite)
ENV: LEOCRM_SECRET_KEY (required)
LEOCRM_DB_PATH (default /data/leocrm.db)
Health: GET /api/health
```
Coolify-Setup:
1. Forgejo-Webhook registriert auf `https://forgejo.media-on.de/Leopoldadmin/leocrm` Push auf `main`
2. Coolify-Service vom Typ "Docker Compose" mit `docker-compose.yml` aus dem Repo-Root
3. Domain: `leocrm.media-on.de` mit Let's-Encrypt-SSL
## 7. Sicherheit (v0.1 Minimum)
- bcrypt für Passwörter (kein Plain-Text)
- Session-Cookie signed, `httponly=True`, `samesite='lax'`, in Prod `secure=True`
- CSRF: für v0.1 ausgeschlossen (Login + GET-only-Pattern reicht für Single-User-Demo); v0.2 fügt CSRF-Tokens hinzu
- SQL-Injection: durch SQLAlchemy-ORM ausgeschlossen
- Input-Validierung: Pydantic-Schemas
- Default-User `admin/admin` ist dokumentiert und nur in Demo gedacht
## 8. Skalierbarkeit & Grenzen
- v0.1: < 100 Companies, < 1000 Contacts problemlos; darüber SQLite-Performance grenzwertig
- v0.2: Migration auf Postgres + Connection-Pool
- Stateless-App: horizontal skalierbar; SQLite-Volume wird zum Engpass → Postgres-Migration
-201
View File
@@ -1,201 +0,0 @@
# leocrm — Design (v0.1, Konkrete Specs)
## 1. Verzeichnisstruktur
```
leocrm/
├── README.md
├── LICENSE
├── .gitignore
├── pyproject.toml # Projekt-Metadaten, dependencies
├── requirements.txt # Pinned-Versionen für Reproduzierbarkeit
├── Dockerfile # Single-Stage, python:3.11-slim
├── docker-compose.yml # Coolify-kompatibel
├── .dockerignore
├── .env.example # LEOCRM_SECRET_KEY, LEOCRM_DB_PATH
├── docs/
│ ├── architecture.md # (siehe architecture.md)
│ ├── test_report.md # wird in B7 erzeugt
│ ├── runtime_report.md # wird in B7 erzeugt
│ └── api.md # Auto-generierte OpenAPI-Übersicht (manuell)
├── deploy/
│ ├── env.md # ENV-Variablen-Doku
│ ├── healthcheck.md # Coolify-Healthcheck-Config
│ ├── runbook.md # Runbook (Start/Stop/Logs/Restart)
│ └── rollback.md # Rollback-Procedure
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI-App-Factory, Middleware, Router-Mount
│ ├── config.py # Settings (pydantic-settings)
│ ├── db/
│ │ ├── __init__.py
│ │ ├── models.py # User, Company, Contact
│ │ ├── session.py # engine, SessionLocal, get_db
│ │ └── init_db.py # create_all + seed
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── auth.py # LoginIn
│ │ ├── company.py # CompanyIn, CompanyOut
│ │ └── contact.py # ContactIn, ContactOut
│ ├── services/
│ │ ├── __init__.py
│ │ ├── auth_service.py
│ │ ├── company_service.py
│ │ └── contact_service.py
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── html_routes.py
│ │ └── api_routes.py
│ ├── templates/
│ │ ├── base.html # Layout: Navbar + Container + Footer
│ │ ├── login.html
│ │ ├── dashboard.html
│ │ ├── companies/
│ │ │ ├── list.html
│ │ │ ├── form.html # new + edit
│ │ │ └── detail.html
│ │ └── contacts/
│ │ ├── form.html
│ │ └── detail.html
│ └── static/
│ └── style.css # Minimal-CSS, kein Build-Step
└── tests/
├── __init__.py
├── conftest.py # TestClient + DB-Fixture (sqlite:///:memory:)
├── test_health.py
├── test_auth.py
├── test_companies_api.py
├── test_contacts_api.py
└── test_html_smoke.py # Light HTML-Tests (Status, Redirects)
```
## 2. Konkrete Funktions-Signaturen
### app/services/auth_service.py
```python
def hash_password(plain: str) -> str: ...
def verify_password(plain: str, hashed: str) -> bool: ...
def authenticate_user(db: Session, username: str, password: str) -> User | None: ...
```
### app/services/company_service.py
```python
def list_companies(db: Session, q: str | None = None, limit: int = 50, offset: int = 0) -> list[Company]: ...
def get_company(db: Session, company_id: int) -> Company | None: ...
def create_company(db: Session, data: CompanyIn) -> Company: ...
def update_company(db: Session, company_id: int, data: CompanyIn) -> Company | None: ...
def delete_company(db: Session, company_id: int) -> bool: ... # cascade
def count_companies(db: Session) -> int: ...
```
### app/services/contact_service.py
```python
def list_contacts(db: Session, company_id: int | None = None, limit: int = 50, offset: int = 0) -> list[Contact]: ...
def get_contact(db: Session, contact_id: int) -> Contact | None: ...
def create_contact(db: Session, data: ContactIn) -> Contact: ... # Validierung: company muss existieren
def update_contact(db: Session, contact_id: int, data: ContactIn) -> Contact | None: ...
def delete_contact(db: Session, contact_id: int) -> bool: ...
def count_contacts(db: Session) -> int: ...
```
### app/routes/api_routes.py (Auszug)
```python
@router.get("/api/health")
def health(db: Session = Depends(get_db)) -> dict: ...
@router.post("/api/auth/login")
def login(payload: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)): ...
@router.post("/api/auth/logout")
def logout(request: Request): ...
@router.get("/api/companies")
def list_companies(q: str | None = None, limit: int = 50, offset: int = 0, db: Session = Depends(get_db)) -> list[CompanyOut]: ...
@router.post("/api/companies", status_code=201)
def create_company(payload: CompanyIn, db: Session = Depends(get_db)) -> CompanyOut: ...
@router.get("/api/companies/{cid}")
def get_company(cid: int, db: Session = Depends(get_db)) -> CompanyOut: ...
@router.patch("/api/companies/{cid}")
def update_company(cid: int, payload: CompanyIn, db: Session = Depends(get_db)) -> CompanyOut: ...
@router.delete("/api/companies/{cid}", status_code=204)
def delete_company(cid: int, db: Session = Depends(get_db)): ...
# Analog für /api/contacts
```
### app/routes/html_routes.py (Auszug)
```python
@router.get("/login")
def login_form(request: Request): ... # 200 wenn nicht eingeloggt, sonst redirect /
@router.post("/login")
def login_submit(payload: ..., request: Request, db: Session = Depends(get_db)): ...
@router.post("/logout")
def logout(request: Request): ...
@router.get("/")
def dashboard(request: Request, db: Session = Depends(get_db)): ... # Auth-Required
@router.get("/companies")
def companies_list(request: Request, q: str | None = None, db: Session = Depends(get_db)): ...
# ... analog /companies/new, /companies/{id}, /companies/{id}/edit, /companies/{id}/delete
# ... /contacts/new, /contacts/{id}, /contacts/{id}/edit, /contacts/{id}/delete
```
## 3. Middleware-Reihenfolge (app/main.py)
1. `SessionMiddleware(secret_key=..., https_only=in_prod)`
2. CORS nur in Dev (in Prod nicht nötig, da Same-Origin)
3. Custom `AuthRequiredMiddleware` für HTML: prüft Session auf allen Pfaden außer `["/login", "/static/*", "/api/health", "/api/auth/login"]`
4. API-Auth prüft jede Route via Dependency `current_user_required`
## 4. Templates (Jinja2)
### base.html
- Bootstrap-5 via CDN (kein npm-Build, da Single-Page-Container)
- Navbar: Logo "leocrm" + Links "Dashboard", "Firmen", "Kontakte" + User-Name + Logout-Form
- Block `content`
### login.html
- Form mit `username`, `password`, Submit "Anmelden"
- Fehlermeldung bei `?error=1`
### dashboard.html
- 3 Cards: Anzahl Firmen, Anzahl Kontakte, letzte 5 Einträge
- Link "Neue Firma" / "Neuer Kontakt"
### companies/list.html
- Tabelle mit Spalten Name, Stadt, Land, # Kontakte, Aktionen
- Suchform oben (Input + Submit)
- Pagination (limit/offset)
- Button "Neue Firma"
### companies/form.html
- Felder: Name*, Straße, PLZ, Stadt, Land, E-Mail, Telefon, Web, Notizen
- Submit + Cancel
### companies/detail.html
- Alle Felder als Definition-List
- Liste der Kontakte als Tabelle
- Button "Kontakt hinzufügen" (verlinkt auf `/contacts/new?company_id={id}`)
- Buttons "Bearbeiten", "Löschen"
### contacts/form.html + detail.html
- Analog, mit Dropdown/Input für company_id (Pflicht)
## 5. CSS (app/static/style.css)
- Minimal, nur Layout-Feinschliff (Buttons-Padding, Card-Spacing, Tabellen-Hover)
- Keine Custom-Designs, Bootstrap-Defaults reichen
## 6. ENV-Variablen
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `LEOCRM_SECRET_KEY` | (Dev-Fallback `"dev-insecure-key"`) | Session-Signing-Key; in Prod Pflicht |
| `LEOCRM_DB_PATH` | `/data/leocrm.db` | SQLite-Pfad |
| `LEOCRM_SEED_ON_START` | `true` | Wenn `true`, wird bei leerer DB geseedet |
## 7. OpenAPI / Swagger
FastAPI generiert automatisch `/docs` (Swagger UI) und `/openapi.json`. Beide bleiben aktiv, auch im Prod-Mode (kein Auth auf `/docs` für v0.1, dokumentiert als bekannte Einschränkung; v0.2 mit Auth).
## 8. Logging
- Uvicorn-Default-Logging (stdout) reicht für v0.1
- Kein File-Logging, kein Sentry
-121
View File
@@ -1,121 +0,0 @@
# leocrm — Requirements (v0.1, Mini-CRM, Single-Tenant)
## 1. Ziel
Ein schlankes, in sich geschlossenes Mini-CRM, das folgende Kern-Workflows abdeckt:
- Anmeldung (Login/Logout) per Session-Cookie
- Verwaltung von **Firmen** (Anlegen, Anzeigen, Bearbeiten, Löschen, Liste mit Suche)
- Verwaltung von **Kontaktpersonen** (Anlegen, Anzeigen, Bearbeiten, Löschen, Zuordnung zu Firma)
- Übersichts-Dashboard mit Anzahl Firmen, Kontakten und letzten Änderungen
Single-Tenant, eine Org, eine SQLite-Datei. Keine Rollen, keine Multi-User-Berechtigungen jenseits Login ja/nein.
## 2. Personas
- **Admin/Nutzer** (1 Person pro Org): meldet sich an, verwaltet Daten.
- **Entwickler**: betreibt die App lokal und in Coolify.
## 3. Funktionale Anforderungen (Must-Have)
| ID | Anforderung | Akzeptanzkriterium |
|----|-------------|---------------------|
| F-1 | Login-Formular | GET `/login` zeigt Formular; POST mit gültigen Credentials setzt Session-Cookie und leitet auf `/` weiter; ungültige Credentials → 401 + Fehlermeldung. |
| F-2 | Logout | POST `/logout` löscht Session und leitet auf `/login` weiter. |
| F-3 | Auth-Schutz | Alle Seiten außer `/login` und `/api/health` erfordern eine aktive Session; ohne Session → 302 → `/login`. |
| F-4 | Dashboard | GET `/` zeigt Anzahl Companies, Anzahl Contacts, letzte 5 Änderungen (Companies + Contacts). |
| F-5 | Companies-Liste | GET `/companies` zeigt Tabelle mit Name, Stadt, Land, Anzahl Contacts; Volltextsuche über Name/Stadt filtert. |
| F-6 | Company anlegen | GET `/companies/new` zeigt Formular; POST erstellt Datensatz, Redirect auf Detail. |
| F-7 | Company-Detail | GET `/companies/{id}` zeigt Stammdaten + Liste der zugeordneten Contacts + Link „Contact hinzufügen". |
| F-8 | Company bearbeiten | GET `/companies/{id}/edit` zeigt vorausgefülltes Formular; PATCH speichert, Redirect auf Detail. |
| F-9 | Company löschen | POST `/companies/{id}/delete` löscht inkl. zugeordneter Contacts (cascade). |
| F-10 | Contact anlegen | GET `/contacts/new?company_id={id}` zeigt Formular; POST erstellt Datensatz mit FK auf Company. |
| F-11 | Contact-Detail | GET `/contacts/{id}` zeigt alle Felder + Link zur Firma. |
| F-12 | Contact bearbeiten | GET `/contacts/{id}/edit` zeigt Formular; PATCH speichert. |
| F-13 | Contact löschen | POST `/contacts/{id}/delete` löscht. FK-Company bleibt erhalten. |
| F-14 | API parallel zu HTML | Für jede HTML-Aktion gibt es einen äquivalenten JSON-API-Endpoint (siehe API-Spec unten), damit Tests Headless durchlaufen können. |
| F-15 | Health-Endpoint | GET `/api/health` → 200 `{"status":"ok","db":"ok"}`. |
| F-16 | Demo-Seed | Beim ersten Start wird automatisch 1 Admin-User (`admin`/`admin`), 2 Beispiel-Firmen und 3 Beispiel-Kontakte angelegt, falls DB leer. |
## 4. Datenmodell
```
User
id INTEGER PK
username TEXT UNIQUE NOT NULL
password_hash TEXT NOT NULL
created_at TIMESTAMP DEFAULT now
Company
id INTEGER PK
name TEXT NOT NULL
street TEXT
zip TEXT
city TEXT
country TEXT DEFAULT 'DE'
email TEXT
phone TEXT
website TEXT
notes TEXT
created_at TIMESTAMP DEFAULT now
updated_at TIMESTAMP DEFAULT now
Contact
id INTEGER PK
company_id INTEGER FK -> Company.id ON DELETE CASCADE
first_name TEXT NOT NULL
last_name TEXT NOT NULL
email TEXT
phone TEXT
position TEXT
notes TEXT
created_at TIMESTAMP DEFAULT now
updated_at TIMESTAMP DEFAULT now
```
## 5. API-Spec (für Headless-Tests)
| Methode | Pfad | Body / Params | Erfolg | Fehler |
|--------|------|---------------|--------|--------|
| GET | `/api/health` | — | 200 | — |
| POST | `/api/auth/login` | `{username,password}` | 200 + Set-Cookie | 401 |
| POST | `/api/auth/logout` | — | 204 | — |
| GET | `/api/companies` | `?q=&limit=&offset=` | 200 List | — |
| POST | `/api/companies` | JSON | 201 | 400 |
| GET | `/api/companies/{id}` | — | 200 | 404 |
| PATCH | `/api/companies/{id}` | JSON | 200 | 404, 400 |
| DELETE | `/api/companies/{id}` | — | 204 | 404 |
| GET | `/api/contacts` | `?company_id=` | 200 | — |
| POST | `/api/contacts` | JSON | 201 | 400, 404 (Company) |
| GET | `/api/contacts/{id}` | — | 200 | 404 |
| PATCH | `/api/contacts/{id}` | JSON | 200 | 404 |
| DELETE | `/api/contacts/{id}` | — | 204 | 404 |
## 6. Nicht-Ziele (Out of Scope für v0.1)
- Multi-Tenant / Multi-Org
- Rollen-/Rechtesystem (jeder Login-User darf alles)
- E-Mail-Versand, Datei-Upload, Bilder
- Audit-Log, Soft-Delete, Papierkorb
- 2FA, OAuth, Passwort-Reset per Mail
- Internationalisierung (deutsche UI, hartkodiert)
- Mobile-Apps, Push-Notifications
- Reporting, Dashboards mit Charts (nur Counts)
## 7. Annahmen
- Coolify-Host `server.media-on.de` ist erreichbar; Coolify-Token liegt in der Plugin-Config
- Forgejo-Host `forgejo.media-on.de` ist erreichbar; Token liegt in der Plugin-Config
- SQLite reicht für v0.1 (< 100k Datensätze), Migration auf Postgres später möglich
- Deployment erfolgt als einzelner Coolify-Service (Docker Compose mit 1 Container)
- Default-Admin-Credentials `admin/admin` werden beim ersten Start geseeded und sind nur in v0.1; im v0.2 verpflichtender Passwort-Change
## 8. Qualitätskriterien
- **Type-Check**: `python -m mypy app/` ohne Fehler (oder bewusst als nicht-blockend dokumentiert)
- **Tests**: `pytest` mit mindestens **15 Tests**, alle grün (Health, Auth happy + fail, Company CRUD 5, Contact CRUD 5, Cascade-Delete 1, Search 1)
- **Server-Start**: `uvicorn app.main:app` startet ohne ImportError oder 500er
- **Health-Endpoint**: 200, nicht 500
- **API-Endpoints**: alle 13 oben genannten Endpunkte liefern die spezifizierten Status-Codes
- **Build**: Docker-Image buildet ohne Fehler, Image-Größe < 300 MB
- **Deploy**: Coolify-Service ist `running:healthy` nach `POST /api/v1/deploy`
- **Git**: alle Änderungen committed, `git status` ist sauber
-121
View File
@@ -1,121 +0,0 @@
# leocrm — Task-Graph (v0.1)
## Phasen & Reihenfolge
```
B0 Setup B1 Requirements B2 Architecture B3 Scaffold B4 Auth B5 Companies
───────── ───────────── ────────────── ────────── ────── ──────────
repo+git requirements.md architecture.md pyproject+req.txt user-model company-model
.env.example design.md (parallel) app/main.py auth_service company_service
.gitignore tasks.md Dockerfile /api/auth/* /api/companies/*
README docker-compose login-form companies-list
init-commit plan-approval commit+pushed logout form+detail
→ B4 ↓
B6 Contacts
───────────
contact-model
contact_service
/api/contacts/*
contacts-form+detail
B7 Test+Runtime
───────────────
15+ pytest tests
server-start
/api/health = 200
test_report.md
runtime_report.md
B8 Deploy-Prep
──────────────
coolify project
compose verified
env.md, runbook.md
rollback.md
quality-reviewer
B9 Deploy
────────
coolify service
deploy trigger
running:healthy
release_auditor
user-report
```
## Tasks (atomar, klein, testbar)
| ID | Block | Task | Abhängigkeit | Geschätzter Aufwand |
|----|-------|------|--------------|---------------------|
| T-01 | B3 | pyproject.toml + requirements.txt mit allen Pinned-Deps | — | 5 min |
| T-02 | B3 | app/main.py Skeleton (FastAPI-App, leere Router, Health-Endpoint) | T-01 | 5 min |
| T-03 | B3 | Dockerfile + docker-compose.yml + .dockerignore + .env.example | T-01 | 5 min |
| T-04 | B3 | README.md + .gitignore + LICENSE | — | 3 min |
| T-05 | B3 | Init-Commit + Push zu Forgejo (branch=main) | T-01..T-04 | 2 min |
| T-06 | B4 | app/db/models.py (User, Company, Contact) | T-02 | 8 min |
| T-07 | B4 | app/db/session.py + app/db/init_db.py (mit seed) | T-06 | 5 min |
| T-08 | B4 | app/schemas/auth.py (LoginIn) + app/services/auth_service.py | T-06 | 5 min |
| T-09 | B4 | app/routes/api_routes.py: /api/health + /api/auth/login + /api/auth/logout | T-08 | 8 min |
| T-10 | B4 | app/routes/html_routes.py: /login + /logout + base.html + login.html | T-09 | 8 min |
| T-11 | B4 | Auth-Middleware + current_user-Dependency | T-09, T-10 | 5 min |
| T-12 | B4 | Commit B4 + Push + lokaler Server-Test | T-11 | 5 min |
| T-13 | B5 | app/schemas/company.py (CompanyIn, CompanyOut) | T-06 | 3 min |
| T-14 | B5 | app/services/company_service.py (list, get, create, update, delete, count) | T-13 | 8 min |
| T-15 | B5 | app/routes/api_routes.py: /api/companies/* (alle 5 Endpoints) | T-14 | 8 min |
| T-16 | B5 | app/routes/html_routes.py: /companies + /companies/new + /companies/{id} + /companies/{id}/edit + /companies/{id}/delete | T-15 | 12 min |
| T-17 | B5 | app/templates/companies/*.html + dashboard.html mit Companies-Count | T-16 | 10 min |
| T-18 | B5 | Commit B5 + Push | T-17 | 2 min |
| T-19 | B6 | app/schemas/contact.py | T-06 | 3 min |
| T-20 | B6 | app/services/contact_service.py | T-19 | 8 min |
| T-21 | B6 | app/routes/api_routes.py: /api/contacts/* | T-20 | 8 min |
| T-22 | B6 | app/routes/html_routes.py: /contacts/* + Cascade-Delete-View | T-21 | 10 min |
| T-23 | B6 | app/templates/contacts/*.html + dashboard.html mit Contacts-Count | T-22 | 8 min |
| T-24 | B6 | Commit B6 + Push | T-23 | 2 min |
| T-25 | B7 | tests/conftest.py (TestClient + in-memory-DB) | T-24 | 5 min |
| T-26 | B7 | tests/test_health.py (2 Tests) | T-25 | 3 min |
| T-27 | B7 | tests/test_auth.py (3 Tests: happy, wrong-pw, no-creds) | T-25 | 5 min |
| T-28 | B7 | tests/test_companies_api.py (5 Tests) | T-25 | 8 min |
| T-29 | B7 | tests/test_contacts_api.py (5 Tests + 1 Cascade-Test) | T-25 | 10 min |
| T-30 | B7 | tests/test_html_smoke.py (3 Tests: redirect-when-anon, login-form, dashboard-renders) | T-25 | 5 min |
| T-31 | B7 | pytest ausführen, alle 18 Tests grün, test_report.md schreiben | T-26..T-30 | 5 min |
| T-32 | B7 | uvicorn app.main:app starten, /api/health=200, /api/auth/login, 5 Endpoints testen, runtime_report.md schreiben | T-31 | 5 min |
| T-33 | B7 | Commit B7 + Push | T-32 | 2 min |
| T-34 | B8 | deploy/env.md (Variablen-Doku) | — | 3 min |
| T-35 | B8 | deploy/healthcheck.md (Coolify-Config) | — | 3 min |
| T-36 | B8 | deploy/runbook.md (Start/Stop/Logs/Restart/Update) | — | 5 min |
| T-37 | B8 | deploy/rollback.md (Schritte + Coolify-Rollback) | — | 5 min |
| T-38 | B8 | docker build lokal: Image < 300 MB, Container startet | T-03 | 5 min |
| T-39 | B8 | quality_reviewer über alle Artefakte, findings.md | T-33..T-38 | 10 min |
| T-40 | B8 | Coolify: Projekt anlegen via coolify_action=projects_create | — | 3 min |
| T-41 | B8 | Coolify: Service/Compose-Ressource erstellen via coolify_action=resources_create | T-40 | 5 min |
| T-42 | B8 | Commit B8 + Push | T-41 | 2 min |
| T-43 | B9 | Coolify: ENV LEOCRM_SECRET_KEY setzen via coolify_action=security_envs_update | T-41 | 2 min |
| T-44 | B9 | Coolify: Domain setzen (PATCH application) | T-41 | 2 min |
| T-45 | B9 | Coolify: Deploy auslösen POST /api/v1/deploy | T-44 | 3 min |
| T-46 | B9 | Polling: Service running:healthy | T-45 | 3 min |
| T-47 | B9 | Public-Smoke: GET https://leocrm.media-on.de/api/health → 200 | T-46 | 2 min |
| T-48 | B9 | release_auditor: final audit, next-steps.md | T-47 | 5 min |
| T-49 | B9 | Final-Commit + User-Report | T-48 | 3 min |
## Quality-Gates (zwischen Blocks)
| Gate | Trigger | Aktion |
|------|---------|--------|
| G1 | B0 → B1 | project_registry.register, orchestrator_state.phase=intake |
| G2 | B1 → B2 | quality_reviewer über requirements.md, design.md, tasks.md |
| G3 | B2 → B3 | plan_mode_guard set mode=implementation_allowed (nach User-Approval) |
| G4 | B3 → B4 | docker build OK, app startet, /api/health=200 |
| G5 | B4 → B5 | 3 Auth-Tests grün, Login-HTML rendert |
| G6 | B5 → B6 | 5 Company-API-Tests grün, List/Detail-HTML rendert |
| G7 | B6 → B7 | 5 Contact-API-Tests grün, Cascade-Delete-Test grün |
| G8 | B7 → B8 | 18 Tests grün, Runtime-Report geschrieben, plan_mode=runtime_verification_allowed |
| G9 | B8 → B9 | quality_reviewer OK, plan_mode=deployment_preparation_allowed → release_handoff_allowed |
| G10 | B9 → Done | release_auditor OK, /api/health public=200 |
## Parallelisierungsmöglichkeiten
- B4-T-09 (api) und T-10 (html) parallelisierbar
- B5-T-15 (api) und T-16 (html) parallelisierbar
- B6-T-21 (api) und T-22 (html) parallelisierbar
- B7: alle test_*.py parallel schreibbar
- B8: alle deploy/*.md parallel schreibbar