269 lines
12 KiB
Python
269 lines
12 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
|
|
|
|
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)
|