Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into fix/fracciones_americanas
This commit is contained in:
@@ -46,7 +46,7 @@ class ClientProviderProgramsDTO(BaseModel):
|
||||
program_number: Optional[str] = Field(
|
||||
None, max_length=40, description="Program number"
|
||||
)
|
||||
prosec: Optional[int] = Field(None, description="PROSEC")
|
||||
prosec: Optional[str] = Field(None, max_length=8, description="PROSEC")
|
||||
prosec_authorization: Optional[str] = Field(
|
||||
None, max_length=20, description="PROSEC authorization"
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin):
|
||||
# Program information
|
||||
program: Mapped[Optional[str]] = mapped_column(String(7))
|
||||
program_number: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
prosec: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
prosec: Mapped[Optional[str]] = mapped_column(String(8))
|
||||
prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
secon_auth_date: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25))
|
||||
|
||||
@@ -43,7 +43,7 @@ class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO):
|
||||
broker_key: str
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
vu: Optional["CustomsBrokerVUCreateDTO"] = None
|
||||
vu: Optional["CustomsBrokerVUResponseDTO"] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -95,6 +95,11 @@ class CustomsBrokerVUCreateDTO(BaseModel):
|
||||
doda_web_service_access_key: Optional[str] = None
|
||||
doda_fiel_access_key: Optional[str] = None
|
||||
doda_xml_files_path: Optional[str] = None
|
||||
tenant_id: Optional[int] = None
|
||||
company_id: Optional[int] = None
|
||||
|
||||
class CustomsBrokerVUResponseDTO(CustomsBrokerVUCreateDTO):
|
||||
customs_broker_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -112,6 +117,8 @@ class CustomsBrokerPersonnelDTO(BaseModel):
|
||||
last_name: Optional[str] = None
|
||||
middle_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
tenant_id: Optional[int] = None
|
||||
company_id: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -62,7 +62,7 @@ def update_customs_broker(
|
||||
|
||||
@router.put(
|
||||
"/customs-broker-vu/{broker_key}",
|
||||
response_model=dto.CustomsBrokerVUCreateDTO,
|
||||
response_model=dto.CustomsBrokerVUResponseDTO,
|
||||
)
|
||||
def update_customs_broker_vu(
|
||||
broker_key: str,
|
||||
@@ -77,7 +77,7 @@ def update_customs_broker_vu(
|
||||
if not broker:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker not found")
|
||||
|
||||
updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data)
|
||||
updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data, tenant_id, company_id)
|
||||
if not updated_vu:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker VU not found")
|
||||
return updated_vu
|
||||
@@ -102,7 +102,7 @@ def update_customs_broker_personnel(
|
||||
raise HTTPException(status_code=404, detail="Customs Broker not found")
|
||||
|
||||
updated_personnel = services.CustomsBrokerPersonnelService.update_personnel(
|
||||
db, broker_key, line, personnel_data
|
||||
db, broker_key, line, personnel_data, tenant_id, company_id
|
||||
)
|
||||
if not updated_personnel:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -90,9 +90,17 @@ class CustomsBrokerVUService:
|
||||
return new_vu
|
||||
|
||||
@staticmethod
|
||||
def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO):
|
||||
def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO, tenant_id: int, company_id: int):
|
||||
# We need the custom broker ID to insert a new VU
|
||||
broker = db.query(models.CustomsBroker).filter(models.CustomsBroker.broker_key == broker_key).first()
|
||||
broker = (
|
||||
db.query(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not broker:
|
||||
return None
|
||||
|
||||
@@ -108,6 +116,8 @@ class CustomsBrokerVUService:
|
||||
else:
|
||||
# Create new
|
||||
new_vu_data = vu_data.model_dump()
|
||||
new_vu_data["tenant_id"] = tenant_id
|
||||
new_vu_data["company_id"] = company_id
|
||||
new_vu = models.CustomsBrokerVU(customs_broker_id=broker.id, **new_vu_data)
|
||||
db.add(new_vu)
|
||||
db.commit()
|
||||
@@ -125,24 +135,36 @@ class CustomsBrokerVUService:
|
||||
|
||||
class CustomsBrokerPersonnelService:
|
||||
@staticmethod
|
||||
def get_by_broker_key_and_line(db: Session, broker_key: str, line: int):
|
||||
def get_by_broker_key_and_line(db: Session, broker_key: str, line: int, tenant_id: int, company_id: int):
|
||||
return (
|
||||
db.query(models.CustomsBrokerPersonnel)
|
||||
.join(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBrokerPersonnel.line == line,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_personnel(db: Session, broker_key: str, personnel_data: dto.CustomsBrokerPersonnelDTO):
|
||||
broker = db.query(models.CustomsBroker).filter(models.CustomsBroker.broker_key == broker_key).first()
|
||||
def create_personnel(db: Session, broker_key: str, personnel_data: dto.CustomsBrokerPersonnelDTO, tenant_id: int, company_id: int):
|
||||
broker = (
|
||||
db.query(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not broker:
|
||||
return None
|
||||
|
||||
new_personnel_data = personnel_data.model_dump()
|
||||
new_personnel_data["tenant_id"] = tenant_id
|
||||
new_personnel_data["company_id"] = company_id
|
||||
new_personnel = models.CustomsBrokerPersonnel(customs_broker_id=broker.id, **new_personnel_data)
|
||||
db.add(new_personnel)
|
||||
db.commit()
|
||||
@@ -155,16 +177,22 @@ class CustomsBrokerPersonnelService:
|
||||
broker_key: str,
|
||||
line: int,
|
||||
personnel_data: dto.CustomsBrokerPersonnelDTO,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
):
|
||||
personnel = CustomsBrokerPersonnelService.get_by_broker_key_and_line(
|
||||
db, broker_key, line
|
||||
db, broker_key, line, tenant_id, company_id
|
||||
)
|
||||
if personnel:
|
||||
for key, value in personnel_data.model_dump(exclude_unset=True).items():
|
||||
setattr(personnel, key, value)
|
||||
db.commit()
|
||||
db.refresh(personnel)
|
||||
return personnel
|
||||
return personnel
|
||||
else:
|
||||
return CustomsBrokerPersonnelService.create_personnel(
|
||||
db, broker_key, personnel_data, tenant_id, company_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_personnel(db: Session, broker_key: str, line: int):
|
||||
|
||||
@@ -6,6 +6,8 @@ from sqlalchemy.orm import Session
|
||||
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
|
||||
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
|
||||
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen
|
||||
from api.v1.modules.public.reference_data.transport_types.models import TransportType
|
||||
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
|
||||
|
||||
# Import A76 Services
|
||||
from api.v1.modules.a76.customs_brokers.services import CustomsBrokerService
|
||||
@@ -15,6 +17,8 @@ from api.v1.modules.a76.clients_and_providers.service import ClientProviderServi
|
||||
from api.v1.modules.public.reference_data.pedimento_codes.dto import PedimentoCodeDTO
|
||||
from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSectionDTO
|
||||
from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO
|
||||
from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO
|
||||
from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO
|
||||
from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO
|
||||
from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO
|
||||
from .dtos.pedimentos import PedimentosResponse
|
||||
@@ -53,6 +57,20 @@ class PedimentoCatalogService:
|
||||
except Exception as e:
|
||||
print(f"Error fetching code_pedimento_regimens: {e}")
|
||||
|
||||
try:
|
||||
response.transport_types = [
|
||||
TransportTypeDTO.model_validate(obj) for obj in db.query(TransportType).limit(200).all()
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Error fetching transport_types: {e}")
|
||||
|
||||
try:
|
||||
response.transport_modes = [
|
||||
TransportModeDTO.model_validate(obj) for obj in db.query(TransportMode).limit(100).all()
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Error fetching transport_modes: {e}")
|
||||
|
||||
# Helper to fetch tenant/company specific data
|
||||
def fetch_tenant_data():
|
||||
# Customs Brokers
|
||||
|
||||
@@ -11,6 +11,8 @@ from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSec
|
||||
from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO
|
||||
from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO
|
||||
from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO
|
||||
from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO
|
||||
from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO
|
||||
from .dtos.pedimentos import PedimentosResponse
|
||||
|
||||
|
||||
@@ -22,6 +24,8 @@ class PedimentoCatalogsResponse(BaseModel):
|
||||
code_pedimento_regimens: List[CodePedimentoRegimenDTO] = []
|
||||
customs_brokers: List[CustomsBrokerResponseDTO] = []
|
||||
clients: List[ClientProviderResponseDTO] = []
|
||||
transport_types: List[TransportTypeDTO] = []
|
||||
transport_modes: List[TransportModeDTO] = []
|
||||
|
||||
|
||||
class PedimentoCreationResponse(PedimentoCatalogsResponse):
|
||||
|
||||
@@ -25,6 +25,10 @@ class TrailerService:
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
if filters.get("trailer_number"):
|
||||
query = query.filter(
|
||||
models.Trailer.trailer_number.ilike(f"%{filters['trailer_number']}%")
|
||||
)
|
||||
if filters.get("plate_number"):
|
||||
query = query.filter(
|
||||
models.Trailer.plate_number.ilike(f"%{filters['plate_number']}%")
|
||||
@@ -75,8 +79,8 @@ class TrailerService:
|
||||
db: Session,
|
||||
trailer_number: str,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
trailer_data: dto.TrailerUpdateDTO,
|
||||
company_id: int,
|
||||
) -> Optional[models.Trailer]:
|
||||
"""Update a trailer"""
|
||||
trailer = TrailerService.get_by_id(db, trailer_number, tenant_id, company_id)
|
||||
|
||||
@@ -9,7 +9,7 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin):
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
transporter_key = Column(String(5), primary_key=True, nullable=False)
|
||||
transporter_key = Column(String(23), primary_key=True, nullable=False)
|
||||
name = Column(String(256), nullable=True)
|
||||
short_name = Column(String(10), nullable=True)
|
||||
responsible = Column(String(100), nullable=True)
|
||||
@@ -27,4 +27,4 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin):
|
||||
ftp_user = Column(String(200), nullable=True)
|
||||
ftp_password = Column(String(100), nullable=True)
|
||||
ftp_directory = Column(String(1000), nullable=True)
|
||||
filler_code = Column(String(4), nullable=True)
|
||||
filler_code = Column(String(20), nullable=True)
|
||||
|
||||
@@ -25,6 +25,10 @@ class TransporterService:
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
if filters.get("transporter_key"):
|
||||
query = query.filter(
|
||||
models.Transporter.transporter_key.ilike(f"%{filters['transporter_key']}%")
|
||||
)
|
||||
if filters.get("name"):
|
||||
query = query.filter(
|
||||
models.Transporter.name.ilike(f"%{filters['name']}%")
|
||||
@@ -75,8 +79,8 @@ class TransporterService:
|
||||
db: Session,
|
||||
transporter_key: str,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
transporter_data: dto.TransporterUpdateDTO,
|
||||
company_id: int,
|
||||
) -> Optional[models.Transporter]:
|
||||
"""Update a transporter"""
|
||||
transporter = TransporterService.get_by_id(
|
||||
|
||||
@@ -25,6 +25,10 @@ class VehicleService:
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
if filters.get("vehicle_key"):
|
||||
query = query.filter(
|
||||
models.Vehicle.vehicle_key.ilike(f"%{filters['vehicle_key']}%")
|
||||
)
|
||||
if filters.get("plate_number"):
|
||||
query = query.filter(
|
||||
models.Vehicle.plate_number.ilike(f"%{filters['plate_number']}%")
|
||||
@@ -75,8 +79,8 @@ class VehicleService:
|
||||
db: Session,
|
||||
vehicle_key: str,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
vehicle_data: dto.VehicleUpdateDTO,
|
||||
company_id: int,
|
||||
) -> Optional[models.Vehicle]:
|
||||
"""Update a vehicle"""
|
||||
vehicle = VehicleService.get_by_id(db, vehicle_key, tenant_id, company_id)
|
||||
|
||||
32
backend/api/v1/modules/core/help_center/models.py
Normal file
32
backend/api/v1/modules/core/help_center/models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, String, Text, DateTime, Integer
|
||||
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)
|
||||
|
||||
# Library Mode Fields
|
||||
category = Column(String(255), nullable=True, default="General")
|
||||
order = Column(Integer, nullable=True, default=0)
|
||||
|
||||
# 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"<HelpArticle(title='{self.title}', slug='{self.slug}')>"
|
||||
227
backend/api/v1/modules/core/help_center/routes.py
Normal file
227
backend/api/v1/modules/core/help_center/routes.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import shutil
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.config import settings
|
||||
from core.security import get_current_user, has_role
|
||||
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.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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)):
|
||||
"""
|
||||
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.post("/upload-image/")
|
||||
def upload_help_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Sube una imagen para usar en los artículos."""
|
||||
try:
|
||||
file_ext = os.path.splitext(file.filename)[1]
|
||||
new_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
file_location = f"uploads/help/{new_filename}"
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs("uploads/help", exist_ok=True)
|
||||
|
||||
with open(file_location, "wb+") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
return {"url": f"/api/uploads/help/{new_filename}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/upload-asset/")
|
||||
def upload_help_asset(
|
||||
file: UploadFile = File(...),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca."""
|
||||
try:
|
||||
file_ext = os.path.splitext(file.filename)[1].lower()
|
||||
new_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
|
||||
# Guardar en una carpeta segun el tipo o general
|
||||
folder = "uploads/help/assets"
|
||||
if file_ext in ['.pdf']:
|
||||
folder = "uploads/help/pdfs"
|
||||
elif file_ext in ['.mp4', '.mov', '.avi']:
|
||||
folder = "uploads/help/videos"
|
||||
|
||||
file_location = f"{folder}/{new_filename}"
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
with open(file_location, "wb+") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(file_location)
|
||||
|
||||
return {
|
||||
"url": f"/api/{file_location}",
|
||||
"filename": file.filename,
|
||||
"size": file_size,
|
||||
"mime_type": file.content_type
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/articles/", response_model=List[HelpArticleInDB])
|
||||
def list_articles(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Lista todos los artículos de ayuda."""
|
||||
return HelpCenterService.get_all(db)
|
||||
|
||||
@router.get("/modifications/", response_model=List[HelpArticleInDB], dependencies=[Depends(verify_sync_token)])
|
||||
def get_modifications(since: datetime, db: Session = Depends(get_core_db)):
|
||||
"""Obtiene artículos modificados desde la fecha indicada (Polling). Requiere X-Sync-Token."""
|
||||
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),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""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),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Crea un nuevo artículo."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"DEBUG: Creating new article: {article.title} by {current_user.get('preferred_username')}")
|
||||
|
||||
# Fill last_editor with admin username
|
||||
if current_user.get('preferred_username'):
|
||||
article.last_editor = current_user.get('preferred_username')
|
||||
|
||||
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)
|
||||
def update_article(
|
||||
article_uuid: UUID,
|
||||
article_data: HelpArticleUpdate,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Actualiza un artículo."""
|
||||
if current_user.get('preferred_username'):
|
||||
article_data.last_editor = current_user.get('preferred_username')
|
||||
|
||||
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),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""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
|
||||
66
backend/api/v1/modules/core/help_center/schemas.py
Normal file
66
backend/api/v1/modules/core/help_center/schemas.py
Normal file
@@ -0,0 +1,66 @@
|
||||
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
|
||||
category: Optional[str] = "General"
|
||||
order: Optional[int] = 0
|
||||
content_type: str = "article"
|
||||
file_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
class HelpArticleCreate(HelpArticleBase):
|
||||
pass
|
||||
|
||||
class HelpArticleUpdate(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
last_editor: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
content_type: Optional[str] = None
|
||||
file_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: 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
|
||||
client_category: Optional[str] = "General"
|
||||
client_order: Optional[int] = 0
|
||||
client_content_type: str = "article"
|
||||
client_file_url: Optional[str] = None
|
||||
client_file_size: Optional[int] = None
|
||||
client_mime_type: Optional[str] = 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
|
||||
server_category: Optional[str] = None
|
||||
server_order: Optional[int] = None
|
||||
server_content_type: Optional[str] = None
|
||||
server_file_url: Optional[str] = None
|
||||
server_file_size: Optional[int] = None
|
||||
server_mime_type: Optional[str] = None
|
||||
message: str
|
||||
240
backend/api/v1/modules/core/help_center/services.py
Normal file
240
backend/api/v1/modules/core/help_center/services.py
Normal file
@@ -0,0 +1,240 @@
|
||||
import json
|
||||
import re
|
||||
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 _inject_metadata(article: HelpArticle) -> HelpArticle:
|
||||
if not article or not article.content:
|
||||
return article
|
||||
|
||||
# Look for <!-- a76_metadata: { ... } -->
|
||||
match = re.search(r'<!-- a76_metadata: (.*?) -->', 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<!-- a76_metadata: .*? -->', '', 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<!-- a76_metadata: {json.dumps(metadata)} -->"
|
||||
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def get_all(db: Session) -> List[HelpArticle]:
|
||||
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]:
|
||||
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]:
|
||||
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)
|
||||
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:
|
||||
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 HelpCenterService._inject_metadata(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
|
||||
|
||||
# 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 HelpCenterService._inject_metadata(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).
|
||||
"""
|
||||
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
|
||||
# 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=content_with_meta,
|
||||
updated_at=client_updated_at,
|
||||
last_editor=sync_data.last_editor,
|
||||
category=sync_data.client_category,
|
||||
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
|
||||
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:
|
||||
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.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,
|
||||
server_content=db_article.content,
|
||||
server_title=db_article.title,
|
||||
server_slug=db_article.slug,
|
||||
server_category=db_article.category,
|
||||
server_order=db_article.order,
|
||||
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."
|
||||
)
|
||||
|
||||
# Caso C: Iguales
|
||||
else:
|
||||
return HelpSyncResponse(status="OK", message="Already in sync.")
|
||||
269
backend/api/v1/modules/core/help_center/tasks.py
Normal file
269
backend/api/v1/modules/core/help_center/tasks.py
Normal file
@@ -0,0 +1,269 @@
|
||||
import logging
|
||||
import httpx
|
||||
from uuid import UUID
|
||||
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):
|
||||
"""
|
||||
Difunde una actualización de artículo a todos los spokes configurados.
|
||||
"""
|
||||
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,
|
||||
client_category=article.category,
|
||||
client_order=article.order
|
||||
).model_dump(mode='json')
|
||||
|
||||
with httpx.Client() as client:
|
||||
for spoke_url in spokes:
|
||||
# Loop Prevention: Skip if the spoke is the origin
|
||||
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,
|
||||
client_category=article.category,
|
||||
client_order=article.order
|
||||
)
|
||||
|
||||
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.")
|
||||
|
||||
# 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:
|
||||
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:
|
||||
try:
|
||||
# Logic similar to sync_article but simpler (Force Update from Hub)
|
||||
# We assume Hub is Truth in this Polling flow
|
||||
|
||||
# Try to find by UUID
|
||||
local_article = db.query(HelpArticle).filter(HelpArticle.uuid == art_data['uuid']).first()
|
||||
|
||||
# Fallback: find by Slug if UUID doesn't match
|
||||
if not local_article:
|
||||
local_article = db.query(HelpArticle).filter(HelpArticle.slug == art_data['slug']).first()
|
||||
|
||||
server_updated_at = datetime.fromisoformat(art_data['updated_at'])
|
||||
if server_updated_at.tzinfo is None:
|
||||
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
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'],
|
||||
category=art_data.get('category', "General"),
|
||||
order=art_data.get('order', 0)
|
||||
)
|
||||
db.add(new_article)
|
||||
logger.info(f"Created new article: {art_data['slug']}")
|
||||
else:
|
||||
# Update existing article
|
||||
# If UUID changed in Hub but slug is the same, we update UUID too
|
||||
local_article.uuid = art_data['uuid']
|
||||
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']
|
||||
local_article.category = art_data.get('category', "General")
|
||||
local_article.order = art_data.get('order', 0)
|
||||
logger.info(f"Updated article: {art_data['slug']}")
|
||||
|
||||
db.commit() # Commit each article to avoid bulk failure
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error syncing article {art_data.get('slug', 'unknown')}: {e}")
|
||||
|
||||
|
||||
# Download assets after bulk update (Polling)
|
||||
from .utils import download_file_from_hub, sync_assets_from_content
|
||||
for art_data in articles_data:
|
||||
# art_data contains the virtual fields because it was dumped via HelpArticleInDB
|
||||
if "file_url" in art_data and art_data['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:
|
||||
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()
|
||||
76
backend/api/v1/modules/core/help_center/utils.py
Normal file
76
backend/api/v1/modules/core/help_center/utils.py
Normal file
@@ -0,0 +1,76 @@
|
||||
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():
|
||||
logger.info(f"File {clean_path} already exists, skipping download.")
|
||||
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]
|
||||
# The file in backend is served usually under /api/uploads/...
|
||||
# But clean_path is just "uploads/...". So the Hub route is base_url + "/" + clean_path
|
||||
hub_file_url = f"{base_url}/{clean_path}"
|
||||
|
||||
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} URL: {hub_file_url}")
|
||||
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: 
|
||||
"""
|
||||
if not content:
|
||||
return
|
||||
|
||||
# Regex for markdown images: 
|
||||
image_pattern = r'!\[.*?\]\((/api/uploads/.*?)\)'
|
||||
matches = re.findall(image_pattern, content)
|
||||
|
||||
for asset_url in matches:
|
||||
download_file_from_hub(asset_url)
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user