Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into fix/fracciones_americanas

This commit is contained in:
2026-02-26 16:55:03 -06:00
90 changed files with 6935 additions and 1099 deletions

View File

@@ -3,7 +3,7 @@ import logging
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -137,6 +137,7 @@ class TenantCRUDRoutes(
description=f"Get paginated list of {self.resource_name}s with optional filters",
)
async def list_resources(
request: Request,
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
@@ -145,13 +146,6 @@ class TenantCRUDRoutes(
le=self.max_page_size,
description="Page size",
),
status: Optional[str] = Query(None, description="Filter by status"),
operation_type: Optional[str] = Query(
None, description="Filter by operation type"
),
invoice_type: Optional[str] = Query(
None, description="Filter by invoice type"
),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
@@ -164,13 +158,15 @@ class TenantCRUDRoutes(
)
skip = (page - 1) * page_size
filters = {}
if status:
filters["status"] = status
if operation_type:
filters["operation_type"] = operation_type
if invoice_type:
filters["invoice_type"] = invoice_type
# Extraer todos los parámetros de búsqueda dinámicamente
# Excluimos los parámetros estándar de paginación y control
standard_params = {"company_id", "page", "page_size"}
filters = {
k: v
for k, v in request.query_params.items()
if k not in standard_params and v is not None and v != ""
}
items, total = self.service.get_all(
db, tenant_id, company_id, skip, page_size, filters

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}')>"

View 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

View 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

View 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.")

View 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()

View 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: ![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)

View File

@@ -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"])