diff --git a/.coverage b/.coverage deleted file mode 100644 index c7f0898..0000000 Binary files a/.coverage and /dev/null differ diff --git a/app.py b/app.py deleted file mode 100644 index 5a4972d..0000000 --- a/app.py +++ /dev/null @@ -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//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//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//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//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//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//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) diff --git a/specs/current/architecture.md b/specs/current/architecture.md deleted file mode 100644 index 66661ed..0000000 --- a/specs/current/architecture.md +++ /dev/null @@ -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 diff --git a/specs/current/design.md b/specs/current/design.md deleted file mode 100644 index 85ff1c1..0000000 --- a/specs/current/design.md +++ /dev/null @@ -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 diff --git a/specs/current/requirements.md b/specs/current/requirements.md deleted file mode 100644 index 4313bd3..0000000 --- a/specs/current/requirements.md +++ /dev/null @@ -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 diff --git a/specs/current/tasks.md b/specs/current/tasks.md deleted file mode 100644 index 6d7b8c9..0000000 --- a/specs/current/tasks.md +++ /dev/null @@ -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