From 2e6c2c5c17b43acd874d07ce2616d99702baf95f Mon Sep 17 00:00:00 2001 From: leocrm-bot Date: Sun, 28 Jun 2026 12:36:10 +0200 Subject: [PATCH] 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 --- .coverage | Bin 73728 -> 0 bytes app.py | 256 ---------------------------------- specs/current/architecture.md | 116 --------------- specs/current/design.md | 201 -------------------------- specs/current/requirements.md | 121 ---------------- specs/current/tasks.md | 121 ---------------- 6 files changed, 815 deletions(-) delete mode 100644 .coverage delete mode 100644 app.py delete mode 100644 specs/current/architecture.md delete mode 100644 specs/current/design.md delete mode 100644 specs/current/requirements.md delete mode 100644 specs/current/tasks.md diff --git a/.coverage b/.coverage deleted file mode 100644 index c7f089829be517abdae21e54ca354b859911f690..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 73728 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 N(kkxH^JH<~{{c0_iT?lq 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