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
|
@@ -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)
|
winebox/routers/wines.py
ADDED
|
@@ -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)
|
winebox/schemas/wine.py
ADDED
|
@@ -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"]
|
winebox/services/auth.py
ADDED
|
@@ -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)]
|