autokanban/app/main.py
2025-11-20 12:53:12 +01:00

287 lines
13 KiB
Python

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
# Load environment variables
load_dotenv()
app = FastAPI()
# Use environment variable for session secret, fallback to random for dev
SESSION_SECRET = os.getenv("SESSION_SECRET", "CHANGE_THIS_SECRET")
app.add_middleware(SessionMiddleware, secret_key=SESSION_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")
ENABLE_PHYSICAL_PRINTER = True # Set to False to skip physical printing (only generate preview images)
PRINTER_DEVICE = "/dev/serial0" # Serial device path
PRINTER_BAUDRATE = 19200 # Try 9600 if printer doesn't respond
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():
TASKS_FILE.parent.mkdir(parents=True, exist_ok=True) # Ensure data/ directory exists
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:
# Always generate preview image
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 - increased sizes for physical printer readability
font_title = load_font(FONT_BOLD, 42, font_label="title")
font_label_f = load_font(FONT_BOLD, 24, font_label="label")
font_text = load_font(FONT_REGULAR, 28, font_label="text")
font_icon = load_font(FA_FONT, 60, 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=20) # Reduced from 24 due to larger font
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)
# Save preview image
img_path = OUT_DIR / f"task_{task.id}.png"
img.save(img_path)
preview_img = str(img_path)
# Print to physical printer if enabled
if ENABLE_PHYSICAL_PRINTER:
try:
printer = escpos.printer.Serial(devfile=PRINTER_DEVICE, baudrate=PRINTER_BAUDRATE, timeout=1)
# Print the formatted image instead of plain text
printer.image(img)
printer.text("\n\n") # Add some spacing after image
printer.cut()
except Exception as print_err:
print(f"[ERROR] Printer failed: {print_err}")
import traceback
traceback.print_exc()
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)