fix: ruff lint + format fixes in tests, ESLint fixes in frontend
This commit is contained in:
@@ -12,6 +12,7 @@ from app.config import settings
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all SQLAlchemy models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""FastAPI dependencies: pagination, current user extraction, RBAC role enforcement.
|
||||
"""
|
||||
"""FastAPI dependencies: pagination, current user extraction, RBAC role enforcement."""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
@@ -35,7 +34,12 @@ async def get_current_user(
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "UNAUTHORIZED", "message": "Missing authentication token"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "Missing authentication token",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
@@ -43,14 +47,21 @@ async def get_current_user(
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid or expired access token"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "Invalid or expired access token",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
user_id_str = payload.get("sub")
|
||||
if not user_id_str:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "INVALID_TOKEN", "message": "Token missing subject"}},
|
||||
detail={
|
||||
"error": {"code": "INVALID_TOKEN", "message": "Token missing subject"}
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -58,7 +69,12 @@ async def get_current_user(
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid user ID in token"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "Invalid user ID in token",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_uuid)
|
||||
@@ -71,7 +87,12 @@ async def get_current_user(
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "ACCOUNT_DISABLED", "message": "Account is deactivated"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "ACCOUNT_DISABLED",
|
||||
"message": "Account is deactivated",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return user
|
||||
@@ -87,7 +108,9 @@ def require_role(allowed_roles: list[str]):
|
||||
"""
|
||||
|
||||
async def role_checker(user: User = Depends(get_current_user)) -> User:
|
||||
user_role = user.role.value if isinstance(user.role, UserRole) else str(user.role)
|
||||
user_role = (
|
||||
user.role.value if isinstance(user.role, UserRole) else str(user.role)
|
||||
)
|
||||
if user_role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
|
||||
+13
-1
@@ -9,7 +9,18 @@ from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, contacts, copilot, datev, files, image_retouch, ocr, sales, users, vehicles
|
||||
from app.routers import (
|
||||
auth,
|
||||
contacts,
|
||||
copilot,
|
||||
datev,
|
||||
files,
|
||||
image_retouch,
|
||||
ocr,
|
||||
sales,
|
||||
users,
|
||||
vehicles,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -49,6 +60,7 @@ api_v1_router.include_router(datev.router)
|
||||
api_v1_router.include_router(copilot.router)
|
||||
api_v1_router.include_router(image_retouch.router)
|
||||
|
||||
|
||||
# Health endpoint (no auth required)
|
||||
@api_v1_router.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
|
||||
@@ -56,46 +56,67 @@ class Contact(Base):
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
company_name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True,
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
legal_form: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
String(50),
|
||||
nullable=True,
|
||||
)
|
||||
address_street: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
String(255),
|
||||
nullable=True,
|
||||
)
|
||||
address_zip: Mapped[str | None] = mapped_column(
|
||||
String(10), nullable=True,
|
||||
String(10),
|
||||
nullable=True,
|
||||
)
|
||||
address_city: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
String(100),
|
||||
nullable=True,
|
||||
)
|
||||
address_country: Mapped[str] = mapped_column(
|
||||
String(2), nullable=False, default="DE", index=True,
|
||||
String(2),
|
||||
nullable=False,
|
||||
default="DE",
|
||||
index=True,
|
||||
)
|
||||
vat_id: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True,
|
||||
String(20),
|
||||
nullable=True,
|
||||
)
|
||||
phone: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
String(50),
|
||||
nullable=True,
|
||||
)
|
||||
email: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
String(255),
|
||||
nullable=True,
|
||||
)
|
||||
website: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
String(255),
|
||||
nullable=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, index=True,
|
||||
String(20),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
vat_id_status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="ungeprueft",
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="ungeprueft",
|
||||
)
|
||||
is_private: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False,
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
@@ -104,7 +125,9 @@ class Contact(Base):
|
||||
onupdate=func.now(),
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True,
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
contact_persons: Mapped[list["ContactPerson"]] = relationship(
|
||||
@@ -133,18 +156,10 @@ class Contact(Base):
|
||||
"role": self.role,
|
||||
"vat_id_status": self.vat_id_status,
|
||||
"is_private": self.is_private,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
"deleted_at": (
|
||||
self.deleted_at.isoformat() if self.deleted_at else None
|
||||
),
|
||||
"contact_persons": [
|
||||
p.to_dict() for p in (self.contact_persons or [])
|
||||
],
|
||||
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||
"updated_at": (self.updated_at.isoformat() if self.updated_at else None),
|
||||
"deleted_at": (self.deleted_at.isoformat() if self.deleted_at else None),
|
||||
"contact_persons": [p.to_dict() for p in (self.contact_persons or [])],
|
||||
}
|
||||
|
||||
|
||||
@@ -165,19 +180,25 @@ class ContactPerson(Base):
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False,
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
function: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
String(100),
|
||||
nullable=True,
|
||||
)
|
||||
phone: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
String(50),
|
||||
nullable=True,
|
||||
)
|
||||
email: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
String(255),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
contact: Mapped["Contact"] = relationship(back_populates="contact_persons")
|
||||
@@ -194,7 +215,5 @@ class ContactPerson(Base):
|
||||
"function": self.function,
|
||||
"phone": self.phone,
|
||||
"email": self.email,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.database import Base
|
||||
|
||||
class CopilotRole(str, enum.Enum):
|
||||
"""Roles for chat messages."""
|
||||
|
||||
user = "user"
|
||||
assistant = "assistant"
|
||||
|
||||
@@ -37,10 +38,14 @@ class CopilotSession(Base):
|
||||
index=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, default="Neue Konversation",
|
||||
String(255),
|
||||
nullable=False,
|
||||
default="Neue Konversation",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
@@ -57,7 +62,9 @@ class CopilotSession(Base):
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CopilotSession id={self.id} user_id={self.user_id} title={self.title}>"
|
||||
return (
|
||||
f"<CopilotSession id={self.id} user_id={self.user_id} title={self.title}>"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize session for API responses."""
|
||||
@@ -99,16 +106,22 @@ class CopilotChat(Base):
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
actions: Mapped[list | None] = mapped_column(
|
||||
JSONB, nullable=True, default=None,
|
||||
JSONB,
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
session: Mapped["CopilotSession"] = relationship(back_populates="messages")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CopilotChat id={self.id} role={self.role} session_id={self.session_id}>"
|
||||
return (
|
||||
f"<CopilotChat id={self.id} role={self.role} session_id={self.session_id}>"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize chat message for API responses."""
|
||||
@@ -116,7 +129,9 @@ class CopilotChat(Base):
|
||||
"id": str(self.id),
|
||||
"session_id": str(self.session_id),
|
||||
"user_id": str(self.user_id),
|
||||
"role": self.role.value if isinstance(self.role, CopilotRole) else self.role,
|
||||
"role": self.role.value
|
||||
if isinstance(self.role, CopilotRole)
|
||||
else self.role,
|
||||
"content": self.content,
|
||||
"actions": self.actions,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
|
||||
@@ -23,9 +23,7 @@ class DATEVExport(Base):
|
||||
)
|
||||
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
end_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
file_path: Mapped[str | None] = mapped_column(
|
||||
String(500), nullable=True
|
||||
)
|
||||
file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
total_amount: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, default=0
|
||||
)
|
||||
|
||||
@@ -30,24 +30,12 @@ class File(Base):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
original_filename: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False
|
||||
)
|
||||
stored_filename: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False
|
||||
)
|
||||
file_path: Mapped[str] = mapped_column(
|
||||
String(512), nullable=False
|
||||
)
|
||||
mime_type: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False
|
||||
)
|
||||
file_size: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False
|
||||
)
|
||||
thumbnail_path: Mapped[str | None] = mapped_column(
|
||||
String(512), nullable=True
|
||||
)
|
||||
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
stored_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
thumbnail_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
@@ -60,9 +48,7 @@ class File(Base):
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
vehicle: Mapped["Vehicle"] = relationship(
|
||||
"Vehicle", back_populates="files"
|
||||
)
|
||||
vehicle: Mapped["Vehicle"] = relationship("Vehicle", back_populates="files")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<File id={self.id} vehicle_id={self.vehicle_id} filename={self.original_filename}>"
|
||||
@@ -78,10 +64,6 @@ class File(Base):
|
||||
"mime_type": self.mime_type,
|
||||
"file_size": self.file_size,
|
||||
"thumbnail_path": self.thumbnail_path,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||
"updated_at": (self.updated_at.isoformat() if self.updated_at else None),
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ class OCRResult(Base):
|
||||
)
|
||||
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
mime_type: Mapped[str] = mapped_column(String(100), nullable=False, default="image/png")
|
||||
mime_type: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, default="image/png"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum(OCRStatus, name="ocr_status", create_constraint=True),
|
||||
nullable=False,
|
||||
@@ -80,7 +82,9 @@ class OCRResult(Base):
|
||||
"file_path": self.file_path,
|
||||
"file_name": self.file_name,
|
||||
"mime_type": self.mime_type,
|
||||
"status": self.status.value if isinstance(self.status, OCRStatus) else str(self.status),
|
||||
"status": self.status.value
|
||||
if isinstance(self.status, OCRStatus)
|
||||
else str(self.status),
|
||||
"raw_text": self.raw_text,
|
||||
"structured_data": self.structured_data,
|
||||
"confidence_score": self.confidence_score,
|
||||
|
||||
@@ -38,24 +38,14 @@ class RetouchResult(Base):
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
original_file_path: Mapped[str] = mapped_column(
|
||||
String(512), nullable=False
|
||||
)
|
||||
original_file_name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False
|
||||
)
|
||||
original_file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
original_file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
mime_type: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, default="image/png"
|
||||
)
|
||||
retouched_file_path: Mapped[str | None] = mapped_column(
|
||||
String(512), nullable=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending"
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
)
|
||||
retouched_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
@@ -84,10 +74,6 @@ class RetouchResult(Base):
|
||||
"retouched_file_path": self.retouched_file_path,
|
||||
"status": self.status,
|
||||
"error_message": self.error_message,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||
"updated_at": (self.updated_at.isoformat() if self.updated_at else None),
|
||||
}
|
||||
|
||||
@@ -66,21 +66,13 @@ class Sale(Base):
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
sale_price: Mapped[Decimal] = mapped_column(
|
||||
Numeric(12, 2), nullable=False
|
||||
)
|
||||
sale_price: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
sale_date: Mapped[date] = mapped_column(
|
||||
Date, nullable=False, default=func.current_date()
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="draft"
|
||||
)
|
||||
is_gwg: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
contract_pdf_path: Mapped[str | None] = mapped_column(
|
||||
String(500), nullable=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
|
||||
is_gwg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
contract_pdf_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -115,7 +107,9 @@ class Sale(Base):
|
||||
"seller_contact_id": (
|
||||
str(self.seller_contact_id) if self.seller_contact_id else None
|
||||
),
|
||||
"sale_price": float(self.sale_price) if self.sale_price is not None else None,
|
||||
"sale_price": float(self.sale_price)
|
||||
if self.sale_price is not None
|
||||
else None,
|
||||
"sale_date": self.sale_date.isoformat() if self.sale_date else None,
|
||||
"status": self.status,
|
||||
"is_gwg": self.is_gwg,
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.database import Base
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
"""User roles for RBAC."""
|
||||
|
||||
admin = "admin"
|
||||
verkaeufer = "verkaeufer"
|
||||
buchhaltung = "buchhaltung"
|
||||
@@ -29,13 +30,18 @@ class User(Base):
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True,
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False,
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
full_name: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False,
|
||||
String(200),
|
||||
nullable=False,
|
||||
)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
Enum(UserRole, name="user_role"),
|
||||
@@ -43,10 +49,14 @@ class User(Base):
|
||||
default=UserRole.verkaeufer,
|
||||
)
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(5), nullable=False, default="de",
|
||||
String(5),
|
||||
nullable=False,
|
||||
default="de",
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True,
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
|
||||
@@ -56,12 +56,8 @@ class Vehicle(Base):
|
||||
|
||||
__tablename__ = "vehicles"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"char_length(fin) = 17", name="ck_vehicles_fin_length"
|
||||
),
|
||||
CheckConstraint(
|
||||
"condition IN ('new', 'used')", name="ck_vehicles_condition"
|
||||
),
|
||||
CheckConstraint("char_length(fin) = 17", name="ck_vehicles_fin_length"),
|
||||
CheckConstraint("condition IN ('new', 'used')", name="ck_vehicles_condition"),
|
||||
CheckConstraint(
|
||||
"availability IN ('available', 'reserved', 'sold')",
|
||||
name="ck_vehicles_availability",
|
||||
@@ -87,24 +83,18 @@ class Vehicle(Base):
|
||||
String(17), unique=True, nullable=False, index=True
|
||||
)
|
||||
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
first_registration: Mapped[date | None] = mapped_column(
|
||||
Date, nullable=True
|
||||
)
|
||||
first_registration: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
power_kw: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
power_hp: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fuel_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
transmission: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
color: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
condition: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="used"
|
||||
)
|
||||
condition: Mapped[str] = mapped_column(String(20), nullable=False, default="used")
|
||||
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
availability: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="available"
|
||||
)
|
||||
price: Mapped[Decimal] = mapped_column(
|
||||
Numeric(12, 2), nullable=False
|
||||
)
|
||||
price: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
vehicle_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
lkw_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
machine_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
@@ -112,9 +102,7 @@ class Vehicle(Base):
|
||||
operating_hours: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(12, 1), nullable=True
|
||||
)
|
||||
operating_hours_unit: Mapped[str | None] = mapped_column(
|
||||
String(5), nullable=True
|
||||
)
|
||||
operating_hours_unit: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
||||
mileage_km: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
@@ -149,9 +137,7 @@ class Vehicle(Base):
|
||||
"fin": self.fin,
|
||||
"year": self.year,
|
||||
"first_registration": (
|
||||
self.first_registration.isoformat()
|
||||
if self.first_registration
|
||||
else None
|
||||
self.first_registration.isoformat() if self.first_registration else None
|
||||
),
|
||||
"power_kw": self.power_kw,
|
||||
"power_hp": self.power_hp,
|
||||
@@ -174,15 +160,9 @@ class Vehicle(Base):
|
||||
"operating_hours_unit": self.operating_hours_unit,
|
||||
"mileage_km": self.mileage_km,
|
||||
"description": self.description,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
"deleted_at": (
|
||||
self.deleted_at.isoformat() if self.deleted_at else None
|
||||
),
|
||||
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||
"updated_at": (self.updated_at.isoformat() if self.updated_at else None),
|
||||
"deleted_at": (self.deleted_at.isoformat() if self.deleted_at else None),
|
||||
}
|
||||
|
||||
|
||||
@@ -232,14 +212,8 @@ class MobileDeListing(Base):
|
||||
"vehicle_id": str(self.vehicle_id),
|
||||
"ad_id": self.ad_id,
|
||||
"sync_status": self.sync_status,
|
||||
"synced_at": (
|
||||
self.synced_at.isoformat() if self.synced_at else None
|
||||
),
|
||||
"synced_at": (self.synced_at.isoformat() if self.synced_at else None),
|
||||
"error_log": self.error_log,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||
"updated_at": (self.updated_at.isoformat() if self.updated_at else None),
|
||||
}
|
||||
|
||||
@@ -25,8 +25,12 @@ router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
async def list_contacts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
search: str | None = Query(None, description="Search in company_name, city, email, vat_id"),
|
||||
role: str | None = Query(None, description="Filter by role (kaeufer, verkaeufer, beide)"),
|
||||
search: str | None = Query(
|
||||
None, description="Search in company_name, city, email, vat_id"
|
||||
),
|
||||
role: str | None = Query(
|
||||
None, description="Filter by role (kaeufer, verkaeufer, beide)"
|
||||
),
|
||||
is_eu: bool | None = Query(None, description="Filter EU (true) or Inland (false)"),
|
||||
is_private: bool | None = Query(None, description="Filter private contacts"),
|
||||
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
|
||||
@@ -66,7 +70,9 @@ async def create_contact(
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.get("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
|
||||
@router.get(
|
||||
"/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def get_contact(
|
||||
contact_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -77,12 +83,16 @@ async def get_contact(
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
detail={
|
||||
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
|
||||
},
|
||||
)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.put("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
|
||||
@router.put(
|
||||
"/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def update_contact(
|
||||
contact_id: uuid.UUID,
|
||||
body: ContactUpdate,
|
||||
@@ -100,12 +110,16 @@ async def update_contact(
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
detail={
|
||||
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
|
||||
},
|
||||
)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.delete("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
|
||||
@router.delete(
|
||||
"/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def delete_contact(
|
||||
contact_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -116,7 +130,9 @@ async def delete_contact(
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
detail={
|
||||
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
|
||||
},
|
||||
)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
@@ -139,7 +155,9 @@ async def add_contact_person(
|
||||
if person is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
detail={
|
||||
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
|
||||
},
|
||||
)
|
||||
return ContactPersonResponse.model_validate(person)
|
||||
|
||||
@@ -159,6 +177,11 @@ async def remove_contact_person(
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "PERSON_NOT_FOUND", "message": "Contact person not found"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "PERSON_NOT_FOUND",
|
||||
"message": "Contact person not found",
|
||||
}
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -68,7 +68,9 @@ async def copilot_action(
|
||||
return ActionResponse(**result)
|
||||
|
||||
|
||||
@router.get("/history", response_model=ChatHistoryResponse, status_code=status.HTTP_200_OK)
|
||||
@router.get(
|
||||
"/history", response_model=ChatHistoryResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def copilot_history(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
|
||||
@@ -19,7 +19,9 @@ from app.services import datev_service
|
||||
router = APIRouter(prefix="/datev", tags=["datev"])
|
||||
|
||||
|
||||
@router.post("/export", response_model=DATEVExportResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post(
|
||||
"/export", response_model=DATEVExportResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_export(
|
||||
body: DATEVExportCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -37,7 +39,9 @@ async def create_export(
|
||||
return DATEVExportResponse.model_validate(export)
|
||||
|
||||
|
||||
@router.get("/exports", response_model=DATEVExportListResponse, status_code=status.HTTP_200_OK)
|
||||
@router.get(
|
||||
"/exports", response_model=DATEVExportListResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def list_exports(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
@@ -68,7 +72,12 @@ async def download_export(
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "EXPORT_NOT_FOUND", "message": "DATEV export not found"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "EXPORT_NOT_FOUND",
|
||||
"message": "DATEV export not found",
|
||||
}
|
||||
},
|
||||
)
|
||||
filename, csv_bytes = result
|
||||
return Response(
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File as FastAPIFile, HTTPException, UploadFile, status
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File as FastAPIFile,
|
||||
HTTPException,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import FileResponse as FastAPIFileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -22,9 +29,7 @@ from app.services import file_service, vehicle_service
|
||||
router = APIRouter(prefix="/vehicles", tags=["files"])
|
||||
|
||||
|
||||
async def _verify_vehicle_exists(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> Vehicle:
|
||||
async def _verify_vehicle_exists(db: AsyncSession, vehicle_id: uuid.UUID) -> Vehicle:
|
||||
"""Verify that a vehicle exists, raising 404 if not."""
|
||||
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -79,7 +88,12 @@ async def process_image(
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"error": {"code": "INVALID_VEHICLE_ID", "message": "Invalid vehicle UUID"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_VEHICLE_ID",
|
||||
"message": "Invalid vehicle UUID",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Fetch vehicle info for better retouch prompt
|
||||
@@ -112,7 +126,9 @@ async def process_image(
|
||||
return RetouchProcessResponse(
|
||||
message="Retouch processing queued",
|
||||
retouch_id=result.id,
|
||||
status=result.status.value if isinstance(result.status, RetouchStatus) else str(result.status),
|
||||
status=result.status.value
|
||||
if isinstance(result.status, RetouchStatus)
|
||||
else str(result.status),
|
||||
)
|
||||
|
||||
|
||||
@@ -136,7 +152,12 @@ async def get_retouch_result(
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "RETOUCH_NOT_FOUND", "message": "Retouch result not found"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "RETOUCH_NOT_FOUND",
|
||||
"message": "Retouch result not found",
|
||||
}
|
||||
},
|
||||
)
|
||||
return RetouchResultResponse.model_validate(result)
|
||||
|
||||
|
||||
@@ -2,7 +2,17 @@
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Query,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -78,7 +88,12 @@ async def upload_scan(
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"error": {"code": "INVALID_VEHICLE_ID", "message": "Invalid vehicle UUID"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_VEHICLE_ID",
|
||||
"message": "Invalid vehicle UUID",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -101,7 +116,9 @@ async def upload_scan(
|
||||
return OCRUploadResponse(
|
||||
message="OCR processing queued",
|
||||
ocr_result_id=ocr_result.id,
|
||||
status=ocr_result.status.value if isinstance(ocr_result.status, OCRStatus) else str(ocr_result.status),
|
||||
status=ocr_result.status.value
|
||||
if isinstance(ocr_result.status, OCRStatus)
|
||||
else str(ocr_result.status),
|
||||
)
|
||||
|
||||
|
||||
@@ -120,7 +137,9 @@ async def get_ocr_result(
|
||||
if ocr_result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "OCR_NOT_FOUND", "message": "OCR result not found"}},
|
||||
detail={
|
||||
"error": {"code": "OCR_NOT_FOUND", "message": "OCR result not found"}
|
||||
},
|
||||
)
|
||||
return OCRResultResponse.model_validate(ocr_result)
|
||||
|
||||
@@ -164,7 +183,9 @@ async def apply_ocr_to_vehicle(
|
||||
):
|
||||
"""Apply OCR structured data to the linked vehicle."""
|
||||
try:
|
||||
ocr_result, vehicle, updated_fields = await ocr_service.apply_to_vehicle(db, result_id)
|
||||
ocr_result, vehicle, updated_fields = await ocr_service.apply_to_vehicle(
|
||||
db, result_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -27,9 +27,15 @@ router = APIRouter(prefix="/sales", tags=["sales"])
|
||||
async def list_sales(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
status_filter: str | None = Query(None, alias="status", description="Filter by sale status"),
|
||||
date_from: str | None = Query(None, description="Filter sales from this date (YYYY-MM-DD)"),
|
||||
date_to: str | None = Query(None, description="Filter sales up to this date (YYYY-MM-DD)"),
|
||||
status_filter: str | None = Query(
|
||||
None, alias="status", description="Filter by sale status"
|
||||
),
|
||||
date_from: str | None = Query(
|
||||
None, description="Filter sales from this date (YYYY-MM-DD)"
|
||||
),
|
||||
date_to: str | None = Query(
|
||||
None, description="Filter sales up to this date (YYYY-MM-DD)"
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List sales with pagination, filtering by status and date range."""
|
||||
@@ -43,7 +49,12 @@ async def list_sales(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"error": {"code": "INVALID_DATE", "message": f"Invalid date_from format: {date_from}"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_DATE",
|
||||
"message": f"Invalid date_from format: {date_from}",
|
||||
}
|
||||
},
|
||||
)
|
||||
if date_to:
|
||||
try:
|
||||
@@ -51,7 +62,12 @@ async def list_sales(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"error": {"code": "INVALID_DATE", "message": f"Invalid date_to format: {date_to}"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_DATE",
|
||||
"message": f"Invalid date_to format: {date_to}",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
sales, total = await sale_service.list_sales(
|
||||
@@ -127,7 +143,9 @@ async def update_sale(
|
||||
return SaleResponse.model_validate(sale)
|
||||
|
||||
|
||||
@router.delete("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
|
||||
@router.delete(
|
||||
"/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def delete_sale(
|
||||
sale_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -143,7 +161,11 @@ async def delete_sale(
|
||||
return SaleResponse.model_validate(sale)
|
||||
|
||||
|
||||
@router.post("/{sale_id}/contract", response_model=ContractResponse, status_code=status.HTTP_200_OK)
|
||||
@router.post(
|
||||
"/{sale_id}/contract",
|
||||
response_model=ContractResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def regenerate_contract(
|
||||
sale_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -179,7 +201,12 @@ async def download_contract(
|
||||
if not sale.contract_pdf_path or not os.path.exists(sale.contract_pdf_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTRACT_NOT_FOUND", "message": "Contract PDF not generated yet"}},
|
||||
detail={
|
||||
"error": {
|
||||
"code": "CONTRACT_NOT_FOUND",
|
||||
"message": "Contract PDF not generated yet",
|
||||
}
|
||||
},
|
||||
)
|
||||
return FileResponse(
|
||||
path=sale.contract_pdf_path,
|
||||
@@ -188,7 +215,11 @@ async def download_contract(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{sale_id}/verify-ust-id", response_model=UstIdVerifyResponse, status_code=status.HTTP_200_OK)
|
||||
@router.post(
|
||||
"/{sale_id}/verify-ust-id",
|
||||
response_model=UstIdVerifyResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def verify_ust_id(
|
||||
sale_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -89,7 +89,9 @@ async def update_user(
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK)
|
||||
@router.delete(
|
||||
"/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -30,7 +30,9 @@ async def list_vehicles(
|
||||
availability: str | None = Query(None, description="Filter by availability"),
|
||||
min_price: float | None = Query(None, ge=0, description="Minimum price"),
|
||||
max_price: float | None = Query(None, ge=0, description="Maximum price"),
|
||||
search: str | None = Query(None, description="Search in make, model, fin, location"),
|
||||
search: str | None = Query(
|
||||
None, description="Search in make, model, fin, location"
|
||||
),
|
||||
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -72,7 +74,9 @@ async def create_vehicle(
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.get("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
@router.get(
|
||||
"/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def get_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -83,12 +87,16 @@ async def get_vehicle(
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
detail={
|
||||
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
|
||||
},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.put("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
@router.put(
|
||||
"/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def update_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
body: VehicleUpdate,
|
||||
@@ -112,12 +120,16 @@ async def update_vehicle(
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
detail={
|
||||
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
|
||||
},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.delete("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
@router.delete(
|
||||
"/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def delete_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -128,7 +140,9 @@ async def delete_vehicle(
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
detail={
|
||||
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
|
||||
},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
@@ -148,7 +162,9 @@ async def push_to_mobile_de(
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
detail={
|
||||
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
|
||||
},
|
||||
)
|
||||
|
||||
listing = await mobilede_service.push_listing(db, vehicle)
|
||||
@@ -176,7 +192,9 @@ async def get_mobile_de_status(
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
detail={
|
||||
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
|
||||
},
|
||||
)
|
||||
|
||||
listing = await mobilede_service.get_listing_status(db, vehicle_id)
|
||||
|
||||
@@ -24,6 +24,7 @@ class ContactPersonBase(BaseModel):
|
||||
|
||||
class ContactPersonCreate(ContactPersonBase):
|
||||
"""POST /api/v1/contacts/:id/persons request body."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -10,14 +10,18 @@ class ChatRequest(BaseModel):
|
||||
"""Request body for POST /copilot/chat."""
|
||||
|
||||
message: str = Field(..., min_length=1, description="User message to the Copilot")
|
||||
session_id: Optional[str] = Field(None, description="Existing session UUID to continue")
|
||||
session_id: Optional[str] = Field(
|
||||
None, description="Existing session UUID to continue"
|
||||
)
|
||||
|
||||
|
||||
class ActionItem(BaseModel):
|
||||
"""A single action proposed by the Copilot."""
|
||||
|
||||
type: str = Field(..., description="Action type, e.g. search_vehicles")
|
||||
params: dict[str, Any] = Field(default_factory=dict, description="Action parameters")
|
||||
params: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Action parameters"
|
||||
)
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
@@ -26,7 +30,9 @@ class ChatResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
response: str = Field(..., description="Assistant text response")
|
||||
actions: list[ActionItem] = Field(default_factory=list, description="Proposed actions")
|
||||
actions: list[ActionItem] = Field(
|
||||
default_factory=list, description="Proposed actions"
|
||||
)
|
||||
session_id: str = Field(..., description="Session UUID")
|
||||
message_id: str = Field(..., description="Assistant message UUID")
|
||||
|
||||
@@ -35,7 +41,9 @@ class ActionRequest(BaseModel):
|
||||
"""Request body for POST /copilot/action — user confirms an action."""
|
||||
|
||||
action: str = Field(..., min_length=1, description="Action type to execute")
|
||||
params: dict[str, Any] = Field(default_factory=dict, description="Action parameters")
|
||||
params: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Action parameters"
|
||||
)
|
||||
session_id: Optional[str] = Field(None, description="Session context")
|
||||
|
||||
|
||||
@@ -89,6 +97,8 @@ class VoiceResponse(BaseModel):
|
||||
|
||||
transcription: str = Field(..., description="Transcribed text")
|
||||
response: str = Field(..., description="Assistant text response")
|
||||
actions: list[ActionItem] = Field(default_factory=list, description="Proposed actions")
|
||||
actions: list[ActionItem] = Field(
|
||||
default_factory=list, description="Proposed actions"
|
||||
)
|
||||
session_id: str = Field(..., description="Session UUID")
|
||||
message_id: str = Field(..., description="Assistant message UUID")
|
||||
|
||||
@@ -9,12 +9,14 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""POST /api/v1/auth/login request body."""
|
||||
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""JWT token pair returned after login or refresh."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
@@ -23,11 +25,13 @@ class TokenResponse(BaseModel):
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
"""POST /api/v1/auth/refresh request body."""
|
||||
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user fields shared across schemas."""
|
||||
|
||||
email: EmailStr
|
||||
full_name: str = Field(..., min_length=1, max_length=200)
|
||||
role: Literal["admin", "verkaeufer", "buchhaltung"] = "verkaeufer"
|
||||
@@ -36,11 +40,13 @@ class UserBase(BaseModel):
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""POST /api/v1/users request body."""
|
||||
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""PUT /api/v1/users/:id request body (all fields optional)."""
|
||||
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
role: Optional[Literal["admin", "verkaeufer", "buchhaltung"]] = None
|
||||
@@ -50,6 +56,7 @@ class UserUpdate(BaseModel):
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User response schema (never exposes password_hash)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
@@ -64,6 +71,7 @@ class UserResponse(BaseModel):
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""Paginated user list response."""
|
||||
|
||||
items: list[UserResponse]
|
||||
total: int
|
||||
page: int
|
||||
@@ -72,9 +80,11 @@ class UserListResponse(BaseModel):
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard error response format."""
|
||||
|
||||
error: dict
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response."""
|
||||
|
||||
status: str = "ok"
|
||||
|
||||
@@ -50,6 +50,7 @@ class VehicleBase(BaseModel):
|
||||
|
||||
class VehicleCreate(VehicleBase):
|
||||
"""POST /api/v1/vehicles request body."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ async def get_user_by_id(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def authenticate_user(db: AsyncSession, email: str, password: str) -> Optional[User]:
|
||||
async def authenticate_user(
|
||||
db: AsyncSession, email: str, password: str
|
||||
) -> Optional[User]:
|
||||
"""Authenticate a user by email and password."""
|
||||
user = await get_user_by_email(db, email)
|
||||
if user is None:
|
||||
@@ -67,6 +69,7 @@ def generate_token_pair(user: User) -> dict:
|
||||
lang=user.language,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
@@ -135,10 +138,7 @@ async def list_users(
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
select(User).order_by(User.created_at.desc()).offset(offset).limit(page_size)
|
||||
)
|
||||
users = list(result.scalars().all())
|
||||
return users, total
|
||||
|
||||
@@ -137,24 +137,18 @@ async def list_contacts(
|
||||
return contacts, total
|
||||
|
||||
|
||||
async def get_contact_by_id(
|
||||
db: AsyncSession, contact_id: uuid.UUID
|
||||
) -> Contact | None:
|
||||
async def get_contact_by_id(db: AsyncSession, contact_id: uuid.UUID) -> Contact | None:
|
||||
"""Get a single contact by ID, excluding soft-deleted. Eager-loads contact persons."""
|
||||
stmt = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(
|
||||
and_(Contact.id == contact_id, Contact.deleted_at.is_(None))
|
||||
)
|
||||
.where(and_(Contact.id == contact_id, Contact.deleted_at.is_(None)))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_contact(
|
||||
db: AsyncSession, data: dict[str, Any]
|
||||
) -> Contact:
|
||||
async def create_contact(db: AsyncSession, data: dict[str, Any]) -> Contact:
|
||||
"""Create a new contact with optional nested contact persons.
|
||||
|
||||
The data dict may contain a 'contact_persons' list of dicts.
|
||||
|
||||
@@ -74,10 +74,12 @@ def _parse_ai_response(raw_content: str) -> dict[str, Any]:
|
||||
valid_actions = []
|
||||
for action in actions:
|
||||
if isinstance(action, dict) and "type" in action:
|
||||
valid_actions.append({
|
||||
"type": action["type"],
|
||||
"params": action.get("params", {}),
|
||||
})
|
||||
valid_actions.append(
|
||||
{
|
||||
"type": action["type"],
|
||||
"params": action.get("params", {}),
|
||||
}
|
||||
)
|
||||
|
||||
return {"response": response_text, "actions": valid_actions}
|
||||
|
||||
@@ -172,7 +174,9 @@ async def chat(
|
||||
6. Save the assistant message
|
||||
7. Return response with actions and IDs
|
||||
"""
|
||||
session = await _get_or_create_session(db, user_id, session_id, first_message=message)
|
||||
session = await _get_or_create_session(
|
||||
db, user_id, session_id, first_message=message
|
||||
)
|
||||
|
||||
# Save user message
|
||||
user_msg = CopilotChat(
|
||||
|
||||
@@ -60,9 +60,7 @@ async def create_export(
|
||||
csv_content = generate_datev_csv(sales)
|
||||
|
||||
# Calculate total amount
|
||||
total_amount = sum(
|
||||
(s.sale_price or Decimal("0")) for s in sales
|
||||
)
|
||||
total_amount = sum((s.sale_price or Decimal("0")) for s in sales)
|
||||
|
||||
# Save CSV file
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
@@ -111,7 +109,9 @@ async def list_exports(
|
||||
return exports, total
|
||||
|
||||
|
||||
async def get_export_csv(db: AsyncSession, export_id: uuid.UUID) -> tuple[str, bytes] | None:
|
||||
async def get_export_csv(
|
||||
db: AsyncSession, export_id: uuid.UUID
|
||||
) -> tuple[str, bytes] | None:
|
||||
"""Get the CSV content for a DATEV export.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -98,7 +98,9 @@ async def upload_file(
|
||||
"""
|
||||
# Validate MIME type
|
||||
if not validate_mime_type(mime_type, original_filename):
|
||||
raise ValueError(f"Unsupported MIME type: {mime_type} for file: {original_filename}")
|
||||
raise ValueError(
|
||||
f"Unsupported MIME type: {mime_type} for file: {original_filename}"
|
||||
)
|
||||
|
||||
# Validate file size (20MB limit for uploads)
|
||||
file_size = len(file_content)
|
||||
@@ -173,9 +175,7 @@ async def list_files(
|
||||
Returns (files, total_count).
|
||||
"""
|
||||
# Count total files for this vehicle
|
||||
count_stmt = select(func.count(File.id)).where(
|
||||
File.vehicle_id == vehicle_id
|
||||
)
|
||||
count_stmt = select(func.count(File.id)).where(File.vehicle_id == vehicle_id)
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
|
||||
@@ -48,9 +48,7 @@ def _get_status_url(listing_id: str) -> str:
|
||||
return f"{_MOBILE_DE_API_BASE}/api/seller/listings/{listing_id}/status"
|
||||
|
||||
|
||||
async def push_listing(
|
||||
db: AsyncSession, vehicle: Vehicle
|
||||
) -> MobileDeListing:
|
||||
async def push_listing(db: AsyncSession, vehicle: Vehicle) -> MobileDeListing:
|
||||
"""Push a vehicle listing to mobile.de.
|
||||
|
||||
Creates a MobileDeListing record with status 'pending',
|
||||
@@ -150,9 +148,7 @@ async def update_listing(
|
||||
return listing
|
||||
|
||||
|
||||
async def delete_listing(
|
||||
db: AsyncSession, listing: MobileDeListing
|
||||
) -> MobileDeListing:
|
||||
async def delete_listing(db: AsyncSession, listing: MobileDeListing) -> MobileDeListing:
|
||||
"""Delete a listing from mobile.de.
|
||||
|
||||
Sends DELETE /api/seller/listings/{id}.
|
||||
@@ -228,8 +224,7 @@ async def retry_failed_listing(
|
||||
if retry_count >= MAX_RETRIES:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"Max retries ({MAX_RETRIES}) exceeded. "
|
||||
f"Last error: {listing.error_log}"
|
||||
f"Max retries ({MAX_RETRIES}) exceeded. Last error: {listing.error_log}"
|
||||
)
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
|
||||
@@ -46,12 +46,12 @@ async def upload_file(
|
||||
Validates MIME type and file size before saving.
|
||||
"""
|
||||
if not validate_mime_type(mime_type):
|
||||
raise ValueError(f"Invalid MIME type: {mime_type}. Allowed: {ALLOWED_MIME_TYPES}")
|
||||
raise ValueError(
|
||||
f"Invalid MIME type: {mime_type}. Allowed: {ALLOWED_MIME_TYPES}"
|
||||
)
|
||||
|
||||
if not validate_file_size(len(file_bytes)):
|
||||
raise ValueError(
|
||||
f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB"
|
||||
)
|
||||
raise ValueError(f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB")
|
||||
|
||||
# Ensure upload directory exists
|
||||
upload_dir = settings.UPLOAD_DIR
|
||||
@@ -173,6 +173,7 @@ async def apply_to_vehicle(
|
||||
if ocr_field == "first_registration" and isinstance(value, str):
|
||||
try:
|
||||
from datetime import datetime as dt
|
||||
|
||||
parsed = dt.strptime(value, "%d.%m.%Y").date()
|
||||
setattr(vehicle, vehicle_field, parsed)
|
||||
updated_fields.append(vehicle_field)
|
||||
@@ -180,6 +181,7 @@ async def apply_to_vehicle(
|
||||
except ValueError:
|
||||
try:
|
||||
from datetime import date
|
||||
|
||||
parsed = date.fromisoformat(value)
|
||||
setattr(vehicle, vehicle_field, parsed)
|
||||
updated_fields.append(vehicle_field)
|
||||
|
||||
@@ -82,7 +82,9 @@ async def compare_prices(
|
||||
|
||||
# Calculate average price
|
||||
if listings:
|
||||
average_price = round(sum(listing.price for listing in listings) / len(listings), 2)
|
||||
average_price = round(
|
||||
sum(listing.price for listing in listings) / len(listings), 2
|
||||
)
|
||||
else:
|
||||
average_price = None
|
||||
|
||||
|
||||
@@ -80,7 +80,9 @@ def generate_retouch_prompt(vehicle_info: dict[str, Any] | None = None) -> str:
|
||||
if make and model:
|
||||
parts.append(f"The vehicle is a {make} {model}.")
|
||||
if color:
|
||||
parts.append(f"The vehicle color is {color}; ensure it looks accurate and rich.")
|
||||
parts.append(
|
||||
f"The vehicle color is {color}; ensure it looks accurate and rich."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
return base_prompt
|
||||
@@ -98,7 +100,9 @@ async def upload_retouch_file(
|
||||
Raises ValueError for invalid MIME type or file size.
|
||||
"""
|
||||
if not validate_mime_type(mime_type):
|
||||
raise ValueError(f"Invalid MIME type: {mime_type}. Only image/* types are allowed.")
|
||||
raise ValueError(
|
||||
f"Invalid MIME type: {mime_type}. Only image/* types are allowed."
|
||||
)
|
||||
|
||||
if not validate_file_size(len(file_bytes)):
|
||||
raise ValueError(f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB")
|
||||
@@ -151,7 +155,11 @@ async def list_results(
|
||||
total = total_result.scalar_one()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
data_stmt = data_stmt.order_by(RetouchResult.created_at.desc()).offset(offset).limit(page_size)
|
||||
data_stmt = (
|
||||
data_stmt.order_by(RetouchResult.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(data_stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
|
||||
@@ -131,9 +131,7 @@ async def list_vehicles(
|
||||
return vehicles, total
|
||||
|
||||
|
||||
async def get_vehicle_by_id(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> Vehicle | None:
|
||||
async def get_vehicle_by_id(db: AsyncSession, vehicle_id: uuid.UUID) -> Vehicle | None:
|
||||
"""Get a single vehicle by ID, excluding soft-deleted."""
|
||||
stmt = select(Vehicle).where(
|
||||
and_(Vehicle.id == vehicle_id, Vehicle.deleted_at.is_(None))
|
||||
@@ -142,20 +140,14 @@ async def get_vehicle_by_id(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_vehicle_by_fin(
|
||||
db: AsyncSession, fin: str
|
||||
) -> Vehicle | None:
|
||||
async def get_vehicle_by_fin(db: AsyncSession, fin: str) -> Vehicle | None:
|
||||
"""Get a single vehicle by FIN, excluding soft-deleted."""
|
||||
stmt = select(Vehicle).where(
|
||||
and_(Vehicle.fin == fin, Vehicle.deleted_at.is_(None))
|
||||
)
|
||||
stmt = select(Vehicle).where(and_(Vehicle.fin == fin, Vehicle.deleted_at.is_(None)))
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_vehicle(
|
||||
db: AsyncSession, data: dict[str, Any]
|
||||
) -> Vehicle:
|
||||
async def create_vehicle(db: AsyncSession, data: dict[str, Any]) -> Vehicle:
|
||||
"""Create a new vehicle.
|
||||
|
||||
Raises ValueError if FIN already exists.
|
||||
|
||||
@@ -11,7 +11,6 @@ from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
|
||||
|
||||
# HTML template for the sales contract
|
||||
_CONTRACT_HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
@@ -191,7 +190,11 @@ def build_contract_html(sale: Any) -> str:
|
||||
|
||||
# Determine GwG clause
|
||||
gwg_section = ""
|
||||
if sale.is_gwg and sale.sale_price is not None and sale.sale_price <= Decimal("800"):
|
||||
if (
|
||||
sale.is_gwg
|
||||
and sale.sale_price is not None
|
||||
and sale.sale_price <= Decimal("800")
|
||||
):
|
||||
gwg_section = _GWG_CLAUSE_HTML
|
||||
|
||||
html = _CONTRACT_HTML_TEMPLATE.format(
|
||||
@@ -207,7 +210,9 @@ def build_contract_html(sale: Any) -> str:
|
||||
vehicle_model=getattr(vehicle, "model", "N/A") if vehicle else "N/A",
|
||||
vehicle_fin=getattr(vehicle, "fin", "N/A") if vehicle else "N/A",
|
||||
vehicle_type=getattr(vehicle, "vehicle_type", "N/A") if vehicle else "N/A",
|
||||
first_registration=_format_date(getattr(vehicle, "first_registration", None)) if vehicle else "N/A",
|
||||
first_registration=_format_date(getattr(vehicle, "first_registration", None))
|
||||
if vehicle
|
||||
else "N/A",
|
||||
mileage_km=getattr(vehicle, "mileage_km", "N/A") if vehicle else "N/A",
|
||||
power_kw=getattr(vehicle, "power_kw", "N/A") if vehicle else "N/A",
|
||||
power_hp=getattr(vehicle, "power_hp", "N/A") if vehicle else "N/A",
|
||||
|
||||
@@ -73,7 +73,9 @@ async def _search_contacts(db: AsyncSession, params: dict[str, Any]) -> dict[str
|
||||
}
|
||||
|
||||
|
||||
async def _get_sale_overview(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]:
|
||||
async def _get_sale_overview(
|
||||
db: AsyncSession, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Get an overview of sales, optionally filtered by status or date range."""
|
||||
from app.models.sale import Sale
|
||||
from sqlalchemy import func, select
|
||||
@@ -93,7 +95,9 @@ async def _get_sale_overview(db: AsyncSession, params: dict[str, Any]) -> dict[s
|
||||
total = total_result.scalar_one()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
data_stmt = data_stmt.offset(offset).limit(page_size).order_by(Sale.created_at.desc())
|
||||
data_stmt = (
|
||||
data_stmt.offset(offset).limit(page_size).order_by(Sale.created_at.desc())
|
||||
)
|
||||
result = await db.execute(data_stmt)
|
||||
sales = list(result.scalars().all())
|
||||
|
||||
@@ -110,7 +114,9 @@ async def _create_vehicle(db: AsyncSession, params: dict[str, Any]) -> dict[str,
|
||||
required = ["make", "model", "fin", "price", "vehicle_type"]
|
||||
missing = [f for f in required if not params.get(f)]
|
||||
if missing:
|
||||
raise ValueError(f"Missing required fields for create_vehicle: {', '.join(missing)}")
|
||||
raise ValueError(
|
||||
f"Missing required fields for create_vehicle: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
vehicle = await vehicle_service.create_vehicle(db, params)
|
||||
return vehicle.to_dict()
|
||||
@@ -121,7 +127,9 @@ async def _create_contact(db: AsyncSession, params: dict[str, Any]) -> dict[str,
|
||||
required = ["company_name", "role"]
|
||||
missing = [f for f in required if not params.get(f)]
|
||||
if missing:
|
||||
raise ValueError(f"Missing required fields for create_contact: {', '.join(missing)}")
|
||||
raise ValueError(
|
||||
f"Missing required fields for create_contact: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
if "address_country" not in params:
|
||||
params["address_country"] = "DE"
|
||||
@@ -193,7 +201,9 @@ def get_available_actions() -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
async def execute_action(db: AsyncSession, action_type: str, params: dict[str, Any]) -> Any:
|
||||
async def execute_action(
|
||||
db: AsyncSession, action_type: str, params: dict[str, Any]
|
||||
) -> Any:
|
||||
"""Execute a registered action by type.
|
||||
|
||||
Raises ValueError if the action type is not registered.
|
||||
|
||||
@@ -109,9 +109,7 @@ def map_fields(vehicle: Vehicle) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
if vehicle.first_registration is not None:
|
||||
ad["firstRegistration"] = _format_first_registration(
|
||||
vehicle.first_registration
|
||||
)
|
||||
ad["firstRegistration"] = _format_first_registration(vehicle.first_registration)
|
||||
|
||||
if mileage is not None:
|
||||
ad["mileage"] = mileage
|
||||
|
||||
@@ -98,10 +98,18 @@ def _parse_response(raw_content: str) -> dict[str, Any]:
|
||||
data = json.loads(text[start : end + 1])
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse OpenRouter response: %s", text[:200])
|
||||
return {"structured_data": {}, "confidence_score": 0.0, "raw_text": raw_content}
|
||||
return {
|
||||
"structured_data": {},
|
||||
"confidence_score": 0.0,
|
||||
"raw_text": raw_content,
|
||||
}
|
||||
else:
|
||||
logger.error("No JSON found in OpenRouter response: %s", text[:200])
|
||||
return {"structured_data": {}, "confidence_score": 0.0, "raw_text": raw_content}
|
||||
return {
|
||||
"structured_data": {},
|
||||
"confidence_score": 0.0,
|
||||
"raw_text": raw_content,
|
||||
}
|
||||
|
||||
# Extract confidence score (may be inside or outside the data)
|
||||
confidence = data.pop("confidence_score", None)
|
||||
|
||||
@@ -71,13 +71,17 @@ def generate_thumbnail(
|
||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||
if img.mode == "P":
|
||||
img = img.convert("RGBA")
|
||||
background.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None)
|
||||
background.paste(
|
||||
img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None
|
||||
)
|
||||
img = background
|
||||
elif img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Use ImageOps.fit for a centered crop to exact thumbnail size
|
||||
thumbnail = ImageOps.fit(img, THUMBNAIL_SIZE, method=Image.Resampling.LANCZOS)
|
||||
thumbnail = ImageOps.fit(
|
||||
img, THUMBNAIL_SIZE, method=Image.Resampling.LANCZOS
|
||||
)
|
||||
thumbnail.save(thumbnail_path, quality=85, optimize=True)
|
||||
|
||||
logger.info("Thumbnail generated: %s", thumbnail_path)
|
||||
|
||||
@@ -38,9 +38,33 @@ _EU_FALLBACK_PATTERN = re.compile(r"^[A-Z]{2}[A-Za-z0-9]{5,15}$")
|
||||
|
||||
# Set of supported EU country codes (ISO 3166-1 alpha-2).
|
||||
_EU_COUNTRY_CODES: set[str] = {
|
||||
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR",
|
||||
"HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO",
|
||||
"SE", "SI", "SK",
|
||||
"AT",
|
||||
"BE",
|
||||
"BG",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DE",
|
||||
"DK",
|
||||
"EE",
|
||||
"ES",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"HR",
|
||||
"HU",
|
||||
"IE",
|
||||
"IT",
|
||||
"LT",
|
||||
"LU",
|
||||
"LV",
|
||||
"MT",
|
||||
"NL",
|
||||
"PL",
|
||||
"PT",
|
||||
"RO",
|
||||
"SE",
|
||||
"SI",
|
||||
"SK",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user