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.');