436 lines
14 KiB
Python
436 lines
14 KiB
Python
"""
|
|
Naue Roll Calculator - Beta
|
|
Flask app with HTTP Basic Authentication
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from io import BytesIO
|
|
import json
|
|
import os
|
|
|
|
from flask import (
|
|
Flask,
|
|
jsonify,
|
|
render_template,
|
|
request,
|
|
send_file,
|
|
send_from_directory,
|
|
)
|
|
from flask_httpauth import HTTPBasicAuth
|
|
from werkzeug.security import check_password_hash
|
|
|
|
from core_presets import CORE_PRESETS
|
|
from conversation_service import (
|
|
ConversationNotFoundError,
|
|
ConversationReportNotFoundError,
|
|
ConversationService,
|
|
)
|
|
from ollama_nlu import OllamaNLUClient
|
|
from pdf_report import (
|
|
ReportValidationError,
|
|
report_from_calculation_result,
|
|
render_roll_report,
|
|
safe_report_filename,
|
|
)
|
|
from roll_calculation import (
|
|
calculate_product_length,
|
|
calculate_roll,
|
|
modify_calculation,
|
|
)
|
|
from transport_calculation import analyze_transport, transport_presets
|
|
|
|
app = Flask(__name__)
|
|
auth = HTTPBasicAuth()
|
|
|
|
# ============================================================================
|
|
# CONFIGURATION
|
|
# ============================================================================
|
|
|
|
# Benutzer für Beta-Phase (in Produktion aus env-Variablen laden!)
|
|
BETA_USERS = {
|
|
"beta": {
|
|
"password_hash": "pbkdf2:sha256:600000$HNtF3VdZKdmtg8Vw$8f976a99a457c5e924916dc6f735dd631042c8c126af8d4bda9abcd7532d904f"
|
|
},
|
|
"naue": {
|
|
"password_hash": "pbkdf2:sha256:600000$RVPiW2mYXJJIp3d7$0f838b8619d386e2da57e60ae8ccb944bb0f9f63d81ce3d174ec3034b0abcd19"
|
|
},
|
|
"cniehues": {
|
|
"password_hash": "pbkdf2:sha256:600000$iNmhakf34xk26xrz$ae78846461370c52b7f56fad5ac0ae8e33860d6f00075ac78e3a5b8c31d51a3d"
|
|
},
|
|
"lvollmert": {
|
|
"password_hash": "pbkdf2:sha256:600000$8FGop5ymmHpuIqfK$8ba223e20c514cf6bc1297435a2e7e2be81668951297f0e01ad6a931718ad7de"
|
|
},
|
|
"mtazl": {
|
|
"password_hash": "pbkdf2:sha256:600000$H19skoalhWxlLnY5$d30bfeae38470b19900648fd110978b3677607c3a161d663a7e5d5c2167a3710"
|
|
},
|
|
"controlling": {
|
|
"password_hash": "pbkdf2:sha256:600000$ksj86lrKz6nnSpXz$5161e0b5c76bd72d6b2cd04866ef49e69f463f474d7e47075171894bf3366f29"
|
|
}
|
|
}
|
|
|
|
# Logging für Auditing
|
|
LOG_FILE = "access_log.json"
|
|
BUILD_INFO_FILE = "build_info.json"
|
|
UNKNOWN_BUILD_INFO = {
|
|
"version": "unknown",
|
|
"branch": "unknown",
|
|
"commit": "unknown",
|
|
"timestamp": "unknown"
|
|
}
|
|
|
|
# ============================================================================
|
|
# BUILD INFO
|
|
# ============================================================================
|
|
|
|
def load_build_info():
|
|
"""Load deployment/build metadata for templates."""
|
|
try:
|
|
with open(BUILD_INFO_FILE, "r") as f:
|
|
data = json.load(f)
|
|
except Exception as e:
|
|
print(f"[Build Info Error] {e}")
|
|
return UNKNOWN_BUILD_INFO.copy()
|
|
|
|
if not isinstance(data, dict):
|
|
return UNKNOWN_BUILD_INFO.copy()
|
|
|
|
build_info = UNKNOWN_BUILD_INFO.copy()
|
|
for key in build_info:
|
|
value = data.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
build_info[key] = value.strip()
|
|
return build_info
|
|
|
|
BUILD_INFO = load_build_info()
|
|
CONVERSATION_SERVICE = ConversationService(
|
|
OllamaNLUClient(),
|
|
build_info=BUILD_INFO,
|
|
)
|
|
|
|
|
|
@app.context_processor
|
|
def inject_build_info():
|
|
"""Make build metadata available in all templates."""
|
|
return {"build_info": BUILD_INFO}
|
|
|
|
|
|
# ============================================================================
|
|
# AUTHENTICATION
|
|
# ============================================================================
|
|
|
|
@auth.verify_password
|
|
def verify_password(username, password):
|
|
"""Verify HTTP Basic Auth credentials"""
|
|
user_config = BETA_USERS.get(username)
|
|
if not user_config:
|
|
return None
|
|
|
|
password_hash = user_config.get("password_hash")
|
|
if password_hash and check_password_hash(password_hash, password):
|
|
return username
|
|
return None
|
|
|
|
|
|
def log_access(username, endpoint, method, status=200):
|
|
"""Log all access attempts for audit trail"""
|
|
log_entry = {
|
|
"timestamp": datetime.now().isoformat(),
|
|
"username": username,
|
|
"endpoint": endpoint,
|
|
"method": method,
|
|
"status": status
|
|
}
|
|
try:
|
|
logs = []
|
|
if os.path.exists(LOG_FILE):
|
|
with open(LOG_FILE, "r") as f:
|
|
logs = json.load(f)
|
|
logs.append(log_entry)
|
|
with open(LOG_FILE, "w") as f:
|
|
json.dump(logs, f, indent=2)
|
|
except Exception as e:
|
|
print(f"[Logging Error] {e}")
|
|
|
|
# ============================================================================
|
|
# ROUTES
|
|
# ============================================================================
|
|
|
|
@app.route("/", methods=["GET"])
|
|
@auth.login_required
|
|
def index():
|
|
"""Main calculator page - requires authentication"""
|
|
log_access(auth.current_user(), "/", "GET")
|
|
return render_template(
|
|
"roll_calculator.html",
|
|
core_presets=CORE_PRESETS,
|
|
transport_presets=transport_presets(),
|
|
)
|
|
|
|
|
|
@app.route("/static/<path:filename>", methods=["GET"])
|
|
@auth.login_required
|
|
def serve_static(filename):
|
|
"""Serve static files (CSS, JS, JSON) - protected"""
|
|
log_access(auth.current_user(), f"/static/{filename}", "GET")
|
|
return send_from_directory("static", filename)
|
|
|
|
|
|
@app.route("/api/health", methods=["GET"])
|
|
@auth.login_required
|
|
def health_check():
|
|
"""Simple health check endpoint"""
|
|
return jsonify({"status": "ok", "version": "14.1"}), 200
|
|
|
|
|
|
@app.route("/api/user", methods=["GET"])
|
|
@auth.login_required
|
|
def get_user():
|
|
"""Get current authenticated user info"""
|
|
user = auth.current_user()
|
|
return jsonify({
|
|
"username": user,
|
|
"authenticated": True,
|
|
"timestamp": datetime.now().isoformat()
|
|
}), 200
|
|
|
|
|
|
@app.route("/api/calculations/roll", methods=["POST"])
|
|
@auth.login_required
|
|
def create_roll_calculation():
|
|
"""Resolve and execute a deterministic direct roll-diameter request."""
|
|
result = calculate_roll(request.get_json(silent=True), build_info=BUILD_INFO)
|
|
log_access(auth.current_user(), "/api/calculations/roll", "POST")
|
|
return jsonify(result), 200
|
|
|
|
|
|
@app.route("/api/calculations/product-length", methods=["POST"])
|
|
@auth.login_required
|
|
def create_product_length_calculation():
|
|
"""Calculate deterministic product length for a target roll diameter."""
|
|
payload = request.get_json(silent=True)
|
|
if not isinstance(payload, dict):
|
|
result = {
|
|
"status": "invalid_parameter",
|
|
"invalid": [{"field": "request", "message": "JSON object required"}],
|
|
}
|
|
else:
|
|
allowed = {
|
|
"target_roll_diameter_mm",
|
|
"core_diameter_mm",
|
|
"thickness_mm",
|
|
"thickness_stddev_mm",
|
|
"width_m",
|
|
"area_weight_g_m2",
|
|
}
|
|
unknown = sorted(set(payload) - allowed)
|
|
if unknown:
|
|
result = {
|
|
"status": "invalid_parameter",
|
|
"invalid": [{
|
|
"field": "request",
|
|
"message": "unknown request fields: " + ", ".join(unknown),
|
|
}],
|
|
}
|
|
else:
|
|
result = calculate_product_length(**payload)
|
|
log_access(auth.current_user(), "/api/calculations/product-length", "POST")
|
|
return jsonify(result), 200
|
|
|
|
|
|
@app.route("/api/calculations/roll/modify", methods=["POST"])
|
|
@auth.login_required
|
|
def modify_roll_calculation():
|
|
"""Apply explicit changes to structured calculation state and recalculate."""
|
|
payload = request.get_json(silent=True)
|
|
if not isinstance(payload, dict):
|
|
return jsonify({"status": "invalid_parameter", "invalid": [
|
|
{"field": "request", "message": "JSON object required"}
|
|
]}), 400
|
|
result = modify_calculation(
|
|
payload.get("state"),
|
|
payload.get("changes"),
|
|
build_info=BUILD_INFO,
|
|
)
|
|
log_access(auth.current_user(), "/api/calculations/roll/modify", "POST")
|
|
return jsonify(result), 200
|
|
|
|
|
|
@app.route("/api/calculations/transport", methods=["POST"])
|
|
@auth.login_required
|
|
def create_transport_calculation():
|
|
"""Execute the shared transport-capacity calculation."""
|
|
result = analyze_transport(request.get_json(silent=True))
|
|
log_access(auth.current_user(), "/api/calculations/transport", "POST")
|
|
return jsonify(result), 200
|
|
|
|
|
|
def _create_pdf(calculation_request):
|
|
result = calculate_roll(calculation_request, build_info=BUILD_INFO)
|
|
report = report_from_calculation_result(result)
|
|
generated_at = datetime.now().astimezone()
|
|
pdf = render_roll_report(
|
|
report,
|
|
generated_at=generated_at,
|
|
build_info=BUILD_INFO,
|
|
)
|
|
article_number = report["article"]["number"] if report["article"] else None
|
|
filename = safe_report_filename(article_number, generated_at)
|
|
return pdf, filename
|
|
|
|
|
|
@app.route("/api/reports/roll-calculation.pdf", methods=["POST"])
|
|
@auth.login_required
|
|
def create_roll_calculation_report():
|
|
"""Recalculate a request authoritatively and render its one-page PDF."""
|
|
try:
|
|
pdf, filename = _create_pdf(request.get_json(silent=True))
|
|
except ReportValidationError as error:
|
|
return jsonify({"error": str(error)}), 400
|
|
|
|
log_access(
|
|
auth.current_user(),
|
|
"/api/reports/roll-calculation.pdf",
|
|
"POST",
|
|
)
|
|
return send_file(
|
|
BytesIO(pdf),
|
|
mimetype="application/pdf",
|
|
as_attachment=True,
|
|
download_name=filename,
|
|
max_age=0,
|
|
)
|
|
|
|
|
|
@app.route("/api/conversations", methods=["POST"])
|
|
@auth.login_required
|
|
def create_conversation():
|
|
"""Create one process-local conversational calculation session."""
|
|
conversation_id = CONVERSATION_SERVICE.create_conversation()
|
|
log_access(auth.current_user(), "/api/conversations", "POST", 201)
|
|
return jsonify({
|
|
"status": "created",
|
|
"conversation_id": conversation_id,
|
|
}), 201
|
|
|
|
|
|
@app.route(
|
|
"/api/conversations/<conversation_id>/messages",
|
|
methods=["POST"],
|
|
)
|
|
@auth.login_required
|
|
def create_conversation_message(conversation_id):
|
|
"""Interpret one utterance and run the deterministic calculation workflow."""
|
|
payload = request.get_json(silent=True)
|
|
if not isinstance(payload, dict) or set(payload) != {"message"}:
|
|
return jsonify({
|
|
"status": "invalid_parameter",
|
|
"message": "JSON object with exactly one message field required",
|
|
}), 400
|
|
if not isinstance(payload["message"], str) or not payload["message"].strip():
|
|
return jsonify({
|
|
"status": "invalid_parameter",
|
|
"message": "message must be non-empty text",
|
|
}), 400
|
|
try:
|
|
result = CONVERSATION_SERVICE.handle_message(
|
|
conversation_id,
|
|
payload["message"],
|
|
)
|
|
except ConversationNotFoundError:
|
|
return jsonify({
|
|
"status": "conversation_not_found",
|
|
"message": "Conversation does not exist or has expired",
|
|
}), 404
|
|
|
|
status_code = 200
|
|
if result.get("status") == "nlu_error":
|
|
status_code = 503 if result.get("error") in {
|
|
"OllamaTimeoutError",
|
|
"OllamaUnavailableError",
|
|
} else 502
|
|
log_access(
|
|
auth.current_user(),
|
|
f"/api/conversations/{conversation_id}/messages",
|
|
"POST",
|
|
status_code,
|
|
)
|
|
return jsonify(result), status_code
|
|
|
|
|
|
@app.route(
|
|
"/api/conversations/reports/<report_id>.pdf",
|
|
methods=["GET"],
|
|
)
|
|
@auth.login_required
|
|
def get_conversation_report(report_id):
|
|
"""Recalculate a stored input-only report request and return its PDF."""
|
|
try:
|
|
calculation_request = CONVERSATION_SERVICE.report_request(report_id)
|
|
pdf, filename = _create_pdf(calculation_request)
|
|
except ConversationReportNotFoundError:
|
|
return jsonify({"error": "Conversation report not found"}), 404
|
|
except ReportValidationError as error:
|
|
return jsonify({"error": str(error)}), 400
|
|
|
|
log_access(
|
|
auth.current_user(),
|
|
f"/api/conversations/reports/{report_id}.pdf",
|
|
"GET",
|
|
)
|
|
return send_file(
|
|
BytesIO(pdf),
|
|
mimetype="application/pdf",
|
|
as_attachment=True,
|
|
download_name=filename,
|
|
max_age=0,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# ERROR HANDLERS
|
|
# ============================================================================
|
|
|
|
@app.errorhandler(401)
|
|
def unauthorized(e):
|
|
"""Handle 401 Unauthorized - browser will prompt for credentials"""
|
|
return jsonify({"error": "Unauthorized - please provide valid credentials"}), 401
|
|
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(e):
|
|
"""Handle 404 Not Found"""
|
|
return jsonify({"error": "Not found"}), 404
|
|
|
|
|
|
@app.errorhandler(500)
|
|
def internal_error(e):
|
|
"""Handle 500 Internal Server Error"""
|
|
return jsonify({"error": "Internal server error"}), 500
|
|
|
|
|
|
# ============================================================================
|
|
# STARTUP
|
|
# ============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
os.makedirs("templates", exist_ok=True)
|
|
os.makedirs("static", exist_ok=True)
|
|
|
|
print("""
|
|
╔═══════════════════════════════════════════════════════════╗
|
|
║ Naue Roll Calculator - V14 + QoL Update ║
|
|
║ HTTP Basic Auth Enabled ║
|
|
║ ║
|
|
║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║
|
|
║ ║
|
|
║ Starten auf: http://localhost:5000 ║
|
|
╚═══════════════════════════════════════════════════════════╝
|
|
""")
|
|
|
|
app.run(
|
|
host="0.0.0.0",
|
|
port=5000,
|
|
debug=False,
|
|
use_reloader=False
|
|
)
|