Asset items
This commit is contained in:
@ -1,8 +1,10 @@
|
||||
from litestar import Router
|
||||
|
||||
from app.controllers.asset_item import AssetItemController
|
||||
from app.controllers.company import CompanyController
|
||||
from app.controllers.fiscal_payment_mapping import FiscalPaymentMappingController
|
||||
from app.controllers.machine import MachineController
|
||||
from app.domain.asset_item import AssetItem
|
||||
from app.domain.company import Company
|
||||
from app.domain.fiscal_payment_mapping import FiscalPaymentMapping
|
||||
from app.domain.machine import Machine
|
||||
@ -17,10 +19,12 @@ def create_router() -> Router:
|
||||
CompanyController,
|
||||
MachineController,
|
||||
FiscalPaymentMappingController,
|
||||
AssetItemController,
|
||||
],
|
||||
signature_namespace={
|
||||
"Company": Company,
|
||||
"Machine": Machine,
|
||||
"FiscalPaymentMapping": FiscalPaymentMapping,
|
||||
"AssetItem": AssetItem,
|
||||
},
|
||||
)
|
||||
|
||||
83
app/controllers/asset_item.py
Normal file
83
app/controllers/asset_item.py
Normal file
@ -0,0 +1,83 @@
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from litestar import Controller, get, post
|
||||
from litestar.contrib.repository.filters import LimitOffset, SearchFilter
|
||||
from litestar.di import Provide
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.company import Company
|
||||
from app.domain.asset_item import (
|
||||
AssetItem,
|
||||
AssetItemReadDTO,
|
||||
AssetItemWriteDTO,
|
||||
Repository,
|
||||
Service,
|
||||
)
|
||||
from app.lib.responses import ObjectListResponse, ObjectResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
DETAIL_ROUTE = "/{asset_item_id:int}"
|
||||
|
||||
|
||||
async def provides_service(db_session: AsyncSession, company_id: int) -> Service:
|
||||
"""Constructs repository and service objects for the request."""
|
||||
from app.controllers.company import provides_service
|
||||
|
||||
company_service = provides_service(db_session)
|
||||
company = await company_service.get(company_id)
|
||||
return Service(Repository(session=db_session, company=company))
|
||||
|
||||
|
||||
async def get_company(db_session: AsyncSession, company_id: int) -> Company:
|
||||
from app.controllers.company import provides_service
|
||||
|
||||
company_service = provides_service(db_session)
|
||||
return await company_service.get(company_id)
|
||||
|
||||
|
||||
class AssetItemController(Controller):
|
||||
dto = AssetItemWriteDTO
|
||||
return_dto = AssetItemReadDTO
|
||||
path = "/company/{company_id:int}/asset-items"
|
||||
dependencies = {
|
||||
"service": Provide(provides_service, sync_to_thread=False),
|
||||
}
|
||||
tags = ["AssetItems"]
|
||||
|
||||
@post()
|
||||
async def create_asset_item(
|
||||
self, data: AssetItem, service: Service
|
||||
) -> AssetItem:
|
||||
return await service.create(data)
|
||||
|
||||
@get()
|
||||
async def get_asset_items(
|
||||
self,
|
||||
service: Service,
|
||||
search: Optional[str] = None,
|
||||
) -> ObjectListResponse[AssetItem]:
|
||||
filters = [
|
||||
LimitOffset(limit=20, offset=0),
|
||||
]
|
||||
|
||||
if search is not None:
|
||||
filters.append(
|
||||
SearchFilter(
|
||||
field_name="caption",
|
||||
value=search,
|
||||
),
|
||||
)
|
||||
|
||||
content = await service.list(*filters)
|
||||
return ObjectListResponse(content=content)
|
||||
|
||||
@get(DETAIL_ROUTE)
|
||||
async def get_asset_item(
|
||||
self, service: Service, asset_item_id: int
|
||||
) -> ObjectResponse[AssetItem]:
|
||||
content = await service.get(asset_item_id)
|
||||
return ObjectResponse(content=content)
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from litestar.contrib.sqlalchemy.plugins import EngineConfig, SQLAlchemyAsyncConfig
|
||||
from litestar.exceptions import ClientException
|
||||
from litestar.status_codes import HTTP_409_CONFLICT
|
||||
from sqlalchemy import URL
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
|
||||
async def provide_transaction(
|
||||
db_session: AsyncSession,
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
try:
|
||||
async with db_session.begin():
|
||||
yield db_session
|
||||
except IntegrityError as exc:
|
||||
raise ClientException(
|
||||
status_code=HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
sessionmaker = async_sessionmaker(expire_on_commit=False)
|
||||
|
||||
db_connection_string = URL.create(
|
||||
drivername="postgresql+asyncpg",
|
||||
username="televend",
|
||||
password="televend",
|
||||
host="localhost",
|
||||
port=5433,
|
||||
database="televend",
|
||||
)
|
||||
db_config = SQLAlchemyAsyncConfig(
|
||||
connection_string=db_connection_string.render_as_string(hide_password=False),
|
||||
engine_config=EngineConfig(
|
||||
echo=True,
|
||||
),
|
||||
)
|
||||
79
app/domain/asset_item.py
Normal file
79
app/domain/asset_item.py
Normal file
@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import sqlalchemy
|
||||
from litestar.contrib.sqlalchemy.base import BigIntBase
|
||||
from litestar.contrib.sqlalchemy.dto import SQLAlchemyDTO
|
||||
from litestar.dto import DTOConfig, MsgspecDTO
|
||||
from msgspec import Struct, Meta
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.domain.enums import AssetItemProductLineEnum, AssetItemStatusEnum
|
||||
from app.lib import service
|
||||
from app.lib.company_owned_repository import CompanyOwnedRepository
|
||||
|
||||
|
||||
class AssetItem(BigIntBase):
|
||||
__tablename__ = "asset_items" # type: ignore[assignment]
|
||||
|
||||
company_id: Mapped[int]
|
||||
product_line: Mapped[AssetItemProductLineEnum] = mapped_column(
|
||||
sqlalchemy.Enum(AssetItemProductLineEnum, name="asset_product_line_enum")
|
||||
)
|
||||
brand_id: Mapped[int]
|
||||
model_id: Mapped[int]
|
||||
serial_number: Mapped[str]
|
||||
external_id: Mapped[Optional[str]]
|
||||
alive: Mapped[bool]
|
||||
status: Mapped[AssetItemStatusEnum] = mapped_column(
|
||||
sqlalchemy.Enum(AssetItemStatusEnum, name="asset_status_enum")
|
||||
)
|
||||
created_by_id: Mapped[Optional[int]]
|
||||
created_at: Mapped[datetime]
|
||||
last_modified_by_id: Mapped[Optional[int]]
|
||||
last_modified_at: Mapped[datetime]
|
||||
is_fiscal_device: Mapped[bool]
|
||||
warehouse_id: Mapped[Optional[int]]
|
||||
|
||||
|
||||
PositiveInt = Annotated[int, Meta(gt=0)]
|
||||
|
||||
|
||||
class AssetItemWriteStruct(Struct):
|
||||
company_id: PositiveInt
|
||||
product_line: AssetItemProductLineEnum
|
||||
brand_id: PositiveInt
|
||||
model_id: PositiveInt
|
||||
serial_number: Annotated[str, Meta(max_length=10)]
|
||||
external_id: Annotated[str, Meta(max_length=10)] | None
|
||||
alive: bool
|
||||
status: AssetItemStatusEnum
|
||||
created_by_id: PositiveInt | None
|
||||
created_at: datetime
|
||||
last_modified_by_id: PositiveInt | None
|
||||
last_modified_at: datetime | None
|
||||
is_fiscal_device: bool
|
||||
warehouse_id: PositiveInt | None
|
||||
|
||||
|
||||
class XXAssetItemWriteDTO(MsgspecDTO[AssetItemWriteStruct]):
|
||||
...
|
||||
|
||||
|
||||
class Repository(CompanyOwnedRepository[AssetItem]):
|
||||
model_type = AssetItem
|
||||
alive_flag = "alive"
|
||||
company_id_field = "company_id"
|
||||
|
||||
|
||||
class Service(service.Service[AssetItem]):
|
||||
repository_type = Repository
|
||||
|
||||
|
||||
write_config = DTOConfig(exclude={"id"})
|
||||
AssetItemWriteDTO = SQLAlchemyDTO[Annotated[AssetItem, write_config]]
|
||||
# AssetItemWriteDTO = MsgspecDTO[AssetItemWriteStruct]
|
||||
AssetItemReadDTO = SQLAlchemyDTO[AssetItem]
|
||||
|
||||
43
app/domain/enums.py
Normal file
43
app/domain/enums.py
Normal file
@ -0,0 +1,43 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class FiscalModuleEnum(str, Enum):
|
||||
CROATIA = "CROATIA"
|
||||
HUNGARY = "HUNGARY"
|
||||
ITALY = "ITALY"
|
||||
MONTENEGRO = "MONTENEGRO"
|
||||
ROMANIA = "ROMANIA"
|
||||
RUSSIA = "RUSSIA"
|
||||
SERBIA = "SERBIA"
|
||||
|
||||
|
||||
class PaymentTypeEnum(str, Enum):
|
||||
CA = "CA"
|
||||
DA = "DA"
|
||||
DB = "DB"
|
||||
DC = "DC"
|
||||
DD = "DD"
|
||||
PA4 = "PA4"
|
||||
NEG = "NEG"
|
||||
PA3 = "PA3"
|
||||
TA = "TA"
|
||||
WLT = "WLT"
|
||||
|
||||
|
||||
class AssetItemProductLineEnum(str, Enum):
|
||||
VENDING_MACHINE = "VENDING_MACHINE"
|
||||
HORECA_MACHINE = "HORECA_MACHINE"
|
||||
PROFESSIONAL_COFFEE_MACHINE = "PROFESSIONAL_COFFEE_MACHINE"
|
||||
TELEMETRY_DEVICE = "TELEMETRY_DEVICE"
|
||||
COIN_CHANGER = "COIN_CHANGER"
|
||||
CASHLESS_PAYMENT_DEVICE = "CASHLESS_PAYMENT_DEVICE"
|
||||
BANKNOTE_ACCEPTOR = "BANKNOTE_ACCEPTOR"
|
||||
BOILER = "BOILER"
|
||||
STEAMER = "STEAMER"
|
||||
|
||||
|
||||
class AssetItemStatusEnum(str, Enum):
|
||||
AVAILABLE = "AVAILABLE"
|
||||
IN_USE = "IN_USE"
|
||||
REPARATION = "REPARATION"
|
||||
DISPOSED = "DISPOSED"
|
||||
@ -1,4 +1,3 @@
|
||||
from enum import Enum
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import sqlalchemy
|
||||
@ -7,33 +6,11 @@ from litestar.contrib.sqlalchemy.dto import SQLAlchemyDTO
|
||||
from litestar.dto import DTOConfig
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.domain.enums import FiscalModuleEnum, PaymentTypeEnum
|
||||
from app.lib import service
|
||||
from app.lib.filter_repository import FilterRepository
|
||||
|
||||
|
||||
class FiscalModuleEnum(str, Enum):
|
||||
CROATIA = "CROATIA"
|
||||
HUNGARY = "HUNGARY"
|
||||
ITALY = "ITALY"
|
||||
MONTENEGRO = "MONTENEGRO"
|
||||
ROMANIA = "ROMANIA"
|
||||
RUSSIA = "RUSSIA"
|
||||
SERBIA = "SERBIA"
|
||||
|
||||
|
||||
class PaymentTypeEnum(str, Enum):
|
||||
CA = "CA"
|
||||
DA = "DA"
|
||||
DB = "DB"
|
||||
DC = "DC"
|
||||
DD = "DD"
|
||||
PA4 = "PA4"
|
||||
NEG = "NEG"
|
||||
PA3 = "PA3"
|
||||
TA = "TA"
|
||||
WLT = "WLT"
|
||||
|
||||
|
||||
class FiscalPaymentMapping(BigIntBase):
|
||||
__tablename__ = "fiscal_payment_mapping" # type: ignore[assignment]
|
||||
|
||||
|
||||
143
app/lib/sqlalchemy_plugin.py
Normal file
143
app/lib/sqlalchemy_plugin.py
Normal file
@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, cast, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import msgspec
|
||||
from litestar.contrib.sqlalchemy.plugins.init import SQLAlchemyInitPlugin
|
||||
from litestar.contrib.sqlalchemy.plugins.init.config import SQLAlchemyAsyncConfig
|
||||
from litestar.contrib.sqlalchemy.plugins.init.config.common import (
|
||||
SESSION_SCOPE_KEY,
|
||||
SESSION_TERMINUS_ASGI_EVENTS,
|
||||
)
|
||||
from litestar.utils import delete_litestar_scope_state, get_litestar_scope_state
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseSettings:
|
||||
URL: str = "postgresql+asyncpg://televend:televend@localhost:5433/televend"
|
||||
ECHO: bool = True
|
||||
ECHO_POOL: bool | Literal["debug"] = False
|
||||
POOL_DISABLE: bool = False
|
||||
POOL_MAX_OVERFLOW: int = 10
|
||||
POOL_SIZE: int = 5
|
||||
POOL_TIMEOUT: int = 30
|
||||
DB_SESSION_DEPENDENCY_KEY: str = "db_session"
|
||||
|
||||
|
||||
settings = DatabaseSettings()
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
from litestar.types.asgi_types import Message, Scope
|
||||
|
||||
__all__ = [
|
||||
"async_session_factory",
|
||||
"config",
|
||||
"engine",
|
||||
"plugin",
|
||||
]
|
||||
|
||||
|
||||
def _default(val: Any) -> str:
|
||||
if isinstance(val, UUID):
|
||||
return str(val)
|
||||
raise TypeError()
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.URL,
|
||||
echo=settings.ECHO,
|
||||
echo_pool=settings.ECHO_POOL,
|
||||
json_serializer=msgspec.json.Encoder(enc_hook=_default),
|
||||
max_overflow=settings.POOL_MAX_OVERFLOW,
|
||||
pool_size=settings.POOL_SIZE,
|
||||
pool_timeout=settings.POOL_TIMEOUT,
|
||||
poolclass=NullPool if settings.POOL_DISABLE else None,
|
||||
)
|
||||
"""Configure via DatabaseSettings.
|
||||
|
||||
Overrides default JSON
|
||||
serializer to use `msgspec`. See [`create_async_engine()`][sqlalchemy.ext.asyncio.create_async_engine]
|
||||
for detailed instructions.
|
||||
"""
|
||||
async_session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
"""Database session factory.
|
||||
|
||||
See [`async_sessionmaker()`][sqlalchemy.ext.asyncio.async_sessionmaker].
|
||||
"""
|
||||
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _sqla_on_connect(dbapi_connection: Any, _: Any) -> Any:
|
||||
"""Using orjson for serialization of the json column values means that the
|
||||
output is binary, not `str` like `json.dumps` would output.
|
||||
|
||||
SQLAlchemy expects that the json serializer returns `str` and calls
|
||||
`.encode()` on the value to turn it to bytes before writing to the
|
||||
JSONB column. I'd need to either wrap `orjson.dumps` to return a
|
||||
`str` so that SQLAlchemy could then convert it to binary, or do the
|
||||
following, which changes the behaviour of the dialect to expect a
|
||||
binary value from the serializer.
|
||||
|
||||
See Also:
|
||||
https://github.com/sqlalchemy/sqlalchemy/blob/14bfbadfdf9260a1c40f63b31641b27fe9de12a0/lib/sqlalchemy/dialects/postgresql/asyncpg.py#L934
|
||||
"""
|
||||
|
||||
def encoder(bin_value: bytes) -> bytes:
|
||||
# \x01 is the prefix for jsonb used by PostgreSQL.
|
||||
# asyncpg requires it when format='binary'
|
||||
return b"\x01" + bin_value
|
||||
|
||||
def decoder(bin_value: bytes) -> Any:
|
||||
# the byte is the \x01 prefix for jsonb used by PostgreSQL.
|
||||
# asyncpg returns it when format='binary'
|
||||
return msgspec.json.decode(bin_value[1:])
|
||||
|
||||
dbapi_connection.await_(
|
||||
dbapi_connection.driver_connection.set_type_codec(
|
||||
"jsonb",
|
||||
encoder=encoder,
|
||||
decoder=decoder,
|
||||
schema="pg_catalog",
|
||||
format="binary",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def before_send_handler(message: Message, scope: Scope) -> None:
|
||||
"""Custom `before_send_handler` for SQLAlchemy plugin that inspects the
|
||||
status of response and commits, or rolls back the database.
|
||||
|
||||
Args:
|
||||
message: ASGI message
|
||||
_:
|
||||
scope: ASGI scope
|
||||
"""
|
||||
session = cast("AsyncSession | None", get_litestar_scope_state(scope, SESSION_SCOPE_KEY))
|
||||
try:
|
||||
if session is not None and message["type"] == "http.response.start":
|
||||
if 200 <= message["status"] < 300:
|
||||
await session.commit()
|
||||
else:
|
||||
await session.rollback()
|
||||
finally:
|
||||
if session is not None and message["type"] in SESSION_TERMINUS_ASGI_EVENTS:
|
||||
await session.close()
|
||||
delete_litestar_scope_state(scope, SESSION_SCOPE_KEY)
|
||||
|
||||
|
||||
config = SQLAlchemyAsyncConfig(
|
||||
session_dependency_key=settings.DB_SESSION_DEPENDENCY_KEY,
|
||||
engine_instance=engine,
|
||||
session_maker=async_session_factory,
|
||||
before_send_handler=before_send_handler,
|
||||
)
|
||||
|
||||
plugin = SQLAlchemyInitPlugin(config=config)
|
||||
Reference in New Issue
Block a user