Skip to content

Commit 02fb889

Browse files
committed
product service added
1 parent e33bb3d commit 02fb889

File tree

4 files changed

+149
-60
lines changed

4 files changed

+149
-60
lines changed

fiscalapi/models/common_models.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,48 @@
11
import datetime
2-
from typing import Generic, Optional, TypeVar
2+
from typing import Any, Generic, List, Optional, TypeVar
33
from pydantic import BaseModel, ConfigDict, Field
44
from pydantic.alias_generators import to_snake
55

66
T = TypeVar('T', bound=BaseModel)
77

88
class ApiResponse(BaseModel, Generic[T]):
99
succeeded: bool = Field(alias="succeeded")
10-
message: str = Field(alias="message")
11-
details: str = Field(alias="details")
10+
message: Optional[str] = Field(alias="message")
11+
details: Optional[str] = Field(alias="details")
1212
data: Optional[T] = Field(None, alias="data")
1313

1414
model_config = ConfigDict(
1515
populate_by_name=True,
1616
alias_generator=to_snake
1717
)
1818

19-
19+
20+
class PagedList(BaseModel, Generic[T]):
21+
"""Modelo para la estructura de la lista paginada."""
22+
items: List[T] = Field(default=[], alias="items", description="Lista de elementos paginados")
23+
page_number: int = Field(alias="pageNumber", description="Número de página actual")
24+
total_pages: int = Field(alias="totalPages", description="Cantidad total de páginas")
25+
total_count: int = Field(alias="totalCount", description="Cantidad total de elementos")
26+
has_previous_page: bool = Field(alias="hasPreviousPage", description="Indica si hay una página anterior")
27+
has_next_page: bool = Field(alias="hasNextPage", description="Indica si hay una página siguiente")
28+
29+
30+
class ValidationFailure(BaseModel):
31+
"""Modelo para errores de validación."""
32+
propertyName: str
33+
errorMessage: str
34+
attemptedValue: Optional[Any] = None
35+
customState: Optional[Any] = None
36+
severity: Optional[int] = None
37+
errorCode: Optional[str] = None
38+
formattedMessagePlaceholderValues: Optional[dict] = None
39+
2040

2141
class BaseDto(BaseModel):
2242
"""Modelo base para DTOs."""
23-
id: str = Field(alias="id")
24-
created_at: Optional[datetime.datetime] = Field(alias="createdAt")
25-
updated_at: Optional[datetime.datetime] = Field(alias="updatedAt")
43+
id: Optional[str] = Field(default=None, alias="id")
44+
created_at: Optional[datetime.datetime] = Field(default=None, alias="createdAt")
45+
updated_at: Optional[datetime.datetime] = Field(default=None, alias="updatedAt")
2646

2747
model_config = ConfigDict(populate_by_name=True)
2848

@@ -35,14 +55,14 @@ class CatalogDto(BaseDto):
3555

3656
class FiscalApiSettings(BaseModel):
3757
"""
38-
Configuración para la API Fiscal.
58+
Objeto que contiene la configuración necesaria para interactuar con Fiscalapi.
3959
"""
40-
api_url: str = Field(..., description="URL base de Fiscalapi")
60+
api_url: str = Field(..., description="URL base de la api.")
4161
api_key: str = Field(..., description="Api Key")
4262
tenant: str = Field(..., description="Tenant Key.")
43-
api_version: str = Field("v4", description="Versión de la API Fiscal.")
44-
time_zone: str = Field("America/Mexico_City", description="Zona horaria para las operaciones.")
63+
api_version: str = Field("v4", description="Versión de la api.")
64+
time_zone: str = Field("America/Mexico_City", description="Zona horaria ")
4565

4666
class Config:
47-
title = "Fiscal API Settings"
67+
title = "FiscalApi Settings"
4868
description = "Configuración para Fiscalapi"
Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,47 @@
11
from decimal import Decimal
2-
from typing import Optional
32
from pydantic import ConfigDict, Field
43
from fiscalapi.models.common_models import BaseDto, CatalogDto
5-
4+
from typing import Literal, Optional
65

