""" 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 machine_constraints import ( calculate_machine_max_product_length, check_production_feasibility, ) 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/", 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 @app.route("/api/calculations/production-feasibility", methods=["POST"]) @auth.login_required def create_production_feasibility_calculation(): """Check configured production-machine constraints through the shared domain.""" result = check_production_feasibility(request.get_json(silent=True)) log_access( auth.current_user(), "/api/calculations/production-feasibility", "POST", ) return jsonify(result), 200 @app.route("/api/calculations/machine-max-product-length", methods=["POST"]) @auth.login_required def create_machine_max_product_length_calculation(): """Calculate machine-limited product length through the shared domain.""" payload = request.get_json(silent=True) allowed = { "machine", "core_diameter_mm", "thickness_mm", "thickness_stddev_mm", "product_width_m", "area_weight_g_m2", } if not isinstance(payload, dict): result = { "status": "invalid_parameter", "invalid": [{"field": "request", "message": "JSON object required"}], } else: unknown = sorted(set(payload) - allowed) if unknown: result = { "status": "invalid_parameter", "invalid": [{ "field": "request", "message": "unknown request fields: " + ", ".join(unknown), }], } else: result = calculate_machine_max_product_length( machine=payload.get("machine"), core_diameter_mm=payload.get("core_diameter_mm"), thickness_mm=payload.get("thickness_mm"), thickness_stddev_mm=payload.get("thickness_stddev_mm"), product_width_m=payload.get("product_width_m"), area_weight_g_m2=payload.get("area_weight_g_m2"), ) log_access( auth.current_user(), "/api/calculations/machine-max-product-length", "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//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/.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 )