autokanban/app/main.py

270 lines
12 KiB
Python
Raw Permalink Normal View History

2025-10-20 22:41:13 +02:00
from fastapi import FastAPI, Request, Form, status, Response
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from typing import List
from pydantic import BaseModel
import uuid
import escpos.printer
import os
import json
from pathlib import Path
from app.models import Task
from dotenv import load_dotenv
from PIL import Image, ImageDraw, ImageFont
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key="CHANGE_THIS_SECRET")
templates = Jinja2Templates(directory="app/templates")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.mount("/out", StaticFiles(directory="out"), name="out")
TASKS_FILE = Path("data/tasks.json")
DEBUG_PRINT_TO_IMAGE = True # Set to True to enable card image generation instead of real printing
OUT_DIR = Path("out")
OUT_DIR.mkdir(exist_ok=True)
CARD_WIDTH_PX = 354 # 58mm * 300dpi / 25.4
CARD_HEIGHT_PX = 236 # ~40mm * 300dpi / 25.4 (adjust as needed)
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" # Change to a font path available on your system
# Font paths for card preview (use your actual font files)
FONT_BOLD = "app/static/fonts/HealTheWebB-Regular.otf"
FONT_REGULAR = "app/static/fonts/HealTheWebA-Regular.otf"
FA_FONT = "app/static/fonts/fontawesome-free-7.1.0-desktop/otfs/Font Awesome 7 Free-Solid-900.otf"
# Simple keyword to FontAwesome unicode mapping
KEYWORD_ICONS = [
(['kaffee', 'coffee'], '\uf0f4'), # fa-coffee
(['druck', 'print', 'drucker', 'printer'], '\uf02f'), # fa-print
(['flipchart', 'tafel', 'whiteboard'], '\uf5da'), # fa-chalkboard
(['material', 'paket', 'box', 'lieferung', 'liefer'], '\uf466'), # fa-box
(['reinigung', 'clean', 'putz', 'wischen'], '\uf2f9'), # fa-broom
(['user', 'person', 'benutzer', 'name'], '\uf007'), # fa-user
(['aufgabe', 'task', 'todo'], '\uf0ae'), # fa-tasks
(['reparatur', 'reparieren', 'repair', 'defekt', 'fix', 'wartung', 'maintenance'], '\uf0ad'), # fa-wrench
(['upgrade', 'update', 'aufrüsten', 'erneuern', 'verbessern'], '\uf0ee'), # fa-arrow-up
(['laser', 'lasercutter'], '\uf0c3'), # fa-cut
(['3d', 'druck', 'drucker', '3d-druck', '3d-printer'], '\uf1b2'), # fa-cube
(['cnc', 'fräse', 'fräsen', 'fräser'], '\uf85c'), # fa-tools
(['löt', 'löten', 'solder'], '\uf5fc'), # fa-tools
(['computer', 'pc', 'laptop', 'rechner'], '\uf109'), # fa-desktop
(['software', 'programm', 'app', 'installation'], '\uf121'), # fa-code
(['kabel', 'stecker', 'anschluss'], '\uf1e6'), # fa-plug
(['werkzeug', 'tool', 'tools', 'schraubenzieher', 'hammer'], '\uf7d9'), # fa-hammer
(['strom', 'elektrik', 'elektronik', 'spannung'], '\uf0e7'), # fa-bolt
(['sensor', 'arduino', 'microcontroller', 'raspberry', 'pi', 'esp32'], '\uf2db'), # fa-microchip
(['holz', 'wood', 'säge', 'sägen'], '\uf6e3'), # fa-tree
(['metall', 'blech', 'schweißen', 'schweissen'], '\uf670'), # fa-industry
(['projekt', 'projektarbeit'], '\uf542'), # fa-project-diagram
]
def load_tasks():
if TASKS_FILE.exists():
with open(TASKS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return [Task(**item) for item in data]
return []
def save_tasks():
with open(TASKS_FILE, "w", encoding="utf-8") as f:
json.dump([t.dict() for t in tasks], f, ensure_ascii=False, indent=2)
tasks: List[Task] = load_tasks()
load_dotenv()
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") # Must be set in .env
N_APPROVED = 5 # Number of last approved tasks to show
def get_icon_for_task(content):
c = content.lower()
for keywords, icon in KEYWORD_ICONS:
if any(k in c for k in keywords):
return icon
return '\uf328' # fa-sticky-note as default
@app.get("/")
def index(request: Request):
approved_tasks = [t for t in tasks if t.status == "approved"][-N_APPROVED:]
login_result = request.session.pop("login_result", None)
print_result = request.session.pop("print_result", None)
preview_image = request.session.pop("preview_image", None)
return templates.TemplateResponse(
"index.html",
{
"request": request,
"tasks": tasks,
"admin": request.session.get("admin", False),
"approved_tasks": approved_tasks,
"login_result": login_result,
"print_result": print_result,
"preview_image": preview_image,
},
)
@app.post("/submit")
def submit_task(request: Request, content: str = Form(...), user: str = Form(...), priority: int = Form(...)):
task = Task.create(content=content, user=user, priority=priority)
tasks.append(task)
save_tasks()
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/approve/{task_id}")
def approve_task(request: Request, task_id: str):
if not request.session.get("admin"):
return Response("Nicht autorisiert", status_code=401)
for task in tasks:
if task.id == task_id and task.status == "pending":
task.status = "approved"
save_tasks()
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
import logging
def load_font(path, size, fallback=None, font_label=None):
try:
return ImageFont.truetype(path, size)
except Exception as e:
logging.error(f"Failed to load font '{path}' ({font_label or ''}): {e}")
if fallback:
logging.info(f"Falling back to default font for {font_label or path}")
return fallback
return ImageFont.load_default()
@app.post("/print/{task_id}")
def print_task(request: Request, task_id: str):
msg = None
preview_img = None
font_error_msgs = []
for task in tasks:
if task.id == task_id:
if (task.status == "approved") or (task.status == "printed" and request.session.get("admin")):
try:
if DEBUG_PRINT_TO_IMAGE:
# Generate styled card image with icon
img = Image.new("L", (CARD_WIDTH_PX, CARD_HEIGHT_PX), 255)
draw = ImageDraw.Draw(img)
draw.rectangle([(0,0),(CARD_WIDTH_PX-1,CARD_HEIGHT_PX-1)], outline=0, width=4)
# Robust font loading
font_title = load_font(FONT_BOLD, 36, font_label="title")
font_label_f = load_font(FONT_BOLD, 18, font_label="label")
font_text = load_font(FONT_REGULAR, 22, font_label="text")
font_icon = load_font(FA_FONT, 48, font_label="icon")
# Prepare content for line wrapping
import textwrap
y = 24
max_text_width = CARD_WIDTH_PX - 40
content_lines = []
if font_title:
wrapper = textwrap.TextWrapper(width=24)
lines = wrapper.wrap(task.content)
for line in lines:
# Check pixel width, wrap further if needed
while font_title.getlength(line) > max_text_width:
# Reduce by one word at a time
split = line.rsplit(' ', 1)
if len(split) == 2:
content_lines.append(split[0])
line = split[1]
else:
# Single long word
content_lines.append(line[:20])
line = line[20:]
content_lines.append(line)
else:
content_lines = textwrap.wrap(task.content, width=24)
# Center lines vertically
# Calculate text heights using getbbox
def get_text_height(font, text):
try:
bbox = font.getbbox(text)
return bbox[3] - bbox[1]
except Exception:
return 24
line_heights = [get_text_height(font_title, line) for line in content_lines]
total_text_height = sum(line_heights) + (len(content_lines)-1)*2
y = (CARD_HEIGHT_PX - total_text_height) // 2 - 10
for idx, line in enumerate(content_lines):
try:
w = font_title.getlength(line)
except Exception:
w = len(line) * 18
draw.text(((CARD_WIDTH_PX-w)//2, y), line, font=font_title, fill=0)
y += line_heights[idx] + 2
# User (centered below text)
user_label = f"Von: {task.user}"
try:
w = font_label_f.getlength(user_label)
user_h = get_text_height(font_label_f, user_label)
except Exception:
w = len(user_label) * 10
user_h = 18
draw.text(((CARD_WIDTH_PX-w)//2, y+8), user_label, font=font_label_f, fill=0)
y += user_h + 12
# Priority (centered below user)
prio_label = f"Priorität: {task.priority}"
try:
w = font_label_f.getlength(prio_label)
except Exception:
w = len(prio_label) * 10
draw.text(((CARD_WIDTH_PX-w)//2, y), prio_label, font=font_label_f, fill=0)
# Icon in lower right corner
icon = get_icon_for_task(task.content)
if font_icon:
icon_bbox = font_icon.getbbox(icon)
icon_w = icon_bbox[2] - icon_bbox[0]
icon_h = icon_bbox[3] - icon_bbox[1]
icon_x = CARD_WIDTH_PX - icon_w - 16
icon_y = CARD_HEIGHT_PX - icon_h - 12
draw.text((icon_x, icon_y), icon, font=font_icon, fill=0)
else:
font_error_msgs.append("[!] Icon font missing")
# If any font errors, show a warning on the card
if font_error_msgs:
draw.text((10, CARD_HEIGHT_PX-24), ", ".join(font_error_msgs), font=ImageFont.load_default(), fill=128)
img_path = OUT_DIR / f"task_{task.id}.png"
img.save(img_path)
preview_img = str(img_path)
msg = "success"
task.status = "printed"
else:
printer = escpos.printer.Serial(devfile="/dev/ttyUSB0", baudrate=19200, timeout=1)
printer.text(f"Task: {task.content}\nVon: {task.user}\nPriorität: {task.priority}\n")
printer.cut()
task.status = "printed"
msg = "success"
except Exception as e:
print(f"Printer error: {e}")
msg = f"error:{e}"
else:
msg = "not_allowed"
save_tasks()
request.session["print_result"] = msg
if preview_img:
request.session["preview_image"] = preview_img
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/login")
def admin_login(request: Request, password: str = Form(...)):
if password == ADMIN_PASSWORD:
request.session["admin"] = True
request.session["login_result"] = "success"
else:
request.session["admin"] = False
request.session["login_result"] = "fail"
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/logout")
def admin_logout(request: Request):
request.session["admin"] = False
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)