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 @@ -