Resolviendo conflicto
This commit is contained in:
@@ -9,7 +9,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from core.database import rls_company_var, rls_tenant_var
|
||||
from core.database import (
|
||||
RLS_COMPANY_KEY,
|
||||
RLS_TENANT_KEY,
|
||||
rls_company_var,
|
||||
rls_tenant_var,
|
||||
)
|
||||
|
||||
from .services.service import AuditService
|
||||
from .utils.serialization import serialize_for_json
|
||||
@@ -56,13 +61,17 @@ def _resolve_audit_company_tenant(session: Session, target) -> tuple:
|
||||
if company_id is not None:
|
||||
resolution_source = "company_self_id"
|
||||
if company_id is None:
|
||||
company_id = rls_company_var.get()
|
||||
company_id = session.info.get(RLS_COMPANY_KEY)
|
||||
if company_id is None:
|
||||
company_id = rls_company_var.get()
|
||||
if company_id is not None:
|
||||
resolution_source = "rls_context"
|
||||
|
||||
tenant_id = getattr(target, "tenant_id", None)
|
||||
if tenant_id is None:
|
||||
tenant_id = rls_tenant_var.get()
|
||||
tenant_id = session.info.get(RLS_TENANT_KEY)
|
||||
if tenant_id is None:
|
||||
tenant_id = rls_tenant_var.get()
|
||||
if tenant_id is not None and resolution_source == "target":
|
||||
resolution_source = "rls_context"
|
||||
|
||||
|
||||
@@ -164,16 +164,13 @@ class TariffFractionService:
|
||||
search_description = term
|
||||
|
||||
try:
|
||||
usa_items = await usa_service.search(
|
||||
usa_items, total = await usa_service.search_with_total(
|
||||
fraccion=search_term,
|
||||
descripcion=search_description,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
items = [TariffFractionMapper.to_domain_usa(item) for item in usa_items]
|
||||
total = len(items) + skip
|
||||
if len(items) == limit:
|
||||
total += 1
|
||||
return items, total
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -245,30 +242,25 @@ class TariffFractionService:
|
||||
# Note: Sitar search might not return total count.
|
||||
# We fetch page items. Pagination might be tricky if Sitar doesn't return total.
|
||||
# Assuming Sitar returns a list.
|
||||
sitar_items = await sitar_service.search(
|
||||
sitar_items, total = await sitar_service.search_with_total(
|
||||
fraccion=sitar_fraccion,
|
||||
nico=sitar_nico,
|
||||
description=sitar_description,
|
||||
nivel=level_filter, # Dynamic level
|
||||
skip=skip,
|
||||
limit=limit
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
# STRICT API USAGE:
|
||||
# We do NOT fallback to local DB on empty list, as user requested strict API consumption.
|
||||
# We also do NOT attempt enrichment as codes mismatch (API uses '010191A' vs Local '01012101').
|
||||
|
||||
|
||||
# Map items
|
||||
items = [TariffFractionMapper.to_domain(item) for item in sitar_items]
|
||||
# Legacy browse behavior: keep table in ascending fracción order.
|
||||
items = sorted(items, key=lambda row: ((row.code or ""), (row.nico or "")))
|
||||
|
||||
# Estimate total (Sitar service doesn't return total currently)
|
||||
# If we got full limit, assume there are more.
|
||||
total = len(items) + skip
|
||||
if len(items) == limit:
|
||||
total += 1 # Indicate more pages
|
||||
|
||||
|
||||
# total comes from SITAR PaginatedFraccionesResponse (matches API-wide count for the query).
|
||||
return items, total
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -103,7 +103,7 @@ async def list_us_tariff_fractions(
|
||||
search_description = search
|
||||
|
||||
try:
|
||||
sitar_items = await svc.search(
|
||||
sitar_items, total = await svc.search_with_total(
|
||||
fraccion=search_term,
|
||||
descripcion=search_description,
|
||||
skip=skip,
|
||||
@@ -118,10 +118,6 @@ async def list_us_tariff_fractions(
|
||||
"pages": 0,
|
||||
}
|
||||
|
||||
total = len(sitar_items) + skip
|
||||
if len(sitar_items) == page_size:
|
||||
total += 1
|
||||
|
||||
items = [
|
||||
USTariffFractionResponseDTO.model_validate(_sitar_row_to_us_response_payload(row))
|
||||
for row in sitar_items
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Dict, Any, Optional
|
||||
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.exceptions import BaseAPIException
|
||||
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from sqlalchemy import func, or_, and_
|
||||
@@ -69,6 +70,8 @@ def get_creation_data(
|
||||
return InvoiceCatalogService.get_creation_data(db, tenant_id, company_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except BaseAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("get_creation_data failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Error al cargar datos de creación: {str(e)}")
|
||||
@@ -97,6 +100,8 @@ def get_edition_data(
|
||||
return data
|
||||
except HTTPException:
|
||||
raise
|
||||
except BaseAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("get_edition_data failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Error al cargar datos de edición: {str(e)}")
|
||||
@@ -191,6 +196,8 @@ def create_invoice(
|
||||
return services.InvoiceService.create(db, data, tenant_id, company_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except BaseAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("create_invoice failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Error al guardar factura: {str(e)}")
|
||||
@@ -337,6 +344,8 @@ def list_invoices(
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
except BaseAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("list_invoices failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error in invoices list: {str(e)}")
|
||||
|
||||
@@ -63,6 +63,7 @@ class UserInfoResponseDTO(BaseModel):
|
||||
preferred_username: Optional[str] = None
|
||||
tenant_id: Optional[int] = None
|
||||
tenant_slug: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
roles: list[str] = []
|
||||
permissions: list[str] = []
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import httpx
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from core.config import settings
|
||||
from fastapi import HTTPException
|
||||
@@ -23,6 +24,96 @@ class AuthService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> Optional[str]:
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip()
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return None
|
||||
|
||||
def _pick_text(self, *candidates: Any) -> Optional[str]:
|
||||
for candidate in candidates:
|
||||
value = self._clean_text(candidate)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]:
|
||||
try:
|
||||
claims = jwt.get_unverified_claims(access_token)
|
||||
return claims if isinstance(claims, dict) else {}
|
||||
except JWTError:
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fallback de datos de usuario consultando el Hub admin API.
|
||||
Es opcional y no debe romper /me si falla.
|
||||
"""
|
||||
if not keycloak_user_id:
|
||||
return None
|
||||
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
login_resp = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/login",
|
||||
json={
|
||||
"username": settings.HUB_ADMIN_EMAIL,
|
||||
"password": settings.HUB_ADMIN_PASSWORD,
|
||||
},
|
||||
)
|
||||
if login_resp.status_code != 200:
|
||||
return None
|
||||
|
||||
admin_token = login_resp.json().get("access_token")
|
||||
if not admin_token:
|
||||
return None
|
||||
|
||||
user_resp = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
if user_resp.status_code == 200:
|
||||
payload = user_resp.json()
|
||||
return payload if isinstance(payload, dict) else None
|
||||
except Exception as exc:
|
||||
logger.debug("kc_admin_user_lookup_failed: %s", exc)
|
||||
|
||||
return None
|
||||
|
||||
def _extract_avatar_url(self, *sources: Any) -> Optional[str]:
|
||||
for source in sources:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
|
||||
direct = self._pick_text(
|
||||
source.get("avatar_url"),
|
||||
source.get("avatarUrl"),
|
||||
source.get("picture"),
|
||||
source.get("photo"),
|
||||
)
|
||||
if direct:
|
||||
return direct
|
||||
|
||||
attrs = source.get("attributes")
|
||||
if isinstance(attrs, dict):
|
||||
attr_candidate = attrs.get("avatar_url")
|
||||
if isinstance(attr_candidate, list) and attr_candidate:
|
||||
value = self._clean_text(attr_candidate[0])
|
||||
if value:
|
||||
return value
|
||||
if isinstance(attr_candidate, str):
|
||||
value = self._clean_text(attr_candidate)
|
||||
if value:
|
||||
return value
|
||||
|
||||
return None
|
||||
|
||||
async def login(
|
||||
self,
|
||||
login_data: LoginRequestDTO,
|
||||
@@ -55,6 +146,38 @@ class AuthService:
|
||||
except Exception as exc:
|
||||
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
|
||||
|
||||
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
|
||||
# No bloquea login si Workspace no responde.
|
||||
access_token = data.get("access_token")
|
||||
if access_token:
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
from core.workspace_profile_client import WorkspaceProfileClient
|
||||
|
||||
workspace_profile = None
|
||||
try:
|
||||
workspace_profile = await WorkspaceProfileClient().get_me(access_token)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"workspace_profile_sync_failed",
|
||||
extra={
|
||||
"event": "workspace_profile_sync_failed",
|
||||
"phase": "login",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
workspace_profile = None
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=access_token,
|
||||
keycloak_user_id=(workspace_profile or {}).get("sub")
|
||||
or data.get("sub")
|
||||
or data.get("user_id"),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
workspace_profile=workspace_profile,
|
||||
force=True,
|
||||
)
|
||||
|
||||
# AUDIT LOG: Login Success
|
||||
try:
|
||||
from api.v1.modules.a76.audit_log.services.service import AuditService
|
||||
@@ -102,7 +225,37 @@ class AuthService:
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return TokenResponseDTO(**response.json())
|
||||
data = response.json()
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
from core.workspace_profile_client import WorkspaceProfileClient
|
||||
|
||||
workspace_profile = None
|
||||
try:
|
||||
workspace_profile = await WorkspaceProfileClient().get_me(
|
||||
data.get("access_token", "")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"workspace_profile_sync_failed",
|
||||
extra={
|
||||
"event": "workspace_profile_sync_failed",
|
||||
"phase": "refresh",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
workspace_profile = None
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=data.get("access_token"),
|
||||
keycloak_user_id=(workspace_profile or {}).get("sub")
|
||||
or data.get("sub")
|
||||
or data.get("user_id"),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
workspace_profile=workspace_profile,
|
||||
force=True,
|
||||
)
|
||||
return TokenResponseDTO(**data)
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
|
||||
@@ -115,9 +268,78 @@ class AuthService:
|
||||
Obtiene información del usuario desde el Hub
|
||||
"""
|
||||
from core.security import verify_token
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
# Aprovechamos la verificación (y cache) de security.py
|
||||
user_info = await verify_token(access_token)
|
||||
return UserInfoResponseDTO(**user_info)
|
||||
|
||||
kc_user = self._decode_kc_user_from_token(access_token)
|
||||
keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub"))
|
||||
|
||||
needs_admin_fallback = any(
|
||||
not self._clean_text(user_info.get(field))
|
||||
for field in ("email", "preferred_username")
|
||||
) or self._extract_avatar_url(user_info) is None
|
||||
|
||||
kc_admin_user = None
|
||||
if needs_admin_fallback:
|
||||
kc_admin_user = await self._get_kc_admin_user(keycloak_user_id)
|
||||
|
||||
first_name = self._pick_text(
|
||||
user_info.get("first_name"),
|
||||
user_info.get("given_name"),
|
||||
kc_user.get("given_name"),
|
||||
kc_user.get("first_name"),
|
||||
(kc_admin_user or {}).get("firstName"),
|
||||
(kc_admin_user or {}).get("first_name"),
|
||||
)
|
||||
last_name = self._pick_text(
|
||||
user_info.get("last_name"),
|
||||
user_info.get("family_name"),
|
||||
kc_user.get("family_name"),
|
||||
kc_user.get("last_name"),
|
||||
(kc_admin_user or {}).get("lastName"),
|
||||
(kc_admin_user or {}).get("last_name"),
|
||||
)
|
||||
full_name = self._pick_text(
|
||||
f"{first_name} {last_name}" if first_name and last_name else None,
|
||||
first_name,
|
||||
last_name,
|
||||
)
|
||||
|
||||
enriched_user_info = dict(user_info)
|
||||
enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub")
|
||||
enriched_user_info["email"] = self._pick_text(
|
||||
user_info.get("email"),
|
||||
(kc_admin_user or {}).get("email"),
|
||||
kc_user.get("email"),
|
||||
)
|
||||
enriched_user_info["preferred_username"] = self._pick_text(
|
||||
user_info.get("preferred_username"),
|
||||
user_info.get("username"),
|
||||
kc_user.get("preferred_username"),
|
||||
kc_user.get("username"),
|
||||
(kc_admin_user or {}).get("username"),
|
||||
)
|
||||
enriched_user_info["avatar_url"] = self._extract_avatar_url(
|
||||
user_info,
|
||||
kc_user,
|
||||
kc_admin_user or {},
|
||||
)
|
||||
enriched_user_info["name"] = self._pick_text(
|
||||
user_info.get("name"),
|
||||
full_name,
|
||||
kc_user.get("name"),
|
||||
enriched_user_info.get("preferred_username"),
|
||||
)
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=access_token,
|
||||
keycloak_user_id=enriched_user_info.get("sub"),
|
||||
tenant_id=enriched_user_info.get("tenant_id"),
|
||||
workspace_profile=enriched_user_info,
|
||||
)
|
||||
return UserInfoResponseDTO(**enriched_user_info)
|
||||
|
||||
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
|
||||
"""
|
||||
|
||||
@@ -29,8 +29,10 @@ def track_and_dispatch(
|
||||
if company_id is not None:
|
||||
headers["rls_company_id"] = str(int(company_id))
|
||||
|
||||
token_t = rls_tenant_var.set(int(tenant_id))
|
||||
token_c = rls_company_var.set(int(company_id) if company_id is not None else None)
|
||||
prev_tenant = rls_tenant_var.get()
|
||||
prev_company = rls_company_var.get()
|
||||
rls_tenant_var.set(int(tenant_id))
|
||||
rls_company_var.set(int(company_id) if company_id is not None else None)
|
||||
try:
|
||||
celery_task = task.apply_async(
|
||||
args=args or [],
|
||||
@@ -39,8 +41,8 @@ def track_and_dispatch(
|
||||
headers=headers,
|
||||
)
|
||||
finally:
|
||||
rls_tenant_var.reset(token_t)
|
||||
rls_company_var.reset(token_c)
|
||||
rls_tenant_var.set(prev_tenant)
|
||||
rls_company_var.set(prev_company)
|
||||
|
||||
tracker = TaskTrackerService(db)
|
||||
tracker.register_dispatch(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Modelo de relación entre usuarios (Keycloak) y tenants
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
@@ -13,6 +14,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
DateTime,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -56,6 +58,17 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin):
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True, comment="URL de la imagen de perfil"
|
||||
)
|
||||
workspace_user_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(255), nullable=True, comment="User ID (sub) proveniente de Workspace"
|
||||
)
|
||||
workspace_avatar_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True, comment="Avatar URL sincronizado desde Workspace"
|
||||
)
|
||||
workspace_profile_synced_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Última sincronización de perfil con Workspace",
|
||||
)
|
||||
# Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub;
|
||||
# se sincroniza al editar perfil desde Anexo76)
|
||||
first_name: Mapped[Optional[str]] = mapped_column(
|
||||
|
||||
@@ -154,6 +154,7 @@ def get_user_avatar_image(
|
||||
|
||||
@router.get("/me/profile", response_model=UserResponseDTO)
|
||||
async def get_my_profile(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
@@ -180,7 +181,17 @@ async def get_my_profile(
|
||||
)
|
||||
|
||||
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
||||
return await service.get_current_user_profile(keycloak_user_id, current_user=current_user)
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
access_token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip() or None
|
||||
)
|
||||
return await service.get_current_user_profile(
|
||||
keycloak_user_id,
|
||||
current_user=current_user,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/me/profile", response_model=UserResponseDTO)
|
||||
@@ -214,6 +225,14 @@ async def update_my_profile(
|
||||
status_code=400, detail="User does not belong to any tenant"
|
||||
)
|
||||
|
||||
# Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub
|
||||
# y no debe mutarse localmente en Anexo76.
|
||||
if current_user.get("sub"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Avatar is managed by Workspace for this user",
|
||||
)
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
access_token = (
|
||||
auth_header[7:].strip()
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func
|
||||
@@ -15,6 +16,30 @@ from ..user_tenant.models import UserTenant
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_valid_http_url(url: Optional[str]) -> bool:
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
parsed = urlparse(url.strip())
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]:
|
||||
if not user_tenant or not user_tenant.avatar_url:
|
||||
return None
|
||||
avatar_out = str(user_tenant.avatar_url)
|
||||
|
||||
if avatar_out.startswith("http://") or avatar_out.startswith("https://"):
|
||||
return avatar_out if _is_valid_http_url(avatar_out) else None
|
||||
|
||||
from core.s3_keys import public_user_avatar_api_path
|
||||
|
||||
# Entregamos siempre el endpoint público del backend para assets locales/S3.
|
||||
return public_user_avatar_api_path(
|
||||
user_tenant.tenant_id,
|
||||
user_tenant.keycloak_user_id,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_user(
|
||||
user_data: Dict[str, Any],
|
||||
role: Optional[str] = None,
|
||||
@@ -43,18 +68,20 @@ def _normalize_user(
|
||||
|
||||
# Agregar campos de perfil si user_tenant está disponible
|
||||
if user_tenant:
|
||||
avatar_out = user_tenant.avatar_url
|
||||
if avatar_out:
|
||||
from core.s3_keys import public_user_avatar_api_path
|
||||
workspace_avatar = (
|
||||
user_tenant.workspace_avatar_url
|
||||
if _is_valid_http_url(user_tenant.workspace_avatar_url)
|
||||
else None
|
||||
)
|
||||
legacy_avatar = _legacy_avatar_public_url(user_tenant)
|
||||
avatar_out = workspace_avatar or legacy_avatar
|
||||
|
||||
# Siempre devolver la URL pública del endpoint de servicio de imágenes,
|
||||
# independientemente de si es clave S3 (tenants/...) o ruta local (/uploads/...).
|
||||
avatar_out = public_user_avatar_api_path(
|
||||
user_tenant.tenant_id, user_tenant.keycloak_user_id
|
||||
)
|
||||
normalized.update(
|
||||
{
|
||||
"avatar_url": avatar_out,
|
||||
"workspace_avatar_url": workspace_avatar,
|
||||
"legacy_avatar_url": legacy_avatar,
|
||||
"workspace_user_id": user_tenant.workspace_user_id,
|
||||
"phone": user_tenant.phone,
|
||||
"bio": user_tenant.bio,
|
||||
"preferences": user_tenant.preferences or {},
|
||||
@@ -629,11 +656,27 @@ class UserService:
|
||||
"usage_percentage": round(usage_percentage, 2),
|
||||
}
|
||||
|
||||
async def get_current_user_profile(self, keycloak_user_id: str, current_user: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
async def get_current_user_profile(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
current_user: Dict[str, Any] = None,
|
||||
access_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Obtiene el perfil completo del usuario actual"""
|
||||
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
|
||||
user_info = current_user or {"id": keycloak_user_id}
|
||||
|
||||
# Perfil "me": sincronización con cache corto (5 min).
|
||||
if access_token:
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=access_token,
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
|
||||
).first()
|
||||
|
||||
@@ -6,7 +6,7 @@ All specific resource services inherit from this.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import httpx
|
||||
|
||||
@@ -17,6 +17,29 @@ class SitarAPIBaseService:
|
||||
_token: Optional[str] = None
|
||||
_token_expires: Optional[datetime] = None
|
||||
|
||||
@staticmethod
|
||||
def _parse_paginated_list_and_total(data: Any) -> Tuple[List[Any], int]:
|
||||
"""
|
||||
Paginated list endpoints (fracciones, fracciones-usa) return:
|
||||
{ "data": [...], "total", "page", "limit", "total_pages" }.
|
||||
Older responses may be a plain JSON array; then total is len(rows) for that page only.
|
||||
"""
|
||||
if isinstance(data, list):
|
||||
return data, len(data)
|
||||
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
||||
rows = data["data"]
|
||||
raw_total = data.get("total")
|
||||
total = int(raw_total) if raw_total is not None else len(rows)
|
||||
return rows, total
|
||||
raise ValueError(
|
||||
f"Unexpected SITAR list response shape: {type(data).__name__}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_paginated_list(data: Any) -> List[Any]:
|
||||
rows, _ = SitarAPIBaseService._parse_paginated_list_and_total(data)
|
||||
return rows
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize base service with API credentials"""
|
||||
self.base_url = os.getenv("SITAR_API_URL")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Fracciones Service"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Tuple
|
||||
from ..common import SitarAPIBaseService
|
||||
from .schemas import FraccionesResponse
|
||||
|
||||
@@ -18,7 +18,7 @@ class FraccionesService(SitarAPIBaseService):
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
async def search(
|
||||
async def search_with_total(
|
||||
self,
|
||||
fraccion: Optional[str] = None,
|
||||
nico: Optional[str] = None,
|
||||
@@ -26,8 +26,8 @@ class FraccionesService(SitarAPIBaseService):
|
||||
nivel: Optional[int] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[FraccionesResponse]:
|
||||
"""Search Mexican tariff fractions"""
|
||||
) -> Tuple[List[FraccionesResponse], int]:
|
||||
"""Search Mexican tariff fractions; total matches SITAR PaginatedFraccionesResponse.total."""
|
||||
params = {"skip": skip, "limit": min(limit, 1000)}
|
||||
if fraccion:
|
||||
params["fraccion"] = fraccion
|
||||
@@ -38,8 +38,29 @@ class FraccionesService(SitarAPIBaseService):
|
||||
if nivel is not None:
|
||||
params["nivel"] = nivel
|
||||
|
||||
data = await self._make_request("GET", "/api/v1/fracciones/", params=params)
|
||||
return [FraccionesResponse(**item) for item in data]
|
||||
raw = await self._make_request("GET", "/api/v1/fracciones/", params=params)
|
||||
rows, total = self._parse_paginated_list_and_total(raw)
|
||||
return [FraccionesResponse(**item) for item in rows], total
|
||||
|
||||
async def search(
|
||||
self,
|
||||
fraccion: Optional[str] = None,
|
||||
nico: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
nivel: Optional[int] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[FraccionesResponse]:
|
||||
"""Search Mexican tariff fractions"""
|
||||
items, _ = await self.search_with_total(
|
||||
fraccion=fraccion,
|
||||
nico=nico,
|
||||
description=description,
|
||||
nivel=nivel,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return items
|
||||
|
||||
async def get_by_id(self, sysid: int) -> FraccionesResponse:
|
||||
"""Get single Fraccion record by SYSID"""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Fracciones USA Service"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from ..common import SitarAPIBaseService
|
||||
from .schemas import FraccionesUSAResponse
|
||||
@@ -22,6 +22,24 @@ class FraccionesUSAService(SitarAPIBaseService):
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
async def search_with_total(
|
||||
self,
|
||||
fraccion: Optional[str] = None,
|
||||
descripcion: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Tuple[List[FraccionesUSAResponse], int]:
|
||||
"""Search USA tariff fractions; total matches SITAR PaginatedFraccionesUSAResponse.total."""
|
||||
params = {"skip": skip, "limit": min(limit, 1000)}
|
||||
if fraccion:
|
||||
params["fraccion"] = fraccion
|
||||
if descripcion:
|
||||
params["descripcion"] = descripcion
|
||||
|
||||
raw = await self._make_request("GET", "api/v1/fracciones-usa/", params=params)
|
||||
rows, total = self._parse_paginated_list_and_total(raw)
|
||||
return [FraccionesUSAResponse(**item) for item in rows], total
|
||||
|
||||
async def search(
|
||||
self,
|
||||
fraccion: Optional[str] = None,
|
||||
@@ -30,14 +48,13 @@ class FraccionesUSAService(SitarAPIBaseService):
|
||||
limit: int = 100,
|
||||
) -> List[FraccionesUSAResponse]:
|
||||
"""Search USA tariff fractions"""
|
||||
params = {"skip": skip, "limit": min(limit, 1000)}
|
||||
if fraccion:
|
||||
params["fraccion"] = fraccion
|
||||
if descripcion:
|
||||
params["descripcion"] = descripcion
|
||||
|
||||
data = await self._make_request("GET", "api/v1/fracciones-usa/", params=params)
|
||||
return [FraccionesUSAResponse(**item) for item in data]
|
||||
items, _ = await self.search_with_total(
|
||||
fraccion=fraccion,
|
||||
descripcion=descripcion,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return items
|
||||
|
||||
async def get_by_id(self, consecutivo: int) -> FraccionesUSAResponse:
|
||||
"""Get single USA Fraccion record by CONSECUTIVO"""
|
||||
@@ -68,10 +85,9 @@ class FraccionesUSAService(SitarAPIBaseService):
|
||||
params["descripcion"] = descripcion
|
||||
|
||||
try:
|
||||
data = service._make_request_sync("GET", "api/v1/fracciones-usa/", params=params)
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [FraccionesUSAResponse(**item) for item in data]
|
||||
raw = service._make_request_sync("GET", "api/v1/fracciones-usa/", params=params)
|
||||
rows, _ = service._parse_paginated_list_and_total(raw)
|
||||
return [FraccionesUSAResponse(**item) for item in rows]
|
||||
except Exception as exc:
|
||||
logger.warning("SITAR fracciones-usa search_sync failed: %s", exc)
|
||||
return []
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
"""PROSEC Schemas"""
|
||||
"""PROSEC Schemas — aligned with SITAR OpenAPI ProsecResponse."""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ProsecResponse(BaseModel):
|
||||
"""PROSEC (Programa de Promoción Sectorial)"""
|
||||
"""PROSEC (Programa de Promoción Sectorial)."""
|
||||
|
||||
FRACCION: Optional[str] = Field(None, max_length=10)
|
||||
PRODUCTO: Optional[str] = Field(None, max_length=999)
|
||||
TASA: Optional[str] = Field(None, max_length=19)
|
||||
SECTOR: Optional[str] = Field(None, max_length=2)
|
||||
ANEXO: Optional[str] = Field(None, max_length=19)
|
||||
DOF: Optional[str] = Field(None, max_length=8)
|
||||
NOTAS: Optional[str] = Field(None, max_length=5000)
|
||||
FRACCION: Optional[str] = Field("", max_length=10)
|
||||
ARTICULO: Optional[str] = Field("", max_length=3)
|
||||
SECTOR: Optional[str] = Field("", max_length=6)
|
||||
TASATXT: Optional[str] = Field("", max_length=19)
|
||||
TASANUM: Optional[str] = Field(default="0")
|
||||
TIPOTASA: Optional[int] = Field(default=0)
|
||||
DOF: Optional[str] = Field("", max_length=8)
|
||||
OBSERVACION: Optional[str] = ""
|
||||
NICO: Optional[str] = Field("", max_length=2)
|
||||
SYSID: int
|
||||
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
"""TLCS Schemas"""
|
||||
"""TLCS Schemas — aligned with SITAR OpenAPI TLCSResponse."""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class TLCSResponse(BaseModel):
|
||||
"""TLCS (Tratados de Libre Comercio) response model"""
|
||||
"""TLCS (Tratados de Libre Comercio) response model."""
|
||||
|
||||
FRACCION: Optional[str] = Field(None, max_length=8)
|
||||
PAIS: Optional[str] = Field(None, max_length=3)
|
||||
TASATXT: Optional[str] = Field(None, max_length=19)
|
||||
TASANUM: Optional[str] = None
|
||||
TASACALCULADA: Optional[str] = Field(None, max_length=19)
|
||||
TLC: Optional[str] = Field(None, max_length=6)
|
||||
NOTA: Optional[str] = None
|
||||
FRACCION: str = Field(max_length=10)
|
||||
PAIS: str = Field(max_length=3)
|
||||
ORDEN: Optional[int] = None
|
||||
TASATXT: Optional[str] = Field(None, max_length=44)
|
||||
TIPOTASA: Optional[int] = None
|
||||
TASA1NUM: Optional[str] = None
|
||||
FACTOR1: Optional[str] = None
|
||||
TASA2NUM: Optional[str] = None
|
||||
FACTOR2: Optional[str] = None
|
||||
DOF: Optional[str] = Field(None, max_length=8)
|
||||
OBSERVACION: Optional[str] = None
|
||||
NOTAS: Optional[str] = Field(None, max_length=999)
|
||||
NICO: Optional[str] = Field("", max_length=2)
|
||||
SYSID: int
|
||||
|
||||
|
||||
Reference in New Issue
Block a user