76
class ProductTax(BaseDto):
8-
"""Modelo para impuestos de productos."""
9-
product_id: str = Field(alias="productId")
10-
tax: Optional[dict] = Field(alias="tax")
11-
rate: Decimal = Field( ge=0, le=1, alias="rate")
12-
tax_flag: Optional[dict] = Field(alias="taxFlag")
13-
tax_type: Optional[dict] = Field(alias="taxType")
14-
15-
model_config = ConfigDict(populate_by_name=True)
7+
"""Modelo impuesto de producto."""
8+
9+
rate: Decimal = Field(ge=0, le=1, alias="rate", description="Tasa de impuesto")
10+
11+
tax_id: Optional[Literal["001", "002", "003"]] = Field(default=None, alias="taxId", description="Impuesto")
12+
tax: Optional[CatalogDto] = Field(default=None, alias="tax", description="Impuesto expandido")
13+
14+
tax_flag_id: Optional[Literal["T", "R"]] = Field(default=None, alias="taxFlagId", description="Traslado o Retención")
15+
tax_flag: Optional[CatalogDto] = Field(default=None, alias="taxFlag", description="Traslado o Retención expandido")
16+
17+
tax_type_id: Optional[Literal["Tasa", "Cuota", "Exento"]] = Field(default=None, alias="taxTypeId", description="Tipo de impuesto")
18+
tax_type: Optional[CatalogDto] = Field(default=None, alias="taxType", description="Tipo de impuesto expandido")
19+
20+
21+
model_config = ConfigDict(
22+
populate_by_name=True,
23+
json_encoders={Decimal: str}
24+
)
1625

1726
class Product(BaseDto):
18-
"""Modelo para productos."""
27+
"""Modelo producto."""
1928
description: str = Field(alias="description")
2029
unit_price: Decimal = Field(alias="unitPrice")
21-
sat_unit_measurement: CatalogDto = Field(alias="satUnitMeasurement")
22-
sat_tax_object: CatalogDto = Field(alias="satTaxObject")
23-
sat_product_code: CatalogDto = Field(alias="satProductCode")
24-
product_taxes: list[ProductTax] = Field(alias="productTaxes")
2530

26-
model_config = ConfigDict(populate_by_name=True)
31+
sat_unit_measurement_id: Optional[str] = Field(default="H87", alias="satUnitMeasurementId", description="Unidad de medida SAT")
32+
sat_unit_measurement: Optional[CatalogDto] = Field(default=None, alias="satUnitMeasurement", description="Unidad de medida SAT expandida")
33+
34+
sat_tax_object_id: Optional[str] = Field(default="02", alias="satTaxObjectId", description="Objeto de impuesto SAT")
35+
sat_tax_object: Optional[CatalogDto] = Field(default=None, alias="satTaxObject", description="Objeto de impuesto SAT expandido")
36+
37+
sat_product_code_id: Optional[str] = Field(default="01010101", alias="satProductCodeId", description="Código de producto SAT")
38+
sat_product_code: Optional[CatalogDto] = Field(default=None, alias="satProductCode", description="Código de producto SAT expandido")
39+
40+
product_taxes: Optional[list[ProductTax]] = Field(default=None, alias="productTaxes", description="Impuestos del producto")
41+
42+
43+
44+
model_config = ConfigDict(
45+
populate_by_name=True,
46+
json_encoders={Decimal: str}
47+
)
Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,18 @@
11
from typing import Type, TypeVar
22
from pydantic import BaseModel
33
import requests
4-
from fiscalapi.models.common_models import ApiResponse, FiscalApiSettings
4+
from fiscalapi.models.common_models import ApiResponse, FiscalApiSettings, ValidationFailure
55

66
T = TypeVar('T', bound=BaseModel)
77

88
class BaseService:
9-
"""
10-
Clase base que agrupa lógica repetida en los servicios,
11-
como la construcción de URLs, cabeceras y manejo de responses.
12-
"""
13-
149
def __init__(self, settings: FiscalApiSettings):
1510
self.settings = settings
1611
self.api_version = settings.api_version
1712
self.base_url = settings.api_url
1813
self.api_key = settings.api_key
1914

2015
def _get_headers(self) -> dict:
21-
"""
22-
Construye las cabeceras http necesarias.
23-
"""
2416
return {
2517
"Content-Type": "application/json",
2618
"X-TENANT-KEY": self.settings.tenant,
@@ -29,43 +21,77 @@ def _get_headers(self) -> dict:
2921
}
3022

3123
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
32-
"""
33-
Realiza una llamada HTTP con la librería requests.
34-
"""
3524
url = f"{self.base_url}/api/{self.api_version}/{endpoint}"
3625
headers = self._get_headers()
3726

38-
# Unir los headers definidos por el usuario con los headers por defecto.
3927
if "headers" in kwargs:
40-
headers.update(kwargs["headers"])
41-
del kwargs["headers"]
28+
headers.update(kwargs.pop("headers"))
29+
30+
# Disable certificate validation (for development only!)
31+
kwargs.setdefault("verify", False)
32+
33+
return requests.request(method=method, url=url, headers=headers, **kwargs)
4234

