Initial commit: Wochenplaner Backend + Docker Setup

This commit is contained in:
Wochenplaner Deploy
2026-06-01 11:23:04 +00:00
commit e5f44112d0
7 changed files with 460 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
.env
.env.*
backend/data/
*.db
venv/
.venv/
.git/
+16
View File
@@ -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"]
+13
View File
@@ -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
+341
View File
@@ -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)
+49
View File
@@ -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)
+7
View File
@@ -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
+24
View File
@@ -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