Initial commit: Wochenplaner Backend + Docker Setup
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.env.*
|
||||
backend/data/
|
||||
*.db
|
||||
venv/
|
||||
.venv/
|
||||
.git/
|
||||
+16
@@ -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"]
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user