diff --git a/alembic/versions/202605120002_car_trims.py b/alembic/versions/202605120002_car_trims.py new file mode 100644 index 0000000..f8b465c --- /dev/null +++ b/alembic/versions/202605120002_car_trims.py @@ -0,0 +1,46 @@ +"""car trims + +Revision ID: 202605120002 +Revises: 202605120001 +Create Date: 2026-05-12 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "202605120002" +down_revision: str | None = "202605120001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("cars", sa.Column("trim", sa.String(length=120), nullable=True)) + op.create_table( + "car_trims", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("model_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("body_type", sa.String(length=60), nullable=True), + sa.Column("fuel_type", sa.String(length=32), nullable=True), + sa.Column("transmission", sa.String(length=32), nullable=True), + sa.Column("drive_type", sa.String(length=32), nullable=True), + sa.Column("year_from", sa.Integer(), nullable=True), + sa.Column("year_to", sa.Integer(), nullable=True), + sa.Column("market", sa.String(length=80), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["model_id"], ["car_models.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("model_id", "name", name="uq_car_trims_model_name"), + ) + op.create_index(op.f("ix_car_trims_model_id"), "car_trims", ["model_id"]) + op.create_index(op.f("ix_car_trims_name"), "car_trims", ["name"]) + + +def downgrade() -> None: + op.drop_index(op.f("ix_car_trims_name"), table_name="car_trims") + op.drop_index(op.f("ix_car_trims_model_id"), table_name="car_trims") + op.drop_table("car_trims") + op.drop_column("cars", "trim") diff --git a/app/api/catalog.py b/app/api/catalog.py index 3ca63a3..fd3ee2a 100644 --- a/app/api/catalog.py +++ b/app/api/catalog.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db.session import get_session -from app.models.car import CarMake +from app.models.car import CarMake, CarModel from app.schemas.car import CarMakeRead router = APIRouter(prefix="/catalog", tags=["catalog"]) @@ -13,9 +13,13 @@ router = APIRouter(prefix="/catalog", tags=["catalog"]) @router.get("/makes", response_model=list[CarMakeRead]) async def list_makes(session: AsyncSession = Depends(get_session)) -> list[CarMake]: result = await session.execute( - select(CarMake).options(selectinload(CarMake.models)).order_by(CarMake.name) + select(CarMake) + .options(selectinload(CarMake.models).selectinload(CarModel.trims)) + .order_by(CarMake.name) ) makes = list(result.scalars()) for make in makes: make.models.sort(key=lambda model: model.name) + for model in make.models: + model.trims.sort(key=lambda trim: trim.name) return makes diff --git a/app/db/seed.py b/app/db/seed.py index 8b1af5a..16e4f45 100644 --- a/app/db/seed.py +++ b/app/db/seed.py @@ -1,4 +1,5 @@ import asyncio +import argparse from datetime import date from decimal import Decimal @@ -7,25 +8,25 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db.session import async_session_factory -from app.models.car import Car, CarMake, CarModel +from app.models.car import Car, CarMake, CarModel, CarTrim from app.models.expense import FuelEntry, ServiceEntry, ServiceType from app.models.user import User -from app.services.catalog_data import CAR_CATALOG +from app.services.catalog_data import CAR_CATALOG, CAR_TRIMS, COMMON_TRIMS, MAKE_COUNTRIES MOCK_PLATE_PREFIX = "MOCK" MOCK_CARS = [ - ("KIA Sportage", "KIA", "Sportage", 2021, "gasoline", 36200, Decimal("2450000")), - ("Toyota Camry", "Toyota", "Camry", 2020, "gasoline", 58400, Decimal("2850000")), - ("Hyundai Tucson", "Hyundai", "Tucson", 2022, "gasoline", 27100, Decimal("2750000")), - ("Volkswagen Tiguan", "Volkswagen", "Tiguan", 2019, "gasoline", 73400, Decimal("2300000")), - ("BMW X3", "BMW", "X3", 2021, "diesel", 48900, Decimal("4350000")), - ("Mercedes GLC", "Mercedes", "GLC", 2020, "gasoline", 52200, Decimal("4500000")), - ("Nissan X-Trail", "Nissan", "X-Trail", 2018, "gasoline", 91400, Decimal("1850000")), - ("Skoda Octavia", "Skoda", "Octavia", 2021, "gasoline", 46800, Decimal("2050000")), - ("Tesla Model 3", "Tesla", "Model 3", 2022, "electric", 33800, Decimal("3900000")), - ("Haval Jolion", "Haval", "Jolion", 2023, "gasoline", 19600, Decimal("2150000")), + ("KIA Sportage", "KIA", "Sportage", "GT-Line 1.6T DCT AWD", 2021, "gasoline", 36200, Decimal("2450000")), + ("Toyota Camry", "Toyota", "Camry", "Comfort 2.5 AT", 2020, "gasoline", 58400, Decimal("2850000")), + ("Hyundai Tucson", "Hyundai", "Tucson", "Prestige 2.0 AT AWD", 2022, "gasoline", 27100, Decimal("2750000")), + ("Volkswagen Tiguan", "Volkswagen", "Tiguan", "Status 2.0 TSI DSG 4Motion", 2019, "gasoline", 73400, Decimal("2300000")), + ("BMW X3", "BMW", "X3", "20d xDrive", 2021, "diesel", 48900, Decimal("4350000")), + ("Mercedes GLC", "Mercedes", "GLC", "GLC 300 4MATIC AMG Line", 2020, "gasoline", 52200, Decimal("4500000")), + ("Nissan X-Trail", "Nissan", "X-Trail", "Premium", 2018, "gasoline", 91400, Decimal("1850000")), + ("Skoda Octavia", "Skoda", "Octavia", "Comfort", 2021, "gasoline", 46800, Decimal("2050000")), + ("Tesla Model 3", "Tesla", "Model 3", "Long Range AWD", 2022, "electric", 33800, Decimal("3900000")), + ("Haval Jolion", "Haval", "Jolion", "Premium", 2023, "gasoline", 19600, Decimal("2150000")), ] @@ -43,15 +44,40 @@ async def seed_catalog(session: AsyncSession) -> None: for make_name, model_names in CAR_CATALOG.items(): make = existing.get(make_name) if make is None: - make = CarMake(name=make_name) + make = CarMake(name=make_name, country=MAKE_COUNTRIES.get(make_name)) session.add(make) await session.flush() existing_models = set() else: + if not make.country and MAKE_COUNTRIES.get(make_name): + make.country = MAKE_COUNTRIES[make_name] existing_models = {model.name for model in make.models} for model_name in model_names: if model_name not in existing_models: session.add(CarModel(make_id=make.id, name=model_name)) + await session.flush() + await seed_trims(session) + + +async def seed_trims(session: AsyncSession) -> None: + result = await session.execute( + select(CarMake).options(selectinload(CarMake.models).selectinload(CarModel.trims)) + ) + makes = {make.name: make for make in result.scalars()} + for make in makes.values(): + for model in make.models: + trim_rows = CAR_TRIMS.get((make.name, model.name)) or [ + {**item, "body_type": infer_body_type(model.name)} for item in COMMON_TRIMS + ] + existing = {trim.name for trim in model.trims} + for row in trim_rows: + if row["name"] not in existing: + session.add(CarTrim(model_id=model.id, **row)) + + +def infer_body_type(model_name: str) -> str: + suv_markers = ("Q", "X", "CX", "CR-V", "RAV", "Tiguan", "Tucson", "Sportage", "Jolion") + return "SUV" if any(marker in model_name for marker in suv_markers) else "sedan" async def pick_owner(session: AsyncSession) -> User: @@ -82,12 +108,16 @@ async def seed_mock_usage(session: AsyncSession, owner: User) -> None: await clear_previous_mock(session) today = date.today() - for index, (name, make, model, year, fuel_type, start_odo, price) in enumerate(MOCK_CARS, start=1): + for index, (name, make, model, trim, year, fuel_type, start_odo, price) in enumerate( + MOCK_CARS, + start=1, + ): car = Car( owner_id=owner.id, name=name, make=make, model=model, + trim=trim, year=year, plate_number=f"{MOCK_PLATE_PREFIX}-{index:02d}", fuel_type=fuel_type, @@ -201,9 +231,13 @@ async def seed_mock_usage(session: AsyncSession, owner: User) -> None: car.current_odometer = odometer -async def main() -> None: +async def main(catalog_only: bool = False) -> None: async with async_session_factory() as session: await seed_catalog(session) + if catalog_only: + await session.commit() + print("Seeded vehicle catalog") + return owner = await pick_owner(session) await seed_mock_usage(session, owner) await session.commit() @@ -211,4 +245,7 @@ async def main() -> None: if __name__ == "__main__": - asyncio.run(main()) + parser = argparse.ArgumentParser() + parser.add_argument("--catalog-only", action="store_true") + args = parser.parse_args() + asyncio.run(main(catalog_only=args.catalog_only)) diff --git a/app/models/car.py b/app/models/car.py index 533c786..a7b6586 100644 --- a/app/models/car.py +++ b/app/models/car.py @@ -1,7 +1,7 @@ from datetime import date, datetime from decimal import Decimal -from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, UniqueConstraint, func +from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base @@ -15,6 +15,7 @@ class Car(Base): name: Mapped[str] = mapped_column(String(160)) make: Mapped[str | None] = mapped_column(String(80)) model: Mapped[str | None] = mapped_column(String(80)) + trim: Mapped[str | None] = mapped_column(String(120)) year: Mapped[int | None] plate_number: Mapped[str | None] = mapped_column(String(32)) vin: Mapped[str | None] = mapped_column(String(32)) @@ -53,3 +54,23 @@ class CarModel(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) make = relationship("CarMake", back_populates="models") + trims = relationship("CarTrim", back_populates="model", cascade="all, delete-orphan") + + +class CarTrim(Base): + __tablename__ = "car_trims" + __table_args__ = (UniqueConstraint("model_id", "name", name="uq_car_trims_model_name"),) + + id: Mapped[int] = mapped_column(primary_key=True) + model_id: Mapped[int] = mapped_column(ForeignKey("car_models.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(120), index=True) + body_type: Mapped[str | None] = mapped_column(String(60)) + fuel_type: Mapped[str | None] = mapped_column(String(32)) + transmission: Mapped[str | None] = mapped_column(String(32)) + drive_type: Mapped[str | None] = mapped_column(String(32)) + year_from: Mapped[int | None] = mapped_column(Integer) + year_to: Mapped[int | None] = mapped_column(Integer) + market: Mapped[str | None] = mapped_column(String(80)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + model = relationship("CarModel", back_populates="trims") diff --git a/app/schemas/car.py b/app/schemas/car.py index e580dbf..23d6856 100644 --- a/app/schemas/car.py +++ b/app/schemas/car.py @@ -8,6 +8,7 @@ class CarBase(BaseModel): name: str make: str | None = None model: str | None = None + trim: str | None = None year: int | None = None plate_number: str | None = None vin: str | None = None @@ -25,6 +26,7 @@ class CarUpdate(BaseModel): name: str | None = None make: str | None = None model: str | None = None + trim: str | None = None year: int | None = None plate_number: str | None = None vin: str | None = None @@ -42,9 +44,24 @@ class CarRead(CarBase): model_config = ConfigDict(from_attributes=True) +class CarTrimRead(BaseModel): + id: int + name: str + body_type: str | None = None + fuel_type: str | None = None + transmission: str | None = None + drive_type: str | None = None + year_from: int | None = None + year_to: int | None = None + market: str | None = None + + model_config = ConfigDict(from_attributes=True) + + class CarModelRead(BaseModel): id: int name: str + trims: list[CarTrimRead] = [] model_config = ConfigDict(from_attributes=True) diff --git a/app/services/catalog_data.py b/app/services/catalog_data.py index bf21da6..4ff3e2b 100644 --- a/app/services/catalog_data.py +++ b/app/services/catalog_data.py @@ -55,3 +55,103 @@ CAR_CATALOG: dict[str, list[str]] = { "Zeekr": ["001", "007", "009", "X"], "УАЗ": ["Буханка", "Патриот", "Пикап", "Хантер"], } + +MAKE_COUNTRIES: dict[str, str] = { + "BMW": "Germany", + "Mercedes": "Germany", + "Volkswagen": "Germany", + "Audi": "Germany", + "Porsche": "Germany", + "Skoda": "Czech Republic", + "Toyota": "Japan", + "Lexus": "Japan", + "Nissan": "Japan", + "Honda": "Japan", + "Mazda": "Japan", + "Subaru": "Japan", + "Mitsubishi": "Japan", + "Hyundai": "South Korea", + "KIA": "South Korea", + "Genesis": "South Korea", + "Tesla": "USA", + "Ford": "USA", + "Chevrolet": "USA", + "Haval": "China", + "Chery": "China", + "Geely": "China", + "BYD": "China", + "LADA": "Russia", + "УАЗ": "Russia", +} + +COMMON_TRIMS = [ + { + "name": "Base", + "body_type": None, + "fuel_type": "gasoline", + "transmission": "AT", + "drive_type": "FWD", + "market": "Global", + }, + { + "name": "Comfort", + "body_type": None, + "fuel_type": "gasoline", + "transmission": "AT", + "drive_type": "FWD", + "market": "Global", + }, + { + "name": "Premium", + "body_type": None, + "fuel_type": "gasoline", + "transmission": "AT", + "drive_type": "AWD", + "market": "Global", + }, +] + +CAR_TRIMS: dict[tuple[str, str], list[dict[str, str | int | None]]] = { + ("Toyota", "Camry"): [ + {"name": "Standard 2.0 AT", "body_type": "sedan", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "FWD", "year_from": 2018, "year_to": 2024, "market": "Global"}, + {"name": "Comfort 2.5 AT", "body_type": "sedan", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "FWD", "year_from": 2018, "year_to": 2024, "market": "Global"}, + {"name": "Prestige 2.5 AT", "body_type": "sedan", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "FWD", "year_from": 2018, "year_to": 2024, "market": "Global"}, + {"name": "Hybrid 2.5 e-CVT", "body_type": "sedan", "fuel_type": "hybrid", "transmission": "e-CVT", "drive_type": "FWD", "year_from": 2018, "year_to": 2025, "market": "Global"}, + ], + ("Toyota", "RAV4"): [ + {"name": "Comfort 2.0 CVT", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "CVT", "drive_type": "FWD", "year_from": 2019, "year_to": 2025, "market": "Global"}, + {"name": "Style 2.0 CVT AWD", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "CVT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"}, + {"name": "Hybrid 2.5 e-CVT AWD", "body_type": "SUV", "fuel_type": "hybrid", "transmission": "e-CVT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"}, + ], + ("KIA", "Sportage"): [ + {"name": "Classic 2.0 AT", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "FWD", "year_from": 2018, "year_to": 2025, "market": "Global"}, + {"name": "Comfort 2.0 AT AWD", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2018, "year_to": 2025, "market": "Global"}, + {"name": "Prestige 2.0 Diesel AWD", "body_type": "SUV", "fuel_type": "diesel", "transmission": "AT", "drive_type": "AWD", "year_from": 2018, "year_to": 2024, "market": "Global"}, + {"name": "GT-Line 1.6T DCT AWD", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "DCT", "drive_type": "AWD", "year_from": 2021, "year_to": 2025, "market": "Global"}, + ], + ("Hyundai", "Tucson"): [ + {"name": "Lifestyle 2.0 AT", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "FWD", "year_from": 2019, "year_to": 2025, "market": "Global"}, + {"name": "Prestige 2.0 AT AWD", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"}, + {"name": "Hybrid 1.6T AWD", "body_type": "SUV", "fuel_type": "hybrid", "transmission": "AT", "drive_type": "AWD", "year_from": 2021, "year_to": 2025, "market": "Global"}, + ], + ("Volkswagen", "Tiguan"): [ + {"name": "Respect 1.4 TSI DSG", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "DSG", "drive_type": "FWD", "year_from": 2017, "year_to": 2024, "market": "Global"}, + {"name": "Status 2.0 TSI DSG 4Motion", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "DSG", "drive_type": "AWD", "year_from": 2017, "year_to": 2024, "market": "Global"}, + {"name": "R-Line 2.0 TSI DSG 4Motion", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "DSG", "drive_type": "AWD", "year_from": 2017, "year_to": 2024, "market": "Global"}, + ], + ("BMW", "X3"): [ + {"name": "20i xDrive", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2018, "year_to": 2025, "market": "Global"}, + {"name": "20d xDrive", "body_type": "SUV", "fuel_type": "diesel", "transmission": "AT", "drive_type": "AWD", "year_from": 2018, "year_to": 2025, "market": "Global"}, + {"name": "30i xDrive M Sport", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2018, "year_to": 2025, "market": "Global"}, + ], + ("Mercedes", "GLC"): [ + {"name": "GLC 200 4MATIC", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"}, + {"name": "GLC 220d 4MATIC", "body_type": "SUV", "fuel_type": "diesel", "transmission": "AT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"}, + {"name": "GLC 300 4MATIC AMG Line", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"}, + ], + ("Tesla", "Model 3"): [ + {"name": "Rear-Wheel Drive", "body_type": "sedan", "fuel_type": "electric", "transmission": "single-speed", "drive_type": "RWD", "year_from": 2019, "year_to": 2026, "market": "Global"}, + {"name": "Long Range AWD", "body_type": "sedan", "fuel_type": "electric", "transmission": "single-speed", "drive_type": "AWD", "year_from": 2019, "year_to": 2026, "market": "Global"}, + {"name": "Performance AWD", "body_type": "sedan", "fuel_type": "electric", "transmission": "single-speed", "drive_type": "AWD", "year_from": 2019, "year_to": 2026, "market": "Global"}, + ], +} diff --git a/web/index.html b/web/index.html index d6c7958..75fbf03 100644 --- a/web/index.html +++ b/web/index.html @@ -266,10 +266,28 @@ Модель + +