| """ |
| Cliente para comunicarse con el servicio Veureu Compliance |
| |
| Este módulo se comunica con el microservicio compliance-service |
| que maneja OAuth, QLDB, Polygon y notificaciones en un solo lugar. |
| """ |
|
|
| import requests |
| import json |
| import os |
| import time |
| import random |
| from typing import Optional, Dict, Any, List |
| import streamlit as st |
| from datetime import datetime |
| import logging |
| from urllib.parse import urlparse, parse_qs, urlencode, urlunparse |
|
|
| logger = logging.getLogger(__name__) |
|
|
| class ComplianceClient: |
| """Cliente para el microservicio de cumplimiento normativo""" |
| |
| def __init__(self, compliance_service_url: str = None): |
| |
| self.compliance_service_url = compliance_service_url or os.getenv( |
| "COMPLIANCE_SERVICE_URL", |
| "https://veureu-compliance.hf.space" |
| ) |
| |
| self.timeout = 60 |
| |
| logger.info(f"Compliance client inicializado: {self.compliance_service_url}") |
| |
| def _make_request(self, method: str, endpoint: str, data: Dict = None) -> Optional[Dict[str, Any]]: |
| """ |
| Método helper para hacer peticiones HTTP |
| |
| Args: |
| method: Método HTTP ('GET', 'POST') |
| endpoint: Endpoint del API |
| data: Datos a enviar (solo para POST) |
| |
| Returns: |
| Respuesta JSON o None si error |
| """ |
| try: |
| url = f"{self.compliance_service_url}{endpoint}" |
| logger.info(f"[COMPLIANCE] HTTP {method.upper()} {url} payload={data}") |
| |
| if method.upper() == "GET": |
| response = requests.get(url, timeout=self.timeout) |
| elif method.upper() == "POST": |
| response = requests.post(url, json=data, timeout=self.timeout) |
| else: |
| logger.error(f"Método no soportado: {method}") |
| return None |
| |
| logger.info(f"[COMPLIANCE] Resposta {method.upper()} {endpoint}: status={response.status_code}") |
|
|
| if response.status_code == 200: |
| return response.json() |
| else: |
| logger.error(f"Error en petición {method} {endpoint}: {response.status_code}") |
| logger.error(f"Response: {response.text}") |
| return None |
| |
| except requests.exceptions.Timeout: |
| logger.error(f"Timeout en petición a {endpoint} (URL={self.compliance_service_url}{endpoint}, timeout={self.timeout}s)") |
| return None |
| except requests.exceptions.ConnectionError as e: |
| logger.error(f"Error de conexión a {self.compliance_service_url}{endpoint}: {e}") |
| return None |
| except Exception as e: |
| logger.error(f"Error en petición a {endpoint}: {e}") |
| return None |
| |
| |
| |
| def authenticate_user(self, token: str) -> Optional[Dict[str, Any]]: |
| """Valida token con el servicio de autenticación""" |
| return self._make_request("POST", "/api/auth/validate", {"token": token}) |
| |
| def get_login_url(self, callback_url: str) -> str: |
| """Obtiene URL de login del servicio OAuth""" |
| response = self._make_request("POST", "/api/auth/login-url", {"callback_url": callback_url}) |
| |
| if response: |
| return response.get("login_url") |
| return None |
| |
| def logout_user(self, token: str) -> bool: |
| """Invalida sesión en el servicio de autenticación""" |
| response = self._make_request("POST", "/api/auth/logout", {"token": token}) |
| return response is not None |
| |
| def is_authenticated(self) -> bool: |
| """Verifica si el usuario actual está autenticado""" |
| |
| if 'user' in st.session_state and st.session_state.user: |
| logger.info(f"Usuario ya autenticado tradicionalmente: {st.session_state.user.get('username', 'unknown')}") |
| |
| if 'auth_token' not in st.session_state: |
| st.session_state.auth_token = f"traditional_token_{int(time.time())}" |
| st.session_state.current_user = { |
| "email": f"{st.session_state.user.get('username', 'unknown')}@veureu.local", |
| "name": st.session_state.user.get('username', 'Unknown'), |
| "method": "traditional" |
| } |
| return True |
| |
| |
| if "auth_token" not in st.session_state: |
| return False |
| |
| token = st.session_state.auth_token |
| user_info = self.authenticate_user(token) |
| |
| if user_info: |
| st.session_state.current_user = user_info |
| logger.info(f"Usuario autenticado: {user_info.get('email', 'unknown')}") |
| return True |
| else: |
| |
| logger.warning("Token inválido, limpiando sesión") |
| if "auth_token" in st.session_state: |
| del st.session_state.auth_token |
| if "current_user" in st.session_state: |
| del st.session_state.current_user |
| return False |
| |
| def get_current_user(self) -> Optional[str]: |
| """Obtiene email del usuario actual""" |
| |
| if 'user' in st.session_state and st.session_state.user: |
| return f"{st.session_state.user.get('username', 'unknown')}@veureu.local" |
| |
| |
| if self.is_authenticated(): |
| return st.session_state.get("current_user", {}).get("email") |
| return None |
| |
| def show_login_button(self) -> bool: |
| """Muestra botón de login y maneja redirección""" |
| print("[OAuth] show_login_button llamado") |
| logger.info("[OAuth] show_login_button llamado") |
| |
| |
| if 'user' in st.session_state and st.session_state.user: |
| logger.info(f"[OAuth] Usuario ya autenticado tradicionalmente: {st.session_state.user.get('username', 'unknown')}") |
| return True |
| |
| |
| try: |
| health_response = self._make_request("GET", "/") |
| if health_response and health_response.get("services", {}).get("oauth", {}).get("configured") == False: |
| logger.warning("[OAuth] OAuth no configurado en el servicio, usando modo demo") |
| return self._show_demo_login() |
| except Exception as e: |
| logger.error(f"[OAuth] Error verificando configuración OAuth: {e}") |
| return self._show_demo_login() |
| |
| |
| space_url_env = os.getenv("SPACE_URL") |
| if space_url_env: |
| callback_url = space_url_env |
| else: |
| |
| space_id = os.getenv("SPACE_ID") |
| if space_id: |
| callback_url = f"https://{space_id}.hf.space" |
| else: |
| callback_url = "http://localhost:8501" |
| logger.info(f"[OAuth] Callback calculat: {callback_url}") |
| login_url = self.get_login_url(callback_url) |
| logger.info(f"[OAuth] login_url rebut del servei: {login_url}") |
| if login_url: |
| try: |
| parsed = urlparse(login_url) |
| query = parse_qs(parsed.query) |
| query.setdefault("prompt", ["select_account"]) |
| query.setdefault("access_type", ["offline"]) |
| encoded_query = urlencode(query, doseq=True) |
| login_url = urlunparse(parsed._replace(query=encoded_query)) |
| logger.info(f"[OAuth] login_url ajustat amb prompt=select_account: {login_url}") |
| except Exception as exc: |
| logger.warning(f"[OAuth] No s'ha pogut ajustar la login_url: {exc}") |
| |
| if login_url: |
| st.markdown(f""" |
| ### 🔐 Iniciar sessió |
| |
| Per continuar, necessitas iniciar sessió amb el teu compte de Google. |
| |
| <a href="{login_url}" target="_top"> |
| <button style=" |
| background-color: #4285f4; |
| color: white; |
| padding: 12px 24px; |
| border: none; |
| border-radius: 4px; |
| font-size: 16px; |
| cursor: pointer; |
| text-decoration: none; |
| display: inline-block; |
| "> |
| 🚪 Iniciar sessió amb Google |
| </button> |
| </a> |
| |
| *En iniciar sessió, acceptes els termes del servei i la política de privadesa.* |
| """, unsafe_allow_html=True) |
|
|
| |
| st.markdown( |
| f""" |
| <script> |
| const a = document.querySelector('a[href="{login_url}"]'); |
| if (a) {{ |
| a.addEventListener('click', function(ev) {{ |
| try {{ |
| if (window.top) {{ window.top.location.href = '{login_url}'; ev.preventDefault(); }} |
| }} catch (e) {{ /* ignore */ }} |
| }}); |
| }} |
| </script> |
| """, |
| unsafe_allow_html=True, |
| ) |
| |
| |
| query_params = st.query_params |
| logger.info(f"[OAuth] Query params rebuts: {dict(query_params) if query_params else {}}") |
| |
| if query_params: |
| st.caption("OAuth params rebuts: " + ", ".join([f"{k}={query_params[k]}" for k in query_params.keys()])) |
|
|
| |
| token_key = None |
| for k in ["auth_token", "token", "code"]: |
| if k in query_params: |
| token_key = k |
| break |
| if token_key: |
| token_val = query_params[token_key] |
| if isinstance(token_val, list): |
| token_val = token_val[0] |
| st.session_state.auth_token = token_val |
| st.query_params.clear() |
| |
| if self.is_authenticated(): |
| st.success("✅ Sessió iniciada correctament") |
| st.rerun() |
| return True |
| else: |
| st.warning("⚠️ No s'ha pogut obtenir la URL d'inici de sessió. Torna-ho a intentar en uns minuts.") |
| logger.error("[OAuth] login_url és None: comprova COMPLIANCE_SERVICE_URL i l'estat del servei de compliance") |
| |
| return False |
|
|
| |
|
|
| def create_phone_verification_session( |
| self, |
| *, |
| page: str, |
| user_id: Optional[str] = None, |
| email: Optional[str] = None, |
| action: Optional[str] = None, |
| ) -> Optional[Dict[str, Any]]: |
| """Crea una sessió de verificació de telèfon al backend de compliance. |
| |
| Endpoint: POST /phone_verification/create_session |
| """ |
|
|
| payload: Dict[str, Any] = { |
| "page": page, |
| "user_id": user_id, |
| "email": email, |
| "action": action, |
| } |
| return self._make_request("POST", "/phone_verification/create_session", payload) |
| |
| def _show_demo_login(self) -> bool: |
| """Muestra formulario de login por SMS en ventana desplegable cuando OAuth no está configurado""" |
| |
| if 'sms_step' not in st.session_state: |
| st.session_state.sms_step = 'phone' |
| st.session_state.sms_code = None |
| st.session_state.sms_phone = None |
| |
| st.markdown(""" |
| ### 🔐 Iniciar sessió (Verificació per SMS) |
| |
| Per continuar, necessitas identificar-te i acceptar les condicions d'ús. |
| """) |
| |
| |
| with st.expander("📋 Identificació i Acceptació de Condicions", expanded=False): |
| st.markdown(""" |
| ### **Condicions d'ús del sistema Veureu (provisional)** |
| |
| En iniciar sessió i pujar un vídeo al sistema Veureu, l'usuari declara i accepta el següent: |
| |
| **📋 Declaracions:** |
| - Declara ser titular dels drets d'ús i difusió del vídeo, o disposar de l'autorització expressa dels titulars. |
| - Declara que totes les persones identificables en el vídeo han atorgat el seu consentiment informat per a la seva utilització amb finalitats d'accessibilitat i audiodescripció. |
| - Declara que el vídeo no conté: |
| - Contingut violent, sexual o d'odi, |
| - Informació confidencial o de caràcter sensible, |
| - Imatges de menors sense consentiment parental verificable. |
| |
| **✅ Acceptacions:** |
| - Accepta que es processi el vídeo exclusivament amb finalitats de generació i validació d'audiodescripcions, conforme a la normativa UNE-153010:2020 i al RGPD. |
| - Accepta que el sistema pugui enviar les dades necessàries a proveïdors tecnològics externs (p. ex., models d'IA) que actuen com a encarregats de tractament, sota clàusules contractuals tipus i sense reutilització de dades. |
| - Accepta que les accions realitzades (pujada, acceptació, validació, revocació) siguin registrades en un sistema immutable (AWS QLDB) mitjançant identificadors no personals. |
| - Pot exercir en qualsevol moment el seu dret a revocar el consentiment mitjançant el botó "Revocar permisos", el que eliminarà el material audiovisual i deixarà constància de la revocació en el registre. |
| - Accepta que, fins a la validació interna del material per part de l'equip Dive, el vídeo romandrà en estat "pendent de validació" i no serà utilitzat públicament. |
| """) |
| |
| st.markdown("---") |
| st.markdown("#### 📱 Dades d'identificació") |
| |
| |
| col_country, col_phone = st.columns([1, 3]) |
| with col_country: |
| country_code = st.selectbox("País", [ |
| ("🇪🇸 +34", "+34"), |
| ("🇫🇷 +33", "+33"), |
| ("🇬🇧 +44", "+44"), |
| ("🇩🇪 +49", "+49"), |
| ("🇮🇹 +39", "+39") |
| ], format_func=lambda x: x[0])[1] |
| |
| with col_phone: |
| phone_number = st.text_input("Número de telèfon", placeholder="600 123 000", max_chars=15) |
| |
| |
| accept_terms = st.checkbox("✅ Accepto les condicions d'ús i la política de privadesa", key="sms_accept_terms") |
| |
| |
| sms_sent = False |
| if st.button("📤 Enviar codi SMS", type="primary", disabled=not (phone_number and accept_terms)): |
| full_phone = f"{country_code}{phone_number}" |
| if self._send_sms_code(full_phone): |
| st.session_state.sms_step = 'verify' |
| st.session_state.sms_phone = full_phone |
| st.success(f"✅ Codis enviat a {full_phone}") |
| sms_sent = True |
| else: |
| st.error("❌ Error enviant el codi. Torna-ho a intentar.") |
| |
| |
| if sms_sent or st.session_state.sms_step == 'verify': |
| st.markdown("#### 🔓 Verificació del codi") |
| if st.session_state.sms_code: |
| st.info(f"💡 **Mode demo**: El codi és '{st.session_state.sms_code}'") |
| |
| col_code, col_resend = st.columns([2, 1]) |
| with col_code: |
| verification_code = st.text_input("Codi de 6 dígits", max_chars=6, placeholder="000000") |
| |
| with col_resend: |
| st.markdown("<br>", unsafe_allow_html=True) |
| if st.button("🔄 Reenviar", key="resend_in_expander"): |
| if self._send_sms_code(st.session_state.sms_phone): |
| st.success("✅ Nou codi enviat") |
| else: |
| st.error("❌ Error enviant el codi") |
| |
| if st.button("🔓 Verificar i continuar", type="primary"): |
| if verification_code == st.session_state.sms_code: |
| |
| demo_token = f"sms_token_{int(time.time())}" |
| st.session_state.auth_token = demo_token |
| st.session_state.current_user = { |
| "email": f"user@{st.session_state.sms_phone.replace('+', '')}.sms", |
| "name": "Usuario SMS", |
| "phone": st.session_state.sms_phone |
| } |
| st.success("✅ Sessió iniciada correctament") |
| |
| st.session_state.sms_step = 'phone' |
| st.session_state.sms_code = None |
| st.session_state.sms_phone = None |
| st.rerun() |
| return True |
| else: |
| st.error(f"❌ Codi incorrecte. El codi correcte és: {st.session_state.sms_code}") |
| |
| return False |
| |
| def _send_sms_code(self, phone_number: str) -> bool: |
| """Simula el envío de código SMS (en producción usaría un servicio real)""" |
| try: |
| |
| code = f"{random.randint(100000, 999999)}" |
| st.session_state.sms_code = code |
| |
| |
| st.info(f"🔧 **Mode demo**: Codi generat: {code}") |
| logger.info(f"[SMS] Código generado para {phone_number}: {code}") |
| |
| |
| return True |
| |
| except Exception as e: |
| logger.error(f"[SMS] Error enviando código: {e}") |
| return False |
| |
| def logout(self) -> bool: |
| """Cierra sesión del usuario actual""" |
| if "auth_token" in st.session_state: |
| token = st.session_state.auth_token |
| success = self.logout_user(token) |
| |
| |
| if "auth_token" in st.session_state: |
| del st.session_state.auth_token |
| if "current_user" in st.session_state: |
| del st.session_state.current_user |
| |
| logger.info("Sesión cerrada") |
| return success |
| |
| return True |
| |
| |
| |
| def record_consent(self, user_info: Dict[str, Any], |
| video_info: Dict[str, Any], |
| consent_data: Dict[str, Any]) -> Optional[str]: |
| """Registra consentimiento de usuario vía API""" |
| payload = { |
| "user_info": user_info, |
| "video_info": video_info, |
| "consent_data": consent_data |
| } |
| |
| response = self._make_request("POST", "/api/compliance/record-consent", payload) |
| |
| if response: |
| document_id = response.get("document_id") |
| logger.info(f"Consentimiento registrado: {document_id}") |
| return document_id |
| |
| return None |
|
|
| def notify_video_upload(self, video_name: str, sha1sum: str) -> bool: |
| """Notifica al servei de compliance que s'ha pujat un nou vídeo. |
| |
| Això desencadena l'enviament d'un SMS al validor de vídeos. |
| """ |
|
|
| payload = {"video_name": video_name, "sha1sum": sha1sum} |
| response = self._make_request( |
| "POST", "/api/notifications/video-upload-sms", payload |
| ) |
| return bool(response and response.get("success")) |
|
|
| def notify_user_video_approved(self, phone: str, message: str, sha1sum: str) -> bool: |
| """Envia un SMS a l'usuari indicant que el seu vídeo ha estat aprovat. |
| |
| El backend de compliance decidirà si utilitza Twilio o Zapier segons |
| la configuració (twilio_enabled / zapier_enabled). |
| """ |
|
|
| payload = {"phone": phone, "message": message, "sha1sum": sha1sum} |
| response = self._make_request( |
| "POST", "/api/notifications/user-video-approved-sms", payload |
| ) |
| return bool(response and response.get("success")) |
|
|
| def notify_une_validator_new_ads(self, phone: str, message: str) -> bool: |
| """Envia un SMS al validador UNE indicant que hi ha noves AD per validar. |
| |
| El backend de compliance s'encarrega de triar Twilio o Zapier segons |
| la configuració (twilio_enabled / zapier_enabled). |
| """ |
|
|
| payload = {"phone": phone, "message": message} |
| response = self._make_request( |
| "POST", "/api/notifications/une-validation-sms", payload |
| ) |
| return bool(response and response.get("success")) |
|
|
| def send_login_sms(self, phone: str, code: str) -> bool: |
| """Envia un SMS de verificació de login a través del servei de compliance. |
| |
| Demo només passa telèfon i codi; compliance s'encarrega de Zapier. |
| """ |
|
|
| payload = {"phone": phone, "code": code} |
| response = self._make_request("POST", "/api/notifications/login-sms", payload) |
| return bool(response and response.get("success")) |
|
|
| def publish_events_digest(self, session_id: str, digest_hash: str) -> Optional[Dict[str, Any]]: |
| """Publica el digest d'esdeveniments d'una sessió en blockchain. |
| |
| Retorna un dict amb, si té èxit: |
| - transaction_hash |
| - transaction_url |
| """ |
|
|
| payload = { |
| "session_id": session_id, |
| "digest_hash": digest_hash, |
| } |
|
|
| response = self._make_request( |
| "POST", "/api/blockchain/publish-events-digest", payload |
| ) |
|
|
| if response: |
| return response |
|
|
| return None |
|
|
| def publish_actions_qldb(self, session_id: str, actions: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: |
| """Envia el registre de canvis d'actions.db a una taula QLDB. |
| |
| El backend és responsable d'escriure aquest payload a AWS QLDB quan |
| la funcionalitat de blockchain privada estigui activada. |
| """ |
|
|
| payload = { |
| "session_id": session_id, |
| "actions": actions, |
| } |
|
|
| response = self._make_request( |
| "POST", "/api/blockchain/publish-actions-qldb", payload |
| ) |
|
|
| if response: |
| return response |
|
|
| return None |
| |
| def send_validation_request(self, validation_request: Dict[str, Any]) -> bool: |
| """Envía solicitud de validación a validadores""" |
| response = self._make_request("POST", "/api/compliance/send-validation", validation_request) |
| |
| if response: |
| logger.info(f"Solicitud de validación enviada: {validation_request.get('document_id')}") |
| return True |
| |
| return False |
| |
| def record_validator_decision(self, document_id: str, |
| validator_email: str, |
| decision: str, |
| comments: str = "") -> bool: |
| """Registra decisión de validador""" |
| payload = { |
| "document_id": document_id, |
| "validator_email": validator_email, |
| "decision": decision, |
| "comments": comments |
| } |
| |
| response = self._make_request("POST", "/api/compliance/record-decision", payload) |
| |
| if response: |
| logger.info(f"Decisión registrada: {document_id} -> {decision}") |
| return True |
| |
| return False |
| |
| |
| |
| def publish_monthly_digest(self, period: str, digest_hash: str | None = None) -> Optional[str]: |
| """Publica digest mensual en blockchain. |
| |
| Si digest_hash no és None, s'envia també al backend perquè el faci |
| servir com a arrel del digest mensual. |
| """ |
|
|
| payload: Dict[str, Any] = {"period": period} |
| if digest_hash: |
| payload["digest_hash"] = digest_hash |
|
|
| response = self._make_request("POST", "/api/blockchain/publish-digest", payload) |
| |
| if response: |
| tx_hash = response.get("transaction_hash") |
| logger.info(f"Digest publicado: {period} -> {tx_hash}") |
| return tx_hash |
| |
| return None |
| |
| def get_published_digests(self) -> List[Dict[str, Any]]: |
| """Obtiene lista de digest publicados""" |
| response = self._make_request("GET", "/api/blockchain/digests") |
| |
| if response: |
| digests = response.get("digests", []) |
| logger.info(f"Obtenidos {len(digests)} digest publicados") |
| return digests |
| |
| return [] |
| |
| def verify_digest(self, period: str, expected_hash: str) -> bool: |
| """Verifica integridad de digest en blockchain""" |
| payload = { |
| "period": period, |
| "expected_hash": expected_hash |
| } |
| |
| response = self._make_request("POST", "/api/blockchain/verify-digest", payload) |
| |
| if response: |
| is_valid = response.get("valid", False) |
| logger.info(f"Digest verificado: {period} -> {'VÁLIDO' if is_valid else 'INVÁLIDO'}") |
| return is_valid |
| |
| return False |
| |
| def get_compliance_stats(self) -> Dict[str, Any]: |
| """Obtiene estadísticas de cumplimiento""" |
| response = self._make_request("GET", "/api/compliance/stats") |
| |
| if response: |
| logger.info("Estadísticas de cumplimiento obtenidas") |
| return response |
| |
| return {} |
| |
| def health_check(self) -> bool: |
| """Verifica si el servicio de compliance está disponible""" |
| response = self._make_request("GET", "/") |
| |
| if response: |
| status = response.get("status") |
| if status == "running": |
| logger.info("Servicio compliance funcionando correctamente") |
| return True |
| |
| logger.warning("Servicio compliance no disponible") |
| return False |
| |
| def send_decision_notification(self, notification: Dict[str, Any]) -> bool: |
| """Envía notificación de decisión de validación""" |
| response = self._make_request("POST", "/api/compliance/send-decision-notification", notification) |
| |
| if response: |
| logger.info(f"Notificación de decisión enviada: {notification.get('document_id')}") |
| return True |
| |
| return False |
|
|
| |
| compliance_client = ComplianceClient() |
|
|