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/__init__.py +3 -0
- winebox/cli/__init__.py +1 -0
- winebox/cli/server.py +313 -0
- winebox/cli/user_admin.py +258 -0
- winebox/config.py +43 -0
- winebox/database.py +47 -0
- winebox/main.py +78 -0
- winebox/models/__init__.py +8 -0
- winebox/models/inventory.py +46 -0
- winebox/models/transaction.py +64 -0
- winebox/models/user.py +55 -0
- winebox/models/wine.py +66 -0
- winebox/routers/__init__.py +5 -0
- winebox/routers/auth.py +90 -0
- winebox/routers/cellar.py +102 -0
- winebox/routers/search.py +127 -0
- winebox/routers/transactions.py +63 -0
- winebox/routers/wines.py +287 -0
- winebox/schemas/__init__.py +13 -0
- winebox/schemas/transaction.py +40 -0
- winebox/schemas/wine.py +79 -0
- winebox/services/__init__.py +7 -0
- winebox/services/auth.py +123 -0
- winebox/services/image_storage.py +90 -0
- winebox/services/ocr.py +128 -0
- winebox/services/wine_parser.py +411 -0
- winebox/static/css/style.css +1086 -0
- winebox/static/index.html +271 -0
- winebox/static/js/app.js +703 -0
- winebox-0.1.0.dist-info/METADATA +283 -0
- winebox-0.1.0.dist-info/RECORD +34 -0
- winebox-0.1.0.dist-info/WHEEL +4 -0
- winebox-0.1.0.dist-info/entry_points.txt +3 -0
- winebox-0.1.0.dist-info/licenses/LICENSE +21 -0
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})>"
|
winebox/routers/auth.py
ADDED
|
@@ -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]
|