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)