""" HSEQ SaaS Multi-Tenant — API Routes Flask blueprints voor tenant management API endpoints. Alle endpoints zijn versioned (/api/v2/) en tenant-isolated [1]. Blueprint structuur: - tenant_bp: Tenant CRUD + configuratie - module_bp: Module toggle management - admin_bp: User & role management (owner/admin only) Bronnen: [1] Phase 2 Systeemarchitectuur — phase2_system_architecture_v1.0.md [2] Phase 2 Database Architectuur — phase2_database_architecture_v1.0.md """ from __future__ import annotations import uuid from typing import Optional from flask import Blueprint, Flask, g, jsonify, request from models_v1_0 import ( VALID_PERMISSION_ACTIONS, VALID_PLANS, VALID_ROLES, VALID_STATUSES, Company, CompanyModule, CompanyPermission, CompanyUser, ) from company_manager_v1_0 import ( create_company, delete_company, get_company, list_companies, update_company, ) from module_manager_v1_0 import ( bulk_set_modules, disable_module, enable_module, get_all_modules_status, get_enabled_modules, is_module_enabled, update_module_config, ) from tenant_middleware_v1_0 import ( require_module, require_role, require_tenant, ) # --------------------------------------------------------------------------- # Blueprint definities # --------------------------------------------------------------------------- tenant_bp = Blueprint("tenant", __name__, url_prefix="/api/v2/tenant") module_bp = Blueprint("modules", __name__, url_prefix="/api/v2/modules") admin_bp = Blueprint("admin", __name__, url_prefix="/api/v2/admin") # --------------------------------------------------------------------------- # Tenant CRUD Endpoints # --------------------------------------------------------------------------- @tenant_bp.route("/", methods=["GET"]) @require_tenant def get_current_tenant(): """ GET /api/v2/tenant/ Retourneert de tenant-context van de huidige sessie. Response: 200: Tenant details inclusief modules en instellingen. """ tenant = g.tenant return jsonify({ "status": "success", "data": tenant.to_dict(), }), 200 @tenant_bp.route("/companies", methods=["GET"]) @require_tenant @require_role("owner", "admin") def list_companies_endpoint(): """ GET /api/v2/tenant/companies Lijst van bedrijven met filtering. Alleen toegankelijk voor owner/admin. Query params: status (optional): Filter op status. plan (optional): Filter op abonnement. limit (optional): Max resultaten (default 50, max 100). offset (optional): Paginering offset. Response: 200: Lijst van bedrijven. """ status = request.args.get("status") plan = request.args.get("plan") limit = min(int(request.args.get("limit", 50)), 100) offset = int(request.args.get("offset", 0)) db = g.db_session companies = list_companies( db, status=status, plan=plan, limit=limit, offset=offset, ) return jsonify({ "status": "success", "data": [ { "id": str(c.id), "name": c.name, "slug": c.slug, "plan": c.plan, "status": c.status, "brzo_tier": c.brzo_tier, "contact_email": c.contact_email, "created_at": c.created_at.isoformat(), } for c in companies ], "pagination": {"limit": limit, "offset": offset}, }), 200 @tenant_bp.route("/companies", methods=["POST"]) @require_tenant @require_role("owner") def create_company_endpoint(): """ POST /api/v2/tenant/companies Maakt een nieuw bedrijf (tenant) aan. Alleen owner-rol. Body: name (required): Bedrijfsnaam. contact_email (required): Contact e-mail. plan (optional): Abonnementstype (default: starter). legal_name, kvk_number, brzo_tier, address, contact_phone, logo_url, settings. Response: 201: Aangemaakt bedrijf met UUID. 400: Validatiefout. """ data = request.get_json() if not data: return jsonify({"error": "Bad Request", "message": "JSON body vereist."}), 400 name = data.get("name") contact_email = data.get("contact_email") if not name or not contact_email: return jsonify({ "error": "Bad Request", "message": "Velden 'name' en 'contact_email' zijn verplicht.", }), 400 db = g.db_session try: company = create_company( db, name=name, contact_email=contact_email, plan=data.get("plan", "starter"), legal_name=data.get("legal_name"), kvk_number=data.get("kvk_number"), brzo_tier=data.get("brzo_tier"), address=data.get("address"), contact_phone=data.get("contact_phone"), logo_url=data.get("logo_url"), settings=data.get("settings"), ) db.commit() except ValueError as exc: return jsonify({"error": "Bad Request", "message": str(exc)}), 400 return jsonify({ "status": "success", "data": { "id": str(company.id), "slug": company.slug, "name": company.name, "plan": company.plan, "status": company.status, }, }), 201 @tenant_bp.route("/companies/", methods=["GET"]) @require_tenant @require_role("owner", "admin") def get_company_endpoint(company_id: uuid.UUID): """ GET /api/v2/tenant/companies/ Haalt bedrijfsgegevens op. Owner/admin only. Response: 200: Bedrijfsgegevens. 404: Niet gevonden. """ db = g.db_session company = get_company(db, company_id) if company is None: return jsonify({"error": "Not Found", "message": "Bedrijf niet gevonden."}), 404 return jsonify({ "status": "success", "data": { "id": str(company.id), "name": company.name, "slug": company.slug, "legal_name": company.legal_name, "kvk_number": company.kvk_number, "brzo_tier": company.brzo_tier, "address": company.address, "contact_email": company.contact_email, "contact_phone": company.contact_phone, "logo_url": company.logo_url, "plan": company.plan, "status": company.status, "settings": company.settings, "created_at": company.created_at.isoformat(), "updated_at": company.updated_at.isoformat(), }, }), 200 @tenant_bp.route("/companies/", methods=["PUT"]) @require_tenant @require_role("owner", "admin") def update_company_endpoint(company_id: uuid.UUID): """ PUT /api/v2/tenant/companies/ Werkt bedrijfsgegevens bij. Owner/admin only. Body: Alle velden optioneel — alleen opgegeven velden worden gewijzigd. Response: 200: Bijgewerkt bedrijf. 400: Validatiefout. 404: Niet gevonden. """ data = request.get_json() if not data: return jsonify({"error": "Bad Request", "message": "JSON body vereist."}), 400 db = g.db_session try: company = update_company( db, company_id, name=data.get("name"), legal_name=data.get("legal_name"), kvk_number=data.get("kvk_number"), brzo_tier=data.get("brzo_tier"), address=data.get("address"), contact_email=data.get("contact_email"), contact_phone=data.get("contact_phone"), logo_url=data.get("logo_url"), plan=data.get("plan"), status=data.get("status"), settings=data.get("settings"), ) if company is None: return jsonify({"error": "Not Found", "message": "Bedrijf niet gevonden."}), 404 db.commit() except ValueError as exc: return jsonify({"error": "Bad Request", "message": str(exc)}), 400 return jsonify({ "status": "success", "data": { "id": str(company.id), "name": company.name, "plan": company.plan, "status": company.status, }, }), 200 @tenant_bp.route("/companies/", methods=["DELETE"]) @require_tenant @require_role("owner") def delete_company_endpoint(company_id: uuid.UUID): """ DELETE /api/v2/tenant/companies/ Deactiveert een bedrijf (soft delete → status 'churned'). Owner only. Hard delete wordt niet ondersteund i.v.m. BRZO-bewaarplicht [2]. Response: 200: Succes. 404: Niet gevonden. """ db = g.db_session success = delete_company(db, company_id) if not success: return jsonify({"error": "Not Found", "message": "Bedrijf niet gevonden."}), 404 db.commit() return jsonify({ "status": "success", "message": f"Bedrijf {company_id} gedeactiveerd.", }), 200 # --------------------------------------------------------------------------- # Module Management Endpoints # --------------------------------------------------------------------------- @module_bp.route("/", methods=["GET"]) @require_tenant def list_modules_endpoint(): """ GET /api/v2/modules/ Retourneert status van alle modules voor de huidige tenant. Response: 200: Lijst van modules met enabled-status en configuratie. """ db = g.db_session tenant = g.tenant modules = get_all_modules_status(db, tenant.company_id) return jsonify({ "status": "success", "data": modules, }), 200 @module_bp.route("/", methods=["GET"]) @require_tenant def get_module_endpoint(module_key: str): """ GET /api/v2/modules/ Controleert of een specifieke module ingeschakeld is. Response: 200: Module status. """ db = g.db_session tenant = g.tenant enabled = is_module_enabled(db, tenant.company_id, module_key) return jsonify({ "status": "success", "data": { "module_key": module_key, "enabled": enabled, }, }), 200 @module_bp.route("//enable", methods=["POST"]) @require_tenant @require_role("owner", "admin") def enable_module_endpoint(module_key: str): """ POST /api/v2/modules//enable Schakelt een module in. Owner/admin only. Body (optional): config: Module-specifieke configuratie. Response: 200: Module ingeschakeld. 400: Niet beschikbaar voor huidig plan. 403: Onvoldoende rechten. """ data = request.get_json(silent=True) or {} db = g.db_session tenant = g.tenant try: module = enable_module( db, tenant.company_id, module_key, plan=tenant.plan, config=data.get("config"), ) db.commit() except ValueError as exc: return jsonify({"error": "Bad Request", "message": str(exc)}), 400 if module is None: return jsonify({ "error": "Bad Request", "message": f"Module '{module_key}' niet beschikbaar voor plan '{tenant.plan}'.", }), 400 return jsonify({ "status": "success", "data": { "module_key": module.module_key, "enabled": module.enabled, }, }), 200 @module_bp.route("//disable", methods=["POST"]) @require_tenant @require_role("owner", "admin") def disable_module_endpoint(module_key: str): """ POST /api/v2/modules//disable Schakelt een module uit. Owner/admin only. Response: 200: Module uitgeschakeld. 404: Module niet gevonden. """ db = g.db_session tenant = g.tenant module = disable_module(db, tenant.company_id, module_key) if module is None: return jsonify({ "error": "Not Found", "message": f"Module '{module_key}' niet gevonden voor deze tenant.", }), 404 db.commit() return jsonify({ "status": "success", "data": { "module_key": module.module_key, "enabled": module.enabled, }, }), 200 @module_bp.route("//config", methods=["PUT"]) @require_tenant @require_role("owner", "admin") def update_module_config_endpoint(module_key: str): """ PUT /api/v2/modules//config Werkt module-configuratie bij. Owner/admin only. Body: config: Nieuwe configuratie (wordt gemerged met bestaand). Response: 200: Configuratie bijgewerkt. 404: Module niet gevonden. """ data = request.get_json() if not data or "config" not in data: return jsonify({ "error": "Bad Request", "message": "Veld 'config' is verplicht.", }), 400 db = g.db_session tenant = g.tenant module = update_module_config( db, tenant.company_id, module_key, data["config"], ) if module is None: return jsonify({ "error": "Not Found", "message": f"Module '{module_key}' niet gevonden.", }), 404 db.commit() return jsonify({ "status": "success", "data": { "module_key": module.module_key, "config": module.config, }, }), 200 @module_bp.route("/bulk", methods=["POST"]) @require_tenant @require_role("owner", "admin") def bulk_set_modules_endpoint(): """ POST /api/v2/modules/bulk Stelt modules in bulk in. Owner/admin only. Body: modules: Lijst van in te schakelen module keys. Response: 200: Modules bijgewerkt. 400: Validatiefout. """ data = request.get_json() if not data or "modules" not in data: return jsonify({ "error": "Bad Request", "message": "Veld 'modules' (lijst) is verplicht.", }), 400 db = g.db_session tenant = g.tenant module_keys = data["modules"] try: modules = bulk_set_modules( db, tenant.company_id, module_keys, plan=tenant.plan, ) db.commit() except ValueError as exc: return jsonify({"error": "Bad Request", "message": str(exc)}), 400 return jsonify({ "status": "success", "data": [ {"module_key": m.module_key, "enabled": m.enabled} for m in modules ], }), 200 # --------------------------------------------------------------------------- # Admin Endpoints (User & Permission Management) # --------------------------------------------------------------------------- @admin_bp.route("/users", methods=["GET"]) @require_tenant @require_role("owner", "admin") def list_users_endpoint(): """ GET /api/v2/admin/users Lijst van gebruikers binnen de huidige tenant. Response: 200: Lijst van CompanyUser objecten. """ db = g.db_session tenant = g.tenant users = ( db.query(CompanyUser) .filter_by(company_id=tenant.company_id) .all() ) return jsonify({ "status": "success", "data": [ { "id": str(u.id), "user_id": str(u.user_id), "role": u.role, "status": u.status, "joined_at": u.joined_at.isoformat() if u.joined_at else None, } for u in users ], }), 200 @admin_bp.route("/users", methods=["POST"]) @require_tenant @require_role("owner", "admin") def create_user_endpoint(): """ POST /api/v2/admin/users Voegt een gebruiker toe aan de huidige tenant. Body: user_id (required): UUID van de gebruiker. role (required): Rol binnen de tenant. Response: 201: Gebruiker toegevoegd. 400: Validatiefout. """ data = request.get_json() if not data: return jsonify({"error": "Bad Request", "message": "JSON body vereist."}), 400 user_id = data.get("user_id") role = data.get("role") if not user_id or not role: return jsonify({ "error": "Bad Request", "message": "Velden 'user_id' en 'role' zijn verplicht.", }), 400 if role not in VALID_ROLES: return jsonify({ "error": "Bad Request", "message": f"Ongeldige rol '{role}'. Geldig: {', '.join(VALID_ROLES)}.", }), 400 db = g.db_session tenant = g.tenant try: user_id_uuid = uuid.UUID(user_id) except ValueError: return jsonify({"error": "Bad Request", "message": "Ongeldig user_id formaat."}), 400 company_user = CompanyUser( user_id=user_id_uuid, company_id=tenant.company_id, role=role, status="active", ) db.add(company_user) db.commit() return jsonify({ "status": "success", "data": { "id": str(company_user.id), "user_id": str(company_user.user_id), "role": company_user.role, "status": company_user.status, }, }), 201 @admin_bp.route("/users/", methods=["PUT"]) @require_tenant @require_role("owner", "admin") def update_user_endpoint(user_id: uuid.UUID): """ PUT /api/v2/admin/users/ Werkt de rol of status van een gebruiker bij. Body: role (optional): Nieuwe rol. status (optional): Nieuwe status. Response: 200: Bijgewerkt. 404: Niet gevonden. """ data = request.get_json() if not data: return jsonify({"error": "Bad Request", "message": "JSON body vereist."}), 400 db = g.db_session tenant = g.tenant user = ( db.query(CompanyUser) .filter_by(id=user_id, company_id=tenant.company_id) .first() ) if user is None: return jsonify({"error": "Not Found", "message": "Gebruiker niet gevonden."}), 404 if "role" in data: if data["role"] not in VALID_ROLES: return jsonify({ "error": "Bad Request", "message": f"Ongeldige rol. Geldig: {', '.join(VALID_ROLES)}.", }), 400 user.role = data["role"] if "status" in data: if data["status"] not in ("active", "deactivated"): return jsonify({ "error": "Bad Request", "message": "Ongeldige status. Geldig: active, deactivated.", }), 400 user.status = data["status"] db.commit() return jsonify({ "status": "success", "data": { "id": str(user.id), "role": user.role, "status": user.status, }, }), 200 @admin_bp.route("/users/", methods=["DELETE"]) @require_tenant @require_role("owner") def remove_user_endpoint(user_id: uuid.UUID): """ DELETE /api/v2/admin/users/ Verwijdert een gebruiker uit de tenant. Owner only. Response: 200: Verwijderd. 404: Niet gevonden. """ db = g.db_session tenant = g.tenant user = ( db.query(CompanyUser) .filter_by(id=user_id, company_id=tenant.company_id) .first() ) if user is None: return jsonify({"error": "Not Found", "message": "Gebruiker niet gevonden."}), 404 db.delete(user) db.commit() return jsonify({"status": "success", "message": "Gebruiker verwijderd."}), 200 @admin_bp.route("/permissions", methods=["GET"]) @require_tenant @require_role("owner", "admin") def list_permissions_endpoint(): """ GET /api/v2/admin/permissions Retourneert permissie-configuratie voor de huidige tenant. Query params: role (optional): Filter op specifieke rol. Response: 200: Lijst van permissies. """ db = g.db_session tenant = g.tenant role_filter = request.args.get("role") query = db.query(CompanyPermission).filter( (CompanyPermission.company_id == tenant.company_id) | (CompanyPermission.company_id.is_(None)) ) if role_filter: query = query.filter_by(role=role_filter) permissions = query.all() return jsonify({ "status": "success", "data": [ { "id": str(p.id), "role": p.role, "resource": p.resource, "action": p.action, "allowed": p.allowed, "company_id": str(p.company_id) if p.company_id else None, } for p in permissions ], }), 200 # --------------------------------------------------------------------------- # Blueprint registratie # --------------------------------------------------------------------------- def register_blueprints(app: Flask) -> None: """ Registreert alle API blueprints op de Flask applicatie. Args: app: Flask applicatie-instantie. """ app.register_blueprint(tenant_bp) app.register_blueprint(module_bp) app.register_blueprint(admin_bp)