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
|
||||||