winebox 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
winebox/main.py ADDED
@@ -0,0 +1,78 @@
1
+ """FastAPI application entry point for WineBox."""
2
+
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+ from typing import AsyncGenerator
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.responses import JSONResponse, RedirectResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from winebox import __version__
12
+ from winebox.config import settings
13
+ from winebox.database import close_db, init_db
14
+
15
+
16
+ @asynccontextmanager
17
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
18
+ """Application lifespan manager."""
19
+ # Startup
20
+ # Ensure data directories exist
21
+ settings.image_storage_path.mkdir(parents=True, exist_ok=True)
22
+ Path("data").mkdir(parents=True, exist_ok=True)
23
+
24
+ # Initialize database
25
+ await init_db()
26
+
27
+ yield
28
+
29
+ # Shutdown
30
+ await close_db()
31
+
32
+
33
+ app = FastAPI(
34
+ title=settings.app_name,
35
+ description="Wine Cellar Management Application with OCR label scanning",
36
+ version=__version__,
37
+ lifespan=lifespan,
38
+ )
39
+
40
+
41
+ # Root redirect to web interface - defined first to ensure it's matched
42
+ @app.get("/", tags=["Root"])
43
+ async def root() -> RedirectResponse:
44
+ """Root endpoint - redirects to web interface."""
45
+ return RedirectResponse(url="/static/index.html")
46
+
47
+
48
+ # Health check endpoint
49
+ @app.get("/health", tags=["Health"])
50
+ async def health_check() -> JSONResponse:
51
+ """Health check endpoint."""
52
+ return JSONResponse(
53
+ content={
54
+ "status": "healthy",
55
+ "version": __version__,
56
+ "app_name": settings.app_name,
57
+ }
58
+ )
59
+
60
+
61
+ # Import and include routers
62
+ from winebox.routers import auth, cellar, search, transactions, wines
63
+
64
+ app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
65
+ app.include_router(wines.router, prefix="/api/wines", tags=["Wines"])
66
+ app.include_router(cellar.router, prefix="/api/cellar", tags=["Cellar"])
67
+ app.include_router(transactions.router, prefix="/api/transactions", tags=["Transactions"])
68
+ app.include_router(search.router, prefix="/api/search", tags=["Search"])
69
+
70
+ # Serve static files - mounted after routes to avoid conflicts
71
+ static_path = Path(__file__).parent / "static"
72
+ if static_path.exists():
73
+ app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
74
+
75
+ # Serve images
76
+ images_path = settings.image_storage_path
77
+ if images_path.exists():
78
+ app.mount("/api/images", StaticFiles(directory=str(images_path)), name="images")
@@ -0,0 +1,8 @@
1
+ """Database models for WineBox."""
2
+
3
+ from winebox.models.wine import Wine
4
+ from winebox.models.transaction import Transaction, TransactionType
5
+ from winebox.models.inventory import CellarInventory
6
+ from winebox.models.user import User
7
+
8
+ __all__ = ["Wine", "Transaction", "TransactionType", "CellarInventory", "User"]
@@ -0,0 +1,46 @@
1
+ """Cellar inventory model for tracking current stock levels."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sqlalchemy import DateTime, ForeignKey, Integer, func
8
+ from sqlalchemy.dialects.sqlite import CHAR
9
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
10
+
11
+ from winebox.database import Base
12
+
13
+ if TYPE_CHECKING:
14
+ from winebox.models.wine import Wine
15
+
16
+
17
+ class CellarInventory(Base):
18
+ """Cellar inventory model tracking current wine quantities."""
19
+
20
+ __tablename__ = "cellar_inventory"
21
+
22
+ id: Mapped[str] = mapped_column(
23
+ CHAR(36),
24
+ primary_key=True,
25
+ default=lambda: str(uuid.uuid4()),
26
+ )
27
+ wine_id: Mapped[str] = mapped_column(
28
+ CHAR(36),
29
+ ForeignKey("wines.id", ondelete="CASCADE"),
30
+ nullable=False,
31
+ unique=True,
32
+ index=True,
33
+ )
34
+ quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
35
+ updated_at: Mapped[datetime] = mapped_column(
36
+ DateTime(timezone=True),
37
+ server_default=func.now(),
38
+ onupdate=func.now(),
39
+ nullable=False,
40
+ )
41
+
42
+ # Relationships
43
+ wine: Mapped["Wine"] = relationship("Wine", back_populates="inventory")
44
+
45
+ def __repr__(self) -> str:
46
+ return f"<CellarInventory(wine_id={self.wine_id}, quantity={self.quantity})>"
@@ -0,0 +1,64 @@
1
+ """Transaction model for tracking wine check-ins and check-outs."""
2
+
3
+ import enum
4
+ import uuid
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING
7
+
8
+ from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, func
9
+ from sqlalchemy.dialects.sqlite import CHAR
10
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
11
+
12
+ from winebox.database import Base
13
+
14
+ if TYPE_CHECKING:
15
+ from winebox.models.wine import Wine
16
+
17
+
18
+ class TransactionType(str, enum.Enum):
19
+ """Type of transaction."""
20
+
21
+ CHECK_IN = "CHECK_IN"
22
+ CHECK_OUT = "CHECK_OUT"
23
+
24
+
25
+ class Transaction(Base):
26
+ """Transaction model for tracking wine movements."""
27
+
28
+ __tablename__ = "transactions"
29
+
30
+ id: Mapped[str] = mapped_column(
31
+ CHAR(36),
32
+ primary_key=True,
33
+ default=lambda: str(uuid.uuid4()),
34
+ )
35
+ wine_id: Mapped[str] = mapped_column(
36
+ CHAR(36),
37
+ ForeignKey("wines.id", ondelete="CASCADE"),
38
+ nullable=False,
39
+ index=True,
40
+ )
41
+ transaction_type: Mapped[TransactionType] = mapped_column(
42
+ Enum(TransactionType),
43
+ nullable=False,
44
+ index=True,
45
+ )
46
+ quantity: Mapped[int] = mapped_column(Integer, nullable=False)
47
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
48
+ transaction_date: Mapped[datetime] = mapped_column(
49
+ DateTime(timezone=True),
50
+ server_default=func.now(),
51
+ nullable=False,
52
+ index=True,
53
+ )
54
+ created_at: Mapped[datetime] = mapped_column(
55
+ DateTime(timezone=True),
56
+ server_default=func.now(),
57
+ nullable=False,
58
+ )
59
+
60
+ # Relationships
61
+ wine: Mapped["Wine"] = relationship("Wine", back_populates="transactions")
62
+
63
+ def __repr__(self) -> str:
64
+ return f"<Transaction(id={self.id}, type={self.transaction_type}, quantity={self.quantity})>"
winebox/models/user.py ADDED
@@ -0,0 +1,55 @@
1
+ """User model for authentication."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+
6
+ from sqlalchemy import Boolean, DateTime, String, func
7
+ from sqlalchemy.dialects.sqlite import CHAR
8
+ from sqlalchemy.orm import Mapped, mapped_column
9
+
10
+ from winebox.database import Base
11
+
12
+
13
+ class User(Base):
14
+ """User model for authentication."""
15
+
16
+ __tablename__ = "users"
17
+
18
+ id: Mapped[str] = mapped_column(
19
+ CHAR(36),
20
+ primary_key=True,
21
+ default=lambda: str(uuid.uuid4()),
22
+ )
23
+ username: Mapped[str] = mapped_column(
24
+ String(50),
25
+ unique=True,
26
+ nullable=False,
27
+ index=True,
28
+ )
29
+ email: Mapped[str | None] = mapped_column(
30
+ String(255),
31
+ unique=True,
32
+ nullable=True,
33
+ index=True,
34
+ )
35
+ hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
36
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
37
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
38
+ created_at: Mapped[datetime] = mapped_column(
39
+ DateTime(timezone=True),
40
+ server_default=func.now(),
41
+ nullable=False,
42
+ )
43
+ updated_at: Mapped[datetime] = mapped_column(
44
+ DateTime(timezone=True),
45
+ server_default=func.now(),
46
+ onupdate=func.now(),
47
+ nullable=False,
48
+ )
49
+ last_login: Mapped[datetime | None] = mapped_column(
50
+ DateTime(timezone=True),
51
+ nullable=True,
52
+ )
53
+
54
+ def __repr__(self) -> str:
55
+ return f"<User(id={self.id}, username={self.username}, is_active={self.is_active})>"
winebox/models/wine.py ADDED
@@ -0,0 +1,66 @@
1
+ """Wine model for storing wine information."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sqlalchemy import DateTime, Float, Integer, String, Text, func
8
+ from sqlalchemy.dialects.sqlite import CHAR
9
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
10
+
11
+ from winebox.database import Base
12
+
13
+ if TYPE_CHECKING:
14
+ from winebox.models.inventory import CellarInventory
15
+ from winebox.models.transaction import Transaction
16
+
17
+
18
+ class Wine(Base):
19
+ """Wine model representing a wine in the cellar."""
20
+
21
+ __tablename__ = "wines"
22
+
23
+ id: Mapped[str] = mapped_column(
24
+ CHAR(36),
25
+ primary_key=True,
26
+ default=lambda: str(uuid.uuid4()),
27
+ )
28
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
29
+ winery: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
30
+ vintage: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
31
+ grape_variety: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
32
+ region: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
33
+ country: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
34
+ alcohol_percentage: Mapped[float | None] = mapped_column(Float, nullable=True)
35
+ front_label_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
36
+ back_label_text: Mapped[str | None] = mapped_column(Text, nullable=True)
37
+ front_label_image_path: Mapped[str] = mapped_column(String(512), nullable=False)
38
+ back_label_image_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
39
+ created_at: Mapped[datetime] = mapped_column(
40
+ DateTime(timezone=True),
41
+ server_default=func.now(),
42
+ nullable=False,
43
+ )
44
+ updated_at: Mapped[datetime] = mapped_column(
45
+ DateTime(timezone=True),
46
+ server_default=func.now(),
47
+ onupdate=func.now(),
48
+ nullable=False,
49
+ )
50
+
51
+ # Relationships
52
+ transactions: Mapped[list["Transaction"]] = relationship(
53
+ "Transaction",
54
+ back_populates="wine",
55
+ cascade="all, delete-orphan",
56
+ order_by="Transaction.transaction_date.desc()",
57
+ )
58
+ inventory: Mapped["CellarInventory | None"] = relationship(
59
+ "CellarInventory",
60
+ back_populates="wine",
61
+ uselist=False,
62
+ cascade="all, delete-orphan",
63
+ )
64
+
65
+ def __repr__(self) -> str:
66
+ return f"<Wine(id={self.id}, name={self.name}, vintage={self.vintage})>"
@@ -0,0 +1,5 @@
1
+ """API routers for WineBox."""
2
+
3
+ from winebox.routers import auth, cellar, search, transactions, wines
4
+
5
+ __all__ = ["auth", "wines", "cellar", "transactions", "search"]
@@ -0,0 +1,90 @@
1
+ """Authentication endpoints."""
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, status
7
+ from fastapi.security import OAuth2PasswordRequestForm
8
+ from pydantic import BaseModel
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from winebox.database import get_db
12
+ from winebox.models.user import User
13
+ from winebox.services.auth import (
14
+ ACCESS_TOKEN_EXPIRE_MINUTES,
15
+ authenticate_user,
16
+ create_access_token,
17
+ get_current_user,
18
+ require_auth,
19
+ CurrentUser,
20
+ RequireAuth,
21
+ )
22
+
23
+ router = APIRouter()
24
+
25
+
26
+ class Token(BaseModel):
27
+ """Token response model."""
28
+
29
+ access_token: str
30
+ token_type: str = "bearer"
31
+ expires_in: int = ACCESS_TOKEN_EXPIRE_MINUTES * 60 # seconds
32
+
33
+
34
+ class UserResponse(BaseModel):
35
+ """User response model."""
36
+
37
+ id: str
38
+ username: str
39
+ email: str | None
40
+ is_active: bool
41
+ is_admin: bool
42
+ created_at: datetime
43
+ last_login: datetime | None
44
+
45
+ model_config = {"from_attributes": True}
46
+
47
+
48
+ @router.post("/token", response_model=Token)
49
+ async def login(
50
+ form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
51
+ db: Annotated[AsyncSession, Depends(get_db)],
52
+ ) -> Token:
53
+ """Login with username and password to get an access token."""
54
+ user = await authenticate_user(db, form_data.username, form_data.password)
55
+ if not user:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_401_UNAUTHORIZED,
58
+ detail="Incorrect username or password",
59
+ headers={"WWW-Authenticate": "Bearer"},
60
+ )
61
+
62
+ # Update last login time
63
+ user.last_login = datetime.now(timezone.utc)
64
+ await db.commit()
65
+
66
+ # Create access token
67
+ access_token = create_access_token(
68
+ data={"sub": user.username},
69
+ expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
70
+ )
71
+
72
+ return Token(access_token=access_token)
73
+
74
+
75
+ @router.get("/me", response_model=UserResponse)
76
+ async def get_current_user_info(
77
+ current_user: RequireAuth,
78
+ ) -> UserResponse:
79
+ """Get the current authenticated user's information."""
80
+ return UserResponse.model_validate(current_user)
81
+
82
+
83
+ @router.post("/logout")
84
+ async def logout() -> dict:
85
+ """Logout the current user.
86
+
87
+ Note: Since we use JWT tokens, logout is handled client-side
88
+ by discarding the token. This endpoint is provided for API completeness.
89
+ """
90
+ return {"message": "Successfully logged out"}
@@ -0,0 +1,102 @@
1
+ """Cellar inventory endpoints."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from sqlalchemy import func, select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy.orm import selectinload
9
+
10
+ from winebox.database import get_db
11
+ from winebox.models import CellarInventory, Wine
12
+ from winebox.schemas.wine import WineWithInventory
13
+ from winebox.services.auth import RequireAuth
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ @router.get("", response_model=list[WineWithInventory])
19
+ async def get_cellar_inventory(
20
+ _: RequireAuth,
21
+ db: Annotated[AsyncSession, Depends(get_db)],
22
+ skip: int = 0,
23
+ limit: int = 100,
24
+ ) -> list[WineWithInventory]:
25
+ """Get current cellar inventory (wines in stock)."""
26
+ result = await db.execute(
27
+ select(Wine)
28
+ .options(selectinload(Wine.inventory))
29
+ .join(CellarInventory)
30
+ .where(CellarInventory.quantity > 0)
31
+ .offset(skip)
32
+ .limit(limit)
33
+ .order_by(Wine.name)
34
+ )
35
+ wines = result.scalars().all()
36
+
37
+ return [WineWithInventory.model_validate(wine) for wine in wines]
38
+
39
+
40
+ @router.get("/summary")
41
+ async def get_cellar_summary(
42
+ _: RequireAuth,
43
+ db: Annotated[AsyncSession, Depends(get_db)],
44
+ ) -> dict:
45
+ """Get cellar summary statistics."""
46
+ # Total bottles in cellar
47
+ total_bottles_result = await db.execute(
48
+ select(func.sum(CellarInventory.quantity)).where(CellarInventory.quantity > 0)
49
+ )
50
+ total_bottles = total_bottles_result.scalar() or 0
51
+
52
+ # Unique wines in stock
53
+ unique_wines_result = await db.execute(
54
+ select(func.count(CellarInventory.id)).where(CellarInventory.quantity > 0)
55
+ )
56
+ unique_wines = unique_wines_result.scalar() or 0
57
+
58
+ # Total wines ever tracked (including out of stock)
59
+ total_wines_result = await db.execute(select(func.count(Wine.id)))
60
+ total_wines_tracked = total_wines_result.scalar() or 0
61
+
62
+ # Wines by vintage (in stock)
63
+ vintage_result = await db.execute(
64
+ select(Wine.vintage, func.sum(CellarInventory.quantity))
65
+ .join(CellarInventory)
66
+ .where(CellarInventory.quantity > 0)
67
+ .where(Wine.vintage.isnot(None))
68
+ .group_by(Wine.vintage)
69
+ .order_by(Wine.vintage.desc())
70
+ )
71
+ by_vintage = {str(row[0]): row[1] for row in vintage_result.all()}
72
+
73
+ # Wines by country (in stock)
74
+ country_result = await db.execute(
75
+ select(Wine.country, func.sum(CellarInventory.quantity))
76
+ .join(CellarInventory)
77
+ .where(CellarInventory.quantity > 0)
78
+ .where(Wine.country.isnot(None))
79
+ .group_by(Wine.country)
80
+ .order_by(func.sum(CellarInventory.quantity).desc())
81
+ )
82
+ by_country = {row[0]: row[1] for row in country_result.all()}
83
+
84
+ # Wines by grape variety (in stock)
85
+ grape_result = await db.execute(
86
+ select(Wine.grape_variety, func.sum(CellarInventory.quantity))
87
+ .join(CellarInventory)
88
+ .where(CellarInventory.quantity > 0)
89
+ .where(Wine.grape_variety.isnot(None))
90
+ .group_by(Wine.grape_variety)
91
+ .order_by(func.sum(CellarInventory.quantity).desc())
92
+ )
93
+ by_grape = {row[0]: row[1] for row in grape_result.all()}
94
+
95
+ return {
96
+ "total_bottles": total_bottles,
97
+ "unique_wines": unique_wines,
98
+ "total_wines_tracked": total_wines_tracked,
99
+ "by_vintage": by_vintage,
100
+ "by_country": by_country,
101
+ "by_grape_variety": by_grape,
102
+ }
@@ -0,0 +1,127 @@
1
+ """Search endpoints."""
2
+
3
+ from datetime import datetime
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Depends, Query
7
+ from sqlalchemy import and_, or_, select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ from sqlalchemy.orm import selectinload
10
+
11
+ from winebox.database import get_db
12
+ from winebox.models import CellarInventory, Transaction, TransactionType, Wine
13
+ from winebox.schemas.wine import WineWithInventory
14
+ from winebox.services.auth import RequireAuth
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.get("", response_model=list[WineWithInventory])
20
+ async def search_wines(
21
+ _: RequireAuth,
22
+ db: Annotated[AsyncSession, Depends(get_db)],
23
+ q: Annotated[str | None, Query(description="Full-text search query")] = None,
24
+ vintage: Annotated[int | None, Query(description="Wine vintage year")] = None,
25
+ grape: Annotated[str | None, Query(description="Grape variety")] = None,
26
+ winery: Annotated[str | None, Query(description="Winery name")] = None,
27
+ region: Annotated[str | None, Query(description="Wine region")] = None,
28
+ country: Annotated[str | None, Query(description="Country")] = None,
29
+ checked_in_after: Annotated[datetime | None, Query(description="Checked in after date")] = None,
30
+ checked_in_before: Annotated[datetime | None, Query(description="Checked in before date")] = None,
31
+ checked_out_after: Annotated[datetime | None, Query(description="Checked out after date")] = None,
32
+ checked_out_before: Annotated[datetime | None, Query(description="Checked out before date")] = None,
33
+ in_stock: Annotated[bool | None, Query(description="Only wines currently in stock")] = None,
34
+ skip: int = 0,
35
+ limit: int = 100,
36
+ ) -> list[WineWithInventory]:
37
+ """Search wines by various criteria.
38
+
39
+ Use `q` for full-text search across name, winery, region, and label text.
40
+ Other parameters filter on specific fields.
41
+ """
42
+ query = select(Wine).options(selectinload(Wine.inventory))
43
+ conditions = []
44
+
45
+ # Full-text search (simple LIKE-based search for SQLite)
46
+ if q:
47
+ search_pattern = f"%{q}%"
48
+ conditions.append(
49
+ or_(
50
+ Wine.name.ilike(search_pattern),
51
+ Wine.winery.ilike(search_pattern),
52
+ Wine.region.ilike(search_pattern),
53
+ Wine.country.ilike(search_pattern),
54
+ Wine.grape_variety.ilike(search_pattern),
55
+ Wine.front_label_text.ilike(search_pattern),
56
+ Wine.back_label_text.ilike(search_pattern),
57
+ )
58
+ )
59
+
60
+ # Exact/partial matches on specific fields
61
+ if vintage:
62
+ conditions.append(Wine.vintage == vintage)
63
+
64
+ if grape:
65
+ conditions.append(Wine.grape_variety.ilike(f"%{grape}%"))
66
+
67
+ if winery:
68
+ conditions.append(Wine.winery.ilike(f"%{winery}%"))
69
+
70
+ if region:
71
+ conditions.append(Wine.region.ilike(f"%{region}%"))
72
+
73
+ if country:
74
+ conditions.append(Wine.country.ilike(f"%{country}%"))
75
+
76
+ # Date-based filters (based on transactions)
77
+ if checked_in_after or checked_in_before:
78
+ # Subquery to find wines with check-in transactions in date range
79
+ checkin_subquery = (
80
+ select(Transaction.wine_id)
81
+ .where(Transaction.transaction_type == TransactionType.CHECK_IN)
82
+ )
83
+ if checked_in_after:
84
+ checkin_subquery = checkin_subquery.where(
85
+ Transaction.transaction_date >= checked_in_after
86
+ )
87
+ if checked_in_before:
88
+ checkin_subquery = checkin_subquery.where(
89
+ Transaction.transaction_date <= checked_in_before
90
+ )
91
+ conditions.append(Wine.id.in_(checkin_subquery))
92
+
93
+ if checked_out_after or checked_out_before:
94
+ # Subquery to find wines with check-out transactions in date range
95
+ checkout_subquery = (
96
+ select(Transaction.wine_id)
97
+ .where(Transaction.transaction_type == TransactionType.CHECK_OUT)
98
+ )
99
+ if checked_out_after:
100
+ checkout_subquery = checkout_subquery.where(
101
+ Transaction.transaction_date >= checked_out_after
102
+ )
103
+ if checked_out_before:
104
+ checkout_subquery = checkout_subquery.where(
105
+ Transaction.transaction_date <= checked_out_before
106
+ )
107
+ conditions.append(Wine.id.in_(checkout_subquery))
108
+
109
+ # Stock filter
110
+ if in_stock is True:
111
+ query = query.join(CellarInventory).where(CellarInventory.quantity > 0)
112
+ elif in_stock is False:
113
+ query = query.outerjoin(CellarInventory).where(
114
+ (CellarInventory.quantity == 0) | (CellarInventory.quantity.is_(None))
115
+ )
116
+
117
+ # Apply all conditions
118
+ if conditions:
119
+ query = query.where(and_(*conditions))
120
+
121
+ # Pagination and ordering
122
+ query = query.offset(skip).limit(limit).order_by(Wine.created_at.desc())
123
+
124
+ result = await db.execute(query)
125
+ wines = result.scalars().all()
126
+
127
+ return [WineWithInventory.model_validate(wine) for wine in wines]