43-
response = requests.request(method=method, url=url, headers=headers, **kwargs)
44-
response.raise_for_status() # Levanta excepciones para errores HTTP
45-
return response
35+
def _process_response(self, response: requests.Response, response_model: Type[T]) -> ApiResponse[T]:
36+
status_code = response.status_code
37+
raw_content = response.text
4638

47-
def _process_response(self, response: requests.Response, response_model: Type[BaseModel]) -> ApiResponse:
48-
"""
49-
Procesa y valida la respuesta de la API.
50-
"""
5139
try:
5240
response_data = response.json()
41+
except ValueError:
42+
return ApiResponse[T](
43+
succeeded=False,
44+
http_status_code=status_code,
45+
message="Error processing server response",
46+
details=raw_content,
47+
data=None
48+
)
5349

54-
# Procesar el campo `data` utilizando los modelos con alias
50+
if 200 <= status_code < 300:
5551
if "data" in response_data and response_data["data"] is not None:
5652
response_data["data"] = response_model.model_validate(response_data["data"])
53+
return ApiResponse[T].model_validate(response_data)
5754

58-
return ApiResponse.model_validate(response_data)
59-
except Exception as e:
60-
print(f"Error al procesar la respuesta: {e}")
61-
print(f"Response data: {response.json()}")
62-
raise
55+
try:
56+
generic_error = ApiResponse[object].model_validate(response_data)
57+
except Exception:
58+
return ApiResponse[T](
59+
succeeded=False,
60+
http_status_code=status_code,
61+
message="Error processing server error response",
62+
details=raw_content,
63+
data=None
64+
)
6365

66+
if status_code == 400 and isinstance(response_data.get("data"), list):
67+
try:
68+
failures = [ValidationFailure.model_validate(item) for item in response_data["data"]]
69+
if failures:
70+
details_str = "; ".join(f"{f.propertyName}: {f.errorMessage}" for f in failures)
71+
return ApiResponse[T](
72+
succeeded=False,
73+
http_status_code=400,
74+
message=generic_error.message,
75+
details=details_str,
76+
data=None
77+
)
78+
except Exception:
79+
pass
6480

81+
return ApiResponse[T](
82+
succeeded=False,
83+
http_status_code=status_code,
84+
message=generic_error.message or f"HTTP Error {status_code}",
85+
details=generic_error.details or raw_content,
86+
data=None
87+
)
6588

6689
def send_request(self, method: str, endpoint: str, response_model: Type[T], **kwargs) -> ApiResponse[T]:
67-
"""
68-
Envía una solicitud HTTP y devuelve la respuesta deserializada en un ApiResponse.
69-
"""
90+
payload = kwargs.pop("payload", None)
91+
if payload is not None and isinstance(payload, BaseModel):
92+
# Excluir propiedades con valor None
93+
kwargs["json"] = payload.model_dump(mode="json", by_alias=True, exclude_none=True)
94+
95+
print("Payload Request:", kwargs.get("json"))
7096
response = self._request(method, endpoint, **kwargs)
7197
return self._process_response(response, response_model)
Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
1-
from fiscalapi.models.common_models import ApiResponse
1+
from fiscalapi.models.common_models import ApiResponse, PagedList
22
from fiscalapi.models.fiscalapi_models import Product
33
from fiscalapi.services.common_services import BaseService
44

55

66
class ProductService(BaseService):
7+
8+
# get paged list of products
9+
def get_list(self, page_number: int, page_size: int) -> ApiResponse[PagedList[Product]]:
10+
endpoint = f"products?pageNumber={page_number}&pageSize={page_size}"
11+
return self.send_request("GET", endpoint, PagedList[Product])
12+
13+
# get product by id
714
def get_by_id(self, product_id: int) -> ApiResponse[Product]:
815
endpoint = f"products/{product_id}"
916
return self.send_request("GET", endpoint, Product)
17+
18+
# create product
19+
def create(self, product: Product) -> ApiResponse[Product]:
20+
endpoint = "products"
21+
return self.send_request("POST", endpoint, Product, payload=product)
22+
23+
# update product
24+
def update(self, product: Product) -> ApiResponse[Product]:
25+
endpoint = f"products/{product.id}"
26+
return self.send_request("PUT", endpoint, Product, payload=product)
27+
28+
# delete product
29+
def delete(self, product_id: str) -> ApiResponse[bool]:
30+
endpoint = f"products/{product_id}"
31+
return self.send_request("DELETE", endpoint, bool)

0 commit comments

Comments
 (0)