initial prototype for autokanban
This commit is contained in:
commit
e03b3014fe
5647 changed files with 345269 additions and 0 deletions
269
app/main.py
Normal file
269
app/main.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue