Initial Validation Suite V2 report engine
8
Dockerfile
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ .
|
||||
CMD ["gunicorn","--bind","0.0.0.0:5000","--workers","2","--threads","4","app:app"]
|
||||
29
README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Validation Suite V2
|
||||
|
||||
Lokaler Arbeitsstand mit vollständigem strukturellem Prüfbericht.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
chmod +x start.command stop.command
|
||||
./start.command
|
||||
```
|
||||
|
||||
Browser:
|
||||
|
||||
```text
|
||||
http://localhost:5003
|
||||
```
|
||||
|
||||
Beim ersten Start registrieren. Eine Demo-Validierung mit Kunde, Gerät und sechs Loggern ist bereits angelegt.
|
||||
|
||||
Über **V2-Prüfbericht herunterladen** wird ein langer Bericht erzeugt, der die Kapitelstruktur des Referenzberichts abbildet. Noch fehlende Originalbilder, Winlog-Daten, Zertifikate und das geschützte Logo sind als klar markierte Platzhalter enthalten.
|
||||
|
||||
## Git
|
||||
|
||||
Das Verzeichnis ist bereits als Git-Repository initialisiert. Nach dem Entpacken kann ein Remote ergänzt werden:
|
||||
|
||||
```bash
|
||||
git remote add origin DEINE_GIT_URL
|
||||
git push -u origin main
|
||||
```
|
||||
BIN
_render_check/page-01.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
_render_check/page-02.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
_render_check/page-03.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
_render_check/page-04.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
_render_check/page-05.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
_render_check/page-06.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
_render_check/page-07.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
_render_check/page-08.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
_render_check/page-09.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
_render_check/page-10.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
_render_check/page-11.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
_render_check/page-12.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
_render_check/page-13.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
_render_check/page-14.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
_render_check/page-15.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
_render_check/page-16.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
_render_check/page-17.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
_render_check/page-18.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
_render_check/page-19.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
_render_check/page-20.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
_render_check/page-21.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
_render_check/page-22.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
_render_check/page-23.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
_render_check/page-24.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
_render_check/page-25.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
_render_check/page-26.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
_render_check/page-27.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
_render_check/page-28.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
_render_check/page-29.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
_render_check/page-30.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
_render_check/page-31.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
_render_check/page-32.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
_render_check/page-33.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
_render_check/page-34.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
_render_check/page-35.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
_render_check/page-36.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
_render_check/page-37.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
_render_check/page-38.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
_render_check/page-39.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
_render_check/page-40.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
_render_check/page-41.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
_render_check/page-42.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
_render_check/page-43.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
_render_check/page-44.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
_render_check/page-45.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
_render_check/page-46.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
_render_check/page-47.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
_render_check/page-48.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
_render_check/page-49.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
_render_check/page-50.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
_render_check/page-51.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
_render_check/page-52.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
_render_check/page-53.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
_render_check/page-54.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
_render_check/page-55.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
_render_check/page-56.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
_render_check/page-57.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
_render_check/page-58.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
_render_check/page-59.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
_render_check/page-60.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
_render_check/page-61.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
app/__pycache__/app.cpython-313.pyc
Normal file
272
app/app.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, flash, redirect, render_template, request, send_from_directory, url_for
|
||||
from flask_login import LoginManager, UserMixin, current_user, login_required, login_user, logout_user
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import func, select
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from reporting.report_builder import build_validation_report
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
GENERATED_DIR = BASE_DIR / "generated"
|
||||
GENERATED_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = "users"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
|
||||
class Customer(db.Model):
|
||||
__tablename__ = "customers"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
practice_name = db.Column(db.String(180), nullable=False)
|
||||
operator_name = db.Column(db.String(180), nullable=False)
|
||||
contact_person = db.Column(db.String(180))
|
||||
hygiene_contact = db.Column(db.String(180))
|
||||
street = db.Column(db.String(180))
|
||||
postal_code = db.Column(db.String(20))
|
||||
city = db.Column(db.String(120))
|
||||
email = db.Column(db.String(180))
|
||||
phone = db.Column(db.String(80))
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
return ", ".join(x for x in [
|
||||
self.street or "",
|
||||
f"{self.postal_code or ''} {self.city or ''}".strip()
|
||||
] if x)
|
||||
|
||||
|
||||
class Device(db.Model):
|
||||
__tablename__ = "devices"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"), nullable=False)
|
||||
manufacturer = db.Column(db.String(120), nullable=False)
|
||||
model = db.Column(db.String(120), nullable=False)
|
||||
serial_number = db.Column(db.String(120), unique=True, nullable=False)
|
||||
year_built = db.Column(db.String(20))
|
||||
commissioning_date = db.Column(db.Date)
|
||||
chamber_volume = db.Column(db.String(50))
|
||||
steam_generation = db.Column(db.String(180))
|
||||
water_supply = db.Column(db.String(180))
|
||||
documentation = db.Column(db.String(180))
|
||||
supplier = db.Column(db.String(180))
|
||||
customer = db.relationship("Customer", backref="devices")
|
||||
|
||||
|
||||
class Equipment(db.Model):
|
||||
__tablename__ = "equipment"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
designation = db.Column(db.String(150), nullable=False)
|
||||
manufacturer = db.Column(db.String(120))
|
||||
model = db.Column(db.String(120))
|
||||
serial_number = db.Column(db.String(120), unique=True, nullable=False)
|
||||
measurement_range = db.Column(db.String(150))
|
||||
calibration_date = db.Column(db.Date)
|
||||
calibration_due = db.Column(db.Date)
|
||||
equipment_type = db.Column(db.String(80))
|
||||
|
||||
|
||||
class Validation(db.Model):
|
||||
__tablename__ = "validations"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
report_number = db.Column(db.String(60), unique=True, nullable=False)
|
||||
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"), nullable=False)
|
||||
device_id = db.Column(db.Integer, db.ForeignKey("devices.id"), nullable=False)
|
||||
validation_type = db.Column(db.String(60), nullable=False)
|
||||
project = db.Column(db.String(120), nullable=False)
|
||||
test_date = db.Column(db.Date, nullable=False)
|
||||
test_location = db.Column(db.String(120), nullable=False)
|
||||
examiner = db.Column(db.String(150), nullable=False)
|
||||
participants = db.Column(db.String(255))
|
||||
status = db.Column(db.String(30), default="Entwurf", nullable=False)
|
||||
result = db.Column(db.String(30), default="Bestanden", nullable=False)
|
||||
room_temperature = db.Column(db.String(20))
|
||||
room_humidity = db.Column(db.String(20))
|
||||
vacuum_leak_rate = db.Column(db.String(30))
|
||||
next_validation = db.Column(db.Date)
|
||||
recommendations = db.Column(db.Text)
|
||||
notes = db.Column(db.Text)
|
||||
customer = db.relationship("Customer")
|
||||
device = db.relationship("Device")
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
try:
|
||||
return db.session.get(User, int(user_id))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def parse_date(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def next_report_number():
|
||||
count = db.session.scalar(select(func.count()).select_from(Validation)) or 0
|
||||
return f"VAL-{datetime.now().year}-{count + 1:04d}"
|
||||
|
||||
|
||||
def seed_demo_data():
|
||||
if db.session.scalar(select(func.count()).select_from(Customer)):
|
||||
return
|
||||
|
||||
customer = Customer(
|
||||
practice_name="Urologische Praxis Muster",
|
||||
operator_name="Dr. Max Mustermann",
|
||||
contact_person="Dr. Max Mustermann",
|
||||
hygiene_contact="Frau Beispiel",
|
||||
street="Musterstraße 5",
|
||||
postal_code="90459",
|
||||
city="Nürnberg",
|
||||
email="praxis@example.de",
|
||||
phone="0911 123456",
|
||||
)
|
||||
db.session.add(customer)
|
||||
db.session.flush()
|
||||
|
||||
device = Device(
|
||||
customer_id=customer.id,
|
||||
manufacturer="Euronda",
|
||||
model="E10.7",
|
||||
serial_number="DEMO-EXN250688",
|
||||
year_built="2025",
|
||||
commissioning_date=date(2025, 12, 5),
|
||||
chamber_volume="23 Liter",
|
||||
steam_generation="Eigendampferzeugung",
|
||||
water_supply="Automatische Versorgung über Aquafilter",
|
||||
documentation="Interne CF-Card, Ausgabe am PC möglich",
|
||||
supplier="Medizintechnik Muster",
|
||||
)
|
||||
db.session.add(device)
|
||||
|
||||
equipment = [
|
||||
Equipment(designation="Temperatur Datenlogger 1", manufacturer="Ebro", model="EBI 11 T235", serial_number="15102807", measurement_range="0 °C bis +150 °C", calibration_date=date(2025,1,17), calibration_due=date(2027,1,17), equipment_type="Temperatur"),
|
||||
Equipment(designation="Temperatur Datenlogger 2", manufacturer="Ebro", model="EBI 11 T240", serial_number="15211066", measurement_range="0 °C bis +150 °C", calibration_date=date(2025,1,17), calibration_due=date(2027,1,17), equipment_type="Temperatur"),
|
||||
Equipment(designation="Temperatur Datenlogger 3", manufacturer="Ebro", model="EBI 11 T240", serial_number="15102538", measurement_range="0 °C bis +150 °C", calibration_date=date(2025,1,17), calibration_due=date(2027,1,17), equipment_type="Temperatur"),
|
||||
Equipment(designation="Temperatur Datenlogger 4", manufacturer="Ebro", model="EBI 11 T240", serial_number="15211067", measurement_range="0 °C bis +150 °C", calibration_date=date(2025,1,17), calibration_due=date(2027,1,17), equipment_type="Temperatur"),
|
||||
Equipment(designation="Temperatur Datenlogger 5", manufacturer="Ebro", model="EBI 11 T240", serial_number="15125738", measurement_range="0 °C bis +150 °C", calibration_date=date(2025,1,17), calibration_due=date(2027,1,17), equipment_type="Temperatur"),
|
||||
Equipment(designation="Drucklogger", manufacturer="Ebro", model="EBI 11 P111", serial_number="15092368", measurement_range="-100 mbar bis 4 bar abs.", calibration_date=date(2025,1,17), calibration_due=date(2027,1,17), equipment_type="Druck"),
|
||||
]
|
||||
db.session.add_all(equipment)
|
||||
db.session.flush()
|
||||
|
||||
validation = Validation(
|
||||
report_number="VAL-DEMO-0001",
|
||||
customer_id=customer.id,
|
||||
device_id=device.id,
|
||||
validation_type="Erstvalidierung",
|
||||
project="Erst-Validierung",
|
||||
test_date=date(2025, 12, 5),
|
||||
test_location="Nürnberg",
|
||||
examiner="Andreas Schubert",
|
||||
participants="Frau Beispiel, Dr. Max Mustermann",
|
||||
status="Entwurf",
|
||||
result="Bestanden",
|
||||
room_temperature="21 °C",
|
||||
room_humidity="41 %",
|
||||
vacuum_leak_rate="0,2 mbar/min",
|
||||
next_validation=date(2027, 11, 1),
|
||||
recommendations="Auflagen und Empfehlungen werden im Kapitel 4.2 dokumentiert.",
|
||||
notes="V2-Vorschau: Winlog-Messwerte, Originalbilder, Zertifikate und Logo werden später dynamisch ersetzt.",
|
||||
)
|
||||
db.session.add(validation)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.update(
|
||||
SECRET_KEY=os.getenv("SECRET_KEY", "validation-v2-local-secret"),
|
||||
SQLALCHEMY_DATABASE_URI=os.getenv("DATABASE_URL", "sqlite:///validation-v2.db"),
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||
)
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "login"
|
||||
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("dashboard"))
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
repeat = request.form.get("password_repeat") or ""
|
||||
if len(username) < 3 or len(password) < 6 or password != repeat:
|
||||
flash("Bitte gültige Zugangsdaten eingeben.", "error")
|
||||
elif db.session.scalar(select(User).where(func.lower(User.username) == username.lower())):
|
||||
flash("Benutzername bereits vergeben.", "error")
|
||||
else:
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return redirect(url_for("login"))
|
||||
return render_template("register.html")
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("dashboard"))
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
user = db.session.scalar(select(User).where(func.lower(User.username) == username.lower()))
|
||||
if user and user.check_password(request.form.get("password") or ""):
|
||||
login_user(user)
|
||||
return redirect(url_for("dashboard"))
|
||||
flash("Anmeldung fehlgeschlagen.", "error")
|
||||
return render_template("login.html")
|
||||
|
||||
@app.route("/logout", methods=["POST"])
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for("login"))
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def dashboard():
|
||||
validations = list(db.session.scalars(select(Validation).order_by(Validation.id.desc())))
|
||||
return render_template("dashboard.html", validations=validations)
|
||||
|
||||
@app.route("/validations/<int:item_id>/report")
|
||||
@login_required
|
||||
def report(item_id):
|
||||
validation = db.session.get(Validation, item_id)
|
||||
if not validation:
|
||||
return "Nicht gefunden", 404
|
||||
equipment = list(db.session.scalars(select(Equipment).order_by(Equipment.id)))
|
||||
filename = build_validation_report(validation, equipment, GENERATED_DIR)
|
||||
return send_from_directory(GENERATED_DIR, filename, as_attachment=True)
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
seed_demo_data()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
0
app/reporting/__init__.py
Normal file
BIN
app/reporting/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/reporting/__pycache__/report_builder.cpython-313.pyc
Normal file
496
app/reporting/report_builder.py
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import (
|
||||
BaseDocTemplate, Frame, PageTemplate, Paragraph, Spacer, Table, TableStyle,
|
||||
PageBreak, KeepTogether
|
||||
)
|
||||
|
||||
PAGE_W, PAGE_H = A4
|
||||
|
||||
|
||||
def _header_footer(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setStrokeColor(colors.HexColor("#D0D5DD"))
|
||||
canvas.line(18*mm, PAGE_H-16*mm, PAGE_W-18*mm, PAGE_H-16*mm)
|
||||
canvas.setFont("Helvetica-Bold", 8)
|
||||
canvas.setFillColor(colors.HexColor("#344054"))
|
||||
canvas.drawString(18*mm, PAGE_H-12*mm, "VALIDATION SUITE - PRÜFBERICHT")
|
||||
canvas.setFont("Helvetica", 8)
|
||||
canvas.setFillColor(colors.HexColor("#667085"))
|
||||
canvas.drawRightString(PAGE_W-18*mm, 10*mm, f"Seite {doc.page}")
|
||||
canvas.restoreState()
|
||||
|
||||
|
||||
def _table(data, widths=None, header=True):
|
||||
t = Table(data, colWidths=widths, repeatRows=1 if header else 0)
|
||||
style = [
|
||||
("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#D0D5DD")),
|
||||
("VALIGN", (0,0), (-1,-1), "TOP"),
|
||||
("LEFTPADDING", (0,0), (-1,-1), 6),
|
||||
("RIGHTPADDING", (0,0), (-1,-1), 6),
|
||||
("TOPPADDING", (0,0), (-1,-1), 6),
|
||||
("BOTTOMPADDING", (0,0), (-1,-1), 6),
|
||||
]
|
||||
if header:
|
||||
style += [
|
||||
("BACKGROUND", (0,0), (-1,0), colors.HexColor("#EEF2FF")),
|
||||
("TEXTCOLOR", (0,0), (-1,0), colors.HexColor("#3730A3")),
|
||||
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
|
||||
]
|
||||
t.setStyle(TableStyle(style))
|
||||
return t
|
||||
|
||||
|
||||
def _placeholder(title, height=85):
|
||||
data = [[Paragraph(f"<b>{title}</b><br/><font color='#667085'>Platzhalter - wird aus der jeweiligen Validierung ersetzt.</font>", getSampleStyleSheet()["BodyText"])]]
|
||||
t = Table(data, colWidths=[165*mm], rowHeights=[height*mm])
|
||||
t.setStyle(TableStyle([
|
||||
("BOX",(0,0),(-1,-1),1,colors.HexColor("#98A2B3")),
|
||||
("BACKGROUND",(0,0),(-1,-1),colors.HexColor("#F8FAFC")),
|
||||
("VALIGN",(0,0),(-1,-1),"MIDDLE"),
|
||||
("ALIGN",(0,0),(-1,-1),"CENTER"),
|
||||
]))
|
||||
return t
|
||||
|
||||
|
||||
def build_validation_report(validation, equipment, output_dir: Path) -> str:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{validation.report_number}_V2_Vorschau.pdf"
|
||||
path = output_dir / filename
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
styles.add(ParagraphStyle(name="CoverTitle", parent=styles["Title"], fontSize=24, leading=29, alignment=TA_CENTER, textColor=colors.HexColor("#172033")))
|
||||
styles.add(ParagraphStyle(name="Chapter", parent=styles["Heading1"], fontSize=17, leading=21, spaceBefore=8, spaceAfter=10, textColor=colors.HexColor("#172033")))
|
||||
styles.add(ParagraphStyle(name="Section", parent=styles["Heading2"], fontSize=13, leading=17, spaceBefore=8, spaceAfter=7, textColor=colors.HexColor("#344054")))
|
||||
styles.add(ParagraphStyle(name="Small", parent=styles["BodyText"], fontSize=8, leading=11, textColor=colors.HexColor("#667085")))
|
||||
styles.add(ParagraphStyle(name="BodyV2", parent=styles["BodyText"], fontSize=9.3, leading=13.5, spaceAfter=6))
|
||||
|
||||
frame = Frame(18*mm, 17*mm, PAGE_W-36*mm, PAGE_H-36*mm, id="normal")
|
||||
doc = BaseDocTemplate(str(path), pagesize=A4, leftMargin=18*mm, rightMargin=18*mm, topMargin=20*mm, bottomMargin=18*mm)
|
||||
doc.addPageTemplates([PageTemplate(id="main", frames=frame, onPage=_header_footer)])
|
||||
|
||||
story = []
|
||||
|
||||
# Cover
|
||||
story += [
|
||||
Spacer(1, 24*mm),
|
||||
_placeholder("GESCHÜTZTES ORIGINALLOGO", 25),
|
||||
Spacer(1, 14*mm),
|
||||
Paragraph("Prüfbericht zur Validierung", styles["CoverTitle"]),
|
||||
Paragraph("Funktions- und Leistungsqualifikation Klein-Sterilisator", styles["Heading2"]),
|
||||
Spacer(1, 10*mm),
|
||||
_table([
|
||||
["Hersteller / Gerät", f"{validation.device.manufacturer} / {validation.device.model}"],
|
||||
["Seriennummer", validation.device.serial_number],
|
||||
["Projekt", validation.project],
|
||||
["Auftraggeber / Standort", f"{validation.customer.practice_name}<br/>{validation.customer.operator_name}<br/>{validation.customer.address}"],
|
||||
["Ort und Datum der Prüfung", f"{validation.test_location}, {validation.test_date.strftime('%d.%m.%Y')}"],
|
||||
["Technische Validierung durchgeführt", validation.examiner],
|
||||
["Mitwirkende Personen", validation.participants or "–"],
|
||||
["Berichtsnummer", validation.report_number],
|
||||
], widths=[55*mm, 110*mm], header=False),
|
||||
Spacer(1, 12*mm),
|
||||
Paragraph("Nur gültig mit Unterschrift des verantwortlichen Betreibers und des Prüfers.", styles["Small"]),
|
||||
Spacer(1, 12*mm),
|
||||
_table([
|
||||
["Ort / Datum", "Unterschrift verantwortlicher Betreiber"],
|
||||
["", ""],
|
||||
["Ort / Datum", "Unterschrift Prüfer / Berichtsersteller"],
|
||||
["", ""],
|
||||
], widths=[55*mm, 110*mm], header=False),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
# Summary
|
||||
story += [
|
||||
Paragraph("Zusammenfassendes Ergebnis der Validierung", styles["Chapter"]),
|
||||
Paragraph(
|
||||
f"Es wurde die Validierung (Leistungsbeurteilung) eines sich im Betrieb befindlichen "
|
||||
f"Klein-Sterilisators (< 1 STE / 60 Liter) durchgeführt. Im Rahmen der Leistungsqualifikation "
|
||||
f"wurden mechanische, thermoelektrische und verfahrenstechnische Leistungsanforderungen überprüft.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
Paragraph(
|
||||
"Die geforderten Spezifikationen, Prozesse und Umgebungsbedingungen wurden überprüft. "
|
||||
"Die Bewertung der Messdaten wird nach dem Import der Winlog-Validation-Daten automatisch "
|
||||
"in die Tabellen und Diagramme dieses Berichts übernommen.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
_table([
|
||||
["Prüfung", "Ergebnis"],
|
||||
["Vakuumtest", validation.result],
|
||||
["Bowie-Dick / Leerkammerprofil", validation.result],
|
||||
["Testlauf 1 - 134 °C hohl verpackt", validation.result],
|
||||
["Testlauf 2 - 134 °C hohl verpackt", validation.result],
|
||||
["Testlauf 3 - 134 °C hohl verpackt", validation.result],
|
||||
["Gesamtergebnis", validation.result],
|
||||
], widths=[120*mm, 45*mm]),
|
||||
Spacer(1, 8*mm),
|
||||
Paragraph(
|
||||
f"Die nächste Leistungsbeurteilung / Validierung ist vorgesehen für "
|
||||
f"{validation.next_validation.strftime('%m/%Y') if validation.next_validation else 'noch festzulegen'}. "
|
||||
"Prozessrelevante Änderungen können eine erneute Leistungsüberprüfung notwendig machen.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
# TOC-style overview
|
||||
toc = [
|
||||
("1.", "Funktionsqualifikation (BQ)"),
|
||||
("1.1", "Anlass und Ziel der Prüfung"),
|
||||
("1.2", "Gesetzliche Grundlagen"),
|
||||
("1.3", "Angaben zum Gerät"),
|
||||
("1.4", "Leistungsüberprüfung"),
|
||||
("1.5", "Checkliste Leistungsanforderung"),
|
||||
("1.8", "Umgebungsbedingungen"),
|
||||
("1.9", "Chargenkontrolle"),
|
||||
("1.10", "Beschreibung der verwendeten Programme"),
|
||||
("1.11", "Beladungsbeschreibungen"),
|
||||
("2.", "Eingesetzte Prüfmittel"),
|
||||
("2.1", "Beschreibung der Messgeräte"),
|
||||
("2.2", "Prüfmittel zur thermoelektrischen Untersuchung"),
|
||||
("3.", "Prüfkonfiguration"),
|
||||
("3.1", "Leistungsbeurteilung - Thermoelektrische Prüfungen"),
|
||||
("4.", "Ergebnisse der Validierung"),
|
||||
("4.2", "Empfehlungen und Auflagen"),
|
||||
("5.", "Anhang"),
|
||||
("6.", "Programmabläufe / Zyklen"),
|
||||
("7.", "Risikoeinstufung / Abschlussgespräch"),
|
||||
("8.", "Zertifikate"),
|
||||
("9.", "Werkskalibrierzertifikate Sensoren"),
|
||||
]
|
||||
story += [Paragraph("Inhaltsverzeichnis", styles["Chapter"]), _table([["Kapitel","Inhalt"]] + toc, widths=[25*mm,140*mm]), PageBreak()]
|
||||
|
||||
# Static chapters
|
||||
story += [
|
||||
Paragraph("1. Funktionsqualifikation (BQ)", styles["Chapter"]),
|
||||
Paragraph("1.1 Anlass und Ziel der Prüfung", styles["Section"]),
|
||||
Paragraph(
|
||||
f"Es wurde die Validierung eines sich im Betrieb befindlichen {validation.device.manufacturer} "
|
||||
f"{validation.device.model} Klein-Sterilisators durchgeführt. Die Validierung gilt nur für das "
|
||||
"zum Zeitpunkt der Prüfung berücksichtigte Gerät, die geprüfte Beladung und die verwendeten Programme.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
Paragraph(
|
||||
"Mit der Validierung wird der dokumentierte Nachweis erbracht, dass ein Verfahren die vorher "
|
||||
"spezifizierten Anforderungen im praktischen Einsatz reproduzierbar erfüllt.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
Paragraph("1.2 Gesetzliche Grundlagen", styles["Section"]),
|
||||
Paragraph(
|
||||
"Die Validierung erfolgt in Anlehnung an die für den Anwendungsbereich maßgeblichen Regelwerke, "
|
||||
"Normen, Leitlinien und Empfehlungen. Die in der Originalvorlage enthaltenen fachlichen Textbausteine "
|
||||
"werden in der finalen Berichtsversion unverändert versioniert übernommen.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
_table([
|
||||
["Regelwerk / Grundlage"],
|
||||
["DIN EN ISO 17665 - Sterilisation von Produkten für die Gesundheitsfürsorge"],
|
||||
["DIN EN 13060 - Dampf-Kleinsterilisatoren"],
|
||||
["Leitlinien der DGKH, DGSV und AKI"],
|
||||
["Empfehlungen von RKI und BfArM zur Aufbereitung von Medizinprodukten"],
|
||||
], widths=[165*mm]),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
story += [
|
||||
Paragraph("1.3 Angaben zum Gerät", styles["Chapter"]),
|
||||
_table([
|
||||
["Merkmal", "Angabe"],
|
||||
["Sterilisator-Typ", f"{validation.device.manufacturer} {validation.device.model}"],
|
||||
["Seriennummer", validation.device.serial_number],
|
||||
["Baujahr", validation.device.year_built or "–"],
|
||||
["Erstinbetriebnahme", validation.device.commissioning_date.strftime("%d.%m.%Y") if validation.device.commissioning_date else "–"],
|
||||
["Kammervolumen", validation.device.chamber_volume or "–"],
|
||||
["Art der Dampferzeugung", validation.device.steam_generation or "–"],
|
||||
["Wasserqualität / Versorgung", validation.device.water_supply or "–"],
|
||||
["Chargendokumentation", validation.device.documentation or "–"],
|
||||
["Lieferant", validation.device.supplier or "–"],
|
||||
["Betreiber", validation.customer.operator_name],
|
||||
["QM / Hygienebeauftragte", validation.customer.hygiene_contact or "–"],
|
||||
], widths=[60*mm,105*mm]),
|
||||
Spacer(1, 5*mm),
|
||||
_placeholder("Gerätefoto - aktueller Sterilisator", 80),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
# Checklists
|
||||
checklist_rows = [
|
||||
["Prüfkriterium", "Ja", "Nein", "Kommentar"],
|
||||
["Trennung von Controlling- und Monitoring-Schaltkreisen", "X", "", ""],
|
||||
["Temperaturüberwachung Sterilisationsprozess", "X", "", ""],
|
||||
["Drucküberwachung Sterilisationsprozess", "X", "", ""],
|
||||
["Wasserzulauf / Ablauf", "X", "", "Automatische Versorgung"],
|
||||
["Automatische Aufzeichnung der Sterilisationsergebnisse", "X", "", validation.device.documentation or ""],
|
||||
["Überwachung Wasserqualität", "X", "", "Interne Überwachung"],
|
||||
["Separater Netzanschluss", "X", "", ""],
|
||||
["Sichtkontrolle Kessel, Sensoren, Türbereich", "X", "", ""],
|
||||
["Bedienungshandbuch / Installationsprotokoll", "X", "", "Vorhanden"],
|
||||
["Wartungshandbuch", "X", "", "Vorhanden"],
|
||||
["Arbeitsanweisungen / Checklisten / Beladungsmuster", "X", "", "Werden im System dokumentiert"],
|
||||
["Schulungsnachweise der Mitarbeiter", "X", "", ""],
|
||||
["Risikoeinstufung der Medizinprodukte", "X", "", ""],
|
||||
["Freigabedokumentation vorhanden", "X", "", ""],
|
||||
]
|
||||
story += [
|
||||
Paragraph("1.5 Checkliste Leistungsanforderung", styles["Chapter"]),
|
||||
_table(checklist_rows, widths=[90*mm,15*mm,15*mm,45*mm]),
|
||||
PageBreak(),
|
||||
Paragraph("1.8 Umgebungsbedingungen", styles["Chapter"]),
|
||||
_table([
|
||||
["Kontrolle", "Ja", "Nein", "Kommentar"],
|
||||
["Änderungen seit letzter Validierung", "", "X", validation.validation_type],
|
||||
["Aufstellungsort und Umgebungsbedingungen in Ordnung", "X", "", ""],
|
||||
["Relative Luftfeuchtigkeit in Ordnung", "X", "", validation.room_humidity or ""],
|
||||
["Gerät nach Sichtprüfung in Ordnung", "X", "", ""],
|
||||
["Trennung zwischen Rein- und Unreinbereich", "X", "", ""],
|
||||
["Sterilgutlagerung unter geschützten Bedingungen", "X", "", ""],
|
||||
["Raumbelüftung vorhanden", "X", "", ""],
|
||||
["Desinfektionsmittel-, Seifen- und Papierhandtuchspender vorhanden", "X", "", ""],
|
||||
], widths=[92*mm,14*mm,14*mm,45*mm]),
|
||||
Spacer(1, 6*mm),
|
||||
Paragraph(
|
||||
f"Aufbereitungsraum: Raumtemperatur {validation.room_temperature or '–'}, "
|
||||
f"relative Luftfeuchtigkeit {validation.room_humidity or '–'}, gemessen am "
|
||||
f"{validation.test_date.strftime('%d.%m.%Y')}.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
_placeholder("Abbildung: Übersicht Aufbereitungsraum", 55),
|
||||
Spacer(1, 4*mm),
|
||||
_placeholder("Abbildung: Reiner / unreiner Bereich", 55),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
story += [
|
||||
Paragraph("1.9 Chargenkontrolle", styles["Chapter"]),
|
||||
_table([
|
||||
["Kontrolle", "Durchführung", "Prüfintervall", "Prüfkörper"],
|
||||
["Vakuumtest", "Ja", "wöchentlich", "–"],
|
||||
["Bowie-Dick-Test", "nach Bedarf", "gemäß Validierungsplan", "Prüfkörper"],
|
||||
["Chargenüberwachung Chemoindikatoren", "Ja", "bei jeder Charge", "Helix-Test"],
|
||||
], widths=[55*mm,35*mm,40*mm,35*mm]),
|
||||
Paragraph("1.10 Beschreibung der verwendeten Programme", styles["Chapter"]),
|
||||
Paragraph(
|
||||
"Für alle praxisrelevanten Beladungen werden die nachfolgenden Programme verwendet: "
|
||||
"Vakuumtest, Leerkammerprofil / Bowie-Dick-Simulation sowie drei Testläufe im Programm "
|
||||
"134 °C hohl verpackt.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
Paragraph("1.11 Beladungsbeschreibungen", styles["Chapter"]),
|
||||
Paragraph(
|
||||
"Als schwierigste zu sterilisierende Beladung wird eine repräsentative Worst-Case-Beladung "
|
||||
"aus dem tatsächlichen Instrumentarium definiert. Die konkrete Beladung und alle Fotos werden "
|
||||
"je Validierung neu erfasst.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
_placeholder("Referenzbeladung - Testlauf 1", 60),
|
||||
Spacer(1, 4*mm),
|
||||
_placeholder("Referenzbeladung - Testlauf 2", 60),
|
||||
PageBreak(),
|
||||
_placeholder("Referenzbeladung - Testlauf 3", 60),
|
||||
Spacer(1, 5*mm),
|
||||
_placeholder("Sensorpositionierung / Prüfkonfiguration", 80),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
# Equipment
|
||||
eq_data = [["Bezeichnung", "Typ", "Seriennummer", "Messbereich", "Kalibrierung"]]
|
||||
for e in equipment:
|
||||
eq_data.append([
|
||||
e.designation,
|
||||
f"{e.manufacturer or ''} {e.model or ''}".strip(),
|
||||
e.serial_number,
|
||||
e.measurement_range or "–",
|
||||
e.calibration_date.strftime("%d.%m.%Y") if e.calibration_date else "–",
|
||||
])
|
||||
story += [
|
||||
Paragraph("2. Eingesetzte Prüfmittel", styles["Chapter"]),
|
||||
Paragraph(
|
||||
"Für die Reproduzierbarkeit der thermoelektrischen Prüfungen werden hochauflösende "
|
||||
"Temperatur- und Drucksensoren eingesetzt. Die Auswahl erfolgt je Validierung aus dem "
|
||||
"Prüfmittelregister.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
Paragraph("2.1 Beschreibung der Messgeräte", styles["Section"]),
|
||||
_table(eq_data, widths=[40*mm,42*mm,30*mm,33*mm,25*mm]),
|
||||
PageBreak(),
|
||||
Paragraph("2.2 Prüfmittel zur thermoelektrischen Untersuchung", styles["Chapter"]),
|
||||
_placeholder("Abbildung / Beschreibung Bowie-Dick-Simulationstest", 70),
|
||||
Spacer(1, 5*mm),
|
||||
_placeholder("Abbildung / Beschreibung Chargenüberwachungssystem", 70),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
# Performance qualification
|
||||
story += [
|
||||
Paragraph("3. Prüfkonfiguration", styles["Chapter"]),
|
||||
_placeholder("Beladungsebenen und Positionierung der Sensoren", 110),
|
||||
Spacer(1, 6*mm),
|
||||
_table([
|
||||
["Sensor", "Position / Kanal"],
|
||||
["Drucksensor P111", "Ebene 1, hinten Mitte"],
|
||||
["Temperatursensor T235", "Ebene 1, vorne Mitte"],
|
||||
["Temperatursensor T240", "Position gemäß Fotodokumentation"],
|
||||
], widths=[70*mm,95*mm]),
|
||||
PageBreak(),
|
||||
Paragraph("3. Leistungsqualifikation (LQ)", styles["Chapter"]),
|
||||
Paragraph("3.1 Leistungsbeurteilung - Thermoelektrische Prüfungen", styles["Section"]),
|
||||
Paragraph(
|
||||
"Der Nachweis der erforderlichen Prozessleistung gilt als erbracht, wenn die in der "
|
||||
"Berichtsvorlage festgelegten Temperatur-, Druck-, Zeit- und Trocknungskriterien eingehalten werden. "
|
||||
"Nach Vorliegen des Winlog-Exports werden diese Regeln automatisch auf die importierten Messdaten angewandt.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
Paragraph("1. Vakuumtest", styles["Section"]),
|
||||
Paragraph(
|
||||
f"Der Leckagetest wurde durchgeführt. Erfasste Leckrate: "
|
||||
f"{validation.vacuum_leak_rate or 'noch nicht importiert'}.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
_placeholder("Winlog-Diagramm Vakuumtest", 95),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
# Test runs, each 3 pages
|
||||
for idx in range(1, 4):
|
||||
story += [
|
||||
Paragraph(f"{idx+1}. Standardbeladung ({idx}. Durchlauf)", styles["Chapter"]),
|
||||
Paragraph(f"Testlauf {idx} - 134 °C hohl verpackt", styles["Section"]),
|
||||
_table([
|
||||
["Parameter", "Wert"],
|
||||
["Programm", "134 °C hohl verpackt"],
|
||||
["Beladung", f"Referenzbeladung {idx}"],
|
||||
["Gesamtergebnis", validation.result],
|
||||
["Sterilisationstemperatur", "Winlog-Import ausstehend"],
|
||||
["Haltezeit", "Winlog-Import ausstehend"],
|
||||
["Temperaturband", "Winlog-Import ausstehend"],
|
||||
["Ausgleichszeit", "Winlog-Import ausstehend"],
|
||||
], widths=[70*mm,95*mm]),
|
||||
Spacer(1, 6*mm),
|
||||
_placeholder(f"Beladungsfoto Testlauf {idx}", 70),
|
||||
PageBreak(),
|
||||
Paragraph(f"Gesamte Messung Testlauf {idx}", styles["Chapter"]),
|
||||
_placeholder(f"Winlog-Gesamtdiagramm Testlauf {idx}", 150),
|
||||
PageBreak(),
|
||||
Paragraph(f"Gesamtergebnis Testlauf {idx}", styles["Chapter"]),
|
||||
_table([
|
||||
["Bewertungskriterium", "Ergebnis"],
|
||||
["Temperaturen nicht unter Sterilisationstemperatur", "ausstehend"],
|
||||
["Maximal 3 K oberhalb der gewählten Temperatur", "ausstehend"],
|
||||
["Abweichung zwischen Sensoren maximal 2 K", "ausstehend"],
|
||||
["Ausgleichszeit innerhalb Vorgabe", "ausstehend"],
|
||||
["Haltezeit eingehalten", "ausstehend"],
|
||||
["Gesamtergebnis", validation.result],
|
||||
], widths=[120*mm,45*mm]),
|
||||
Spacer(1, 8*mm),
|
||||
_placeholder(f"Detaildiagramme / Kanäle Testlauf {idx}", 95),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
# Results and recommendations
|
||||
story += [
|
||||
Paragraph("4. Ergebnisse der Validierung", styles["Chapter"]),
|
||||
Paragraph("4.1 Vakuumtest", styles["Section"]),
|
||||
_table([
|
||||
["Prüfung", "Soll", "Ist", "Bewertung"],
|
||||
["Leckrate", "max. 1,3 mbar/min", validation.vacuum_leak_rate or "–", validation.result],
|
||||
], widths=[45*mm,45*mm,35*mm,40*mm]),
|
||||
Paragraph("4.1.2 Testläufe 1 bis 3 - Programm 134 °C", styles["Section"]),
|
||||
_table([
|
||||
["Testlauf", "Temperaturband", "Haltezeit", "Trocknung", "Ergebnis"],
|
||||
["Test 1", "Winlog", "Winlog", "Prüfung", validation.result],
|
||||
["Test 2", "Winlog", "Winlog", "Prüfung", validation.result],
|
||||
["Test 3", "Winlog", "Winlog", "Prüfung", validation.result],
|
||||
], widths=[30*mm,38*mm,32*mm,32*mm,33*mm]),
|
||||
Spacer(1, 8*mm),
|
||||
Paragraph("Nachweis der Trocknungseigenschaften Testläufe 1 bis 3", styles["Section"]),
|
||||
_placeholder("Trocknungsnachweis / Wägeergebnisse", 70),
|
||||
PageBreak(),
|
||||
Paragraph("4.2 Empfehlungen und Auflagen", styles["Chapter"]),
|
||||
Paragraph(validation.recommendations or "Keine Auflagen erfasst.", styles["BodyV2"]),
|
||||
_table([
|
||||
["Nr.", "Auflage / Empfehlung", "Frist", "Status"],
|
||||
["1", "Wird über die Weboberfläche erfasst.", "–", "offen"],
|
||||
], widths=[15*mm,105*mm,25*mm,25*mm]),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
# Annex skeleton. This deliberately yields a long report similar to source.
|
||||
annexes = [
|
||||
"5.1 Vakuumtest - Winlog-Auswertung",
|
||||
"Testlauf Bowie-Dick / Leerkammerprofil",
|
||||
"Testlauf 1 - Allgemeine Angaben",
|
||||
"Testlauf 1 - Gesamte Messung",
|
||||
"Testlauf 1 - Gesamtergebnis",
|
||||
"Testlauf 2 - Allgemeine Angaben",
|
||||
"Testlauf 2 - Gesamte Messung",
|
||||
"Testlauf 2 - Gesamtergebnis",
|
||||
"Testlauf 3 - Allgemeine Angaben",
|
||||
"Testlauf 3 - Gesamte Messung",
|
||||
"Testlauf 3 - Gesamtergebnis",
|
||||
"Chargen- und Freigabedokumentation der Praxis",
|
||||
"Chargendokumentation Bowie-Dick / Leerkammerprofil",
|
||||
"Chargendokumentation Testlauf 1",
|
||||
"Chargendokumentation Testlauf 2",
|
||||
"Chargendokumentation Testlauf 3",
|
||||
"5.2 Freigabedokumentation / Routineprüfungen",
|
||||
"5.3 Nachweis umgeschlagener Indikatoren",
|
||||
"6. Programmabläufe / Zyklen des Sterilisators",
|
||||
"6.1 Zertifikate Praxis",
|
||||
"7. Risikoeinstufung der Praxis",
|
||||
"7.1 Abschlussgespräch",
|
||||
"8. Zertifikate",
|
||||
"9. Werkskalibrierzertifikate Sensoren",
|
||||
]
|
||||
|
||||
for annex in annexes:
|
||||
story += [
|
||||
Paragraph(annex, styles["Chapter"]),
|
||||
Paragraph(
|
||||
"Dieses Kapitel ist bereits fest in der Berichtsvorlage vorgesehen. "
|
||||
"Die Originalbilder, Protokolle, Zertifikate oder importierten PDF-Seiten werden "
|
||||
"im produktiven System automatisch an dieser Stelle eingefügt.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
_placeholder(annex, 170),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
# Extra certificate placeholders to reach realistic report length.
|
||||
for i, e in enumerate(equipment, start=1):
|
||||
story += [
|
||||
Paragraph(f"Kalibrierzertifikat {i}: {e.designation}", styles["Chapter"]),
|
||||
_placeholder(f"Kalibrierzertifikat {e.serial_number} - Seite 1", 205),
|
||||
PageBreak(),
|
||||
Paragraph(f"Kalibrierzertifikat {i}: {e.designation} - Fortsetzung", styles["Chapter"]),
|
||||
_placeholder(f"Kalibrierzertifikat {e.serial_number} - Seite 2", 205),
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
story += [
|
||||
Paragraph("Abschlussvermerk", styles["Chapter"]),
|
||||
Paragraph(
|
||||
"Diese V2-Vorschau bildet die vollständige Berichtsstruktur ab. Das geschützte Logo, "
|
||||
"die Originalbilder, die konkreten Praxis- und Gerätedaten sowie die Winlog-Messwerte "
|
||||
"werden zukünftig je Validierung dynamisch eingesetzt. Die fachlichen Textbausteine "
|
||||
"werden nach der finalen Abstimmung unverändert versioniert.",
|
||||
styles["BodyV2"]
|
||||
),
|
||||
Spacer(1, 15*mm),
|
||||
_table([
|
||||
["Ort / Datum", "Unterschrift verantwortlicher Betreiber"],
|
||||
["", ""],
|
||||
["Ort / Datum", "Unterschrift Prüfer / Berichtsersteller"],
|
||||
["", ""],
|
||||
], widths=[55*mm,110*mm], header=False),
|
||||
]
|
||||
|
||||
doc.build(story)
|
||||
return filename
|
||||
1
app/static/style.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
*{box-sizing:border-box}body{margin:0;background:#f4f6fb;color:#172033;font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif}.shell{min-height:100vh}aside{position:fixed;width:250px;inset:0 auto 0 0;background:#171a2b;color:#fff;padding:24px;display:flex;flex-direction:column;gap:30px}.brand{display:flex;align-items:center;gap:12px}.brand b{display:grid;place-items:center;width:48px;height:48px;border-radius:14px;background:#4f46e5}.brand span{font-weight:800}aside nav a{color:#fff;text-decoration:none}aside form{margin-top:auto}button,.button{border:0;border-radius:10px;padding:11px 15px;font-weight:800;cursor:pointer;text-decoration:none;display:inline-flex}.primary{background:#4f46e5;color:#fff}.content{margin-left:250px;padding:36px}.auth{min-height:100vh;display:grid;place-items:center;padding:20px}.card{background:#fff;border:1px solid #e3e7ef;border-radius:18px;padding:24px;box-shadow:0 18px 45px rgba(30,41,59,.08)}.auth-card{width:min(420px,100%)}form{display:grid;gap:14px}label{display:grid;gap:7px;font-weight:700}input{border:1px solid #d0d5dd;border-radius:10px;padding:11px}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:18px}header{margin-bottom:24px}h1{font-size:2.7rem;margin:.2rem 0}header small{color:#4f46e5;font-weight:900;letter-spacing:.13em}.badge{display:inline-flex;border-radius:999px;background:#eef2ff;color:#3730a3;padding:6px 10px;font-size:.8rem;font-weight:800}.msg{padding:12px;border-radius:10px;margin-bottom:15px}.error{background:#fef3f2;color:#b42318}@media(max-width:800px){aside{position:static;width:100%}.content{margin:0;padding:20px}}
|
||||
1
app/templates/base.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{% block title %}Validation Suite V2{% endblock %}</title><link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"></head><body><div class="shell">{% if current_user.is_authenticated %}<aside><div class="brand"><b>VS</b><span>Validation Suite V2</span></div><nav><a href="{{ url_for('dashboard') }}">Dashboard</a></nav><form action="{{ url_for('logout') }}" method="post"><button>Abmelden</button></form></aside>{% endif %}<main class="{% if current_user.is_authenticated %}content{% else %}auth{% endif %}">{% with messages=get_flashed_messages(with_categories=true) %}{% for category,message in messages %}<div class="msg {{ category }}">{{ message }}</div>{% endfor %}{% endwith %}{% block content %}{% endblock %}</main></div></body></html>
|
||||
1
app/templates/dashboard.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
{% extends "base.html" %}{% block content %}<header><div><small>V2 ARBEITSSTAND</small><h1>Validierungsberichte</h1><p>Die Demo-Validierung erzeugt bereits den vollständigen mehrseitigen Strukturbericht.</p></div></header><section class="grid">{% for v in validations %}<article class="card"><span class="badge">{{ v.status }}</span><h2>{{ v.report_number }}</h2><p><b>{{ v.customer.practice_name }}</b><br>{{ v.device.manufacturer }} {{ v.device.model }}<br>{{ v.test_date.strftime('%d.%m.%Y') }}</p><a class="primary button" href="{{ url_for('report', item_id=v.id) }}">V2-Prüfbericht herunterladen</a></article>{% endfor %}</section>{% endblock %}
|
||||
1
app/templates/login.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
{% extends "base.html" %}{% block content %}<section class="card auth-card"><h1>Anmelden</h1><form method="post"><label>Benutzername<input name="username" required></label><label>Passwort<input type="password" name="password" required></label><button class="primary">Anmelden</button></form><a href="{{ url_for('register') }}">Registrieren</a></section>{% endblock %}
|
||||
1
app/templates/register.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
{% extends "base.html" %}{% block content %}<section class="card auth-card"><h1>Registrieren</h1><form method="post"><label>Benutzername<input name="username" required></label><label>Passwort<input type="password" name="password" required></label><label>Wiederholen<input type="password" name="password_repeat" required></label><button class="primary">Registrieren</button></form></section>{% endblock %}
|
||||
29
docker-compose.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_DB: validation
|
||||
POSTGRES_USER: validation
|
||||
POSTGRES_PASSWORD: validation123
|
||||
volumes:
|
||||
- validation_v2_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL","pg_isready -U validation -d validation"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
web:
|
||||
build: .
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://validation:validation123@db:5432/validation
|
||||
SECRET_KEY: validation-v2-local
|
||||
ports:
|
||||
- "5003:5000"
|
||||
volumes:
|
||||
- ./app/generated:/app/generated
|
||||
- ./app/uploads:/app/uploads
|
||||
volumes:
|
||||
validation_v2_db:
|
||||
1220
preview/VAL-DEMO-0001_V2_Vorschau.pdf
Normal file
6
requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
Flask==3.1.1
|
||||
Flask-Login==0.6.3
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
psycopg[binary]==3.2.9
|
||||
gunicorn==23.0.0
|
||||
reportlab==4.4.2
|
||||
4
start.command
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
docker compose up -d --build
|
||||
echo "Validation Suite V2: http://localhost:5003"
|
||||
3
stop.command
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
docker compose down
|
||||