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:
30
backend/api/v1/common/base_models.py
Normal file
30
backend/api/v1/common/base_models.py
Normal 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)
|
||||
121
backend/api/v1/common/crud_routes.py
Normal file
121
backend/api/v1/common/crud_routes.py
Normal 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
|
||||
31
backend/api/v1/common/dto_mixins.py
Normal file
31
backend/api/v1/common/dto_mixins.py
Normal 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")
|
||||
350
backend/api/v1/common/tenant_crud_routes.py
Normal file
350
backend/api/v1/common/tenant_crud_routes.py
Normal 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
|
||||
Reference in New Issue
Block a user