- Consent giving UI introduced
- Possibility to view the document version the consent is given to - Blocking functionality is no valid consent
This commit is contained in:
@@ -316,8 +316,8 @@ class TenantConsent(db.Model):
|
|||||||
__table_args__ = {'schema': 'public'}
|
__table_args__ = {'schema': 'public'}
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False)
|
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_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=False)
|
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)
|
user_id = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=False)
|
||||||
consent_type = db.Column(db.String(50), nullable=False)
|
consent_type = db.Column(db.String(50), nullable=False)
|
||||||
consent_date = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
consent_date = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from common.services.user.user_services import UserServices
|
from common.services.user.user_services import UserServices
|
||||||
from common.services.user.partner_services import PartnerServices
|
from common.services.user.partner_services import PartnerServices
|
||||||
from common.services.user.tenant_services import TenantServices
|
from common.services.user.tenant_services import TenantServices
|
||||||
|
from common.services.user.consent_service import ConsentService
|
||||||
|
|
||||||
__all__ = ['UserServices', 'PartnerServices', 'TenantServices']
|
__all__ = ['UserServices', 'PartnerServices', 'TenantServices', 'ConsentService']
|
||||||
254
common/services/user/consent_service.py
Normal file
254
common/services/user/consent_service.py
Normal file
@@ -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
|
||||||
@@ -177,52 +177,6 @@ class TenantServices:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_consent_status(tenant_id: int) -> ConsentStatus:
|
def get_consent_status(tenant_id: int) -> ConsentStatus:
|
||||||
cts = current_app.config.get("CONSENT_TYPES")
|
# Delegate to centralized ConsentService to ensure consistent logic
|
||||||
status = ConsentStatus.CONSENTED
|
from common.services.user.consent_service import ConsentService
|
||||||
for ct in cts:
|
return ConsentService.get_consent_status(tenant_id)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ def is_exempt_endpoint(endpoint: str) -> bool:
|
|||||||
'user_bp.no_consent',
|
'user_bp.no_consent',
|
||||||
'user_bp.tenant_consent_renewal',
|
'user_bp.tenant_consent_renewal',
|
||||||
'user_bp.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 = [
|
default_prefixes = [
|
||||||
'security_bp.',
|
'security_bp.',
|
||||||
@@ -160,7 +163,18 @@ def enforce_tenant_consent_ui():
|
|||||||
if not tenant_id:
|
if not tenant_id:
|
||||||
return redirect(prefixed_url_for('security_bp.login', for_redirect=True))
|
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:
|
if status == ConsentStatus.CONSENTED:
|
||||||
current_app.logger.debug('User has consented')
|
current_app.logger.debug('User has consented')
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"dist/chat-client.js": "dist/chat-client.59b28883.js",
|
"dist/chat-client.js": "dist/chat-client.59b28883.js",
|
||||||
"dist/chat-client.css": "dist/chat-client.79757200.css",
|
"dist/chat-client.css": "dist/chat-client.79757200.css",
|
||||||
"dist/main.js": "dist/main.f3dde0f6.js",
|
"dist/main.js": "dist/main.c5b0c81d.js",
|
||||||
"dist/main.css": "dist/main.c40e57ad.css"
|
"dist/main.css": "dist/main.06893f70.css"
|
||||||
}
|
}
|
||||||
@@ -390,22 +390,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- eveai-dev-network
|
- 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:
|
flower:
|
||||||
image: mher/flower:latest
|
image: mher/flower:latest
|
||||||
environment:
|
environment:
|
||||||
@@ -469,25 +453,25 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- eveai-dev-network
|
- eveai-dev-network
|
||||||
|
|
||||||
grafana:
|
# grafana:
|
||||||
image: ${REGISTRY_PREFIX:-}josakola/grafana:latest
|
# image: ${REGISTRY_PREFIX:-}josakola/grafana:latest
|
||||||
build:
|
# build:
|
||||||
context: ./grafana
|
# context: ./grafana
|
||||||
dockerfile: Dockerfile
|
# dockerfile: Dockerfile
|
||||||
ports:
|
# ports:
|
||||||
- "3012:3000" # Dev Grafana volgens port schema
|
# - "3012:3000" # Dev Grafana volgens port schema
|
||||||
volumes:
|
# volumes:
|
||||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
# - ./grafana/provisioning:/etc/grafana/provisioning
|
||||||
- ./grafana/data:/var/lib/grafana
|
# - ./grafana/data:/var/lib/grafana
|
||||||
environment:
|
# environment:
|
||||||
- GF_SECURITY_ADMIN_USER=admin
|
# - GF_SECURITY_ADMIN_USER=admin
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
# - GF_SECURITY_ADMIN_PASSWORD=admin
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
# - GF_USERS_ALLOW_SIGN_UP=false
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
depends_on:
|
# depends_on:
|
||||||
- prometheus
|
# - prometheus
|
||||||
networks:
|
# networks:
|
||||||
- eveai-dev-network
|
# - eveai-dev-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
eveai-dev-network:
|
eveai-dev-network:
|
||||||
|
|||||||
30
eveai_app/static/assets/css/eveai-consent-viewer.css
Normal file
30
eveai_app/static/assets/css/eveai-consent-viewer.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
53
eveai_app/static/assets/js/eveai-consent-viewer.js
Normal file
53
eveai_app/static/assets/js/eveai-consent-viewer.js
Normal file
@@ -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 = '<div class="alert alert-danger">Failed to load document.</div>';
|
||||||
|
} finally {
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
viewerSection.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initConsentViewer);
|
||||||
|
} else {
|
||||||
|
initConsentViewer();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
<!-- Gebundelde CSS (bevat nu al je CSS) -->
|
<!-- Gebundelde CSS (bevat nu al je CSS) -->
|
||||||
<link href="{{ asset_url('dist/main.css') }}" rel="stylesheet" />
|
<link href="{{ asset_url('dist/main.css') }}" rel="stylesheet" />
|
||||||
|
<!-- Consent viewer specific styles -->
|
||||||
|
<link href="{{ prefixed_url_for('static', filename='assets/css/eveai-consent-viewer.css') }}" rel="stylesheet" />
|
||||||
<base href="/admin/">
|
<base href="/admin/">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,9 @@
|
|||||||
{'name': 'Consent Versions', 'url': 'user/consent_versions', 'roles': ['Super User']},
|
{'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': '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': '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 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 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']},
|
{'name': 'Tenant Projects', 'url': 'user/tenant_projects', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
|
|||||||
13
eveai_app/templates/user/consent_renewal.html
Normal file
13
eveai_app/templates/user/consent_renewal.html
Normal file
@@ -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 %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="alert alert-info">This page will guide you through the consent renewal process.</div>
|
||||||
|
<p>Please navigate to the Tenant Consents page to renew the required consents.</p>
|
||||||
|
<a href="{{ prefixed_url_for('user_bp.tenant_consent') }}" class="btn btn-primary">Go to Tenant Consents</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
15
eveai_app/templates/user/no_consent.html
Normal file
15
eveai_app/templates/user/no_consent.html
Normal file
@@ -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 %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="alert alert-danger">You currently do not have access because required consents are missing or expired.</div>
|
||||||
|
<p>Please contact your Tenant Admin or Management Partner to review and accept the latest Data Privacy Agreement and Terms & Conditions.</p>
|
||||||
|
{% if current_user.has_roles('Tenant Admin', 'Partner Admin', 'Super User') %}
|
||||||
|
<a href="{{ prefixed_url_for('user_bp.tenant_consent') }}" class="btn btn-primary">Go to Tenant Consents</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{# HTML fragment to render consent markdown content #}
|
||||||
|
<div class="markdown-body">
|
||||||
|
{{ markdown_content | markdown }}
|
||||||
|
</div>
|
||||||
64
eveai_app/templates/user/tenant_consent.html
Normal file
64
eveai_app/templates/user/tenant_consent.html
Normal file
@@ -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 %}
|
||||||
|
<div class="container">
|
||||||
|
{% if statuses %}
|
||||||
|
<div class="row">
|
||||||
|
{% for s in statuses %}
|
||||||
|
<div class="col-12 col-md-6 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ s.consent_type }}</h5>
|
||||||
|
<p class="card-text">
|
||||||
|
Status: <span class="badge {% if s.status == 'CONSENTED' %}bg-success{% elif s.status == 'RENEWAL_REQUIRED' %}bg-warning text-dark{% else %}bg-danger{% endif %}">{{ s.status }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="card-text small text-muted">Active version: {{ s.active_version or 'n/a' }}, Last accepted: {{ s.last_version or 'n/a' }}</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-view-consent"
|
||||||
|
data-consent-type="{{ s.consent_type }}" data-version="{{ s.active_version }}"
|
||||||
|
data-url="{{ prefixed_url_for('user_bp.view_consent_markdown', consent_type=s.consent_type, version=s.active_version) }}"
|
||||||
|
{% if not s.active_version %}disabled{% endif %}>
|
||||||
|
Bekijk document
|
||||||
|
</button>
|
||||||
|
{% if s.status != 'CONSENTED' %}
|
||||||
|
<form method="post" action="{{ prefixed_url_for('user_bp.accept_tenant_consent', tenant_id=tenant_id, consent_type=s.consent_type) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-primary">I agree</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-success align-self-center">Up to date</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">No consent status information available.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Consent document viewer moved into main content -->
|
||||||
|
<div class="container mt-4" id="consent-viewer-section" style="display:none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>Document viewer:</strong>
|
||||||
|
<span id="viewer-type"></span>
|
||||||
|
<span class="text-muted">version</span>
|
||||||
|
<span id="viewer-version"></span>
|
||||||
|
</div>
|
||||||
|
<div id="viewer-loading" class="text-muted" style="display:none;">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="consent-document-viewer" class="markdown-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
60
eveai_app/templates/user/tenant_consent_renewal.html
Normal file
60
eveai_app/templates/user/tenant_consent_renewal.html
Normal file
@@ -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 %}
|
||||||
|
<div class="container">
|
||||||
|
{% if statuses and statuses|length > 0 %}
|
||||||
|
<div class="alert alert-warning">Some consents need renewal. Please review and accept the latest version.</div>
|
||||||
|
<div class="row">
|
||||||
|
{% for s in statuses %}
|
||||||
|
<div class="col-12 col-md-6 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ s.consent_type }}</h5>
|
||||||
|
<p class="card-text">
|
||||||
|
Status: <span class="badge {% if s.status == 'RENEWAL_REQUIRED' %}bg-warning text-dark{% else %}bg-danger{% endif %}">{{ s.status }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="card-text small text-muted">Active version: {{ s.active_version or 'n/a' }}, Last accepted: {{ s.last_version or 'n/a' }}</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-view-consent"
|
||||||
|
data-consent-type="{{ s.consent_type }}" data-version="{{ s.active_version }}"
|
||||||
|
data-url="{{ prefixed_url_for('user_bp.view_consent_markdown', consent_type=s.consent_type, version=s.active_version) }}"
|
||||||
|
{% if not s.active_version %}disabled{% endif %}>
|
||||||
|
Bekijk document
|
||||||
|
</button>
|
||||||
|
<form method="post" action="{{ prefixed_url_for('user_bp.accept_tenant_consent', tenant_id=tenant_id, consent_type=s.consent_type) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-primary">Renew and accept</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-success">All consents are up to date.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Consent document viewer moved into main content -->
|
||||||
|
<div class="container mt-4" id="consent-viewer-section" style="display:none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>Document viewer:</strong>
|
||||||
|
<span id="viewer-type"></span>
|
||||||
|
<span class="text-muted">version</span>
|
||||||
|
<span id="viewer-version"></span>
|
||||||
|
</div>
|
||||||
|
<div id="viewer-loading" class="text-muted" style="display:none;">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="consent-document-viewer" class="markdown-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
59
eveai_app/templates/user/tenant_consents_overview.html
Normal file
59
eveai_app/templates/user/tenant_consents_overview.html
Normal file
@@ -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 %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
{% for s in statuses %}
|
||||||
|
<div class="col-12 col-md-6 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ s.consent_type }}</h5>
|
||||||
|
<p class="card-text">
|
||||||
|
Status: <span class="badge {% if s.status == 'CONSENTED' %}bg-success{% elif s.status == 'RENEWAL_REQUIRED' %}bg-warning text-dark{% else %}bg-danger{% endif %}">{{ s.status }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="card-text small text-muted">Active version: {{ s.active_version or 'n/a' }}, Last accepted: {{ s.last_version or 'n/a' }}</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-view-consent"
|
||||||
|
data-consent-type="{{ s.consent_type }}" data-version="{{ s.active_version }}"
|
||||||
|
data-url="{{ prefixed_url_for('user_bp.view_consent_markdown', consent_type=s.consent_type, version=s.active_version) }}"
|
||||||
|
{% if not s.active_version %}disabled{% endif %}>
|
||||||
|
Bekijk document
|
||||||
|
</button>
|
||||||
|
{% if s.status != 'CONSENTED' %}
|
||||||
|
<form method="post" action="{{ prefixed_url_for('user_bp.accept_tenant_consent', tenant_id=tenant_id, consent_type=s.consent_type) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-primary">Accept on behalf</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-success align-self-center">Up to date</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Consent document viewer moved into main content -->
|
||||||
|
<div class="container mt-4" id="consent-viewer-section" style="display:none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>Document viewer:</strong>
|
||||||
|
<span id="viewer-type"></span>
|
||||||
|
<span class="text-muted">version</span>
|
||||||
|
<span id="viewer-version"></span>
|
||||||
|
</div>
|
||||||
|
<div id="viewer-loading" class="text-muted" style="display:none;">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="consent-document-viewer" class="markdown-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -135,14 +135,14 @@ def view_content(content_type):
|
|||||||
titles = {
|
titles = {
|
||||||
'changelog': 'Release Notes',
|
'changelog': 'Release Notes',
|
||||||
'terms': 'Terms & Conditions',
|
'terms': 'Terms & Conditions',
|
||||||
'dpadpa': 'Data Privacy Agreement',
|
'dpa': 'Data Privacy Agreement',
|
||||||
# Voeg andere types toe indien nodig
|
# Voeg andere types toe indien nodig
|
||||||
}
|
}
|
||||||
|
|
||||||
descriptions = {
|
descriptions = {
|
||||||
'changelog': 'EveAI Release Notes',
|
'changelog': 'EveAI Release Notes',
|
||||||
'terms': "Terms & Conditions for using AskEveAI's Evie",
|
'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
|
# Voeg andere types toe indien nodig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,3 @@ class EditConsentVersionForm(FlaskForm):
|
|||||||
consent_valid_from = DateField('Consent Valid From', id='form-control datepicker', validators=[DataRequired()])
|
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()])
|
consent_valid_to = DateField('Consent Valid To', id='form-control datepicker', validators=[Optional()])
|
||||||
|
|
||||||
|
|
||||||
class TenantConsentForm(FlaskForm):
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from wtforms import BooleanField
|
|||||||
|
|
||||||
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant, TenantMake, \
|
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant, TenantMake, \
|
||||||
ConsentVersion
|
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.dynamic_field_utils import create_default_config_from_type_config
|
||||||
from common.utils.security_utils import send_confirmation_email, send_reset_email
|
from common.utils.security_utils import send_confirmation_email, send_reset_email
|
||||||
from config.type_defs.service_types import SERVICE_TYPES
|
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_domains_list_view, get_tenant_projects_list_view, get_tenant_makes_list_view, \
|
||||||
get_tenant_partner_services_list_view, get_consent_versions_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 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')
|
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
|
@user_bp.before_request
|
||||||
def log_before_request():
|
def log_before_request():
|
||||||
current_app.logger.debug(f'Before request: {request.path} =====================================')
|
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)
|
return render_list_view('list_view.html', **config)
|
||||||
|
|
||||||
|
|
||||||
|
# Consent Version Management ----------------------------------------------------------------------
|
||||||
@user_bp.route('/consent_versions', methods=['GET', 'POST'])
|
@user_bp.route('/consent_versions', methods=['GET', 'POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User')
|
||||||
def consent_versions():
|
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)
|
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')
|
@roles_accepted('Tenant Admin')
|
||||||
def tenant_consent():
|
def tenant_consent():
|
||||||
dpa_consent = BooleanField("DPA Consent", default=False)
|
# Overview for current session tenant
|
||||||
t_c_consent = BooleanField("T&C Consent", default=False)
|
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/<int:tenant_id>/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/<int:tenant_id>/consents/<string:consent_type>/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/<path:consent_type>/<string:version>/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):
|
def reset_uniquifier(user):
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -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-select2.css'
|
||||||
import '../../../eveai_app/static/assets/css/eveai-tabulator.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-responsive-table.css'
|
||||||
|
import '../../../eveai_app/static/assets/css/eveai-consent-viewer.css'
|
||||||
|
|
||||||
// Javascript Libraries
|
// 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
|
// Eventueel een log om te bevestigen dat de bundel is geladen
|
||||||
console.log('JavaScript en CSS bibliotheken gebundeld en geladen via main.js.');
|
console.log('JavaScript en CSS bibliotheken gebundeld en geladen via main.js.');
|
||||||
|
|||||||
Reference in New Issue
Block a user