winebox 0.1.3__py3-none-any.whl → 0.1.4__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 +1 -1
- winebox/config.py +36 -5
- winebox/main.py +48 -1
- winebox/models/user.py +2 -0
- winebox/routers/auth.py +117 -3
- winebox/routers/wines.py +130 -71
- winebox/services/image_storage.py +138 -9
- winebox/services/vision.py +50 -23
- winebox/static/css/style.css +201 -0
- winebox/static/index.html +176 -19
- winebox/static/js/app.js +343 -62
- {winebox-0.1.3.dist-info → winebox-0.1.4.dist-info}/METADATA +4 -1
- {winebox-0.1.3.dist-info → winebox-0.1.4.dist-info}/RECORD +16 -16
- {winebox-0.1.3.dist-info → winebox-0.1.4.dist-info}/WHEEL +0 -0
- {winebox-0.1.3.dist-info → winebox-0.1.4.dist-info}/entry_points.txt +0 -0
- {winebox-0.1.3.dist-info → winebox-0.1.4.dist-info}/licenses/LICENSE +0 -0
winebox/__init__.py
CHANGED
winebox/config.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""Configuration settings for WineBox application."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import secrets
|
|
4
5
|
from pathlib import Path
|
|
6
|
+
|
|
5
7
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
8
|
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return secrets.token_urlsafe(32)
|
|
11
|
+
# Marker value to detect if secret_key was not explicitly set
|
|
12
|
+
_SECRET_KEY_NOT_SET = "__NOT_SET__"
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
class Settings(BaseSettings):
|
|
@@ -27,6 +29,7 @@ class Settings(BaseSettings):
|
|
|
27
29
|
|
|
28
30
|
# Image storage
|
|
29
31
|
image_storage_path: Path = Path("data/images")
|
|
32
|
+
max_upload_size_mb: int = 10 # Maximum file upload size in MB
|
|
30
33
|
|
|
31
34
|
# Server
|
|
32
35
|
host: str = "0.0.0.0"
|
|
@@ -40,8 +43,36 @@ class Settings(BaseSettings):
|
|
|
40
43
|
use_claude_vision: bool = True # Fall back to Tesseract if False or no API key
|
|
41
44
|
|
|
42
45
|
# Authentication
|
|
43
|
-
|
|
46
|
+
# SECURITY: Set WINEBOX_SECRET_KEY environment variable for production!
|
|
47
|
+
# If not set, a random key will be generated (tokens invalidate on restart)
|
|
48
|
+
secret_key: str = _SECRET_KEY_NOT_SET
|
|
44
49
|
auth_enabled: bool = True # Set to False to disable authentication
|
|
45
50
|
|
|
51
|
+
# Security
|
|
52
|
+
enforce_https: bool = False # Set to True in production to enable HSTS header
|
|
53
|
+
rate_limit_per_minute: int = 60 # Global rate limit per IP per minute
|
|
54
|
+
auth_rate_limit_per_minute: int = 10 # Stricter rate limit for auth endpoints
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def max_upload_size_bytes(self) -> int:
|
|
58
|
+
"""Get max upload size in bytes."""
|
|
59
|
+
return self.max_upload_size_mb * 1024 * 1024
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _initialize_settings() -> Settings:
|
|
63
|
+
"""Initialize settings with security warnings."""
|
|
64
|
+
s = Settings()
|
|
65
|
+
|
|
66
|
+
# Handle secret key - generate if not set, but warn
|
|
67
|
+
if s.secret_key == _SECRET_KEY_NOT_SET:
|
|
68
|
+
s.secret_key = secrets.token_urlsafe(32)
|
|
69
|
+
logger.warning(
|
|
70
|
+
"SECURITY WARNING: No WINEBOX_SECRET_KEY environment variable set. "
|
|
71
|
+
"A random secret key has been generated. JWT tokens will be invalidated "
|
|
72
|
+
"when the server restarts. Set WINEBOX_SECRET_KEY for production use."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return s
|
|
76
|
+
|
|
46
77
|
|
|
47
|
-
settings =
|
|
78
|
+
settings = _initialize_settings()
|
winebox/main.py
CHANGED
|
@@ -4,15 +4,55 @@ from contextlib import asynccontextmanager
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import AsyncGenerator
|
|
6
6
|
|
|
7
|
-
from fastapi import FastAPI
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
8
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
9
9
|
from fastapi.staticfiles import StaticFiles
|
|
10
|
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
11
|
+
from slowapi.errors import RateLimitExceeded
|
|
12
|
+
from slowapi.util import get_remote_address
|
|
13
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
14
|
+
from starlette.responses import Response
|
|
10
15
|
|
|
11
16
|
from winebox import __version__
|
|
12
17
|
from winebox.config import settings
|
|
13
18
|
from winebox.database import close_db, init_db
|
|
14
19
|
|
|
15
20
|
|
|
21
|
+
# Rate limiter configuration
|
|
22
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
26
|
+
"""Middleware to add security headers to all responses."""
|
|
27
|
+
|
|
28
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
29
|
+
response = await call_next(request)
|
|
30
|
+
|
|
31
|
+
# Security headers
|
|
32
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
33
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
34
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
35
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
36
|
+
|
|
37
|
+
# Content Security Policy - allow self and inline styles for the UI
|
|
38
|
+
response.headers["Content-Security-Policy"] = (
|
|
39
|
+
"default-src 'self'; "
|
|
40
|
+
"script-src 'self' 'unsafe-inline'; "
|
|
41
|
+
"style-src 'self' 'unsafe-inline'; "
|
|
42
|
+
"img-src 'self' data: blob:; "
|
|
43
|
+
"font-src 'self'; "
|
|
44
|
+
"frame-ancestors 'none';"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# HTTPS enforcement header (browsers will upgrade to HTTPS)
|
|
48
|
+
if settings.enforce_https:
|
|
49
|
+
response.headers["Strict-Transport-Security"] = (
|
|
50
|
+
"max-age=31536000; includeSubDomains"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return response
|
|
54
|
+
|
|
55
|
+
|
|
16
56
|
@asynccontextmanager
|
|
17
57
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
18
58
|
"""Application lifespan manager."""
|
|
@@ -37,6 +77,13 @@ app = FastAPI(
|
|
|
37
77
|
lifespan=lifespan,
|
|
38
78
|
)
|
|
39
79
|
|
|
80
|
+
# Add rate limiter
|
|
81
|
+
app.state.limiter = limiter
|
|
82
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
83
|
+
|
|
84
|
+
# Add security headers middleware
|
|
85
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
86
|
+
|
|
40
87
|
|
|
41
88
|
# Root redirect to web interface - defined first to ensure it's matched
|
|
42
89
|
@app.get("/", tags=["Root"])
|
winebox/models/user.py
CHANGED
|
@@ -32,7 +32,9 @@ class User(Base):
|
|
|
32
32
|
nullable=True,
|
|
33
33
|
index=True,
|
|
34
34
|
)
|
|
35
|
+
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
35
36
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
37
|
+
anthropic_api_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
36
38
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
37
39
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
38
40
|
created_at: Mapped[datetime] = mapped_column(
|
winebox/routers/auth.py
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
from datetime import datetime, timedelta, timezone
|
|
4
4
|
from typing import Annotated
|
|
5
5
|
|
|
6
|
-
from fastapi import APIRouter, Depends, HTTPException, status
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
7
7
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
8
8
|
from pydantic import BaseModel
|
|
9
|
+
from slowapi import Limiter
|
|
10
|
+
from slowapi.util import get_remote_address
|
|
9
11
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
12
|
|
|
13
|
+
from winebox.config import settings
|
|
11
14
|
from winebox.database import get_db
|
|
12
15
|
from winebox.models.user import User
|
|
13
16
|
from winebox.services.auth import (
|
|
@@ -15,13 +18,18 @@ from winebox.services.auth import (
|
|
|
15
18
|
authenticate_user,
|
|
16
19
|
create_access_token,
|
|
17
20
|
get_current_user,
|
|
21
|
+
get_password_hash,
|
|
18
22
|
require_auth,
|
|
23
|
+
verify_password,
|
|
19
24
|
CurrentUser,
|
|
20
25
|
RequireAuth,
|
|
21
26
|
)
|
|
22
27
|
|
|
23
28
|
router = APIRouter()
|
|
24
29
|
|
|
30
|
+
# Rate limiter for auth endpoints (stricter than global limit)
|
|
31
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
32
|
+
|
|
25
33
|
|
|
26
34
|
class Token(BaseModel):
|
|
27
35
|
"""Token response model."""
|
|
@@ -37,20 +45,61 @@ class UserResponse(BaseModel):
|
|
|
37
45
|
id: str
|
|
38
46
|
username: str
|
|
39
47
|
email: str | None
|
|
48
|
+
full_name: str | None
|
|
40
49
|
is_active: bool
|
|
41
50
|
is_admin: bool
|
|
51
|
+
has_api_key: bool
|
|
42
52
|
created_at: datetime
|
|
43
53
|
last_login: datetime | None
|
|
44
54
|
|
|
45
55
|
model_config = {"from_attributes": True}
|
|
46
56
|
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_user(cls, user: "User") -> "UserResponse":
|
|
59
|
+
"""Create UserResponse from User model."""
|
|
60
|
+
return cls(
|
|
61
|
+
id=user.id,
|
|
62
|
+
username=user.username,
|
|
63
|
+
email=user.email,
|
|
64
|
+
full_name=user.full_name,
|
|
65
|
+
is_active=user.is_active,
|
|
66
|
+
is_admin=user.is_admin,
|
|
67
|
+
has_api_key=user.anthropic_api_key is not None,
|
|
68
|
+
created_at=user.created_at,
|
|
69
|
+
last_login=user.last_login,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PasswordChangeRequest(BaseModel):
|
|
74
|
+
"""Password change request model."""
|
|
75
|
+
|
|
76
|
+
current_password: str
|
|
77
|
+
new_password: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ProfileUpdateRequest(BaseModel):
|
|
81
|
+
"""Profile update request model."""
|
|
82
|
+
|
|
83
|
+
full_name: str | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ApiKeyUpdateRequest(BaseModel):
|
|
87
|
+
"""API key update request model."""
|
|
88
|
+
|
|
89
|
+
api_key: str
|
|
90
|
+
|
|
47
91
|
|
|
48
92
|
@router.post("/token", response_model=Token)
|
|
93
|
+
@limiter.limit(f"{settings.auth_rate_limit_per_minute}/minute")
|
|
49
94
|
async def login(
|
|
95
|
+
request: Request, # Required for rate limiting
|
|
50
96
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
|
51
97
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
52
98
|
) -> Token:
|
|
53
|
-
"""Login with username and password to get an access token.
|
|
99
|
+
"""Login with username and password to get an access token.
|
|
100
|
+
|
|
101
|
+
Rate limited to prevent brute force attacks.
|
|
102
|
+
"""
|
|
54
103
|
user = await authenticate_user(db, form_data.username, form_data.password)
|
|
55
104
|
if not user:
|
|
56
105
|
raise HTTPException(
|
|
@@ -77,7 +126,72 @@ async def get_current_user_info(
|
|
|
77
126
|
current_user: RequireAuth,
|
|
78
127
|
) -> UserResponse:
|
|
79
128
|
"""Get the current authenticated user's information."""
|
|
80
|
-
return UserResponse.
|
|
129
|
+
return UserResponse.from_user(current_user)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.put("/password")
|
|
133
|
+
async def change_password(
|
|
134
|
+
request: PasswordChangeRequest,
|
|
135
|
+
current_user: RequireAuth,
|
|
136
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
137
|
+
) -> dict:
|
|
138
|
+
"""Change the current user's password."""
|
|
139
|
+
# Verify current password
|
|
140
|
+
if not verify_password(request.current_password, current_user.hashed_password):
|
|
141
|
+
raise HTTPException(
|
|
142
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
143
|
+
detail="Current password is incorrect",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Update password
|
|
147
|
+
current_user.hashed_password = get_password_hash(request.new_password)
|
|
148
|
+
await db.commit()
|
|
149
|
+
|
|
150
|
+
return {"message": "Password updated successfully"}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@router.put("/profile", response_model=UserResponse)
|
|
154
|
+
async def update_profile(
|
|
155
|
+
request: ProfileUpdateRequest,
|
|
156
|
+
current_user: RequireAuth,
|
|
157
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
158
|
+
) -> UserResponse:
|
|
159
|
+
"""Update the current user's profile."""
|
|
160
|
+
if request.full_name is not None:
|
|
161
|
+
current_user.full_name = request.full_name
|
|
162
|
+
|
|
163
|
+
await db.commit()
|
|
164
|
+
await db.refresh(current_user)
|
|
165
|
+
|
|
166
|
+
return UserResponse.from_user(current_user)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@router.put("/api-key")
|
|
170
|
+
async def update_api_key(
|
|
171
|
+
request: ApiKeyUpdateRequest,
|
|
172
|
+
current_user: RequireAuth,
|
|
173
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
174
|
+
) -> dict:
|
|
175
|
+
"""Update the current user's Anthropic API key.
|
|
176
|
+
|
|
177
|
+
Note: The API key is stored but cannot be retrieved after setting.
|
|
178
|
+
"""
|
|
179
|
+
current_user.anthropic_api_key = request.api_key
|
|
180
|
+
await db.commit()
|
|
181
|
+
|
|
182
|
+
return {"message": "API key updated successfully"}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@router.delete("/api-key")
|
|
186
|
+
async def delete_api_key(
|
|
187
|
+
current_user: RequireAuth,
|
|
188
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
189
|
+
) -> dict:
|
|
190
|
+
"""Delete the current user's Anthropic API key."""
|
|
191
|
+
current_user.anthropic_api_key = None
|
|
192
|
+
await db.commit()
|
|
193
|
+
|
|
194
|
+
return {"message": "API key deleted successfully"}
|
|
81
195
|
|
|
82
196
|
|
|
83
197
|
@router.post("/logout")
|
winebox/routers/wines.py
CHANGED
|
@@ -9,11 +9,12 @@ from sqlalchemy import select
|
|
|
9
9
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
10
|
from sqlalchemy.orm import selectinload
|
|
11
11
|
|
|
12
|
+
from winebox.config import settings
|
|
12
13
|
from winebox.database import get_db
|
|
13
14
|
from winebox.models import CellarInventory, Transaction, TransactionType, Wine
|
|
14
15
|
from winebox.schemas.wine import WineCreate, WineResponse, WineUpdate, WineWithInventory
|
|
15
16
|
from winebox.services.auth import RequireAuth
|
|
16
|
-
from winebox.services.image_storage import ImageStorageService
|
|
17
|
+
from winebox.services.image_storage import ALLOWED_MIME_TYPES, ImageStorageService
|
|
17
18
|
from winebox.services.ocr import OCRService
|
|
18
19
|
from winebox.services.vision import ClaudeVisionService
|
|
19
20
|
from winebox.services.wine_parser import WineParserService
|
|
@@ -22,6 +23,31 @@ logger = logging.getLogger(__name__)
|
|
|
22
23
|
|
|
23
24
|
router = APIRouter()
|
|
24
25
|
|
|
26
|
+
|
|
27
|
+
async def validate_upload_size(upload_file: UploadFile, field_name: str) -> bytes:
|
|
28
|
+
"""Validate file size and return content.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
upload_file: The uploaded file.
|
|
32
|
+
field_name: Name of the field for error messages.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The file content as bytes.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
HTTPException: If file exceeds size limit.
|
|
39
|
+
"""
|
|
40
|
+
content = await upload_file.read()
|
|
41
|
+
await upload_file.seek(0)
|
|
42
|
+
|
|
43
|
+
if len(content) > settings.max_upload_size_bytes:
|
|
44
|
+
max_mb = settings.max_upload_size_bytes / (1024 * 1024)
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
47
|
+
detail=f"{field_name} exceeds maximum allowed size of {max_mb:.1f} MB",
|
|
48
|
+
)
|
|
49
|
+
return content
|
|
50
|
+
|
|
25
51
|
# Service dependencies
|
|
26
52
|
image_storage = ImageStorageService()
|
|
27
53
|
ocr_service = OCRService()
|
|
@@ -45,7 +71,7 @@ def get_media_type(filename: str | None) -> str:
|
|
|
45
71
|
|
|
46
72
|
@router.post("/scan")
|
|
47
73
|
async def scan_label(
|
|
48
|
-
|
|
74
|
+
current_user: RequireAuth,
|
|
49
75
|
front_label: Annotated[UploadFile, File(description="Front label image")],
|
|
50
76
|
back_label: Annotated[UploadFile | None, File(description="Back label image")] = None,
|
|
51
77
|
) -> dict:
|
|
@@ -53,18 +79,20 @@ async def scan_label(
|
|
|
53
79
|
|
|
54
80
|
Uses Claude Vision for intelligent label analysis when available,
|
|
55
81
|
falls back to Tesseract OCR otherwise.
|
|
82
|
+
Uses user's API key if configured, otherwise falls back to system key.
|
|
56
83
|
"""
|
|
57
|
-
#
|
|
58
|
-
front_data = await front_label
|
|
59
|
-
await front_label.seek(0)
|
|
84
|
+
# Validate and read image data with size limits
|
|
85
|
+
front_data = await validate_upload_size(front_label, "Front label")
|
|
60
86
|
|
|
61
87
|
back_data = None
|
|
62
88
|
if back_label and back_label.filename:
|
|
63
|
-
back_data = await back_label
|
|
64
|
-
|
|
89
|
+
back_data = await validate_upload_size(back_label, "Back label")
|
|
90
|
+
|
|
91
|
+
# Get user's API key (if they have one configured)
|
|
92
|
+
user_api_key = current_user.anthropic_api_key
|
|
65
93
|
|
|
66
94
|
# Try Claude Vision first
|
|
67
|
-
if vision_service.is_available():
|
|
95
|
+
if vision_service.is_available(user_api_key):
|
|
68
96
|
logger.info("Using Claude Vision for label analysis")
|
|
69
97
|
try:
|
|
70
98
|
front_media_type = get_media_type(front_label.filename)
|
|
@@ -75,6 +103,7 @@ async def scan_label(
|
|
|
75
103
|
back_image_data=back_data,
|
|
76
104
|
front_media_type=front_media_type,
|
|
77
105
|
back_media_type=back_media_type,
|
|
106
|
+
user_api_key=user_api_key,
|
|
78
107
|
)
|
|
79
108
|
|
|
80
109
|
return {
|
|
@@ -128,90 +157,120 @@ async def scan_label(
|
|
|
128
157
|
}
|
|
129
158
|
|
|
130
159
|
|
|
160
|
+
# Maximum lengths for form fields (security limits)
|
|
161
|
+
MAX_NAME_LENGTH = 500
|
|
162
|
+
MAX_FIELD_LENGTH = 200
|
|
163
|
+
MAX_NOTES_LENGTH = 2000
|
|
164
|
+
MAX_OCR_TEXT_LENGTH = 10000
|
|
165
|
+
|
|
166
|
+
|
|
131
167
|
@router.post("/checkin", response_model=WineWithInventory, status_code=status.HTTP_201_CREATED)
|
|
132
168
|
async def checkin_wine(
|
|
133
|
-
|
|
169
|
+
current_user: RequireAuth,
|
|
134
170
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
135
171
|
front_label: Annotated[UploadFile, File(description="Front label image")],
|
|
136
|
-
quantity: Annotated[int, Form(ge=1, description="Number of bottles")] = 1,
|
|
172
|
+
quantity: Annotated[int, Form(ge=1, le=10000, description="Number of bottles")] = 1,
|
|
137
173
|
back_label: Annotated[UploadFile | None, File(description="Back label image")] = None,
|
|
138
|
-
name: Annotated[str | None, Form(description="Wine name (auto-detected if not provided)")] = None,
|
|
139
|
-
winery: Annotated[str | None, Form()] = None,
|
|
174
|
+
name: Annotated[str | None, Form(max_length=MAX_NAME_LENGTH, description="Wine name (auto-detected if not provided)")] = None,
|
|
175
|
+
winery: Annotated[str | None, Form(max_length=MAX_FIELD_LENGTH)] = None,
|
|
140
176
|
vintage: Annotated[int | None, Form(ge=1900, le=2100)] = None,
|
|
141
|
-
grape_variety: Annotated[str | None, Form()] = None,
|
|
142
|
-
region: Annotated[str | None, Form()] = None,
|
|
143
|
-
country: Annotated[str | None, Form()] = None,
|
|
177
|
+
grape_variety: Annotated[str | None, Form(max_length=MAX_FIELD_LENGTH)] = None,
|
|
178
|
+
region: Annotated[str | None, Form(max_length=MAX_FIELD_LENGTH)] = None,
|
|
179
|
+
country: Annotated[str | None, Form(max_length=MAX_FIELD_LENGTH)] = None,
|
|
144
180
|
alcohol_percentage: Annotated[float | None, Form(ge=0, le=100)] = None,
|
|
145
|
-
notes: Annotated[str | None, Form(description="Check-in notes")] = None,
|
|
181
|
+
notes: Annotated[str | None, Form(max_length=MAX_NOTES_LENGTH, description="Check-in notes")] = None,
|
|
182
|
+
front_label_text: Annotated[str | None, Form(max_length=MAX_OCR_TEXT_LENGTH, description="Pre-scanned front label text")] = None,
|
|
183
|
+
back_label_text: Annotated[str | None, Form(max_length=MAX_OCR_TEXT_LENGTH, description="Pre-scanned back label text")] = None,
|
|
146
184
|
) -> WineWithInventory:
|
|
147
185
|
"""Check in wine bottles to the cellar.
|
|
148
186
|
|
|
149
187
|
Upload front (required) and back (optional) label images.
|
|
150
|
-
|
|
188
|
+
If front_label_text is provided (from a prior /scan call), scanning is skipped.
|
|
189
|
+
Otherwise, uses Claude Vision for intelligent label analysis when available.
|
|
190
|
+
Uses user's API key if configured, otherwise falls back to system key.
|
|
151
191
|
You can override any auto-detected values.
|
|
152
192
|
"""
|
|
153
|
-
# Read image data for analysis
|
|
154
|
-
front_data = await front_label.read()
|
|
155
|
-
await front_label.seek(0)
|
|
156
|
-
|
|
157
|
-
back_data = None
|
|
158
|
-
if back_label and back_label.filename:
|
|
159
|
-
back_data = await back_label.read()
|
|
160
|
-
await back_label.seek(0)
|
|
161
|
-
|
|
162
193
|
# Save images
|
|
163
194
|
front_image_path = await image_storage.save_image(front_label)
|
|
164
195
|
back_image_path = None
|
|
165
196
|
if back_label and back_label.filename:
|
|
166
197
|
back_image_path = await image_storage.save_image(back_label)
|
|
167
198
|
|
|
168
|
-
#
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
199
|
+
# Use pre-scanned text if provided (avoids duplicate API calls)
|
|
200
|
+
front_text = front_label_text or ""
|
|
201
|
+
back_text = back_label_text
|
|
202
|
+
|
|
203
|
+
# Get user's API key (if they have one configured)
|
|
204
|
+
user_api_key = current_user.anthropic_api_key
|
|
205
|
+
|
|
206
|
+
# Only scan if no pre-scanned text was provided and no name given
|
|
207
|
+
if not front_label_text and not name:
|
|
208
|
+
logger.info("No pre-scanned text provided, scanning labels...")
|
|
209
|
+
|
|
210
|
+
# Read image data for analysis
|
|
211
|
+
await front_label.seek(0)
|
|
212
|
+
front_data = await front_label.read()
|
|
213
|
+
|
|
214
|
+
back_data = None
|
|
215
|
+
if back_label and back_label.filename:
|
|
216
|
+
await back_label.seek(0)
|
|
217
|
+
back_data = await back_label.read()
|
|
218
|
+
|
|
219
|
+
# Try Claude Vision first
|
|
220
|
+
parsed_data = {}
|
|
221
|
+
|
|
222
|
+
if vision_service.is_available(user_api_key):
|
|
223
|
+
logger.info("Using Claude Vision for checkin analysis")
|
|
224
|
+
try:
|
|
225
|
+
front_media_type = get_media_type(front_label.filename)
|
|
226
|
+
back_media_type = get_media_type(back_label.filename if back_label else None)
|
|
227
|
+
|
|
228
|
+
result = await vision_service.analyze_labels(
|
|
229
|
+
front_image_data=front_data,
|
|
230
|
+
back_image_data=back_data,
|
|
231
|
+
front_media_type=front_media_type,
|
|
232
|
+
back_media_type=back_media_type,
|
|
233
|
+
user_api_key=user_api_key,
|
|
234
|
+
)
|
|
235
|
+
parsed_data = result
|
|
236
|
+
front_text = result.get("raw_text", "")
|
|
237
|
+
back_text = result.get("back_label_text")
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.warning(f"Claude Vision failed, falling back to Tesseract: {e}")
|
|
240
|
+
|
|
241
|
+
# Fall back to Tesseract if needed
|
|
242
|
+
if not parsed_data.get("name"):
|
|
243
|
+
logger.info("Using Tesseract OCR for checkin analysis")
|
|
244
|
+
front_text = await ocr_service.extract_text(front_image_path)
|
|
245
|
+
if back_image_path:
|
|
246
|
+
back_text = await ocr_service.extract_text(back_image_path)
|
|
247
|
+
|
|
248
|
+
combined_text = front_text
|
|
249
|
+
if back_text:
|
|
250
|
+
combined_text = f"{front_text}\n{back_text}"
|
|
251
|
+
parsed_data = wine_parser.parse(combined_text)
|
|
252
|
+
|
|
253
|
+
# Use parsed values for fields not provided
|
|
254
|
+
name = name or parsed_data.get("name")
|
|
255
|
+
winery = winery or parsed_data.get("winery")
|
|
256
|
+
vintage = vintage or parsed_data.get("vintage")
|
|
257
|
+
grape_variety = grape_variety or parsed_data.get("grape_variety")
|
|
258
|
+
region = region or parsed_data.get("region")
|
|
259
|
+
country = country or parsed_data.get("country")
|
|
260
|
+
alcohol_percentage = alcohol_percentage or parsed_data.get("alcohol_percentage")
|
|
261
|
+
|
|
262
|
+
# Use provided values
|
|
263
|
+
wine_name = name or "Unknown Wine"
|
|
205
264
|
|
|
206
265
|
# Create wine record
|
|
207
266
|
wine = Wine(
|
|
208
267
|
name=wine_name,
|
|
209
|
-
winery=winery
|
|
210
|
-
vintage=vintage
|
|
211
|
-
grape_variety=grape_variety
|
|
212
|
-
region=region
|
|
213
|
-
country=country
|
|
214
|
-
alcohol_percentage=alcohol_percentage
|
|
268
|
+
winery=winery,
|
|
269
|
+
vintage=vintage,
|
|
270
|
+
grape_variety=grape_variety,
|
|
271
|
+
region=region,
|
|
272
|
+
country=country,
|
|
273
|
+
alcohol_percentage=alcohol_percentage,
|
|
215
274
|
front_label_text=front_text,
|
|
216
275
|
back_label_text=back_text,
|
|
217
276
|
front_label_image_path=front_image_path,
|
|
@@ -254,8 +313,8 @@ async def checkout_wine(
|
|
|
254
313
|
wine_id: str,
|
|
255
314
|
_: RequireAuth,
|
|
256
315
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
257
|
-
quantity: Annotated[int, Form(ge=1, description="Number of bottles to remove")] = 1,
|
|
258
|
-
notes: Annotated[str | None, Form(description="Check-out notes")] = None,
|
|
316
|
+
quantity: Annotated[int, Form(ge=1, le=10000, description="Number of bottles to remove")] = 1,
|
|
317
|
+
notes: Annotated[str | None, Form(max_length=MAX_NOTES_LENGTH, description="Check-out notes")] = None,
|
|
259
318
|
) -> WineWithInventory:
|
|
260
319
|
"""Check out wine bottles from the cellar.
|
|
261
320
|
|