Refactor backend and frontend code for improved structure and functionality

- Rearranged imports in multiple files for consistency and clarity.
- Updated logging middleware to exclude specific paths from logging.
- Enhanced security module by cleaning up token handling and improving tenant validation.
- Added tenant and company scoped mixins for better database model management.
- Implemented generic CRUD routes for tenant-scoped resources.
- Improved error handling and response management in API routes.
- Cleaned up login and logout processes to ensure proper session management.
- Introduced mechanisms to clear local storage and cookies on tenant change.
- Enhanced company store to detect tenant changes and clear data accordingly.
- Added new DTO mixins for currency and value affect flags.
This commit is contained in:
2025-11-11 17:20:47 -06:00
parent ac7f8d19d8
commit b68c4316ff
247 changed files with 2248 additions and 2504 deletions

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
class TimestampMixin:
"""Mixin for common timestamp fields"""
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=func.now(), onupdate=func.now()
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class TenantScopedMixin:
"""Mixin for tenant and company scoped entities"""
tenant_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
class PedimentoRelatedMixin(TenantScopedMixin):
"""Mixin for entities related to pedimentos"""
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)

View File

@@ -0,0 +1,121 @@
from typing import Any, Callable, Generic, Type, TypeVar
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
ModelType = TypeVar("ModelType")
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
class CRUDRouterFactory(
Generic[ModelType, CreateSchemaType, UpdateSchemaType, ResponseSchemaType]
):
"""Factory to create standard CRUD routes"""
def __init__(
self,
model: Type[ModelType],
create_schema: Type[CreateSchemaType],
update_schema: Type[UpdateSchemaType],
response_schema: Type[ResponseSchemaType],
db_dependency: Callable,
auth_dependency: Callable,
prefix: str,
tags: list[str],
id_field: str = "key",
):
self.model = model
self.create_schema = create_schema
self.update_schema = update_schema
self.response_schema = response_schema
self.db_dependency = db_dependency
self.auth_dependency = auth_dependency
self.id_field = id_field
self.router = APIRouter(prefix=prefix, tags=tags)
self._register_routes()
def _register_routes(self):
"""Register all CRUD routes"""
@self.router.get("/", response_model=list[self.response_schema])
def list_items(
skip: int = 0,
limit: int = 100,
db: Session = Depends(self.db_dependency),
current_user: dict = Depends(self.auth_dependency),
):
items = db.query(self.model).offset(skip).limit(limit).all()
return items
@self.router.get(f"/{{{self.id_field}}}", response_model=self.response_schema)
def get_item(
db: Session = Depends(self.db_dependency),
current_user: dict = Depends(self.auth_dependency),
**kwargs,
):
item_id = kwargs.get(self.id_field)
obj = (
db.query(self.model)
.filter(getattr(self.model, self.id_field) == item_id)
.first()
)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
return obj
@self.router.post("/", response_model=self.response_schema)
def create_item(
data: Any,
db: Session = Depends(self.db_dependency),
current_user: dict = Depends(self.auth_dependency),
):
obj = self.model(**data.dict())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@self.router.put(f"/{{{self.id_field}}}", response_model=self.response_schema)
def update_item(
data: Any,
db: Session = Depends(self.db_dependency),
current_user: dict = Depends(self.auth_dependency),
**kwargs,
):
item_id = kwargs.get(self.id_field)
obj = (
db.query(self.model)
.filter(getattr(self.model, self.id_field) == item_id)
.first()
)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
for field, value in data.dict(exclude_unset=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@self.router.delete(f"/{{{self.id_field}}}", status_code=204)
def delete_item(
db: Session = Depends(self.db_dependency),
current_user: dict = Depends(self.auth_dependency),
**kwargs,
):
item_id = kwargs.get(self.id_field)
obj = (
db.query(self.model)
.filter(getattr(self.model, self.id_field) == item_id)
.first()
)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
db.delete(obj)
db.commit()
return None

View File

@@ -0,0 +1,31 @@
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, Field
class CurrencyMixin(BaseModel):
"""Mixin for currency-related fields"""
currency: Optional[str] = Field(None, max_length=3, description="Currency")
currency_factor: Optional[Decimal] = Field(None, description="Currency factor")
class AffectValueMixin(BaseModel):
"""Mixin for value affect flags"""
not_affect_usd_value: Optional[int] = Field(
None, description="Not affect USD value"
)
not_affect_customs_value: Optional[int] = Field(
None, description="Not affect customs value"
)
class UpdateFlagsMixin(BaseModel):
"""Mixin for update flags"""
update_vat: Optional[int] = Field(None, description="Update VAT")
update_advalorem: Optional[int] = Field(None, description="Update advalorem")
update_cc: Optional[int] = Field(None, description="Update CC")
update_ieps: Optional[int] = Field(None, description="Update IEPS")

View File

@@ -0,0 +1,350 @@
from typing import Any, Callable, Dict, Generic, Optional, Type, TypeVar
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
# Type variables for generic types
ModelType = TypeVar("ModelType")
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
ServiceType = TypeVar("ServiceType")
class TenantCRUDRoutes(
Generic[CreateSchemaType, UpdateSchemaType, ResponseSchemaType, ServiceType]
):
"""
Generic CRUD routes factory for tenant-scoped resources
Supports both parent resources (with list/pagination) and child resources (nested under parent).
Usage examples:
1. Parent resource with list (e.g., /pedimentos):
router = TenantCRUDRoutes(
service=PedimentosService,
create_schema=PedimentosCreate,
update_schema=PedimentosUpdate,
response_schema=PedimentosResponse,
prefix="/pedimentos",
tags=["Pedimentos"],
resource_name="Pedimento",
id_name="pedimento_id",
enable_list=True,
).router
2. Child resource (e.g., /pedimentos/{pedimento_id}/config-additional):
router = TenantCRUDRoutes(
service=PedimentoConfigAdditionalService,
create_schema=PedimentoConfigAdditionalCreate,
update_schema=PedimentoConfigAdditionalUpdate,
response_schema=PedimentoConfigAdditionalResponse,
prefix="/{pedimento_id}/config-additional",
tags=["Pedimento Config Additional"],
resource_name="Config additional",
parent_id_name="pedimento_id",
enable_list=False,
).router
"""
def __init__(
self,
service: Type[ServiceType],
create_schema: Type[CreateSchemaType],
update_schema: Type[UpdateSchemaType],
response_schema: Type[ResponseSchemaType],
prefix: str,
tags: list[str],
resource_name: str = "Resource",
id_name: Optional[str] = None, # For parent resources (e.g., "pedimento_id")
parent_id_name: Optional[
str
] = None, # For child resources (e.g., "pedimento_id")
db_dependency: Callable = get_core_db,
auth_dependency: Callable = get_current_user,
validate_parent_match: bool = True, # Validate parent_id matches in create
enable_list: bool = False, # Enable GET list endpoint with pagination
enable_filters: bool = False, # Enable custom filters in list endpoint
default_page_size: int = 50,
max_page_size: int = 100,
):
self.service = service
self.create_schema = create_schema
self.update_schema = update_schema
self.response_schema = response_schema
self.resource_name = resource_name
self.id_name = id_name or parent_id_name or "id"
self.parent_id_name = parent_id_name
self.db_dependency = db_dependency
self.auth_dependency = auth_dependency
self.validate_parent_match = validate_parent_match
self.enable_list = enable_list
self.enable_filters = enable_filters
self.default_page_size = default_page_size
self.max_page_size = max_page_size
self.router = APIRouter(prefix=prefix, tags=tags)
self._register_routes()
def _register_routes(self):
"""Register all CRUD routes"""
# LIST route (optional, for parent resources)
if self.enable_list:
if self.enable_filters:
@self.router.get("/", response_model=Dict[str, Any])
async def list_resources(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
self.default_page_size,
ge=1,
le=self.max_page_size,
description="Page size",
),
status: Optional[str] = Query(None, description="Filter by status"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
f"""List all {self.resource_name}s with pagination"""
tenant_id = validate_access_to_resource(
db, company_id, current_user
)
skip = (page - 1) * page_size
filters = {}
if status:
filters["status"] = status
items, total = self.service.get_all(
db, tenant_id, company_id, skip, page_size, filters
)
return {
"items": [
self.response_schema.model_validate(item) for item in items
],
"total": total,
"page": page,
"page_size": page_size,
}
else:
@self.router.get("/", response_model=Dict[str, Any])
async def list_resources(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
self.default_page_size,
ge=1,
le=self.max_page_size,
description="Page size",
),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
f"""List all {self.resource_name}s with pagination"""
tenant_id = validate_access_to_resource(
db, company_id, current_user
)
skip = (page - 1) * page_size
items, total = self.service.get_all(
db, tenant_id, company_id, skip, page_size, None
)
return {
"items": [
self.response_schema.model_validate(item) for item in items
],
"total": total,
"page": page,
"page_size": page_size,
}
# GET single resource route
# For parent resources: GET /{id}
# For child resources: GET / (parent_id comes from path)
if self.parent_id_name:
# Child resource - single GET without ID in path
@self.router.get("/", response_model=self.response_schema)
async def get_resource(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
**path_params,
):
f"""Get {self.resource_name} by {self.parent_id_name}"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
parent_id = path_params.get(self.parent_id_name)
# Try method with 4 params (pedimento_id, tenant_id, company_id)
if hasattr(self.service, "get_by_pedimento_id"):
resource = self.service.get_by_pedimento_id(
db, parent_id, tenant_id, company_id
)
# Fallback to method with 3 params
elif hasattr(self.service, "get_by_id"):
resource = self.service.get_by_id(
db, parent_id, tenant_id, company_id
)
else:
resource = self.service.get(db, parent_id, tenant_id, company_id)
if not resource:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return resource
else:
# Parent resource - GET by ID in path
@self.router.get(
f"/{{{self.id_name}}}", response_model=self.response_schema
)
async def get_resource_by_id(
resource_id: int = Path(..., alias=self.id_name),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
f"""Get {self.resource_name} by ID"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
resource = self.service.get_by_id(
db, resource_id, tenant_id, company_id
)
if not resource:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return resource
# POST route
@self.router.post("/", response_model=self.response_schema, status_code=201)
async def create_resource(
data: CreateSchemaType,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
**path_params,
):
f"""Create {self.resource_name}"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Validate parent ID match if enabled and parent_id_name exists
if self.validate_parent_match and self.parent_id_name:
parent_id = path_params.get(self.parent_id_name)
data_parent_id = getattr(data, self.parent_id_name, None)
if data_parent_id is not None and data_parent_id != parent_id:
raise HTTPException(
status_code=400,
detail=f"{self.parent_id_name.replace('_', ' ').title()} mismatch",
)
resource = self.service.create(db, data, tenant_id, company_id)
return resource
# PUT route
# For parent resources: PUT /{id}
# For child resources: PUT / (parent_id comes from path)
if self.parent_id_name:
# Child resource
@self.router.put("/", response_model=self.response_schema)
async def update_resource(
data: UpdateSchemaType,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
**path_params,
):
f"""Update {self.resource_name}"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
parent_id = path_params.get(self.parent_id_name)
resource = self.service.update(
db, parent_id, tenant_id, company_id, data
)
if not resource:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return resource
else:
# Parent resource
@self.router.put(
f"/{{{self.id_name}}}", response_model=self.response_schema
)
async def update_resource_by_id(
data: UpdateSchemaType,
resource_id: int = Path(..., alias=self.id_name),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
f"""Update {self.resource_name}"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
resource = self.service.update(
db, resource_id, tenant_id, company_id, data
)
if not resource:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return resource
# DELETE route
# For parent resources: DELETE /{id}
# For child resources: DELETE / (parent_id comes from path)
if self.parent_id_name:
# Child resource
@self.router.delete("/", status_code=204)
async def delete_resource(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
**path_params,
):
f"""Delete {self.resource_name}"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
parent_id = path_params.get(self.parent_id_name)
success = self.service.delete(db, parent_id, tenant_id, company_id)
if not success:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return None
else:
# Parent resource
@self.router.delete(f"/{{{self.id_name}}}", status_code=204)
async def delete_resource_by_id(
resource_id: int = Path(..., alias=self.id_name),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
f"""Delete {self.resource_name}"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
success = self.service.delete(db, resource_id, tenant_id, company_id)
if not success:
raise HTTPException(
status_code=404, detail=f"{self.resource_name} not found"
)
return None