diff --git a/common/models/user.py b/common/models/user.py index ce6bd4b..4fff20e 100644 --- a/common/models/user.py +++ b/common/models/user.py @@ -316,8 +316,8 @@ class TenantConsent(db.Model): __table_args__ = {'schema': 'public'} id = db.Column(db.Integer, primary_key=True) tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False) - partner_id = db.Column(db.Integer, db.ForeignKey('public.partner.id'), nullable=False) - partner_service_id = db.Column(db.Integer, db.ForeignKey('public.partner_service.id'), nullable=False) + partner_id = db.Column(db.Integer, db.ForeignKey('public.partner.id'), nullable=True) + partner_service_id = db.Column(db.Integer, db.ForeignKey('public.partner_service.id'), nullable=True) user_id = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=False) consent_type = db.Column(db.String(50), nullable=False) consent_date = db.Column(db.DateTime, nullable=False, server_default=db.func.now()) diff --git a/common/services/user/__init__.py b/common/services/user/__init__.py index adac945..f1e5463 100644 --- a/common/services/user/__init__.py +++ b/common/services/user/__init__.py @@ -1,5 +1,6 @@ from common.services.user.user_services import UserServices from common.services.user.partner_services import PartnerServices from common.services.user.tenant_services import TenantServices +from common.services.user.consent_service import ConsentService -__all__ = ['UserServices', 'PartnerServices', 'TenantServices'] \ No newline at end of file +__all__ = ['UserServices', 'PartnerServices', 'TenantServices', 'ConsentService'] \ No newline at end of file diff --git a/common/services/user/consent_service.py b/common/services/user/consent_service.py new file mode 100644 index 0000000..deb65e8 --- /dev/null +++ b/common/services/user/consent_service.py @@ -0,0 +1,254 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime as dt, timezone as tz +from typing import List, Optional, Tuple, Dict + +from flask import current_app, request, session +from flask_security import current_user +from sqlalchemy import desc +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from common.extensions import db +from common.models.user import TenantConsent, ConsentVersion, ConsentStatus, PartnerService, PartnerTenant, Tenant + + +@dataclass +class TypeStatus: + consent_type: str + status: ConsentStatus + active_version: Optional[str] + last_version: Optional[str] + + +class ConsentService: + @staticmethod + def get_required_consent_types() -> List[str]: + return list(current_app.config.get("CONSENT_TYPES", [])) + + @staticmethod + def get_active_consent_version(consent_type: str) -> Optional[ConsentVersion]: + try: + # Active version: the one with consent_valid_to IS NULL, latest for this type + return (ConsentVersion.query + .filter_by(consent_type=consent_type, consent_valid_to=None) + .order_by(desc(ConsentVersion.consent_valid_from)) + .first()) + except SQLAlchemyError as e: + current_app.logger.error(f"DB error in get_active_consent_version({consent_type}): {e}") + return None + + @staticmethod + def get_tenant_last_consent(tenant_id: int, consent_type: str) -> Optional[TenantConsent]: + try: + return (TenantConsent.query + .filter_by(tenant_id=tenant_id, consent_type=consent_type) + .order_by(desc(TenantConsent.id)) + .first()) + except SQLAlchemyError as e: + current_app.logger.error(f"DB error in get_tenant_last_consent({tenant_id}, {consent_type}): {e}") + return None + + @staticmethod + def evaluate_type_status(tenant_id: int, consent_type: str) -> TypeStatus: + active = ConsentService.get_active_consent_version(consent_type) + if not active: + current_app.logger.error(f"No active ConsentVersion found for type {consent_type}") + return TypeStatus(consent_type, ConsentStatus.UNKNOWN_CONSENT_VERSION, None, None) + + last = ConsentService.get_tenant_last_consent(tenant_id, consent_type) + if not last: + return TypeStatus(consent_type, ConsentStatus.NOT_CONSENTED, active.consent_version, None) + + # If last consent equals active → CONSENTED + if last.consent_version == active.consent_version: + return TypeStatus(consent_type, ConsentStatus.CONSENTED, active.consent_version, last.consent_version) + + # Else: last refers to an older version; check its ConsentVersion to see grace period + prev_cv = ConsentVersion.query.filter_by(consent_type=consent_type, + consent_version=last.consent_version).first() + if not prev_cv: + current_app.logger.error(f"Tenant {tenant_id} references unknown ConsentVersion {last.consent_version} for {consent_type}") + return TypeStatus(consent_type, ConsentStatus.UNKNOWN_CONSENT_VERSION, active.consent_version, last.consent_version) + + if prev_cv.consent_valid_to: + now = dt.now(tz.utc) + if prev_cv.consent_valid_to >= now: + # Within transition window + return TypeStatus(consent_type, ConsentStatus.RENEWAL_REQUIRED, active.consent_version, last.consent_version) + else: + return TypeStatus(consent_type, ConsentStatus.NOT_CONSENTED, active.consent_version, last.consent_version) + else: + # Should not happen if a newer active exists; treat as unknown config + current_app.logger.error(f"Previous ConsentVersion without valid_to while a newer active exists for {consent_type}") + return TypeStatus(consent_type, ConsentStatus.UNKNOWN_CONSENT_VERSION, active.consent_version, last.consent_version) + + @staticmethod + def aggregate_status(type_statuses: List[TypeStatus]) -> ConsentStatus: + # Priority: UNKNOWN > NOT_CONSENTED > RENEWAL_REQUIRED > CONSENTED + priorities = { + ConsentStatus.UNKNOWN_CONSENT_VERSION: 4, + ConsentStatus.NOT_CONSENTED: 3, + ConsentStatus.RENEWAL_REQUIRED: 2, + ConsentStatus.CONSENTED: 1, + } + if not type_statuses: + return ConsentStatus.CONSENTED + worst = max(type_statuses, key=lambda ts: priorities.get(ts.status, 0)) + return worst.status + + @staticmethod + def get_consent_status(tenant_id: int) -> ConsentStatus: + statuses = [ConsentService.evaluate_type_status(tenant_id, ct) for ct in ConsentService.get_required_consent_types()] + return ConsentService.aggregate_status(statuses) + + @staticmethod + def _is_tenant_admin_for(tenant_id: int) -> bool: + try: + return current_user.is_authenticated and current_user.has_roles('Tenant Admin') and getattr(current_user, 'tenant_id', None) == tenant_id + except Exception: + return False + + @staticmethod + def _is_management_partner_for(tenant_id: int) -> Tuple[bool, Optional[int], Optional[int]]: + """Return (allowed, partner_id, partner_service_id) for management partner context.""" + try: + if not (current_user.is_authenticated and current_user.has_roles('Partner Admin')): + return False, None, None + # Check PartnerTenant relationship via MANAGEMENT_SERVICE + ps = PartnerService.query.filter_by(type='MANAGEMENT_SERVICE').all() + if not ps: + return False, None, None + ps_ids = [p.id for p in ps] + pt = PartnerTenant.query.filter_by(tenant_id=tenant_id).filter(PartnerTenant.partner_service_id.in_(ps_ids)).first() + if not pt: + return False, None, None + the_ps = PartnerService.query.get(pt.partner_service_id) + return True, the_ps.partner_id if the_ps else None, the_ps.id if the_ps else None + except Exception as e: + current_app.logger.error(f"Error in _is_management_partner_for: {e}") + return False, None, None + + @staticmethod + def can_consent_on_behalf(tenant_id: int) -> Tuple[bool, str, Optional[int], Optional[int]]: + # Returns: allowed, mode('tenant_admin'|'management_partner'), partner_id, partner_service_id + if ConsentService._is_tenant_admin_for(tenant_id): + return True, 'tenant_admin', None, None + allowed, partner_id, partner_service_id = ConsentService._is_management_partner_for(tenant_id) + if allowed: + return True, 'management_partner', partner_id, partner_service_id + return False, 'none', None, None + + @staticmethod + def _resolve_consent_content(consent_type: str, version: str) -> Dict: + """Resolve canonical file ref and hash for a consent document. + Uses configurable base dir, type subpaths, and patch-dir strategy. + Defaults: + - base: 'content' + - map: {'Data Privacy Agreement':'dpa','Terms & Conditions':'terms'} + - strategy: 'major_minor' -> a.b.c => a.b/a.b.c.md + - ext: '.md' + """ + import hashlib + from pathlib import Path + + cfg = current_app.config if current_app else {} + base_dir = cfg.get('CONSENT_CONTENT_BASE_DIR', 'content') + type_paths = cfg.get('CONSENT_TYPE_PATHS', { + 'Data Privacy Agreement': 'dpa', + 'Terms & Conditions': 'terms', + }) + strategy = cfg.get('CONSENT_PATCH_DIR_STRATEGY', 'major_minor') + ext = cfg.get('CONSENT_MARKDOWN_EXT', '.md') + + type_dir = type_paths.get(consent_type, consent_type.lower().replace(' ', '_')) + subpath = '' + filename = f"{version}{ext}" + try: + parts = version.split('.') + if strategy == 'major_minor' and len(parts) >= 2: + subpath = f"{parts[0]}.{parts[1]}" + filename = f"{parts[0]}.{parts[1]}.{parts[2] if len(parts)>2 else '0'}{ext}" + # Build canonical path + if subpath: + canonical_ref = f"{base_dir}/{type_dir}/{subpath}/{filename}" + else: + canonical_ref = f"{base_dir}/{type_dir}/{filename}" + except Exception: + canonical_ref = f"{base_dir}/{type_dir}/{version}{ext}" + + # Read file and hash + content_hash = '' + try: + # project root = parent of app package + root = Path(current_app.root_path).parent if current_app else Path('.') + fpath = root / canonical_ref + content_bytes = fpath.read_bytes() if fpath.exists() else b'' + content_hash = hashlib.sha256(content_bytes).hexdigest() if content_bytes else '' + except Exception: + content_hash = '' + + return { + 'canonical_document_ref': canonical_ref, + 'content_hash': content_hash, + } + + @staticmethod + def record_consent(tenant_id: int, consent_type: str) -> TenantConsent: + # Validate type + if consent_type not in ConsentService.get_required_consent_types(): + raise ValueError(f"Unknown consent type: {consent_type}") + active = ConsentService.get_active_consent_version(consent_type) + if not active: + raise RuntimeError(f"No active ConsentVersion for type {consent_type}") + + allowed, mode, partner_id, partner_service_id = ConsentService.can_consent_on_behalf(tenant_id) + if not allowed: + raise PermissionError("Not authorized to record consent for this tenant") + + # Idempotency: if already consented for active version, return existing + existing = (TenantConsent.query + .filter_by(tenant_id=tenant_id, consent_type=consent_type, consent_version=active.consent_version) + .first()) + if existing: + return existing + + # Build consent_data with audit info + ip = request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or request.remote_addr or '' + ua = request.headers.get('User-Agent', '') + locale = session.get('locale') or request.accept_languages.best or '' + content_meta = ConsentService._resolve_consent_content(consent_type, active.consent_version) + consent_data = { + 'source_ip': ip, + 'user_agent': ua, + 'locale': locale, + **content_meta, + } + + tc = TenantConsent( + tenant_id=tenant_id, + partner_id=partner_id, + partner_service_id=partner_service_id, + user_id=getattr(current_user, 'id', None) or 0, + consent_type=consent_type, + consent_version=active.consent_version, + consent_data=consent_data, + ) + try: + db.session.add(tc) + db.session.commit() + current_app.logger.info(f"Consent recorded: tenant={tenant_id}, type={consent_type}, version={active.consent_version}, mode={mode}, user={getattr(current_user, 'id', None)}") + return tc + except IntegrityError as e: + db.session.rollback() + # In case of race, fetch existing + current_app.logger.warning(f"IntegrityError on consent insert, falling back: {e}") + existing = (TenantConsent.query + .filter_by(tenant_id=tenant_id, consent_type=consent_type, consent_version=active.consent_version) + .first()) + if existing: + return existing + raise + except SQLAlchemyError as e: + db.session.rollback() + current_app.logger.error(f"DB error in record_consent: {e}") + raise diff --git a/common/services/user/tenant_services.py b/common/services/user/tenant_services.py index 10d3657..3f96472 100644 --- a/common/services/user/tenant_services.py +++ b/common/services/user/tenant_services.py @@ -177,52 +177,6 @@ class TenantServices: @staticmethod def get_consent_status(tenant_id: int) -> ConsentStatus: - cts = current_app.config.get("CONSENT_TYPES") - status = ConsentStatus.CONSENTED - for ct in cts: - consent = (TenantConsent.query.filter_by(tenant_id=tenant_id, consent_type=ct) - .order_by(desc(TenantConsent.id)) - .first()) - if not consent: - status = ConsentStatus.NOT_CONSENTED - break - cv = ConsentVersion.query.filter_by(consent_type=ct, consent_version=consent.consent_version).first() - if not cv: - current_app.logger.error(f"Consent version {consent.consent_version} not found checking tenant {tenant_id}") - status = ConsentStatus.UNKNOWN_CONSENT_VERSION - break - if cv.consent_valid_to: - if cv.consent_valid_to.date() >= dt.now(tz.utc).date(): - status = ConsentStatus.RENEWAL_REQUIRED - break - else: - status = ConsentStatus.NOT_CONSENTED - break - - return status - - @staticmethod - def get_consent_status_details(tenant_id: int) -> Dict[str, str]: - cts = current_app.config.get("CONSENT_TYPES") - details = {} - for ct in cts: - ct = cv.consent_type - consent = (TenantConsent.query.filter_by(tenant_id=tenant_id, consent_type=ct) - .order_by(desc(TenantConsent.id)) - .first()) - if not consent: - details[ct] = { - 'status': str(ConsentStatus.NOT_CONSENTED), - 'version': str(cv.consent_version) - } - continue - if cv.consent_valid_to.date >= dt.now(tz.utc).date(): - details[ct] = { - 'status': str(ConsentStatus.RENEWAL_REQUIRED), - 'version': str(cv.consent_version) - } - - details[ct] = { - 'status': str(ConsentStatus.CONSENTED), - 'version': str(cv.consent_version) - } + # Delegate to centralized ConsentService to ensure consistent logic + from common.services.user.consent_service import ConsentService + return ConsentService.get_consent_status(tenant_id) diff --git a/common/utils/security_utils.py b/common/utils/security_utils.py index c4105b9..68c1918 100644 --- a/common/utils/security_utils.py +++ b/common/utils/security_utils.py @@ -117,7 +117,10 @@ def is_exempt_endpoint(endpoint: str) -> bool: 'user_bp.no_consent', 'user_bp.tenant_consent_renewal', 'user_bp.consent_renewal', - 'security_bp.consent_sign', + 'user_bp.view_tenant_consents', + 'user_bp.accept_tenant_consent', + 'user_bp.view_consent_markdown', + 'basic_bp.view_content', } default_prefixes = [ 'security_bp.', @@ -160,7 +163,18 @@ def enforce_tenant_consent_ui(): if not tenant_id: return redirect(prefixed_url_for('security_bp.login', for_redirect=True)) - status = session.get('consent_status', ConsentStatus.NOT_CONSENTED) + raw_status = session.get('consent_status', ConsentStatus.NOT_CONSENTED) + # Coerce string to ConsentStatus enum if needed + status = raw_status + try: + if isinstance(raw_status, str): + # Accept formats like 'CONSENTED' or 'ConsentStatus.CONSENTED' + name = raw_status.split('.')[-1] + from common.models.user import ConsentStatus as CS + status = getattr(CS, name, CS.NOT_CONSENTED) + except Exception: + status = ConsentStatus.NOT_CONSENTED + if status == ConsentStatus.CONSENTED: current_app.logger.debug('User has consented') return None diff --git a/config/static-manifest/manifest.json b/config/static-manifest/manifest.json index 9bc0bcc..f5e2a58 100644 --- a/config/static-manifest/manifest.json +++ b/config/static-manifest/manifest.json @@ -1,6 +1,6 @@ { "dist/chat-client.js": "dist/chat-client.59b28883.js", "dist/chat-client.css": "dist/chat-client.79757200.css", - "dist/main.js": "dist/main.f3dde0f6.js", - "dist/main.css": "dist/main.c40e57ad.css" + "dist/main.js": "dist/main.c5b0c81d.js", + "dist/main.css": "dist/main.06893f70.css" } \ No newline at end of file diff --git a/docker/compose_dev.yaml b/docker/compose_dev.yaml index 150319b..a7f4a0f 100644 --- a/docker/compose_dev.yaml +++ b/docker/compose_dev.yaml @@ -390,22 +390,6 @@ services: networks: - eveai-dev-network -# flower: -# image: ${REGISTRY_PREFIX:-}josakola/flower:latest -# build: -# context: .. -# dockerfile: ./docker/flower/Dockerfile -# environment: -# <<: *common-variables -# volumes: -# - ../scripts:/app/scripts -# ports: -# - "3007:5555" # Dev Flower volgens port schema -# depends_on: -# - redis -# networks: -# - eveai-dev-network - flower: image: mher/flower:latest environment: @@ -469,25 +453,25 @@ services: networks: - eveai-dev-network - grafana: - image: ${REGISTRY_PREFIX:-}josakola/grafana:latest - build: - context: ./grafana - dockerfile: Dockerfile - ports: - - "3012:3000" # Dev Grafana volgens port schema - volumes: - - ./grafana/provisioning:/etc/grafana/provisioning - - ./grafana/data:/var/lib/grafana - environment: - - GF_SECURITY_ADMIN_USER=admin - - GF_SECURITY_ADMIN_PASSWORD=admin - - GF_USERS_ALLOW_SIGN_UP=false - restart: unless-stopped - depends_on: - - prometheus - networks: - - eveai-dev-network +# grafana: +# image: ${REGISTRY_PREFIX:-}josakola/grafana:latest +# build: +# context: ./grafana +# dockerfile: Dockerfile +# ports: +# - "3012:3000" # Dev Grafana volgens port schema +# volumes: +# - ./grafana/provisioning:/etc/grafana/provisioning +# - ./grafana/data:/var/lib/grafana +# environment: +# - GF_SECURITY_ADMIN_USER=admin +# - GF_SECURITY_ADMIN_PASSWORD=admin +# - GF_USERS_ALLOW_SIGN_UP=false +# restart: unless-stopped +# depends_on: +# - prometheus +# networks: +# - eveai-dev-network networks: eveai-dev-network: diff --git a/eveai_app/static/assets/css/eveai-consent-viewer.css b/eveai_app/static/assets/css/eveai-consent-viewer.css new file mode 100644 index 0000000..f7bfe8c --- /dev/null +++ b/eveai_app/static/assets/css/eveai-consent-viewer.css @@ -0,0 +1,30 @@ +/* Consent Viewer specific styles */ + +/* Ensure markdown text aligns left by default */ +#consent-document-viewer .markdown-body, +.markdown-body { + text-align: left; +} + +/* Make the viewer pane scrollable with a responsive max height */ +#consent-document-viewer { + max-height: 60vh; + overflow: auto; +} + +/* Optional readability improvements */ +#consent-document-viewer .markdown-body { + line-height: 1.6; +} +#consent-document-viewer .markdown-body pre, +#consent-document-viewer .markdown-body code { + white-space: pre-wrap; +} + +/* Optional: keep the viewer header visible while scrolling */ +#consent-viewer-section .card-header { + position: sticky; + top: 0; + z-index: 1; + background: #fff; +} diff --git a/eveai_app/static/assets/js/eveai-consent-viewer.js b/eveai_app/static/assets/js/eveai-consent-viewer.js new file mode 100644 index 0000000..ea09565 --- /dev/null +++ b/eveai_app/static/assets/js/eveai-consent-viewer.js @@ -0,0 +1,53 @@ +// Centralized consent viewer JS +// Attaches a single delegated click handler to load consent markdown fragments +(function() { + function initConsentViewer() { + const viewerSection = document.getElementById('consent-viewer-section'); + const viewer = document.getElementById('consent-document-viewer'); + const vType = document.getElementById('viewer-type'); + const vVer = document.getElementById('viewer-version'); + const loading = document.getElementById('viewer-loading'); + + if (!viewerSection || !viewer) { + // Page without consent viewer; do nothing + return; + } + + document.addEventListener('click', async function(e) { + const btn = e.target.closest('.btn-view-consent'); + if (!btn) return; + e.preventDefault(); + + const url = btn.getAttribute('data-url'); + const type = btn.getAttribute('data-consent-type') || ''; + const version = btn.getAttribute('data-version') || ''; + if (!url) { + console.warn('Consent viewer: data-url missing on button'); + return; + } + + try { + viewerSection.style.display = 'block'; + if (vType) vType.textContent = type; + if (vVer) vVer.textContent = version; + if (loading) loading.style.display = 'block'; + viewer.innerHTML = ''; + + const resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + const html = await resp.text(); + viewer.innerHTML = html; + } catch (e) { + viewer.innerHTML = '
Failed to load document.
'; + } finally { + if (loading) loading.style.display = 'none'; + viewerSection.scrollIntoView({ behavior: 'smooth' }); + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initConsentViewer); + } else { + initConsentViewer(); + } +})(); diff --git a/eveai_app/templates/head.html b/eveai_app/templates/head.html index 4e4fda6..34a0759 100644 --- a/eveai_app/templates/head.html +++ b/eveai_app/templates/head.html @@ -14,6 +14,8 @@ + + diff --git a/eveai_app/templates/navbar.html b/eveai_app/templates/navbar.html index dfe07ed..0bf2c31 100644 --- a/eveai_app/templates/navbar.html +++ b/eveai_app/templates/navbar.html @@ -73,6 +73,9 @@ {'name': 'Consent Versions', 'url': 'user/consent_versions', 'roles': ['Super User']}, {'name': 'Tenant Overview', 'url': 'user/tenant_overview', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']}, {'name': 'Edit Tenant', 'url': 'user/tenant/' ~ session['tenant'].get('id'), 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']}, + {'name': 'Tenant Consents', 'url': 'user/consent/tenant', 'roles': ['Tenant Admin']}, + {'name': 'Consent Renewal', 'url': 'user/consent/tenant_renewal', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']}, + {'name': 'Consents Overview', 'url': 'user/tenants/' ~ session['tenant'].get('id') ~ '/consents', 'roles': ['Super User', 'Partner Admin']}, {'name': 'Tenant Partner Services', 'url': 'user/tenant_partner_services', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']}, {'name': 'Tenant Makes', 'url': 'user/tenant_makes', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']}, {'name': 'Tenant Projects', 'url': 'user/tenant_projects', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']}, diff --git a/eveai_app/templates/user/consent_renewal.html b/eveai_app/templates/user/consent_renewal.html new file mode 100644 index 0000000..111fbb5 --- /dev/null +++ b/eveai_app/templates/user/consent_renewal.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block title %}Consent Renewal{% endblock %} +{% block content_title %}Consent Renewal{% endblock %} +{% block content_description %}Consent renewal process{% endblock %} + +{% block content %} +
+
This page will guide you through the consent renewal process.
+

Please navigate to the Tenant Consents page to renew the required consents.

+ Go to Tenant Consents +
+{% endblock %} diff --git a/eveai_app/templates/user/no_consent.html b/eveai_app/templates/user/no_consent.html new file mode 100644 index 0000000..4d66c98 --- /dev/null +++ b/eveai_app/templates/user/no_consent.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block title %}Consent Required{% endblock %} +{% block content_title %}Consent Required{% endblock %} +{% block content_description %}Access is restricted until required consents are provided{% endblock %} + +{% block content %} +
+
You currently do not have access because required consents are missing or expired.
+

