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.
@@ -0,0 +1,63 @@
1
+ """Transaction history endpoints."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+ from sqlalchemy import 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 Transaction, TransactionType
12
+ from winebox.schemas.transaction import TransactionResponse
13
+ from winebox.services.auth import RequireAuth
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ @router.get("", response_model=list[TransactionResponse])
19
+ async def list_transactions(
20
+ _: RequireAuth,
21
+ db: Annotated[AsyncSession, Depends(get_db)],
22
+ skip: int = 0,
23
+ limit: int = 100,
24
+ transaction_type: TransactionType | None = None,
25
+ wine_id: str | None = None,
26
+ ) -> list[TransactionResponse]:
27
+ """List all transactions with optional filtering."""
28
+ query = select(Transaction).options(selectinload(Transaction.wine))
29
+
30
+ if transaction_type:
31
+ query = query.where(Transaction.transaction_type == transaction_type)
32
+
33
+ if wine_id:
34
+ query = query.where(Transaction.wine_id == wine_id)
35
+
36
+ query = query.offset(skip).limit(limit).order_by(Transaction.transaction_date.desc())
37
+ result = await db.execute(query)
38
+ transactions = result.scalars().all()
39
+
40
+ return [TransactionResponse.model_validate(t) for t in transactions]
41
+
42
+
43
+ @router.get("/{transaction_id}", response_model=TransactionResponse)
44
+ async def get_transaction(
45
+ transaction_id: str,
46
+ _: RequireAuth,
47
+ db: Annotated[AsyncSession, Depends(get_db)],
48
+ ) -> TransactionResponse:
49
+ """Get a single transaction by ID."""
50
+ result = await db.execute(
51
+ select(Transaction)
52
+ .options(selectinload(Transaction.wine))
53
+ .where(Transaction.id == transaction_id)
54
+ )
55
+ transaction = result.scalar_one_or_none()
56
+
57
+ if not transaction:
58
+ raise HTTPException(
59
+ status_code=status.HTTP_404_NOT_FOUND,
60
+ detail=f"Transaction with ID {transaction_id} not found",
61
+ )
62
+
63
+ return TransactionResponse.model_validate(transaction)
@@ -0,0 +1,287 @@
1
+ """Wine management endpoints."""
2
+
3
+ from typing import Annotated
4
+ from uuid import UUID
5
+
6
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
7
+ from sqlalchemy import 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 WineCreate, WineResponse, WineUpdate, WineWithInventory
14
+ from winebox.services.auth import RequireAuth
15
+ from winebox.services.image_storage import ImageStorageService
16
+ from winebox.services.ocr import OCRService
17
+ from winebox.services.wine_parser import WineParserService
18
+
19
+ router = APIRouter()
20
+
21
+ # Service dependencies
22
+ image_storage = ImageStorageService()
23
+ ocr_service = OCRService()
24
+ wine_parser = WineParserService()
25
+
26
+
27
+ @router.post("/checkin", response_model=WineWithInventory, status_code=status.HTTP_201_CREATED)
28
+ async def checkin_wine(
29
+ _: RequireAuth,
30
+ db: Annotated[AsyncSession, Depends(get_db)],
31
+ front_label: Annotated[UploadFile, File(description="Front label image")],
32
+ quantity: Annotated[int, Form(ge=1, description="Number of bottles")] = 1,
33
+ back_label: Annotated[UploadFile | None, File(description="Back label image")] = None,
34
+ name: Annotated[str | None, Form(description="Wine name (auto-detected if not provided)")] = None,
35
+ winery: Annotated[str | None, Form()] = None,
36
+ vintage: Annotated[int | None, Form(ge=1900, le=2100)] = None,
37
+ grape_variety: Annotated[str | None, Form()] = None,
38
+ region: Annotated[str | None, Form()] = None,
39
+ country: Annotated[str | None, Form()] = None,
40
+ alcohol_percentage: Annotated[float | None, Form(ge=0, le=100)] = None,
41
+ notes: Annotated[str | None, Form(description="Check-in notes")] = None,
42
+ ) -> WineWithInventory:
43
+ """Check in wine bottles to the cellar.
44
+
45
+ Upload front (required) and back (optional) label images.
46
+ OCR will extract text and attempt to identify wine details.
47
+ You can override any auto-detected values.
48
+ """
49
+ # Save images
50
+ front_image_path = await image_storage.save_image(front_label)
51
+ back_image_path = None
52
+ if back_label and back_label.filename:
53
+ back_image_path = await image_storage.save_image(back_label)
54
+
55
+ # Extract text via OCR
56
+ front_text = await ocr_service.extract_text(front_image_path)
57
+ back_text = None
58
+ if back_image_path:
59
+ back_text = await ocr_service.extract_text(back_image_path)
60
+
61
+ # Parse wine details from OCR text
62
+ combined_text = front_text
63
+ if back_text:
64
+ combined_text = f"{front_text}\n{back_text}"
65
+ parsed_data = wine_parser.parse(combined_text)
66
+
67
+ # Use provided values or fall back to parsed values
68
+ wine_name = name or parsed_data.get("name") or "Unknown Wine"
69
+
70
+ # Create wine record
71
+ wine = Wine(
72
+ name=wine_name,
73
+ winery=winery or parsed_data.get("winery"),
74
+ vintage=vintage or parsed_data.get("vintage"),
75
+ grape_variety=grape_variety or parsed_data.get("grape_variety"),
76
+ region=region or parsed_data.get("region"),
77
+ country=country or parsed_data.get("country"),
78
+ alcohol_percentage=alcohol_percentage or parsed_data.get("alcohol_percentage"),
79
+ front_label_text=front_text,
80
+ back_label_text=back_text,
81
+ front_label_image_path=front_image_path,
82
+ back_label_image_path=back_image_path,
83
+ )
84
+ db.add(wine)
85
+ await db.flush() # Get the wine ID
86
+
87
+ # Create transaction
88
+ transaction = Transaction(
89
+ wine_id=wine.id,
90
+ transaction_type=TransactionType.CHECK_IN,
91
+ quantity=quantity,
92
+ notes=notes,
93
+ )
94
+ db.add(transaction)
95
+
96
+ # Create or update inventory
97
+ inventory = CellarInventory(
98
+ wine_id=wine.id,
99
+ quantity=quantity,
100
+ )
101
+ db.add(inventory)
102
+
103
+ await db.commit()
104
+
105
+ # Re-query with eager loading for relationships
106
+ result = await db.execute(
107
+ select(Wine)
108
+ .options(selectinload(Wine.inventory))
109
+ .where(Wine.id == wine.id)
110
+ )
111
+ wine = result.scalar_one()
112
+
113
+ return WineWithInventory.model_validate(wine)
114
+
115
+
116
+ @router.post("/{wine_id}/checkout", response_model=WineWithInventory)
117
+ async def checkout_wine(
118
+ wine_id: str,
119
+ _: RequireAuth,
120
+ db: Annotated[AsyncSession, Depends(get_db)],
121
+ quantity: Annotated[int, Form(ge=1, description="Number of bottles to remove")] = 1,
122
+ notes: Annotated[str | None, Form(description="Check-out notes")] = None,
123
+ ) -> WineWithInventory:
124
+ """Check out wine bottles from the cellar.
125
+
126
+ Remove bottles from inventory. If quantity reaches 0, the wine
127
+ remains in history but shows as out of stock.
128
+ """
129
+ # Get wine with inventory
130
+ result = await db.execute(
131
+ select(Wine)
132
+ .options(selectinload(Wine.inventory))
133
+ .where(Wine.id == wine_id)
134
+ )
135
+ wine = result.scalar_one_or_none()
136
+
137
+ if not wine:
138
+ raise HTTPException(
139
+ status_code=status.HTTP_404_NOT_FOUND,
140
+ detail=f"Wine with ID {wine_id} not found",
141
+ )
142
+
143
+ if not wine.inventory or wine.inventory.quantity < quantity:
144
+ available = wine.inventory.quantity if wine.inventory else 0
145
+ raise HTTPException(
146
+ status_code=status.HTTP_400_BAD_REQUEST,
147
+ detail=f"Not enough bottles in stock. Available: {available}, Requested: {quantity}",
148
+ )
149
+
150
+ # Create transaction
151
+ transaction = Transaction(
152
+ wine_id=wine.id,
153
+ transaction_type=TransactionType.CHECK_OUT,
154
+ quantity=quantity,
155
+ notes=notes,
156
+ )
157
+ db.add(transaction)
158
+
159
+ # Update inventory
160
+ wine.inventory.quantity -= quantity
161
+
162
+ await db.commit()
163
+
164
+ # Re-query with eager loading for relationships
165
+ result = await db.execute(
166
+ select(Wine)
167
+ .options(selectinload(Wine.inventory))
168
+ .where(Wine.id == wine_id)
169
+ )
170
+ wine = result.scalar_one()
171
+
172
+ return WineWithInventory.model_validate(wine)
173
+
174
+
175
+ @router.get("", response_model=list[WineWithInventory])
176
+ async def list_wines(
177
+ _: RequireAuth,
178
+ db: Annotated[AsyncSession, Depends(get_db)],
179
+ skip: int = 0,
180
+ limit: int = 100,
181
+ in_stock: bool | None = None,
182
+ ) -> list[WineWithInventory]:
183
+ """List all wines with optional filtering."""
184
+ query = select(Wine).options(selectinload(Wine.inventory))
185
+
186
+ if in_stock is True:
187
+ query = query.join(CellarInventory).where(CellarInventory.quantity > 0)
188
+ elif in_stock is False:
189
+ query = query.outerjoin(CellarInventory).where(
190
+ (CellarInventory.quantity == 0) | (CellarInventory.quantity.is_(None))
191
+ )
192
+
193
+ query = query.offset(skip).limit(limit).order_by(Wine.created_at.desc())
194
+ result = await db.execute(query)
195
+ wines = result.scalars().all()
196
+
197
+ return [WineWithInventory.model_validate(wine) for wine in wines]
198
+
199
+
200
+ @router.get("/{wine_id}", response_model=WineResponse)
201
+ async def get_wine(
202
+ wine_id: str,
203
+ _: RequireAuth,
204
+ db: Annotated[AsyncSession, Depends(get_db)],
205
+ ) -> WineResponse:
206
+ """Get wine details with full transaction history."""
207
+ result = await db.execute(
208
+ select(Wine)
209
+ .options(
210
+ selectinload(Wine.inventory),
211
+ selectinload(Wine.transactions),
212
+ )
213
+ .where(Wine.id == wine_id)
214
+ )
215
+ wine = result.scalar_one_or_none()
216
+
217
+ if not wine:
218
+ raise HTTPException(
219
+ status_code=status.HTTP_404_NOT_FOUND,
220
+ detail=f"Wine with ID {wine_id} not found",
221
+ )
222
+
223
+ return WineResponse.model_validate(wine)
224
+
225
+
226
+ @router.put("/{wine_id}", response_model=WineWithInventory)
227
+ async def update_wine(
228
+ wine_id: str,
229
+ _: RequireAuth,
230
+ wine_update: WineUpdate,
231
+ db: Annotated[AsyncSession, Depends(get_db)],
232
+ ) -> WineWithInventory:
233
+ """Update wine metadata."""
234
+ result = await db.execute(
235
+ select(Wine)
236
+ .options(selectinload(Wine.inventory))
237
+ .where(Wine.id == wine_id)
238
+ )
239
+ wine = result.scalar_one_or_none()
240
+
241
+ if not wine:
242
+ raise HTTPException(
243
+ status_code=status.HTTP_404_NOT_FOUND,
244
+ detail=f"Wine with ID {wine_id} not found",
245
+ )
246
+
247
+ # Update only provided fields
248
+ update_data = wine_update.model_dump(exclude_unset=True)
249
+ for field, value in update_data.items():
250
+ setattr(wine, field, value)
251
+
252
+ await db.commit()
253
+
254
+ # Re-query with eager loading for relationships
255
+ result = await db.execute(
256
+ select(Wine)
257
+ .options(selectinload(Wine.inventory))
258
+ .where(Wine.id == wine_id)
259
+ )
260
+ wine = result.scalar_one()
261
+
262
+ return WineWithInventory.model_validate(wine)
263
+
264
+
265
+ @router.delete("/{wine_id}", status_code=status.HTTP_204_NO_CONTENT)
266
+ async def delete_wine(
267
+ wine_id: str,
268
+ _: RequireAuth,
269
+ db: Annotated[AsyncSession, Depends(get_db)],
270
+ ) -> None:
271
+ """Delete wine and all associated history."""
272
+ result = await db.execute(select(Wine).where(Wine.id == wine_id))
273
+ wine = result.scalar_one_or_none()
274
+
275
+ if not wine:
276
+ raise HTTPException(
277
+ status_code=status.HTTP_404_NOT_FOUND,
278
+ detail=f"Wine with ID {wine_id} not found",
279
+ )
280
+
281
+ # Delete associated images
282
+ await image_storage.delete_image(wine.front_label_image_path)
283
+ if wine.back_label_image_path:
284
+ await image_storage.delete_image(wine.back_label_image_path)
285
+
286
+ await db.delete(wine)
287
+ await db.commit()
@@ -0,0 +1,13 @@
1
+ """Pydantic schemas for WineBox API."""
2
+
3
+ from winebox.schemas.transaction import TransactionCreate, TransactionResponse
4
+ from winebox.schemas.wine import WineCreate, WineResponse, WineUpdate, WineWithInventory
5
+
6
+ __all__ = [
7
+ "WineCreate",
8
+ "WineUpdate",
9
+ "WineResponse",
10
+ "WineWithInventory",
11
+ "TransactionCreate",
12
+ "TransactionResponse",
13
+ ]
@@ -0,0 +1,40 @@
1
+ """Pydantic schemas for Transaction model."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from winebox.models.transaction import TransactionType
8
+
9
+
10
+ class TransactionCreate(BaseModel):
11
+ """Schema for creating a transaction."""
12
+
13
+ quantity: int = Field(..., ge=1)
14
+ notes: str | None = None
15
+
16
+
17
+ class WineBasicInfo(BaseModel):
18
+ """Basic wine info for transaction response."""
19
+
20
+ id: str
21
+ name: str
22
+ vintage: int | None = None
23
+ winery: str | None = None
24
+
25
+ model_config = ConfigDict(from_attributes=True)
26
+
27
+
28
+ class TransactionResponse(BaseModel):
29
+ """Schema for transaction response."""
30
+
31
+ id: str
32
+ wine_id: str
33
+ transaction_type: TransactionType
34
+ quantity: int
35
+ notes: str | None
36
+ transaction_date: datetime
37
+ created_at: datetime
38
+ wine: WineBasicInfo | None = None
39
+
40
+ model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,79 @@
1
+ """Pydantic schemas for Wine model."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from winebox.schemas.transaction import TransactionResponse
8
+
9
+
10
+ class WineBase(BaseModel):
11
+ """Base wine schema with common fields."""
12
+
13
+ name: str = Field(..., min_length=1, max_length=255)
14
+ winery: str | None = Field(None, max_length=255)
15
+ vintage: int | None = Field(None, ge=1900, le=2100)
16
+ grape_variety: str | None = Field(None, max_length=255)
17
+ region: str | None = Field(None, max_length=255)
18
+ country: str | None = Field(None, max_length=255)
19
+ alcohol_percentage: float | None = Field(None, ge=0, le=100)
20
+
21
+
22
+ class WineCreate(WineBase):
23
+ """Schema for creating a wine (via form, not direct JSON)."""
24
+
25
+ pass
26
+
27
+
28
+ class WineUpdate(BaseModel):
29
+ """Schema for updating wine metadata."""
30
+
31
+ name: str | None = Field(None, min_length=1, max_length=255)
32
+ winery: str | None = Field(None, max_length=255)
33
+ vintage: int | None = Field(None, ge=1900, le=2100)
34
+ grape_variety: str | None = Field(None, max_length=255)
35
+ region: str | None = Field(None, max_length=255)
36
+ country: str | None = Field(None, max_length=255)
37
+ alcohol_percentage: float | None = Field(None, ge=0, le=100)
38
+
39
+
40
+ class InventoryInfo(BaseModel):
41
+ """Schema for inventory information."""
42
+
43
+ quantity: int = Field(..., ge=0)
44
+ updated_at: datetime
45
+
46
+ model_config = ConfigDict(from_attributes=True)
47
+
48
+
49
+ class WineWithInventory(WineBase):
50
+ """Wine schema with current inventory."""
51
+
52
+ id: str
53
+ front_label_text: str
54
+ back_label_text: str | None
55
+ front_label_image_path: str
56
+ back_label_image_path: str | None
57
+ created_at: datetime
58
+ updated_at: datetime
59
+ inventory: InventoryInfo | None = None
60
+
61
+ model_config = ConfigDict(from_attributes=True)
62
+
63
+ @property
64
+ def current_quantity(self) -> int:
65
+ """Get current quantity in stock."""
66
+ return self.inventory.quantity if self.inventory else 0
67
+
68
+ @property
69
+ def in_stock(self) -> bool:
70
+ """Check if wine is in stock."""
71
+ return self.current_quantity > 0
72
+
73
+
74
+ class WineResponse(WineWithInventory):
75
+ """Full wine response with transaction history."""
76
+
77
+ transactions: list[TransactionResponse] = []
78
+
79
+ model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,7 @@
1
+ """Services for WineBox application."""
2
+
3
+ from winebox.services.image_storage import ImageStorageService
4
+ from winebox.services.ocr import OCRService
5
+ from winebox.services.wine_parser import WineParserService
6
+
7
+ __all__ = ["ImageStorageService", "OCRService", "WineParserService"]
@@ -0,0 +1,123 @@
1
+ """Authentication service for user management and JWT tokens."""
2
+
3
+ import secrets
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Annotated
6
+
7
+ from fastapi import Depends, HTTPException, status
8
+ from fastapi.security import HTTPBasic, HTTPBasicCredentials, OAuth2PasswordBearer
9
+ from jose import JWTError, jwt
10
+ from passlib.context import CryptContext
11
+ from sqlalchemy import select
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from winebox.config import settings
15
+ from winebox.database import get_db
16
+ from winebox.models.user import User
17
+
18
+ # Password hashing context
19
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
20
+
21
+ # OAuth2 scheme for token authentication
22
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
23
+
24
+ # HTTP Basic auth scheme (for login form)
25
+ http_basic = HTTPBasic(auto_error=False)
26
+
27
+ # JWT settings
28
+ ALGORITHM = "HS256"
29
+ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours
30
+
31
+
32
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
33
+ """Verify a password against its hash."""
34
+ return pwd_context.verify(plain_password, hashed_password)
35
+
36
+
37
+ def get_password_hash(password: str) -> str:
38
+ """Hash a password."""
39
+ return pwd_context.hash(password)
40
+
41
+
42
+ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
43
+ """Create a JWT access token."""
44
+ to_encode = data.copy()
45
+ if expires_delta:
46
+ expire = datetime.now(timezone.utc) + expires_delta
47
+ else:
48
+ expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
49
+ to_encode.update({"exp": expire})
50
+ encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM)
51
+ return encoded_jwt
52
+
53
+
54
+ async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
55
+ """Get a user by username."""
56
+ result = await db.execute(select(User).where(User.username == username))
57
+ return result.scalar_one_or_none()
58
+
59
+
60
+ async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
61
+ """Authenticate a user with username and password."""
62
+ user = await get_user_by_username(db, username)
63
+ if not user:
64
+ return None
65
+ if not verify_password(password, user.hashed_password):
66
+ return None
67
+ if not user.is_active:
68
+ return None
69
+ return user
70
+
71
+
72
+ async def get_current_user(
73
+ token: Annotated[str | None, Depends(oauth2_scheme)],
74
+ db: Annotated[AsyncSession, Depends(get_db)],
75
+ ) -> User | None:
76
+ """Get the current user from the JWT token."""
77
+ if not token:
78
+ return None
79
+
80
+ try:
81
+ payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
82
+ username: str | None = payload.get("sub")
83
+ if username is None:
84
+ return None
85
+ except JWTError:
86
+ return None
87
+
88
+ user = await get_user_by_username(db, username)
89
+ if user is None or not user.is_active:
90
+ return None
91
+
92
+ return user
93
+
94
+
95
+ async def require_auth(
96
+ user: Annotated[User | None, Depends(get_current_user)],
97
+ ) -> User:
98
+ """Require authentication - raises 401 if not authenticated."""
99
+ if user is None:
100
+ raise HTTPException(
101
+ status_code=status.HTTP_401_UNAUTHORIZED,
102
+ detail="Not authenticated",
103
+ headers={"WWW-Authenticate": "Bearer"},
104
+ )
105
+ return user
106
+
107
+
108
+ async def require_admin(
109
+ user: Annotated[User, Depends(require_auth)],
110
+ ) -> User:
111
+ """Require admin privileges."""
112
+ if not user.is_admin:
113
+ raise HTTPException(
114
+ status_code=status.HTTP_403_FORBIDDEN,
115
+ detail="Admin privileges required",
116
+ )
117
+ return user
118
+
119
+
120
+ # Type aliases for dependency injection
121
+ CurrentUser = Annotated[User | None, Depends(get_current_user)]
122
+ RequireAuth = Annotated[User, Depends(require_auth)]
123
+ RequireAdmin = Annotated[User, Depends(require_admin)]