commit e5f44112d0dccd1db2c556e4bbf8ab64d0ec6094 Author: Wochenplaner Deploy Date: Mon Jun 1 11:23:04 2026 +0000 Initial commit: Wochenplaner Backend + Docker Setup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45fdd --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +.env +.env.* +backend/data/ +*.db +venv/ +.venv/ +.git/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ef25eb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Kein Systemd, keine Ports 80/443 - alles clean nach Coolify-Regeln + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ . + +RUN mkdir -p /app/data && chmod 777 /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..45925a0 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Wochenplaner Pro + +Terminplaner & Aufgabenverwaltung mit Putzplan-Funktion. + +## Stack +- **Backend:** FastAPI + SQLite + JWT Auth +- **Frontend:** React (SPA in HTML) +- **Deployment:** Docker Compose auf Coolify + +## Deployment +- URL: https://reinigung.media-on.de +- Docker Compose in `/` +- Persistent Volume für SQLite-Datenbank diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..82bf490 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,341 @@ +import json, uuid, os +from datetime import datetime, timedelta +from typing import Optional, List + +from fastapi import FastAPI, Depends, HTTPException, status, Request, Body +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from passlib.context import CryptContext +from pydantic import BaseModel + +from models import init_db, SessionLocal, User, AppData, LoginAttempt + +# --- App Setup --- +app = FastAPI(title="Wochenplaner API", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# --- Security --- +SECRET_KEY = os.environ.get("JWT_SECRET", "wochenplaner-jwt-secret-2024") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer(auto_error=False) + +# --- Pydantic Models --- +class LoginRequest(BaseModel): + username: str + password: str + +class TokenResponse(BaseModel): + token: str + user: dict + data: Optional[dict] = None + +class UserCreate(BaseModel): + id: Optional[str] = None + username: str + password: str + role: str = "user" + +class AppDataModel(BaseModel): + resources: Optional[list] = None + tasks: Optional[dict] = None + calendars: Optional[list] = None + generalConfig: Optional[dict] = None + views: Optional[list] = None + printOptions: Optional[dict] = None + globalRowHeight: Optional[int] = None + baseFontSize: Optional[int] = None + headerOptions: Optional[dict] = None + hiddenResourceIds: Optional[list] = None + updatedAt: Optional[str] = None + _lastKnownVersion: Optional[str] = None + +# --- Helper Functions --- +def create_access_token(data: dict) -> str: + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + +def verify_token(token: str) -> dict: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + raise HTTPException(status_code=401, detail="Ungültiger Token") + +def get_current_user(request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> dict: + token = None + if credentials: + token = credentials.credentials + if not token: + token = request.query_params.get("token") + if not token: + raise HTTPException(status_code=401, detail="Nicht authentifiziert") + return verify_token(token) + +def require_role(role: str): + def checker(payload: dict = Depends(get_current_user)): + user_role = payload.get("role", "user") + if user_role == "admin": + return payload + if user_role != role: + raise HTTPException(status_code=403, detail=f"Rolle '{role}' erforderlich") + return payload + return checker + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# --- Init DB --- +init_db() + +# Create default users if not exist +db = SessionLocal() +try: + if not db.query(User).filter(User.username == "admin").first(): + db.add(User(id=str(uuid.uuid4()), username="admin", password_hash=pwd_context.hash("admin"), role="admin")) + db.add(User(id=str(uuid.uuid4()), username="user", password_hash=pwd_context.hash("user"), role="user")) + db.add(User(id=str(uuid.uuid4()), username="viewer", password_hash=pwd_context.hash("viewer"), role="viewer")) + db.commit() + + # Init AppData row + if not db.query(AppData).filter(AppData.key == "main").first(): + default_config = json.dumps({ + "appTitle": "Wochenplaner", + "resourceColTitle": "Bereich", + "dayColors": {"1": "#ffffff", "2": "#ffffff", "3": "#ffffff", "4": "#ffffff", "5": "#ffffff", "6": "#f8fafc", "0": "#f8fafc"} + }) + default_header = json.dumps({ + "weekday": {"fontSize": 10, "color": "#64748b", "bold": False}, + "date": {"fontSize": 12, "color": "#0f172a", "bold": True} + }) + app_data = AppData( + key="main", resources="[]", tasks="{}", calendars="[]", + general_config=default_config, views="[]", + print_options=json.dumps({"fontSize": 12, "showCalendars": True, "printRowHeight": 28}), + global_row_height=36, base_font_size=14, + header_options=default_header, hidden_resource_ids="[]", + updated_at=datetime.utcnow() + ) + db.add(app_data) + db.commit() +finally: + db.close() + +def format_app_data(app_data: AppData) -> dict: + return { + "resources": json.loads(app_data.resources or "[]"), + "tasks": json.loads(app_data.tasks or "{}"), + "calendars": json.loads(app_data.calendars or "[]"), + "generalConfig": json.loads(app_data.general_config or "{}"), + "views": json.loads(app_data.views or "[]"), + "printOptions": json.loads(app_data.print_options or "{}"), + "globalRowHeight": app_data.global_row_height or 36, + "baseFontSize": app_data.base_font_size or 14, + "headerOptions": json.loads(app_data.header_options or "{}"), + "hiddenResourceIds": json.loads(app_data.hidden_resource_ids or "[]"), + "updatedAt": app_data.updated_at.isoformat() if app_data.updated_at else None + } + +# === AUTH ENDPOINTS === +@app.post("/api/login") +def login(req: LoginRequest, request: Request): + db = SessionLocal() + try: + user = db.query(User).filter(User.username == req.username).first() + + # Rate limiting + recent_fails = db.query(LoginAttempt).filter( + LoginAttempt.username == req.username, + LoginAttempt.attempted_at > datetime.utcnow() - timedelta(minutes=10), + LoginAttempt.success == False + ).count() + + if recent_fails >= 5: + db.add(LoginAttempt(username=req.username, success=False)) + db.commit() + raise HTTPException(status_code=429, detail="Zu viele Fehlversuche. Bitte 10 Minuten warten.") + + if not user or not pwd_context.verify(req.password, user.password_hash): + db.add(LoginAttempt(username=req.username, success=False)) + db.commit() + raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten") + + db.add(LoginAttempt(username=req.username, success=True)) + db.commit() + + token = create_access_token({"sub": user.id, "username": user.username, "role": user.role}) + + app_data = db.query(AppData).filter(AppData.key == "main").first() + data = format_app_data(app_data) if app_data else {} + + return TokenResponse( + token=token, + user={"id": user.id, "username": user.username, "role": user.role}, + data=data + ) + finally: + db.close() + +@app.get("/api/me") +def get_me(payload: dict = Depends(get_current_user)): + return {"user": payload} + +# === USER MANAGEMENT === +@app.get("/api/users") +def get_users(payload: dict = Depends(require_role("admin"))): + db = SessionLocal() + try: + users = db.query(User).all() + return [{"id": u.id, "username": u.username, "role": u.role} for u in users] + finally: + db.close() + +@app.post("/api/users") +def create_user(user_data: UserCreate, payload: dict = Depends(require_role("admin"))): + db = SessionLocal() + try: + if db.query(User).filter(User.username == user_data.username).first(): + raise HTTPException(400, "Benutzername existiert bereits") + new_user = User( + id=user_data.id or str(uuid.uuid4()), + username=user_data.username, + password_hash=pwd_context.hash(user_data.password), + role=user_data.role if user_data.role in ["admin", "user", "viewer"] else "user" + ) + db.add(new_user) + db.commit() + return {"id": new_user.id, "username": new_user.username, "role": new_user.role} + finally: + db.close() + +@app.put("/api/users/{user_id}") +def update_user(user_id: str, user_data: UserCreate, payload: dict = Depends(require_role("admin"))): + db = SessionLocal() + try: + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(404, "Benutzer nicht gefunden") + if user_data.username: + user.username = user_data.username + if user_data.password: + user.password_hash = pwd_context.hash(user_data.password) + if user_data.role: + user.role = user_data.role + db.commit() + return {"id": user.id, "username": user.username, "role": user.role} + finally: + db.close() + +@app.delete("/api/users/{user_id}") +def delete_user(user_id: str, payload: dict = Depends(require_role("admin"))): + db = SessionLocal() + try: + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(404, "Benutzer nicht gefunden") + admin_count = db.query(User).filter(User.role == "admin").count() + if user.role == "admin" and admin_count <= 1: + raise HTTPException(400, "Kann letzten Admin nicht löschen") + db.delete(user) + db.commit() + return {"message": "Benutzer gelöscht"} + finally: + db.close() + +@app.put("/api/profile") +def update_profile(data: dict = Body(...), payload: dict = Depends(get_current_user)): + db = SessionLocal() + try: + user = db.query(User).filter(User.id == payload["sub"]).first() + if not user: + raise HTTPException(404, "Benutzer nicht gefunden") + if "username" in data: + user.username = data["username"] + if "password" in data: + user.password_hash = pwd_context.hash(data["password"]) + db.commit() + return {"id": user.id, "username": user.username, "role": user.role} + finally: + db.close() + +# === DATA ENDPOINTS === +@app.get("/api/data") +def get_all_data(payload: dict = Depends(get_current_user)): + db = SessionLocal() + try: + app_data = db.query(AppData).filter(AppData.key == "main").first() + return format_app_data(app_data) + finally: + db.close() + +@app.post("/api/data") +def save_all_data(data: AppDataModel, payload: dict = Depends(get_current_user)): + user_role = payload.get("role", "user") + if user_role == "viewer": + raise HTTPException(status_code=403, detail="Viewer dürfen nicht speichern") + + db = SessionLocal() + try: + app_data = db.query(AppData).filter(AppData.key == "main").first() + if not app_data: + raise HTTPException(404) + + if data._lastKnownVersion and app_data.updated_at: + if data._lastKnownVersion != app_data.updated_at.isoformat(): + raise HTTPException(409, detail="Konflikt: Daten auf Server sind neuer!") + + if data.resources is not None: + app_data.resources = json.dumps(data.resources) + if data.tasks is not None: + app_data.tasks = json.dumps(data.tasks) + if data.calendars is not None: + app_data.calendars = json.dumps(data.calendars) + if data.generalConfig is not None: + app_data.general_config = json.dumps(data.generalConfig) + if data.views is not None: + app_data.views = json.dumps(data.views) + if data.printOptions is not None: + app_data.print_options = json.dumps(data.printOptions) + if data.globalRowHeight is not None: + app_data.global_row_height = data.globalRowHeight + if data.baseFontSize is not None: + app_data.base_font_size = data.baseFontSize + if data.headerOptions is not None: + app_data.header_options = json.dumps(data.headerOptions) + if data.hiddenResourceIds is not None: + app_data.hidden_resource_ids = json.dumps(data.hiddenResourceIds) + + app_data.updated_at = datetime.utcnow() + app_data.updated_by = payload.get("username") + db.commit() + + return format_app_data(app_data) + finally: + db.close() + +# === HEALTH === +@app.get("/health") +def health(): + return {"status": "ok", "time": datetime.utcnow().isoformat()} + +# === STARTUP === +if __name__ == "__main__": + import uvicorn + port = int(os.environ.get("PORT", 8000)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..15432c2 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,49 @@ +from sqlalchemy import create_engine, Column, String, Text, Integer, Boolean, DateTime +from sqlalchemy.orm import declarative_base, sessionmaker, Session +from datetime import datetime +import json +import os + +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") +os.makedirs(DATA_DIR, exist_ok=True) +DATABASE_URL = f"sqlite:///{DATA_DIR}/wochenplaner.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +class User(Base): + __tablename__ = "users" + id = Column(String, primary_key=True) + username = Column(String, unique=True, index=True, nullable=False) + password_hash = Column(String, nullable=False) + role = Column(String, default="user") + created_at = Column(DateTime, default=datetime.utcnow) + +class AppData(Base): + __tablename__ = "app_data" + id = Column(Integer, primary_key=True, autoincrement=True) + key = Column(String, unique=True, default="main") + resources = Column(Text, default="[]") + tasks = Column(Text, default="{}") + calendars = Column(Text, default="[]") + general_config = Column(Text, default="{}") + views = Column(Text, default="[]") + print_options = Column(Text, default="{}") + global_row_height = Column(Integer, default=36) + base_font_size = Column(Integer, default=14) + header_options = Column(Text, default="{}") + hidden_resource_ids = Column(Text, default="[]") + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + updated_by = Column(String, nullable=True) + +class LoginAttempt(Base): + __tablename__ = "login_attempts" + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String, index=True) + ip_address = Column(String, nullable=True) + attempted_at = Column(DateTime, default=datetime.utcnow) + success = Column(Boolean, default=False) + +def init_db(): + Base.metadata.create_all(bind=engine) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..90ddaba --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pydantic==2.7.0 +aiosqlite==0.20.0 +python-multipart==0.0.9 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e610728 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile + image: wochenplaner-backend:latest + container_name: wochenplaner + restart: unless-stopped + + # Kein ports: 80/443 - Traefik belegt diese + # Port 8000 für API - identischer Host/Container-Port + ports: + - "8000:8000" + + environment: + - PORT=8000 + - JWT_SECRET=${JWT_SECRET:-wochenplaner-jwt-secret-change-me} + + volumes: + - wochenplaner_data:/app/data + +volumes: + wochenplaner_data: + driver: local