Please contact your Tenant Admin or Management Partner to review and accept the latest Data Privacy Agreement and Terms & Conditions.

+ {% if current_user.has_roles('Tenant Admin', 'Partner Admin', 'Super User') %} + Go to Tenant Consents + {% endif %} +
+{% endblock %} diff --git a/eveai_app/templates/user/partials/consent_markdown_fragment.html b/eveai_app/templates/user/partials/consent_markdown_fragment.html new file mode 100644 index 0000000..99e4500 --- /dev/null +++ b/eveai_app/templates/user/partials/consent_markdown_fragment.html @@ -0,0 +1,4 @@ +{# HTML fragment to render consent markdown content #} +
+ {{ markdown_content | markdown }} +
diff --git a/eveai_app/templates/user/tenant_consent.html b/eveai_app/templates/user/tenant_consent.html new file mode 100644 index 0000000..3a10444 --- /dev/null +++ b/eveai_app/templates/user/tenant_consent.html @@ -0,0 +1,64 @@ +{% extends 'base.html' %} +{% from "macros.html" import debug_to_console %} + +{% block title %}Tenant Consents{% endblock %} +{% block content_title %}Tenant Consents{% endblock %} +{% block content_description %}Please consent if required before continuing{% endblock %} + +{% block content %} +
+ {% if statuses %} +
+ {% for s in statuses %} +
+
+
+
{{ s.consent_type }}
+

+ Status: {{ s.status }} +

+

Active version: {{ s.active_version or 'n/a' }}, Last accepted: {{ s.last_version or 'n/a' }}

+
+ + {% if s.status != 'CONSENTED' %} +
+ + +
+ {% else %} + Up to date + {% endif %} +
+
+
+
+ {% endfor %} +
+ {% else %} +
No consent status information available.
+ {% endif %} + + + +
+{% endblock %} diff --git a/eveai_app/templates/user/tenant_consent_renewal.html b/eveai_app/templates/user/tenant_consent_renewal.html new file mode 100644 index 0000000..c311368 --- /dev/null +++ b/eveai_app/templates/user/tenant_consent_renewal.html @@ -0,0 +1,60 @@ +{% extends 'base.html' %} + +{% block title %}Consent Renewal{% endblock %} +{% block content_title %}Consent Renewal{% endblock %} +{% block content_description %}Renew consents that require attention{% endblock %} + +{% block content %} +
+ {% if statuses and statuses|length > 0 %} +
Some consents need renewal. Please review and accept the latest version.
+
+ {% for s in statuses %} +
+
+
+
{{ s.consent_type }}
+

+ Status: {{ s.status }} +

+

Active version: {{ s.active_version or 'n/a' }}, Last accepted: {{ s.last_version or 'n/a' }}

+
+ +
+ + +
+
+
+
+
+ {% endfor %} +
+ {% else %} +
All consents are up to date.
+ {% endif %} + + + +
+{% endblock %} diff --git a/eveai_app/templates/user/tenant_consents_overview.html b/eveai_app/templates/user/tenant_consents_overview.html new file mode 100644 index 0000000..10011ce --- /dev/null +++ b/eveai_app/templates/user/tenant_consents_overview.html @@ -0,0 +1,59 @@ +{% extends 'base.html' %} + +{% block title %}Tenant Consents Overview{% endblock %} +{% block content_title %}Tenant Consents Overview{% endblock %} +{% block content_description %}Manage consents for this tenant{% endblock %} + +{% block content %} +
+
+ {% for s in statuses %} +
+
+
+
{{ s.consent_type }}
+

+ Status: {{ s.status }} +

+

Active version: {{ s.active_version or 'n/a' }}, Last accepted: {{ s.last_version or 'n/a' }}

+
+ + {% if s.status != 'CONSENTED' %} +
+ + +
+ {% else %} + Up to date + {% endif %} +
+
+
+
+ {% endfor %} +
+ + + +
+{% endblock %} diff --git a/eveai_app/views/basic_views.py b/eveai_app/views/basic_views.py index ccbe908..d32b1a7 100644 --- a/eveai_app/views/basic_views.py +++ b/eveai_app/views/basic_views.py @@ -135,14 +135,14 @@ def view_content(content_type): titles = { 'changelog': 'Release Notes', 'terms': 'Terms & Conditions', - 'dpadpa': 'Data Privacy Agreement', + 'dpa': 'Data Privacy Agreement', # Voeg andere types toe indien nodig } descriptions = { 'changelog': 'EveAI Release Notes', 'terms': "Terms & Conditions for using AskEveAI's Evie", - 'dpadpa': "Data Privacy Agreement for AskEveAI's Evie", + 'dpa': "Data Privacy Agreement for AskEveAI's Evie", # Voeg andere types toe indien nodig } diff --git a/eveai_app/views/user_forms.py b/eveai_app/views/user_forms.py index 8af6976..f99916d 100644 --- a/eveai_app/views/user_forms.py +++ b/eveai_app/views/user_forms.py @@ -231,6 +231,3 @@ class EditConsentVersionForm(FlaskForm): consent_valid_from = DateField('Consent Valid From', id='form-control datepicker', validators=[DataRequired()]) consent_valid_to = DateField('Consent Valid To', id='form-control datepicker', validators=[Optional()]) - -class TenantConsentForm(FlaskForm): - diff --git a/eveai_app/views/user_views.py b/eveai_app/views/user_views.py index c98fd34..65cf0d9 100644 --- a/eveai_app/views/user_views.py +++ b/eveai_app/views/user_views.py @@ -10,7 +10,7 @@ from wtforms import BooleanField from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant, TenantMake, \ ConsentVersion -from common.extensions import db, security, minio_client, simple_encryption, cache_manager +from common.extensions import db, security, minio_client, simple_encryption, cache_manager, content_manager from common.utils.dynamic_field_utils import create_default_config_from_type_config from common.utils.security_utils import send_confirmation_email, send_reset_email from config.type_defs.service_types import SERVICE_TYPES @@ -31,36 +31,12 @@ from eveai_app.views.list_views.user_list_views import get_tenants_list_view, ge get_tenant_domains_list_view, get_tenant_projects_list_view, get_tenant_makes_list_view, \ get_tenant_partner_services_list_view, get_consent_versions_list_view from eveai_app.views.list_views.list_view_utils import render_list_view +from common.services.user.consent_service import ConsentService +from common.models.user import ConsentStatus user_bp = Blueprint('user_bp', __name__, url_prefix='/user') -# --- Consent flow placeholder views --- -@user_bp.route('/consent/tenant', methods=['GET']) -@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') -def tenant_consent(): - # Placeholder view; UI can be implemented in templates - return render_template('user/tenant_consent.html') if current_app.jinja_env.loader else "Tenant Consent" - - -@user_bp.route('/consent/no_access', methods=['GET']) -@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') -def no_consent(): - return render_template('user/no_consent.html') if current_app.jinja_env.loader else "Consent required - contact your admin" - - -@user_bp.route('/consent/tenant_renewal', methods=['GET']) -@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') -def tenant_consent_renewal(): - return render_template('user/tenant_consent_renewal.html') if current_app.jinja_env.loader else "Tenant Consent Renewal" - - -@user_bp.route('/consent/renewal', methods=['GET']) -@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') -def consent_renewal(): - return render_template('user/consent_renewal.html') if current_app.jinja_env.loader else "Consent renewal in progress" - - @user_bp.before_request def log_before_request(): current_app.logger.debug(f'Before request: {request.path} =====================================') @@ -723,6 +699,7 @@ def tenant_partner_services(): return render_list_view('list_view.html', **config) +# Consent Version Management ---------------------------------------------------------------------- @user_bp.route('/consent_versions', methods=['GET', 'POST']) @roles_accepted('Super User') def consent_versions(): @@ -805,13 +782,138 @@ def edit_consent_version(consent_version_id): return render_template('user/edit_consent_version.html', form=form, consent_version_id=consent_version_id) -@user_bp.route('/tenant_consent', methods=['GET', 'POST']) +# Tenant Consent Management ----------------------------------------------------------------------- +@user_bp.route('/consent/tenant', methods=['GET']) @roles_accepted('Tenant Admin') def tenant_consent(): - dpa_consent = BooleanField("DPA Consent", default=False) - t_c_consent = BooleanField("T&C Consent", default=False) + # Overview for current session tenant + tenant_id = session.get('tenant', {}).get('id') or getattr(current_user, 'tenant_id', None) + if not tenant_id: + flash('No tenant context.', 'danger') + return redirect(prefixed_url_for('user_bp.tenants', for_redirect=True)) + types = ConsentService.get_required_consent_types() + statuses = [ConsentService.evaluate_type_status(tenant_id, t) for t in types] + if current_app.jinja_env.loader: + return render_template('user/tenant_consent.html', statuses=statuses, tenant_id=tenant_id) + # Fallback text if no templates + lines = [f"{s.consent_type}: {s.status} (active={s.active_version}, last={s.last_version})" for s in statuses] + return "\n".join(lines) +@user_bp.route('/consent/no_access', methods=['GET']) +def no_consent(): + return render_template('user/no_consent.html') if current_app.jinja_env.loader else "Consent required - contact your admin" + + +@user_bp.route('/consent/tenant_renewal', methods=['GET']) +@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') +def tenant_consent_renewal(): + # Show renewal statuses only + tenant_id = session.get('tenant', {}).get('id') or getattr(current_user, 'tenant_id', None) + types = ConsentService.get_required_consent_types() + statuses = [s for s in [ConsentService.evaluate_type_status(tenant_id, t) for t in types] if s.status != ConsentStatus.CONSENTED] + if current_app.jinja_env.loader: + return render_template('user/tenant_consent_renewal.html', statuses=statuses, tenant_id=tenant_id) + return "\n".join([f"{s.consent_type}: {s.status}" for s in statuses]) + + +@user_bp.route('/consent/renewal', methods=['GET']) +@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') +def consent_renewal(): + return render_template('user/consent_renewal.html') if current_app.jinja_env.loader else "Consent renewal in progress" + + +@user_bp.route('/tenants//consents', methods=['GET']) +@roles_accepted('Super User', 'Partner Admin') +def view_tenant_consents(tenant_id: int): + # Authorization: Tenant Admin for own tenant or Management Partner + allowed, mode, _, _ = ConsentService.can_consent_on_behalf(tenant_id) + if not (allowed or current_user.has_roles('Super User')): + flash('Not authorized to view consents for this tenant', 'danger') + return redirect(prefixed_url_for('user_bp.tenants', for_redirect=True)) + types = ConsentService.get_required_consent_types() + statuses = [ConsentService.evaluate_type_status(tenant_id, t) for t in types] + if current_app.jinja_env.loader: + return render_template('user/tenant_consents_overview.html', statuses=statuses, tenant_id=tenant_id) + return "\n".join([f"{s.consent_type}: {s.status}" for s in statuses]) + + +@user_bp.route('/tenants//consents//accept', methods=['POST']) +@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') +def accept_tenant_consent(tenant_id: int, consent_type: str): + try: + tc = ConsentService.record_consent(tenant_id, consent_type) + flash(f"Consent for {consent_type} recorded (version {tc.consent_version})", 'success') + except PermissionError: + flash('Not authorized to accept this consent for the tenant', 'danger') + except Exception as e: + current_app.logger.error(f"Failed to record consent: {e}") + flash('Failed to record consent', 'danger') + if current_user.has_roles('Tenant Admin'): + return redirect(prefixed_url_for('user_bp.tenant_consent', for_redirect=True)) + else: + return redirect(prefixed_url_for('user_bp.view_tenant_consents', tenant_id=tenant_id, for_redirect=True)) + + +@user_bp.route('/consents///view', methods=['GET']) +@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') +def view_consent_markdown(consent_type: str, version: str): + """Render the consent markdown for a given type and version as an HTML fragment, using content_manager.""" + try: + current_app.logger.debug(f"Rendering markdown for {consent_type} version {version}") + # Validate type against config + valid_types = set(ConsentService.get_required_consent_types()) + if consent_type not in valid_types: + for t in valid_types: + if t.lower() == consent_type.lower(): + consent_type = t + break + if consent_type not in valid_types: + current_app.logger.warning(f"Unknown consent type requested for view: {consent_type}") + return (render_template('user/partials/consent_markdown_fragment.html', markdown_content=f"Unknown consent type: {consent_type}"), 404) + + # Version must exist in ConsentVersion for the type + cv = ConsentVersion.query.filter_by(consent_type=consent_type, consent_version=version).first() + if not cv: + current_app.logger.warning(f"Unknown consent version requested: type={consent_type}, version={version}") + return (render_template('user/partials/consent_markdown_fragment.html', markdown_content=f"Document not found for version {version}"), 404) + + # Map consent type to content_manager content_type + type_map = { + 'Data Privacy Agreement': 'dpa', + 'Terms & Conditions': 'terms', + } + content_type = type_map.get(consent_type) + if not content_type: + current_app.logger.warning(f"No content_type mapping for consent type {consent_type}") + return (render_template('user/partials/consent_markdown_fragment.html', markdown_content=f"Unknown content mapping for {consent_type}"), 404) + + # Parse major.minor and patch from version (e.g., 1.2.3 -> 1.2 and 1.2.3) + parts = version.split('.') + current_app.logger.debug(f"Parts of version {version}: {parts}") + if len(parts) < 3: + current_app.logger.warning(f"Version does not follow a.b.c pattern: {version}") + major_minor = '.'.join(parts[:2]) if len(parts) >= 2 else version + patch = '' + else: + major_minor = '.'.join(parts[:2]) + patch = parts[2] + + current_app.logger.debug(f"Retrieving markdown for {consent_type}/{content_type} for major.minor={major_minor} and patch={patch} ") + + # Use content_manager to read content + content_data = content_manager.read_content(content_type, major_minor, patch) + if not content_data or not content_data.get('content'): + markdown_content = f"# Document not found\nThe consent document for {consent_type} version {version} could not be located." + status = 404 + else: + markdown_content = content_data['content'] + status = 200 + + return render_template('user/partials/consent_markdown_fragment.html', markdown_content=markdown_content), status + except Exception as e: + current_app.logger.error(f"Error in view_consent_markdown: {e}") + return (render_template('user/partials/consent_markdown_fragment.html', markdown_content="Unexpected error rendering document."), 500) def reset_uniquifier(user): diff --git a/migrations/public/versions/2228f7cafada_correct_not_null_constraints_on_.py b/migrations/public/versions/2228f7cafada_correct_not_null_constraints_on_.py new file mode 100644 index 0000000..4ceddc2 --- /dev/null +++ b/migrations/public/versions/2228f7cafada_correct_not_null_constraints_on_.py @@ -0,0 +1,64 @@ +"""Correct Not Null constraints on TenantConsent + +Revision ID: 2228f7cafada +Revises: a6ee51d72bb4 +Create Date: 2025-10-15 07:44:13.243434 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2228f7cafada' +down_revision = 'a6ee51d72bb4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tenant_consent', schema=None) as batch_op: + batch_op.alter_column('partner_id', + existing_type=sa.INTEGER(), + nullable=True) + batch_op.alter_column('partner_service_id', + existing_type=sa.INTEGER(), + nullable=True) + batch_op.drop_constraint('tenant_consent_created_by_fkey', type_='foreignkey') + batch_op.drop_constraint('tenant_consent_user_id_fkey', type_='foreignkey') + batch_op.drop_constraint('tenant_consent_partner_service_id_fkey', type_='foreignkey') + batch_op.drop_constraint('tenant_consent_updated_by_fkey', type_='foreignkey') + batch_op.drop_constraint('tenant_consent_partner_id_fkey', type_='foreignkey') + batch_op.drop_constraint('tenant_consent_tenant_id_fkey', type_='foreignkey') + batch_op.create_foreign_key(None, 'user', ['user_id'], ['id'], referent_schema='public') + batch_op.create_foreign_key(None, 'user', ['updated_by'], ['id'], referent_schema='public') + batch_op.create_foreign_key(None, 'partner_service', ['partner_service_id'], ['id'], referent_schema='public') + batch_op.create_foreign_key(None, 'tenant', ['tenant_id'], ['id'], referent_schema='public') + batch_op.create_foreign_key(None, 'partner', ['partner_id'], ['id'], referent_schema='public') + batch_op.create_foreign_key(None, 'user', ['created_by'], ['id'], referent_schema='public') + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tenant_consent', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key('tenant_consent_tenant_id_fkey', 'tenant', ['tenant_id'], ['id']) + batch_op.create_foreign_key('tenant_consent_partner_id_fkey', 'partner', ['partner_id'], ['id']) + batch_op.create_foreign_key('tenant_consent_updated_by_fkey', 'user', ['updated_by'], ['id']) + batch_op.create_foreign_key('tenant_consent_partner_service_id_fkey', 'partner_service', ['partner_service_id'], ['id']) + batch_op.create_foreign_key('tenant_consent_user_id_fkey', 'user', ['user_id'], ['id']) + batch_op.create_foreign_key('tenant_consent_created_by_fkey', 'user', ['created_by'], ['id']) + batch_op.alter_column('partner_service_id', + existing_type=sa.INTEGER(), + nullable=False) + batch_op.alter_column('partner_id', + existing_type=sa.INTEGER(), + nullable=False) + + # ### end Alembic commands ### diff --git a/nginx/frontend_src/js/main.js b/nginx/frontend_src/js/main.js index 0ad3e47..12a9d6b 100644 --- a/nginx/frontend_src/js/main.js +++ b/nginx/frontend_src/js/main.js @@ -21,6 +21,7 @@ import '../../../eveai_app/static/assets/css/eveai-markdown.css' import '../../../eveai_app/static/assets/css/eveai-select2.css' import '../../../eveai_app/static/assets/css/eveai-tabulator.css' import '../../../eveai_app/static/assets/css/eveai-responsive-table.css' +import '../../../eveai_app/static/assets/css/eveai-consent-viewer.css' // Javascript Libraries @@ -105,5 +106,8 @@ import '../../../eveai_app/static/assets/js/material-kit-pro.js'; // }); // } +// Eveai Consent Viewer +import '../../../eveai_app/static/assets/js/eveai-consent-viewer.js' + // Eventueel een log om te bevestigen dat de bundel is geladen console.log('JavaScript en CSS bibliotheken gebundeld en geladen via main.js.');