From 62315e5b24f1822774d29cffd44f0973e54ceac3 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Wed, 18 Feb 2026 14:25:24 -0600 Subject: [PATCH 01/16] Implementacion de sistema distribuido de boton de ayuda --- backend/.env.example | 5 + .../api/v1/modules/core/help_center/models.py | 22 ++ .../api/v1/modules/core/help_center/routes.py | 116 +++++++ .../v1/modules/core/help_center/schemas.py | 43 +++ .../v1/modules/core/help_center/services.py | 116 +++++++ .../api/v1/modules/core/help_center/tasks.py | 236 +++++++++++++ backend/api/v1/modules/core/router.py | 2 + backend/core/celery_app.py | 16 +- backend/core/config.py | 14 + backend/core/middleware.py | 2 + backend/main.py | 1 + docker-compose.yml | 10 +- frontend/src/lib/api/help.ts | 68 ++++ .../src/lib/components/help/HelpDrawer.svelte | 229 +++++++++++++ .../src/lib/components/sidebar/modules.ts | 2 +- .../components/sidebar/nav-projects.svelte | 42 +-- frontend/src/lib/stores/help.svelte.ts | 9 + frontend/src/routes/+layout.svelte | 17 +- .../routes/dashboard/help-center/+page.svelte | 321 ++++++++++++++++++ scripts/init_first_time.sh | 69 +++- 20 files changed, 1305 insertions(+), 35 deletions(-) create mode 100644 backend/api/v1/modules/core/help_center/models.py create mode 100644 backend/api/v1/modules/core/help_center/routes.py create mode 100644 backend/api/v1/modules/core/help_center/schemas.py create mode 100644 backend/api/v1/modules/core/help_center/services.py create mode 100644 backend/api/v1/modules/core/help_center/tasks.py create mode 100644 frontend/src/lib/api/help.ts create mode 100644 frontend/src/lib/components/help/HelpDrawer.svelte create mode 100644 frontend/src/lib/stores/help.svelte.ts create mode 100644 frontend/src/routes/dashboard/help-center/+page.svelte diff --git a/backend/.env.example b/backend/.env.example index 23cd9f7b..77ae339c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,3 +27,8 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:3000 # License Service LICENSE_CHECK_ENABLED=True + +# Synchronization (Hub & Spoke) +SYNC_SECRET_TOKEN=change-this-sync-token-in-production +# Only for spokes/clients. Leave empty if this is the Hub. +CENTRAL_SERVER_URL=http://localhost:8000/api/v1/core/help-center/sync/ diff --git a/backend/api/v1/modules/core/help_center/models.py b/backend/api/v1/modules/core/help_center/models.py new file mode 100644 index 00000000..876de284 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/models.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime, timezone +from sqlalchemy import Column, String, Text, DateTime +from sqlalchemy.dialects.postgresql import UUID +from core.database import Base + +class HelpArticle(Base): + """ + Modelo para los artículos de ayuda (Base de Conocimientos). + Sincronizado entre Servidor Central y Clientes. + """ + __tablename__ = "help_articles" + + uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + slug = Column(String(255), unique=True, index=True, nullable=False) + title = Column(String(255), nullable=False) + content = Column(Text, nullable=False) + updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + last_editor = Column(String(255), nullable=False) + + def __repr__(self): + return f"" diff --git a/backend/api/v1/modules/core/help_center/routes.py b/backend/api/v1/modules/core/help_center/routes.py new file mode 100644 index 00000000..b4c281f1 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/routes.py @@ -0,0 +1,116 @@ +from datetime import datetime +from typing import List, Optional +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, Header, status +from sqlalchemy.orm import Session +from core.database import get_core_db +from core.config import settings +from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse +from .services import HelpCenterService +from .tasks import sync_single_article_task + +router = APIRouter(prefix="/help-center", tags=["Help Center"]) + +def verify_sync_token(x_sync_token: str = Header(...)): + if x_sync_token != settings.SYNC_SECRET_TOKEN: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Sync Token" + ) + +def trigger_sync_or_broadcast(article_uuid: UUID): + """ + Helper function to handle synchronization logic. + - If we are a Client (CENTRAL_SERVER_URL is set): Trigger upstream sync. + - If we are the Hub (No CENTRAL_SERVER, but SPOKE_URLS set): Trigger broadcast. + """ + # 1. Upstream Sync (Client -> Hub) + if settings.CENTRAL_SERVER_URL: + sync_single_article_task.delay(str(article_uuid)) + + # 2. Downstream Broadcast (Hub -> Spokes) + # Only if we are the Hub (no upstream) and have spokes configured. + elif not settings.CENTRAL_SERVER_URL and settings.SPOKE_URLS: + from .tasks import broadcast_help_update + # origin_client_uuid is None because this change originated on the Hub itself + broadcast_help_update.delay(str(article_uuid), None) + +@router.post("/sync/", response_model=HelpSyncResponse, dependencies=[Depends(verify_sync_token)]) +def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core_db)): + """ + Endpoint de sincronización inteligente para artículos de ayuda. + Requiere X-Sync-Token en los headers. + """ + result = HelpCenterService.sync_article(db, sync_data) + + # Broadcast to other spokes (Hub logic) + import logging + logger = logging.getLogger(__name__) + logger.info(f"DEBUG: Hub Sync Check. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'") + + if not settings.CENTRAL_SERVER_URL and settings.SPOKE_URLS: + # We are the Hub (no central server to push to) and have Spokes configured + from .tasks import broadcast_help_update + logger.info(f"DEBUG: Triggering broadcast for article {sync_data.article_uuid}") + broadcast_help_update.delay( + str(sync_data.article_uuid), + str(sync_data.origin_client_uuid) if sync_data.origin_client_uuid else None + ) + else: + logger.info("DEBUG: Broadcast skipped (Condition failed)") + + return result + +@router.get("/articles/", response_model=List[HelpArticleInDB]) +def list_articles(db: Session = Depends(get_core_db)): + """Lista todos los artículos de ayuda.""" + return HelpCenterService.get_all(db) + +@router.get("/modifications/", response_model=List[HelpArticleInDB]) +def get_modifications(since: datetime, db: Session = Depends(get_core_db)): + """Obtiene artículos modificados desde la fecha indicada (Polling).""" + return HelpCenterService.get_modifications(db, since) + +@router.get("/articles/{article_uuid}/", response_model=HelpArticleInDB) +def get_article(article_uuid: UUID, db: Session = Depends(get_core_db)): + """Obtiene un artículo por UUID.""" + article = HelpCenterService.get_by_uuid(db, article_uuid) + if not article: + raise HTTPException(status_code=404, detail="Article not found") + return article + +@router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED) +def create_article(article: HelpArticleCreate, db: Session = Depends(get_core_db)): + """Crea un nuevo artículo.""" + new_article = HelpCenterService.create(db, article) + trigger_sync_or_broadcast(new_article.uuid) + return new_article + +@router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB) +def update_article(article_uuid: UUID, article_data: HelpArticleUpdate, db: Session = Depends(get_core_db)): + """Actualiza un artículo.""" + article = HelpCenterService.update(db, article_uuid, article_data) + if not article: + raise HTTPException(status_code=404, detail="Article not found") + + trigger_sync_or_broadcast(article.uuid) + + return article + +@router.delete("/articles/{article_uuid}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_article(article_uuid: UUID, db: Session = Depends(get_core_db)): + """Elimina un artículo.""" + if not HelpCenterService.delete(db, article_uuid): + raise HTTPException(status_code=404, detail="Article not found") + + # Broadcast or Sync the deletion? + # Current sync logic relies on sending the *content*. Deletion sync is harder because the article is gone. + # For now, let's at least trigger the logic. + # WARNING: sync_single_article_task expects the article to exist to send it. + # If we deleted it locally, sync_single_article_task will fail or send nothing. + # We need a dedicated 'sync_deletion' task or similar. + # Since the user didn't explicitly ask for deletion sync, I will SKIP adding complex deletion sync + # logic right now to avoid breaking things, but I'll add the hook for completeness. + # Actually, better to NOT trigger sync on delete if we don't handle it, to avoid errors in logs. + + return None diff --git a/backend/api/v1/modules/core/help_center/schemas.py b/backend/api/v1/modules/core/help_center/schemas.py new file mode 100644 index 00000000..541a87a4 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/schemas.py @@ -0,0 +1,43 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, Field + +class HelpArticleBase(BaseModel): + slug: str + title: str + content: str + last_editor: str + +class HelpArticleCreate(HelpArticleBase): + pass + +class HelpArticleUpdate(BaseModel): + slug: Optional[str] = None + title: Optional[str] = None + content: Optional[str] = None + last_editor: Optional[str] = None + +class HelpArticleInDB(HelpArticleBase): + uuid: UUID + updated_at: datetime + + class Config: + from_attributes = True + +class HelpSyncRequest(BaseModel): + article_uuid: UUID + client_updated_at: datetime + client_content: str + client_title: str + client_slug: str + last_editor: str + origin_client_uuid: Optional[UUID] = None + +class HelpSyncResponse(BaseModel): + status: str + server_updated_at: Optional[datetime] = None + server_content: Optional[str] = None + server_title: Optional[str] = None + server_slug: Optional[str] = None + message: str diff --git a/backend/api/v1/modules/core/help_center/services.py b/backend/api/v1/modules/core/help_center/services.py new file mode 100644 index 00000000..fa7af4ec --- /dev/null +++ b/backend/api/v1/modules/core/help_center/services.py @@ -0,0 +1,116 @@ +from datetime import datetime, timezone +from typing import List, Optional +from uuid import UUID +from sqlalchemy.orm import Session +from .models import HelpArticle +from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse + +class HelpCenterService: + @staticmethod + def get_all(db: Session) -> List[HelpArticle]: + return db.query(HelpArticle).all() + + @staticmethod + def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]: + return db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + + @staticmethod + def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]: + return db.query(HelpArticle).filter(HelpArticle.slug == slug).first() + + @staticmethod + def get_modifications(db: Session, since: datetime) -> List[HelpArticle]: + # Ensure timezone awareness + if since.tzinfo is None: + since = since.replace(tzinfo=timezone.utc) + return db.query(HelpArticle).filter(HelpArticle.updated_at > since).all() + + @staticmethod + def create(db: Session, article: HelpArticleCreate) -> HelpArticle: + db_article = HelpArticle(**article.model_dump()) + db.add(db_article) + db.commit() + db.refresh(db_article) + return db_article + + @staticmethod + def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]: + db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + if not db_article: + return None + + update_data = article_data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_article, key, value) + + db.commit() + db.refresh(db_article) + return db_article + + @staticmethod + def delete(db: Session, article_uuid: UUID) -> bool: + db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + if not db_article: + return False + db.delete(db_article) + db.commit() + return True + + @staticmethod + def sync_article(db: Session, sync_data: HelpSyncRequest) -> HelpSyncResponse: + """ + Lógica de sincronización "Smart Sync" (Last Write Wins). + """ + # Loop Prevention: If the update originated from this client, ignore it. + from core.config import settings + if settings.CLIENT_UUID and sync_data.origin_client_uuid and str(sync_data.origin_client_uuid) == settings.CLIENT_UUID: + return HelpSyncResponse(status="OK", message="Skipped: I am the origin.") + + db_article = db.query(HelpArticle).filter(HelpArticle.uuid == sync_data.article_uuid).first() + + client_updated_at = sync_data.client_updated_at + if client_updated_at.tzinfo is None: + client_updated_at = client_updated_at.replace(tzinfo=timezone.utc) + + if not db_article: + # Caso A: Artículo nuevo desde el cliente + new_article = HelpArticle( + uuid=sync_data.article_uuid, + slug=sync_data.client_slug, + title=sync_data.client_title, + content=sync_data.client_content, + updated_at=client_updated_at, + last_editor=sync_data.last_editor + ) + db.add(new_article) + db.commit() + return HelpSyncResponse(status="OK", message="Article created on server.") + + server_updated_at = db_article.updated_at + if server_updated_at.tzinfo is None: + server_updated_at = server_updated_at.replace(tzinfo=timezone.utc) + + # Caso A: Cliente es más nuevo + if client_updated_at > server_updated_at: + db_article.content = sync_data.client_content + db_article.title = sync_data.client_title + db_article.slug = sync_data.client_slug + db_article.updated_at = client_updated_at + db_article.last_editor = sync_data.last_editor + db.commit() + return HelpSyncResponse(status="OK", message="Server updated with client data.") + + # Caso B: Servidor es más nuevo + elif server_updated_at > client_updated_at: + return HelpSyncResponse( + status="UPDATE_REQUIRED", + server_updated_at=server_updated_at, + server_content=db_article.content, + server_title=db_article.title, + server_slug=db_article.slug, + message="Client is outdated. Update required." + ) + + # Caso C: Iguales + else: + return HelpSyncResponse(status="OK", message="Already in sync.") diff --git a/backend/api/v1/modules/core/help_center/tasks.py b/backend/api/v1/modules/core/help_center/tasks.py new file mode 100644 index 00000000..a4f753e9 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/tasks.py @@ -0,0 +1,236 @@ +import logging +import httpx +from celery import shared_task +from datetime import datetime, timezone +from core.database import CoreSessionLocal +from core.config import settings +from .models import HelpArticle +from .schemas import HelpSyncRequest, HelpSyncResponse + +logger = logging.getLogger(__name__) + +@shared_task(name="sync_all_articles_task") +def sync_all_articles_task(): + """ + Tarea periódica que recorre todos los artículos locales y los sincroniza con el Central. + Solo se ejecuta si hay un CENTRAL_SERVER_URL configurado (Rol: Cliente/Spoke). + """ + if not settings.CENTRAL_SERVER_URL: + logger.info("Skipping sync: No CENTRAL_SERVER_URL configured (Hub mode).") + return + db = CoreSessionLocal() + try: + articles = db.query(HelpArticle).all() + for article in articles: + sync_single_article(article.uuid) + except Exception as e: + logger.error(f"Error in sync_all_articles_task: {e}") + finally: + db.close() + +@shared_task(name="sync_single_article_task") +def sync_single_article_task(article_uuid_str: str): + """ + Sincroniza un único artículo inmediatamente después de una edición local. + """ + sync_single_article(article_uuid_str) + + +@shared_task(name="broadcast_help_update") +def broadcast_help_update(article_uuid_str: str, origin_client_uuid_str: str = None): + """ + Difunde una actualización de artículo a todos los spokes configurados, + excepto al que originó el cambio (si existe). + """ + if not settings.SPOKE_URLS: + logger.info("No SPOKE_URLS configured. Skipping broadcast.") + return + + spokes = [s.strip() for s in settings.SPOKE_URLS.split(",") if s.strip()] + headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN} + + db = CoreSessionLocal() + try: + article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid_str).first() + if not article: + logger.error(f"Article {article_uuid_str} not found for broadcast.") + return + + sync_payload = HelpSyncRequest( + article_uuid=article.uuid, + client_updated_at=article.updated_at, + client_content=article.content, + client_title=article.title, + client_slug=article.slug, + last_editor=article.last_editor, + origin_client_uuid=UUID(origin_client_uuid_str) if origin_client_uuid_str else None + ).model_dump(mode='json') + + with httpx.Client() as client: + for spoke_url in spokes: + # Loop Prevention: Skip if the spoke is the origin + # NOTE: This assumes SPOKE_URLS contains the unique Sync URL of the client. + # A more robust check would be comparing CLIENT_UUID if we knew the URL->UUID mapping ahead of time. + # For this implementation, we rely on the `origin_client_uuid` being passed in the payload + # and the receiving client checking it against its own settings.CLIENT_UUID. + + try: + logger.info(f"Broadcasting update to {spoke_url}") + response = client.post( + spoke_url, + json=sync_payload, + headers=headers, + timeout=5.0 + ) + if response.status_code != 200: + logger.warning(f"Broadcast to {spoke_url} failed: {response.status_code}") + except Exception as e: + logger.error(f"Error broadcasting to {spoke_url}: {e}") + + except Exception as e: + logger.error(f"Broadcast error: {e}") + finally: + db.close() + + +def sync_single_article(article_uuid): + """ + Lógica compartida para sincronizar un artículo con el servidor central. + """ + logger.info(f"DEBUG: Syncing article {article_uuid}. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' (Type: {type(settings.CENTRAL_SERVER_URL)})") + + if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""': + # Enhanced check to catch literal empty quotes if they slip through + return + + db = CoreSessionLocal() + try: + article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + if not article: + return + + sync_data = HelpSyncRequest( + article_uuid=article.uuid, + client_updated_at=article.updated_at, + client_content=article.content, + client_title=article.title, + client_slug=article.slug, + last_editor=article.last_editor, + origin_client_uuid=UUID(settings.CLIENT_UUID) if settings.CLIENT_UUID else None + ) + + headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN} + + with httpx.Client() as client: + response = client.post( + settings.CENTRAL_SERVER_URL, + json=sync_data.model_dump(mode='json'), + headers=headers, + timeout=10.0 + ) + + if response.status_code == 200: + result = HelpSyncResponse(**response.json()) + if result.status == "UPDATE_REQUIRED": + # El servidor tiene una versión más nueva, actualizamos localmente + article.content = result.server_content + article.title = result.server_title + article.slug = result.server_slug + article.updated_at = result.server_updated_at + db.commit() + logger.info(f"Article {article.uuid} updated from server.") + else: + logger.info(f"Article {article.uuid} sync OK: {result.message}") + else: + logger.error(f"Sync failed for article {article.uuid}: {response.status_code} - {response.text}") + + except Exception as e: + logger.error(f"Error syncing article {article.uuid}: {e}") + finally: + db.close() + +from sqlalchemy import func + +@shared_task(name="sync_from_hub_task") +def sync_from_hub_task(): + """ + Tarea de POLLING que el Cliente ejecuta periódicamente. + Consulta al Hub (CENTRAL_SERVER_URL) por artículos modificados desde + la última actualización local. + """ + if not settings.CENTRAL_SERVER_URL: + return + + db = CoreSessionLocal() + try: + # 1. Obtener la fecha de la última actualización local + last_local_update = db.query(func.max(HelpArticle.updated_at)).scalar() + if not last_local_update: + # Si no hay datos, traer todo desde el principio de los tiempos + last_local_update = datetime(2000, 1, 1, tzinfo=timezone.utc) + + # Asegurar timezone awareness + if last_local_update.tzinfo is None: + last_local_update = last_local_update.replace(tzinfo=timezone.utc) + + logger.info(f"Polling Hub for updates since {last_local_update}") + + # 2. Consultar al Hub + headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN} + # CENTRAL_SERVER_URL es ".../help-center/sync/" + # Queremos ".../help-center/modifications/" + hub_url = settings.CENTRAL_SERVER_URL.replace("/sync/", "/modifications/") + + with httpx.Client() as client: + response = client.get( + hub_url, + params={"since": last_local_update.isoformat()}, + headers=headers, + timeout=10.0 + ) + + if response.status_code == 200: + articles_data = response.json() + if not articles_data: + logger.info("No updates found.") + return + + logger.info(f"Found {len(articles_data)} updates from Hub. Applying...") + + # 3. Aplicar actualizaciones + for art_data in articles_data: + # Convertir string a datetime + server_updated_at = datetime.fromisoformat(art_data['updated_at']) + + # Logic similar to sync_article but simpler (Force Update from Hub) + # We assume Hub is Truth in this Polling flow + local_article = db.query(HelpArticle).filter(HelpArticle.uuid == art_data['uuid']).first() + + if not local_article: + new_article = HelpArticle( + uuid=art_data['uuid'], + slug=art_data['slug'], + title=art_data['title'], + content=art_data['content'], + updated_at=server_updated_at, + last_editor=art_data['last_editor'] + ) + db.add(new_article) + else: + if server_updated_at > local_article.updated_at.replace(tzinfo=timezone.utc): + local_article.slug = art_data['slug'] + local_article.title = art_data['title'] + local_article.content = art_data['content'] + local_article.updated_at = server_updated_at + local_article.last_editor = art_data['last_editor'] + + db.commit() + logger.info("Polling sync completed successfully.") + + else: + logger.error(f"Polling failed: {response.status_code} - {response.text}") + + except Exception as e: + logger.error(f"Error in sync_from_hub_task: {e}") + finally: + db.close() diff --git a/backend/api/v1/modules/core/router.py b/backend/api/v1/modules/core/router.py index 861eecaa..be36fde0 100644 --- a/backend/api/v1/modules/core/router.py +++ b/backend/api/v1/modules/core/router.py @@ -5,6 +5,7 @@ from .tenants.routes import router as tenants_router from .user_tenant.routes import router as user_tenant_router from .users.routes import router as users_router from .dashboard.routes import router as dashboard_router +from .help_center.routes import router as help_center_router from fastapi import APIRouter router = APIRouter() @@ -16,3 +17,4 @@ router.include_router(users_router, prefix="/core", tags=["core / users"]) router.include_router(licenses_router, prefix="/core", tags=["core / licenses"]) router.include_router(permissions_router, prefix="/core", tags=["core / permissions"]) router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"]) +router.include_router(help_center_router, prefix="/core", tags=["core / help-center"]) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 1665bb6a..b06b0a51 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -3,18 +3,25 @@ from celery import Celery valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0") +print(f"DEBUG: Celery Broker URL: {valkey_url}") +# Configurar broker y backend explícitamente en el constructor celery_app = Celery( "anexo76_tasks", broker=valkey_url, backend=valkey_url, +) +celery_app.set_default() + +celery_app.conf.update( include=[ "api.v1.modules.a76.reports.importacion.facturas.task", "api.v1.modules.a76.reports.importacion.consolidados.task", "api.v1.modules.a76.reports.importacion.packing_list.task", "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task", "api.v1.modules.a76.reports.exportacion.descargo.task", - "api.v1.modules.a76.imports.tasks" + "api.v1.modules.a76.imports.tasks", + "api.v1.modules.core.help_center.tasks" ] # Ruta al módulo donde están las tareas ) @@ -28,5 +35,12 @@ celery_app.conf.update( enable_utc=True, ) +celery_app.conf.beat_schedule = { + "sync-from-hub-every-minute": { + "task": "sync_from_hub_task", + "schedule": 60.0, # Run every 60 seconds + }, +} + if __name__ == "__main__": celery_app.start() \ No newline at end of file diff --git a/backend/core/config.py b/backend/core/config.py index 31919a35..0304c7cb 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -5,6 +5,7 @@ Configuración centralizada de la aplicación usando Pydantic Settings import os from typing import List +from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -39,6 +40,12 @@ class Settings(BaseSettings): ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + # Synchronization + SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production" + CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/" + SPOKE_URLS: str = "" # Comma separated list of Spoke URLs for Broadcast (Hub only) + CLIENT_UUID: str = "" # Unique identifier for this Client instance (Spoke only) + # CORS CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000" @@ -57,6 +64,13 @@ class Settings(BaseSettings): env_file_encoding="utf-8", ) + @field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", mode="before") + @classmethod + def strip_quotes(cls, v: str) -> str: + if v: + return v.strip().strip('"').strip("'") + return v + @property def core_database_url(self) -> str: """URL de conexión a la base de datos core""" diff --git a/backend/core/middleware.py b/backend/core/middleware.py index d7da611b..444f57ac 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -20,6 +20,7 @@ class TenantMiddleware(BaseHTTPMiddleware): "/api/health", "/api/", "/uploads", + "/api/v1/core/help-center", ] path = request.url.path @@ -74,6 +75,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): "/api/v1/status", "/api/health", "/api/", + "/api/v1/core/help-center", ] # Verificar si la ruta está exenta (comparación exacta o prefijo) diff --git a/backend/main.py b/backend/main.py index 005dd653..a27b65fa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -75,6 +75,7 @@ from api.v1.modules.a76.audit_log.events import register_audit_listeners # Core Modules (Secondary) +import core.celery_app # Initialize Celery App from api.v1.router import router as api_v1_router from core.config import settings from core.database import init_db diff --git a/docker-compose.yml b/docker-compose.yml index 041c1bdf..20fd3b30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -174,6 +174,10 @@ services: - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-d2Navi4c3cdrdK92oE3iiGuXn0T4dY9k} + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL} # Variable clave para definir si es Cliente o Hub + - SPOKE_URLS=${SPOKE_URLS} + - CLIENT_UUID=${CLIENT_UUID} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000} - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} @@ -270,9 +274,13 @@ services: celery_worker: build: ./backend container_name: worker - command: celery -A core.celery_app worker --loglevel=info + command: celery -A core.celery_app worker --loglevel=info -B environment: - VALKEY_URL=redis://valkey:6379/0 + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL} + - SPOKE_URLS=${SPOKE_URLS} + - CLIENT_UUID=${CLIENT_UUID} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-d2Navi4c3cdrdK92oE3iiGuXn0T4dY9k} depends_on: - backend - valkey diff --git a/frontend/src/lib/api/help.ts b/frontend/src/lib/api/help.ts new file mode 100644 index 00000000..09bda8d8 --- /dev/null +++ b/frontend/src/lib/api/help.ts @@ -0,0 +1,68 @@ +import { authStore } from '$lib/auth'; +import { get } from 'svelte/store'; + +const api_url = import.meta.env.VITE_API_URL; +const BASE_URL = `${api_url.endsWith('/') ? api_url : api_url + '/'}v1/core/help-center`; + +function getHeaders() { + const auth = get(authStore); + return { + 'Content-Type': 'application/json', + ...(auth.token ? { 'Authorization': `Bearer ${auth.token}` } : {}) + }; +} + +export interface HelpArticle { + uuid: string; + slug: string; + title: string; + content: string; + updated_at: string; + last_editor: string; +} + +export const helpApi = { + async listArticles(): Promise { + const response = await fetch(`${BASE_URL}/articles/`); + if (!response.ok) throw new Error('Failed to fetch articles'); + return response.json(); + }, + + async getArticle(uuid: string): Promise { + const response = await fetch(`${BASE_URL}/articles/${uuid}/`); + if (!response.ok) throw new Error('Failed to fetch article'); + return response.json(); + }, + + async updateArticle(uuid: string, data: Partial): Promise { + const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { + method: 'PATCH', + headers: getHeaders(), + body: JSON.stringify(data) + }); + if (!response.ok) throw new Error('Failed to update article'); + return response.json(); + }, + + async createArticle(data: { title: string; content: string; slug: string; last_editor: string }): Promise { + const response = await fetch(`${BASE_URL}/articles/`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(data) + }); + if (!response.ok) throw new Error('Failed to create article'); + return response.json(); + }, + + async deleteArticle(uuid: string): Promise { + const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { + method: 'DELETE', + headers: getHeaders() + }); + if (!response.ok) throw new Error('Failed to delete article'); + }, + + async triggerSync(): Promise { + // Opcional: endpoint para forzar sync desde UI si es necesario + } +}; diff --git a/frontend/src/lib/components/help/HelpDrawer.svelte b/frontend/src/lib/components/help/HelpDrawer.svelte new file mode 100644 index 00000000..6a557aed --- /dev/null +++ b/frontend/src/lib/components/help/HelpDrawer.svelte @@ -0,0 +1,229 @@ + + + + + + + + + + {#if selectedArticle} + + {/if} + Base de Conocimientos + + + +
+ {#if !selectedArticle} +
+
+

Artículos Disponibles

+ {#if isAdmin} + + {/if} +
+ {#if isLoading} +

Cargando...

+ {:else if articles.length === 0} +

+ No hay artículos de ayuda disponibles. +

+ {/if} +
+ {#each articles as article} + + {/each} +
+
+ {:else} +
+ {#if isEditing} +
+ + +
+ + +
+
+ {:else} +
+
+

{selectedArticle.title}

+ {#if isAdmin} + + {/if} +
+
+ {@html renderMarkdown(selectedArticle.content)} +
+
+ {/if} +
+ {/if} +
+
+
+ + diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 887ba8bc..672cf07b 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -503,7 +503,7 @@ export function getSidebarData(): SidebarData { }, { name: m["sidebar.reference_data.ayuda"](), - url: "#", + url: "/dashboard/help-center", icon: Frame, }, ], diff --git a/frontend/src/lib/components/sidebar/nav-projects.svelte b/frontend/src/lib/components/sidebar/nav-projects.svelte index f67a27e2..15f67eb9 100644 --- a/frontend/src/lib/components/sidebar/nav-projects.svelte +++ b/frontend/src/lib/components/sidebar/nav-projects.svelte @@ -1,15 +1,16 @@ - + +
+ +
+
+

Centro de Ayuda

+

Gestiona la base de conocimientos distribuida.

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ {#if loading} +
+ +
+ {:else if filteredArticles.length === 0} + + +

No se encontraron artículos.

+
+
+ {:else} +
+ {#each filteredArticles as article} + + +
+ {article.title} +
+ + +
+
+ + Slug: /{article.slug} + +
+ +
+ {@html article.content} +
+
+ + Edito: {article.last_editor} + {new Date(article.updated_at).toLocaleDateString()} + +
+ {/each} +
+ {/if} +
+
+ + + + + + Crear Artículo + + Completa los campos para añadir un nuevo artículo a la base de conocimientos. + + +
+
+ + +
+
+ + +
+
+ + + + + +
+ + + {#if showPreview} +
+
+ {@html renderMarkdown(content)} +
+
+ {/if} + +
+ diff --git a/scripts/frontend-entrypoint.sh b/scripts/frontend-entrypoint.sh index bb2f0a33..f5ca66e3 100755 --- a/scripts/frontend-entrypoint.sh +++ b/scripts/frontend-entrypoint.sh @@ -40,5 +40,11 @@ echo "==========================================" echo "Iniciando aplicación SvelteKit..." echo "==========================================" +# Instalar dependencias nuevas si package.json ha cambiado +if [ "$NODE_ENV" = "development" ]; then + echo "Instalando dependencias (development mode)..." + pnpm install +fi + # Ejecutar el comando que se pasó al contenedor exec "$@" From 3b320d73812c2dc4f9bb661c1803841b92e0040a Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 19 Feb 2026 14:35:01 -0600 Subject: [PATCH 04/16] Agregando archivito --- scripts/frontend-entrypoint.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/frontend-entrypoint.sh b/scripts/frontend-entrypoint.sh index f5ca66e3..9a431440 100755 --- a/scripts/frontend-entrypoint.sh +++ b/scripts/frontend-entrypoint.sh @@ -43,7 +43,8 @@ echo "==========================================" # Instalar dependencias nuevas si package.json ha cambiado if [ "$NODE_ENV" = "development" ]; then echo "Instalando dependencias (development mode)..." - pnpm install + # CI=true evita el error ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY + CI=true pnpm install fi # Ejecutar el comando que se pasó al contenedor From f15d40f6974c8f9ba70a1722627549b2de8a3871 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 23 Feb 2026 10:54:39 -0600 Subject: [PATCH 05/16] Nuevas funcionalidades y correcciones --- .env.example | 4 +- .../api/v1/modules/core/help_center/routes.py | 40 ++++++++++++++----- .../api/v1/modules/core/help_center/tasks.py | 4 ++ backend/main.py | 2 +- .../lib/components/ui/alert-dialog/index.ts | 3 +- .../src/lib/components/ui/dialog/index.ts | 3 +- frontend/src/lib/components/ui/sheet/index.ts | 3 +- .../help-center/editor/[uuid]/+page.svelte | 9 +++++ 8 files changed, 53 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 80f6478a..aa99fb54 100644 --- a/.env.example +++ b/.env.example @@ -29,10 +29,10 @@ CORE_DB_PASSWORD=postgres # ----- Frontend ----- NODE_ENV=development -VITE_API_URL=http://localhost:8000/api +VITE_API_URL=http://localhost:8001/api INTERNAL_API_URL=http://backend:8000/api VITE_KEYCLOAK_REALM=master -VITE_KEYCLOAK_URL=http://localhost:8080 +VITE_KEYCLOAK_URL=http://localhost:8081 VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend # ----- Sitar API ----- diff --git a/backend/api/v1/modules/core/help_center/routes.py b/backend/api/v1/modules/core/help_center/routes.py index fe7f0b15..19b8cb83 100644 --- a/backend/api/v1/modules/core/help_center/routes.py +++ b/backend/api/v1/modules/core/help_center/routes.py @@ -27,16 +27,31 @@ def trigger_sync_or_broadcast(article_uuid: UUID): - If we are a Client (CENTRAL_SERVER_URL is set): Trigger upstream sync. - If we are the Hub (No CENTRAL_SERVER, but SPOKE_URLS set): Trigger broadcast. """ - # 1. Upstream Sync (Client -> Hub) - if settings.CENTRAL_SERVER_URL: - sync_single_article_task.delay(str(article_uuid)) + import logging + logger = logging.getLogger(__name__) - # 2. Downstream Broadcast (Hub -> Spokes) - # Only if we are the Hub (no upstream) and have spokes configured. - elif not settings.CENTRAL_SERVER_URL and settings.SPOKE_URLS: - from .tasks import broadcast_help_update - # origin_client_uuid is None because this change originated on the Hub itself - broadcast_help_update.delay(str(article_uuid), None) + try: + logger.info(f"DEBUG: Triggering sync/broadcast for article {article_uuid}") + logger.debug(f"DEBUG: CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'") + + # 1. Upstream Sync (Client -> Hub) + if settings.CENTRAL_SERVER_URL and settings.CENTRAL_SERVER_URL != '""': + logger.info(f"DEBUG: Queueing sync_single_article_task for {article_uuid}") + sync_single_article_task.delay(str(article_uuid)) + + # 2. Downstream Broadcast (Hub -> Spokes) + # Only if we are the Hub (no upstream) and have spokes configured. + elif (not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""') and settings.SPOKE_URLS: + from .tasks import broadcast_help_update + logger.info(f"DEBUG: Queueing broadcast_help_update for {article_uuid}") + # origin_client_uuid is None because this change originated on the Hub itself + broadcast_help_update.delay(str(article_uuid), None) + else: + logger.info(f"DEBUG: No sync/broadcast needed for {article_uuid} (Config empty or Hub mode without spokes)") + + except Exception as e: + logger.error(f"ERROR in trigger_sync_or_broadcast for article {article_uuid}: {str(e)}", exc_info=True) + # We don't re-raise here to avoid returning 500 to the user if the save was successful @router.post("/sync/", response_model=HelpSyncResponse, dependencies=[Depends(verify_sync_token)]) def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core_db)): @@ -103,8 +118,15 @@ def get_article(article_uuid: UUID, db: Session = Depends(get_core_db)): @router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED) def create_article(article: HelpArticleCreate, db: Session = Depends(get_core_db)): """Crea un nuevo artículo.""" + import logging + logger = logging.getLogger(__name__) + logger.info(f"DEBUG: Creating new article: {article.title}") + new_article = HelpCenterService.create(db, article) + logger.info(f"DEBUG: Article created successfully in DB. UUID: {new_article.uuid}") + trigger_sync_or_broadcast(new_article.uuid) + return new_article @router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB) diff --git a/backend/api/v1/modules/core/help_center/tasks.py b/backend/api/v1/modules/core/help_center/tasks.py index fb7cdf77..437b9f47 100644 --- a/backend/api/v1/modules/core/help_center/tasks.py +++ b/backend/api/v1/modules/core/help_center/tasks.py @@ -64,6 +64,8 @@ def broadcast_help_update(article_uuid_str: str, origin_client_uuid_str: str = N client_title=article.title, client_slug=article.slug, last_editor=article.last_editor, + client_category=article.category, + client_order=article.order, origin_client_uuid=UUID(origin_client_uuid_str) if origin_client_uuid_str else None ).model_dump(mode='json') @@ -117,6 +119,8 @@ def sync_single_article(article_uuid): client_title=article.title, client_slug=article.slug, last_editor=article.last_editor, + client_category=article.category, + client_order=article.order, origin_client_uuid=UUID(settings.CLIENT_UUID) if settings.CLIENT_UUID else None ) diff --git a/backend/main.py b/backend/main.py index a27b65fa..b4768c52 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,7 @@ """ Anexo76 - Aplicación SaaS para gestión de comercio exterior Backend API con FastAPI + Keycloak + SQLAlchemy -""" + """ import logging import subprocess diff --git a/frontend/src/lib/components/ui/alert-dialog/index.ts b/frontend/src/lib/components/ui/alert-dialog/index.ts index cc281c58..98ad2f2c 100644 --- a/frontend/src/lib/components/ui/alert-dialog/index.ts +++ b/frontend/src/lib/components/ui/alert-dialog/index.ts @@ -1,4 +1,5 @@ -import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; +import { AlertDialog } from "bits-ui"; +const AlertDialogPrimitive = AlertDialog; import Trigger from "./alert-dialog-trigger.svelte"; import Title from "./alert-dialog-title.svelte"; import Action from "./alert-dialog-action.svelte"; diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts index dce1d9dc..efc16e73 100644 --- a/frontend/src/lib/components/ui/dialog/index.ts +++ b/frontend/src/lib/components/ui/dialog/index.ts @@ -1,4 +1,5 @@ -import { Dialog as DialogPrimitive } from "bits-ui"; +import { Dialog } from "bits-ui"; +const DialogPrimitive = Dialog; import Title from "./dialog-title.svelte"; import Footer from "./dialog-footer.svelte"; diff --git a/frontend/src/lib/components/ui/sheet/index.ts b/frontend/src/lib/components/ui/sheet/index.ts index 01d40c80..94a584fb 100644 --- a/frontend/src/lib/components/ui/sheet/index.ts +++ b/frontend/src/lib/components/ui/sheet/index.ts @@ -1,4 +1,5 @@ -import { Dialog as SheetPrimitive } from "bits-ui"; +import { Dialog } from "bits-ui"; +const SheetPrimitive = Dialog; import Trigger from "./sheet-trigger.svelte"; import Close from "./sheet-close.svelte"; import Overlay from "./sheet-overlay.svelte"; diff --git a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte index e8442723..7eeec691 100644 --- a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte +++ b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte @@ -1,5 +1,6 @@ @@ -195,110 +241,203 @@ +
+ + +
- -
- -
- + +
+ + +
+ + + +
+ + + + +
- + + {#if showPreview} +
+
+ {@html renderMarkdown(content)} +
+
+ {/if} + {:else} + +
+
+
+ {#if content_type === 'pdf'} +
+ +
+

Configuración de PDF

+ {:else if content_type === 'video'} +
+ +
+

Configuración de Video

+ {:else} +
+ +
+

Configuración de Documento

+ {/if} +

+ Sube el archivo que deseas asociar a este título. +

+
- - -
- - - {#if showPreview} -
-
- {@html renderMarkdown(content)} + {#if file_url} +
+
+
+ +
+
+

Archivo Cargado

+

{file_url}

+

+ {mime_type} • {(file_size ? (file_size / 1024 / 1024).toFixed(2) : '??')} MB +

+
+
+
+ + +
+
+ {:else} + + {/if}
{/if} diff --git a/start.sh b/start.sh index e699ea4d..9bb15911 100755 --- a/start.sh +++ b/start.sh @@ -77,8 +77,10 @@ if [ ! -f .env ]; then CENTRAL_URL="" CLIENT_ID="" SPOKE_URLS="" + HUB_MODE="true" else echo -e "${GREEN}Configurando como CLIENTE...${NC}" + HUB_MODE="false" read -p "Ingrese la URL del Hub [http://100.78.6.108:8001/api/v1/core/help-center/sync/]: " CENTRAL_URL CENTRAL_URL=${CENTRAL_URL:-http://100.78.6.108:8001/api/v1/core/help-center/sync/} read -p "Ingrese el UUID de este cliente (presione Enter para generar uno): " CLIENT_ID @@ -95,6 +97,12 @@ if [ ! -f .env ]; then sed -i "s|SPOKE_URLS=.*|SPOKE_URLS=$SPOKE_URLS|" .env sed -i "s|CLIENT_UUID=.*|CLIENT_UUID=$CLIENT_ID|" .env sed -i "s|SYNC_SECRET_TOKEN=.*|SYNC_SECRET_TOKEN=$SYNC_TOKEN|" .env + # Agregar o actualizar VITE_HUB_MODE + if grep -q "VITE_HUB_MODE=" .env; then + sed -i "s|VITE_HUB_MODE=.*|VITE_HUB_MODE=$HUB_MODE|" .env + else + echo "VITE_HUB_MODE=$HUB_MODE" >> .env + fi else echo -e "${YELLOW}⚠ .env.example no existe, creando .env básico${NC}" cat > .env < Date: Tue, 24 Feb 2026 11:35:46 -0600 Subject: [PATCH 10/16] Nuevo frontend y start cambiado --- .../api/v1/modules/core/help_center/models.py | 10 +- .../v1/modules/core/help_center/services.py | 155 +++++++++++++++--- .../api/v1/modules/core/help_center/tasks.py | 14 ++ .../api/v1/modules/core/help_center/utils.py | 73 +++++++++ docker-compose.yml | 31 ++-- .../routes/dashboard/help-center/+page.svelte | 10 +- .../help-center/editor/[uuid]/+page.svelte | 137 +++++++++------- frontend/vite.config.ts | 8 +- start.sh | 92 ++++++----- 9 files changed, 383 insertions(+), 147 deletions(-) create mode 100644 backend/api/v1/modules/core/help_center/utils.py diff --git a/backend/api/v1/modules/core/help_center/models.py b/backend/api/v1/modules/core/help_center/models.py index c1d84662..e7df432c 100644 --- a/backend/api/v1/modules/core/help_center/models.py +++ b/backend/api/v1/modules/core/help_center/models.py @@ -22,11 +22,11 @@ class HelpArticle(Base): category = Column(String(255), nullable=True, default="General") order = Column(Integer, nullable=True, default=0) - # Multimedia Fields - content_type = Column(String(50), nullable=False, default="article") # article, pdf, video, image, document - file_url = Column(String(512), nullable=True) # URL to the uploaded asset - file_size = Column(Integer, nullable=True) # Size in bytes - mime_type = Column(String(100), nullable=True) # e.g. application/pdf + # Removed missing fields to avoid 500 errors (No migration approach) + # content_type = Column(String(50), nullable=False, default="article") + # file_url = Column(String(512), nullable=True) + # file_size = Column(Integer, nullable=True) + # mime_type = Column(String(100), nullable=True) def __repr__(self): return f"" diff --git a/backend/api/v1/modules/core/help_center/services.py b/backend/api/v1/modules/core/help_center/services.py index 78274d9c..31f91e93 100644 --- a/backend/api/v1/modules/core/help_center/services.py +++ b/backend/api/v1/modules/core/help_center/services.py @@ -1,3 +1,5 @@ +import json +import re from datetime import datetime, timezone from typing import List, Optional from uuid import UUID @@ -6,32 +8,90 @@ from .models import HelpArticle from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse class HelpCenterService: + @staticmethod + def _inject_metadata(article: HelpArticle) -> HelpArticle: + if not article or not article.content: + return article + + # Look for + match = re.search(r'', article.content, re.DOTALL) + if match: + try: + metadata = json.loads(match.group(1)) + article.content_type = metadata.get("content_type", "article") + article.file_url = metadata.get("file_url") + article.file_size = metadata.get("file_size") + article.mime_type = metadata.get("mime_type") + # Remove metadata from content for clean display if needed, + # but usually better to leave it and let parser handle it or hide it here. + # For now, we just set the attributes. + except Exception: + pass + else: + article.content_type = "article" + article.file_url = None + article.file_size = None + article.mime_type = None + + return article + + @staticmethod + def _extract_metadata(content: str, data: dict) -> str: + # Remove existing metadata block if any + content = re.sub(r'\n\n', '', content, flags=re.DOTALL) + + metadata = { + "content_type": data.get("content_type", "article"), + "file_url": data.get("file_url"), + "file_size": data.get("file_size"), + "mime_type": data.get("mime_type") + } + + # Only append if there's something meaningful beyond "article" + if metadata["content_type"] != "article" or metadata["file_url"]: + content += f"\n\n" + + return content + @staticmethod def get_all(db: Session) -> List[HelpArticle]: - return db.query(HelpArticle).all() + articles = db.query(HelpArticle).all() + return [HelpCenterService._inject_metadata(a) for a in articles] @staticmethod def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]: - return db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + return HelpCenterService._inject_metadata(article) @staticmethod def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]: - return db.query(HelpArticle).filter(HelpArticle.slug == slug).first() + article = db.query(HelpArticle).filter(HelpArticle.slug == slug).first() + return HelpCenterService._inject_metadata(article) @staticmethod def get_modifications(db: Session, since: datetime) -> List[HelpArticle]: # Ensure timezone awareness if since.tzinfo is None: since = since.replace(tzinfo=timezone.utc) - return db.query(HelpArticle).filter(HelpArticle.updated_at > since).all() + articles = db.query(HelpArticle).filter(HelpArticle.updated_at > since).all() + return [HelpCenterService._inject_metadata(a) for a in articles] @staticmethod def create(db: Session, article: HelpArticleCreate) -> HelpArticle: - db_article = HelpArticle(**article.model_dump()) + data = article.model_dump() + # Move metadata into content + data["content"] = HelpCenterService._extract_metadata(data["content"], data) + # Remove virtual fields from data to avoid SQLAlchemy errors + virtual_fields = ["content_type", "file_url", "file_size", "mime_type"] + for f in virtual_fields: + if f in data: + del data[f] + + db_article = HelpArticle(**data) db.add(db_article) db.commit() db.refresh(db_article) - return db_article + return HelpCenterService._inject_metadata(db_article) @staticmethod def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]: @@ -39,13 +99,41 @@ class HelpCenterService: if not db_article: return None + # Inject metadata to existing article to get current virtual fields + db_article = HelpCenterService._inject_metadata(db_article) + update_data = article_data.model_dump(exclude_unset=True) + + # Handle metadata update + if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type"]): + # Merge existing metadata with new updates + current_meta = { + "content_type": getattr(db_article, "content_type", "article"), + "file_url": getattr(db_article, "file_url", None), + "file_size": getattr(db_article, "file_size", None), + "mime_type": getattr(db_article, "mime_type", None) + } + # Update with new data if present + for f in ["content_type", "file_url", "file_size", "mime_type"]: + if f in update_data: + current_meta[f] = update_data[f] + + # Use current content or new content + content = update_data.get("content", db_article.content) + update_data["content"] = HelpCenterService._extract_metadata(content, current_meta) + + # Remove virtual fields from data + virtual_fields = ["content_type", "file_url", "file_size", "mime_type"] + for f in virtual_fields: + if f in update_data: + del update_data[f] + for key, value in update_data.items(): setattr(db_article, key, value) db.commit() db.refresh(db_article) - return db_article + return HelpCenterService._inject_metadata(db_article) @staticmethod def delete(db: Session, article_uuid: UUID) -> bool: @@ -74,22 +162,34 @@ class HelpCenterService: if not db_article: # Caso A: Artículo nuevo desde el cliente + # Store metadata in content + client_meta = { + "content_type": sync_data.client_content_type, + "file_url": sync_data.client_file_url, + "file_size": sync_data.client_file_size, + "mime_type": sync_data.client_mime_type + } + content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta) + new_article = HelpArticle( uuid=sync_data.article_uuid, slug=sync_data.client_slug, title=sync_data.client_title, - content=sync_data.client_content, + content=content_with_meta, updated_at=client_updated_at, last_editor=sync_data.last_editor, category=sync_data.client_category, - order=sync_data.client_order, - content_type=sync_data.client_content_type, - file_url=sync_data.client_file_url, - file_size=sync_data.client_file_size, - mime_type=sync_data.client_mime_type + order=sync_data.client_order ) db.add(new_article) db.commit() + + # Download assets if needed (Images in content and main file) + from .utils import download_file_from_hub, sync_assets_from_content + if sync_data.client_file_url: + download_file_from_hub(sync_data.client_file_url) + sync_assets_from_content(sync_data.client_content) + return HelpSyncResponse(status="OK", message="Article created on server.") server_updated_at = db_article.updated_at @@ -98,22 +198,33 @@ class HelpCenterService: # Caso A: Cliente es más nuevo if client_updated_at > server_updated_at: - db_article.content = sync_data.client_content + client_meta = { + "content_type": sync_data.client_content_type, + "file_url": sync_data.client_file_url, + "file_size": sync_data.client_file_size, + "mime_type": sync_data.client_mime_type + } + db_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta) db_article.title = sync_data.client_title db_article.slug = sync_data.client_slug db_article.updated_at = client_updated_at db_article.last_editor = sync_data.last_editor db_article.category = sync_data.client_category db_article.order = sync_data.client_order - db_article.content_type = sync_data.client_content_type - db_article.file_url = sync_data.client_file_url - db_article.file_size = sync_data.client_file_size - db_article.mime_type = sync_data.client_mime_type db.commit() + + # Download assets if needed (Images in content) + from .utils import download_file_from_hub, sync_assets_from_content + if sync_data.client_file_url: + download_file_from_hub(sync_data.client_file_url) + sync_assets_from_content(sync_data.client_content) + return HelpSyncResponse(status="OK", message="Server updated with client data.") # Caso B: Servidor es más nuevo elif server_updated_at > client_updated_at: + # Inject metadata for response + db_article = HelpCenterService._inject_metadata(db_article) return HelpSyncResponse( status="UPDATE_REQUIRED", server_updated_at=server_updated_at, @@ -122,10 +233,10 @@ class HelpCenterService: server_slug=db_article.slug, server_category=db_article.category, server_order=db_article.order, - server_content_type=db_article.content_type, - server_file_url=db_article.file_url, - server_file_size=db_article.file_size, - server_mime_type=db_article.mime_type, + server_content_type=getattr(db_article, "content_type", "article"), + server_file_url=getattr(db_article, "file_url", None), + server_file_size=getattr(db_article, "file_size", None), + server_mime_type=getattr(db_article, "mime_type", None), message="Client is outdated. Update required." ) diff --git a/backend/api/v1/modules/core/help_center/tasks.py b/backend/api/v1/modules/core/help_center/tasks.py index 437b9f47..d069f94c 100644 --- a/backend/api/v1/modules/core/help_center/tasks.py +++ b/backend/api/v1/modules/core/help_center/tasks.py @@ -144,6 +144,12 @@ def sync_single_article(article_uuid): article.updated_at = result.server_updated_at db.commit() logger.info(f"Article {article.uuid} updated from server.") + + # Download assets if needed + from .utils import download_file_from_hub, sync_assets_from_content + if result.server_file_url: + download_file_from_hub(result.server_file_url) + sync_assets_from_content(result.server_content) else: logger.info(f"Article {article.uuid} sync OK: {result.message}") else: @@ -234,6 +240,14 @@ def sync_from_hub_task(): local_article.order = art_data.get('order', 0) db.commit() + + # Download assets after bulk update (Polling) + from .utils import download_file_from_hub, sync_assets_from_content + for art_data in articles_data: + if art_data.get('file_url'): + download_file_from_hub(art_data['file_url']) + sync_assets_from_content(art_data.get('content', '')) + logger.info("Polling sync completed successfully.") else: diff --git a/backend/api/v1/modules/core/help_center/utils.py b/backend/api/v1/modules/core/help_center/utils.py new file mode 100644 index 00000000..c6140e14 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/utils.py @@ -0,0 +1,73 @@ +import os +import re +import httpx +import logging +import uuid +from pathlib import Path +from core.config import settings + +logger = logging.getLogger(__name__) + +def download_file_from_hub(relative_path: str) -> bool: + """ + Downloads a file from the Hub to the local storage. + relative_path: e.g., 'uploads/help/pdfs/myfile.pdf' or '/api/uploads/help/image.png' + """ + if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""': + return False + + # Clean the path + clean_path = relative_path.replace("/api/uploads/", "uploads/") + if clean_path.startswith("/"): + clean_path = clean_path[1:] + + # Check if it starts with uploads + if not clean_path.startswith("uploads/"): + # If it doesn't start with uploads, it might just be the filename or a subpath + # We assume it's relative to /app/ + pass + + local_path = Path(clean_path) + if local_path.exists(): + return True + + # Ensure directories exist + local_path.parent.mkdir(parents=True, exist_ok=True) + + # Resolve Hub Base URL + # CENTRAL_SERVER_URL is usually http://hub:8000/api/v1/core/help-center/sync/ + # We want http://hub:8000/api/ + base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0] + hub_file_url = f"{base_url}/uploads/{clean_path.replace('uploads/', '')}" + + logger.info(f"Downloading asset from Hub: {hub_file_url} -> {local_path}") + + try: + with httpx.Client() as client: + response = client.get(hub_file_url, timeout=30.0) + if response.status_code == 200: + with open(local_path, "wb") as f: + f.write(response.content) + logger.info(f"Successfully downloaded {clean_path}") + return True + else: + logger.warning(f"Failed to download {clean_path}: Status {response.status_code}") + return False + except Exception as e: + logger.error(f"Error downloading {clean_path}: {str(e)}") + return False + +def sync_assets_from_content(content: str): + """ + Parses markdown content for image URLs and downloads them if they are local references. + Example: ![alt text](/api/uploads/help/uuid.png) + """ + if not content: + return + + # Regex for markdown images: ![...](/api/uploads/...) + image_pattern = r'!\[.*?\]\((/api/uploads/.*?)\)' + matches = re.findall(image_pattern, content) + + for asset_url in matches: + download_file_from_hub(asset_url) diff --git a/docker-compose.yml b/docker-compose.yml index 1e756b46..15835a1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,6 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_APP_PASSWORD:-postgres} POSTGRES_INITDB_ARGS: "--encoding=UTF8" - PGDATA: /var/lib/postgresql/data ports: - "5432:5432" volumes: @@ -38,7 +37,7 @@ services: # PostgreSQL - Base de datos Keycloak postgres-keycloak: - image: postgres:16-alpine + image: postgres:18-alpine container_name: anexo76-postgres-keycloak environment: POSTGRES_DB: keycloak @@ -81,10 +80,10 @@ services: KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} KC_DB: postgres - KC_DB_URL_HOST: anexo76-postgres-keycloak + KC_DB_URL_HOST: postgres-keycloak KC_DB_URL_PORT: "5432" KC_DB_URL_DATABASE: keycloak - KC_DB_URL: jdbc:postgresql://anexo76-postgres-keycloak:5432/keycloak + KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak KC_DB_USERNAME: postgres KC_DB_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres} KC_DB_SCHEMA: public @@ -102,7 +101,7 @@ services: - start-dev - --http-relative-path=/kcauth - --db=postgres - - --db-url-host=anexo76-postgres-keycloak + - --db-url-host=postgres-keycloak - --db-url-port=5432 - --db-url-database=keycloak - --db-username=postgres @@ -128,17 +127,13 @@ services: "CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r + Host: localhost\r - \ host: 127.0.0.1\r + Connection: close\r + \r - \ Connection: close\r - - - \ \r - - - \ ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1" + ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1" ] interval: 10s timeout: 5s @@ -247,6 +242,7 @@ services: - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9} + - VITE_HUB_MODE=${VITE_HUB_MODE:-true} ports: - "5173:5173" depends_on: @@ -348,7 +344,16 @@ volumes: networks: backend-net: driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 auth-net: driver: bridge + ipam: + config: + - subnet: 172.21.0.0/16 frontend-net: driver: bridge + ipam: + config: + - subnet: 172.22.0.0/16 diff --git a/frontend/src/routes/dashboard/help-center/+page.svelte b/frontend/src/routes/dashboard/help-center/+page.svelte index efb8d4c9..38828eeb 100644 --- a/frontend/src/routes/dashboard/help-center/+page.svelte +++ b/frontend/src/routes/dashboard/help-center/+page.svelte @@ -172,7 +172,7 @@ >
{#if article.content_type === 'pdf'} @@ -205,7 +205,13 @@
{#if HUB_MODE} diff --git a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte index 168ec6a4..103d8404 100644 --- a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte +++ b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte @@ -16,6 +16,7 @@ Italic, Link as LinkIcon, List, + Heading1, Heading2, FileText, Video as VideoIcon, @@ -26,24 +27,24 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; - let article: HelpArticle | null = null; // $state equivalent in Svelte 5 logic handled manually or via store, using standard let for simplest adaptation - let loading = true; - let processing = false; + let article = $state(null); + let loading = $state(true); + let processing = $state(false); // Editor State - let title = ''; - let slug = ''; - let content = ''; - let category = 'General'; - let order = 0; - let last_editor = 'Admin'; - let content_type = 'article'; - let file_url = ''; - let file_size: number | null = null; - let mime_type = ''; + let title = $state(''); + let slug = $state(''); + let content = $state(''); + let category = $state('General'); + let order = $state(0); + let last_editor = $state('Admin'); + let content_type = $state('article'); + let file_url = $state(''); + let file_size = $state(undefined); + let mime_type = $state(''); // Preview State - let showPreview = true; + let showPreview = $state(true); const HUB_MODE = import.meta.env.VITE_HUB_MODE === 'true'; onMount(async () => { @@ -68,9 +69,10 @@ content = article.content || ''; category = article.category || 'General'; order = article.order || 0; + last_editor = article.last_editor || 'Admin'; content_type = article.content_type || 'article'; file_url = article.file_url || ''; - file_size = article.file_size || null; + file_size = article.file_size ?? undefined; mime_type = article.mime_type || ''; } } catch (e: any) { @@ -83,9 +85,17 @@ async function handleSave() { processing = true; try { - const data = { - title, slug, content, last_editor, category, order, - content_type, file_url, file_size, mime_type + const data = { + title, + slug, + content, + last_editor, + category, + order, + content_type, + file_url, + file_size, + mime_type }; // Auto-generate slug if missing @@ -138,36 +148,47 @@ } async function handleImageUpload(file: File) { + const tid = toast.loading('Subiendo imagen...'); + processing = true; try { - toast.loading('Subiendo imagen...'); const result = await helpApi.uploadImage(file); const imageMarkdown = `\n![${file.name}](${result.url})\n`; insertText(imageMarkdown); - toast.success('Imagen insertada'); + toast.success('Imagen insertada', { id: tid }); } catch (e: any) { - toast.error('Error al subir: ' + e.message); + toast.error('Error al subir: ' + e.message, { id: tid }); + } finally { + processing = false; } } async function handleAssetUpload(file: File) { + const tid = toast.loading(`Subiendo ${file.name}...`); + processing = true; try { - processing = true; - toast.loading(`Subiendo ${file.name}...`); const result = await helpApi.uploadAsset(file); file_url = result.url; file_size = result.size; mime_type = result.mime_type; - + + // Auto-set title if empty + if (!title) { + title = file.name.split('.').shift() || 'Sin Título'; + } + if (!slug) { + slug = title.toLowerCase().replace(/[^\w]+/g, '-'); + } + // Auto-infer content type if not set or article if (content_type === 'article') { if (mime_type === 'application/pdf') content_type = 'pdf'; else if (mime_type.startsWith('video/')) content_type = 'video'; else content_type = 'document'; } - - toast.success('Archivo subido correctamente'); + + toast.success('Archivo subido correctamente', { id: tid }); } catch (e: any) { - toast.error('Error al subir archivo: ' + e.message); + toast.error('Error al subir archivo: ' + e.message, { id: tid }); } finally { processing = false; } @@ -229,24 +250,12 @@
-
- - -
-
- - -
-
- - -
-
+
-
+
+ + +
+
+ + +
@@ -383,9 +400,7 @@

Configuración de Documento

{/if} -

- Sube el archivo que deseas asociar a este título. -

+

Sube el archivo que deseas asociar a este título.

{#if file_url} @@ -396,9 +411,9 @@

Archivo Cargado

-

{file_url}

-

- {mime_type} • {(file_size ? (file_size / 1024 / 1024).toFixed(2) : '??')} MB +

{file_url}

+

+ {mime_type} • {file_size ? (file_size / 1024 / 1024).toFixed(2) : '??'} MB

@@ -406,21 +421,25 @@ -