Initial Validation Suite V2 report engine

This commit is contained in:
Validation Suite 2026-07-10 19:13:07 +00:00
commit 2b5c765e41
79 changed files with 2072 additions and 0 deletions

8
Dockerfile Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
_render_check/page-02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
_render_check/page-03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
_render_check/page-04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
_render_check/page-05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
_render_check/page-06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
_render_check/page-07.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
_render_check/page-08.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
_render_check/page-09.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
_render_check/page-10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
_render_check/page-11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
_render_check/page-12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
_render_check/page-13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
_render_check/page-14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
_render_check/page-15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
_render_check/page-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
_render_check/page-17.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
_render_check/page-18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
_render_check/page-19.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
_render_check/page-20.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
_render_check/page-21.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
_render_check/page-22.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
_render_check/page-23.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
_render_check/page-24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
_render_check/page-25.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
_render_check/page-26.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
_render_check/page-27.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
_render_check/page-28.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
_render_check/page-29.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
_render_check/page-30.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
_render_check/page-31.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
_render_check/page-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
_render_check/page-33.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
_render_check/page-34.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
_render_check/page-35.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
_render_check/page-36.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
_render_check/page-37.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
_render_check/page-38.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
_render_check/page-39.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
_render_check/page-40.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
_render_check/page-41.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
_render_check/page-42.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
_render_check/page-43.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
_render_check/page-44.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
_render_check/page-45.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
_render_check/page-46.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
_render_check/page-47.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
_render_check/page-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
_render_check/page-49.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
_render_check/page-50.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
_render_check/page-51.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
_render_check/page-52.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
_render_check/page-53.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
_render_check/page-54.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
_render_check/page-55.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
_render_check/page-56.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
_render_check/page-57.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
_render_check/page-58.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
_render_check/page-59.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
_render_check/page-60.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
_render_check/page-61.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

272
app/app.py Normal file
View 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()

View file

Binary file not shown.

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

View 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
View 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 %}

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

File diff suppressed because it is too large Load diff

6
requirements.txt Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
#!/bin/bash
cd "$(dirname "$0")"
docker compose down