winebox 0.1.2__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 +40 -5
- winebox/main.py +48 -1
- winebox/models/user.py +2 -0
- winebox/routers/auth.py +117 -3
- winebox/routers/wines.py +227 -32
- winebox/services/image_storage.py +138 -9
- winebox/services/ocr.py +37 -0
- winebox/services/vision.py +278 -0
- winebox/static/css/style.css +545 -0
- winebox/static/favicon.svg +22 -0
- winebox/static/index.html +233 -2
- winebox/static/js/app.js +583 -8
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/METADATA +37 -1
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/RECORD +18 -16
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/WHEEL +0 -0
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/entry_points.txt +0 -0
- {winebox-0.1.2.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"
|
|
@@ -35,9 +38,41 @@ class Settings(BaseSettings):
|
|
|
35
38
|
# OCR
|
|
36
39
|
tesseract_cmd: str | None = None # Use system default if None
|
|
37
40
|
|
|
41
|
+
# Claude Vision (for wine label scanning)
|
|
42
|
+
anthropic_api_key: str | None = None # Set WINEBOX_ANTHROPIC_API_KEY or ANTHROPIC_API_KEY
|
|
43
|
+
use_claude_vision: bool = True # Fall back to Tesseract if False or no API key
|
|
44
|
+
|
|
38
45
|
# Authentication
|
|
39
|
-
|
|
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
|
|
40
49
|
auth_enabled: bool = True # Set to False to disable authentication
|
|
41
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
|
+
|
|
42
77
|
|
|
43
|
-
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Wine management endpoints."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from typing import Annotated
|
|
4
5
|
from uuid import UUID
|
|
5
6
|
|
|
@@ -8,42 +9,185 @@ from sqlalchemy import select
|
|
|
8
9
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
10
|
from sqlalchemy.orm import selectinload
|
|
10
11
|
|
|
12
|
+
from winebox.config import settings
|
|
11
13
|
from winebox.database import get_db
|
|
12
14
|
from winebox.models import CellarInventory, Transaction, TransactionType, Wine
|
|
13
15
|
from winebox.schemas.wine import WineCreate, WineResponse, WineUpdate, WineWithInventory
|
|
14
16
|
from winebox.services.auth import RequireAuth
|
|
15
|
-
from winebox.services.image_storage import ImageStorageService
|
|
17
|
+
from winebox.services.image_storage import ALLOWED_MIME_TYPES, ImageStorageService
|
|
16
18
|
from winebox.services.ocr import OCRService
|
|
19
|
+
from winebox.services.vision import ClaudeVisionService
|
|
17
20
|
from winebox.services.wine_parser import WineParserService
|
|
18
21
|
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
19
24
|
router = APIRouter()
|
|
20
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
|
+
|
|
21
51
|
# Service dependencies
|
|
22
52
|
image_storage = ImageStorageService()
|
|
23
53
|
ocr_service = OCRService()
|
|
24
54
|
wine_parser = WineParserService()
|
|
55
|
+
vision_service = ClaudeVisionService()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_media_type(filename: str | None) -> str:
|
|
59
|
+
"""Get media type from filename."""
|
|
60
|
+
if not filename:
|
|
61
|
+
return "image/jpeg"
|
|
62
|
+
ext = filename.lower().split(".")[-1]
|
|
63
|
+
return {
|
|
64
|
+
"jpg": "image/jpeg",
|
|
65
|
+
"jpeg": "image/jpeg",
|
|
66
|
+
"png": "image/png",
|
|
67
|
+
"gif": "image/gif",
|
|
68
|
+
"webp": "image/webp",
|
|
69
|
+
}.get(ext, "image/jpeg")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/scan")
|
|
73
|
+
async def scan_label(
|
|
74
|
+
current_user: RequireAuth,
|
|
75
|
+
front_label: Annotated[UploadFile, File(description="Front label image")],
|
|
76
|
+
back_label: Annotated[UploadFile | None, File(description="Back label image")] = None,
|
|
77
|
+
) -> dict:
|
|
78
|
+
"""Scan wine label images and extract text without creating a wine record.
|
|
79
|
+
|
|
80
|
+
Uses Claude Vision for intelligent label analysis when available,
|
|
81
|
+
falls back to Tesseract OCR otherwise.
|
|
82
|
+
Uses user's API key if configured, otherwise falls back to system key.
|
|
83
|
+
"""
|
|
84
|
+
# Validate and read image data with size limits
|
|
85
|
+
front_data = await validate_upload_size(front_label, "Front label")
|
|
86
|
+
|
|
87
|
+
back_data = None
|
|
88
|
+
if back_label and back_label.filename:
|
|
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
|
|
93
|
+
|
|
94
|
+
# Try Claude Vision first
|
|
95
|
+
if vision_service.is_available(user_api_key):
|
|
96
|
+
logger.info("Using Claude Vision for label analysis")
|
|
97
|
+
try:
|
|
98
|
+
front_media_type = get_media_type(front_label.filename)
|
|
99
|
+
back_media_type = get_media_type(back_label.filename if back_label else None)
|
|
100
|
+
|
|
101
|
+
result = await vision_service.analyze_labels(
|
|
102
|
+
front_image_data=front_data,
|
|
103
|
+
back_image_data=back_data,
|
|
104
|
+
front_media_type=front_media_type,
|
|
105
|
+
back_media_type=back_media_type,
|
|
106
|
+
user_api_key=user_api_key,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"parsed": {
|
|
111
|
+
"name": result.get("name"),
|
|
112
|
+
"winery": result.get("winery"),
|
|
113
|
+
"vintage": result.get("vintage"),
|
|
114
|
+
"grape_variety": result.get("grape_variety"),
|
|
115
|
+
"region": result.get("region"),
|
|
116
|
+
"country": result.get("country"),
|
|
117
|
+
"alcohol_percentage": result.get("alcohol_percentage"),
|
|
118
|
+
},
|
|
119
|
+
"ocr": {
|
|
120
|
+
"front_label_text": result.get("raw_text", ""),
|
|
121
|
+
"back_label_text": result.get("back_label_text"),
|
|
122
|
+
},
|
|
123
|
+
"method": "claude_vision",
|
|
124
|
+
}
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.warning(f"Claude Vision failed, falling back to Tesseract: {e}")
|
|
127
|
+
|
|
128
|
+
# Fall back to Tesseract OCR
|
|
129
|
+
logger.info("Using Tesseract OCR for label analysis")
|
|
130
|
+
front_text = await ocr_service.extract_text_from_bytes(front_data)
|
|
131
|
+
|
|
132
|
+
back_text = None
|
|
133
|
+
if back_data:
|
|
134
|
+
back_text = await ocr_service.extract_text_from_bytes(back_data)
|
|
135
|
+
|
|
136
|
+
# Parse wine details from OCR text
|
|
137
|
+
combined_text = front_text
|
|
138
|
+
if back_text:
|
|
139
|
+
combined_text = f"{front_text}\n{back_text}"
|
|
140
|
+
parsed_data = wine_parser.parse(combined_text)
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"parsed": {
|
|
144
|
+
"name": parsed_data.get("name"),
|
|
145
|
+
"winery": parsed_data.get("winery"),
|
|
146
|
+
"vintage": parsed_data.get("vintage"),
|
|
147
|
+
"grape_variety": parsed_data.get("grape_variety"),
|
|
148
|
+
"region": parsed_data.get("region"),
|
|
149
|
+
"country": parsed_data.get("country"),
|
|
150
|
+
"alcohol_percentage": parsed_data.get("alcohol_percentage"),
|
|
151
|
+
},
|
|
152
|
+
"ocr": {
|
|
153
|
+
"front_label_text": front_text,
|
|
154
|
+
"back_label_text": back_text,
|
|
155
|
+
},
|
|
156
|
+
"method": "tesseract",
|
|
157
|
+
}
|
|
158
|
+
|
|
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
|
|
25
165
|
|
|
26
166
|
|
|
27
167
|
@router.post("/checkin", response_model=WineWithInventory, status_code=status.HTTP_201_CREATED)
|
|
28
168
|
async def checkin_wine(
|
|
29
|
-
|
|
169
|
+
current_user: RequireAuth,
|
|
30
170
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
31
171
|
front_label: Annotated[UploadFile, File(description="Front label image")],
|
|
32
|
-
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,
|
|
33
173
|
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,
|
|
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,
|
|
36
176
|
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,
|
|
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,
|
|
40
180
|
alcohol_percentage: Annotated[float | None, Form(ge=0, le=100)] = None,
|
|
41
|
-
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,
|
|
42
184
|
) -> WineWithInventory:
|
|
43
185
|
"""Check in wine bottles to the cellar.
|
|
44
186
|
|
|
45
187
|
Upload front (required) and back (optional) label images.
|
|
46
|
-
|
|
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.
|
|
47
191
|
You can override any auto-detected values.
|
|
48
192
|
"""
|
|
49
193
|
# Save images
|
|
@@ -52,30 +196,81 @@ async def checkin_wine(
|
|
|
52
196
|
if back_label and back_label.filename:
|
|
53
197
|
back_image_path = await image_storage.save_image(back_label)
|
|
54
198
|
|
|
55
|
-
#
|
|
56
|
-
front_text =
|
|
57
|
-
back_text =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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"
|
|
69
264
|
|
|
70
265
|
# Create wine record
|
|
71
266
|
wine = Wine(
|
|
72
267
|
name=wine_name,
|
|
73
|
-
winery=winery
|
|
74
|
-
vintage=vintage
|
|
75
|
-
grape_variety=grape_variety
|
|
76
|
-
region=region
|
|
77
|
-
country=country
|
|
78
|
-
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,
|
|
79
274
|
front_label_text=front_text,
|
|
80
275
|
back_label_text=back_text,
|
|
81
276
|
front_label_image_path=front_image_path,
|
|
@@ -118,8 +313,8 @@ async def checkout_wine(
|
|
|
118
313
|
wine_id: str,
|
|
119
314
|
_: RequireAuth,
|
|
120
315
|
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,
|
|
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,
|
|
123
318
|
) -> WineWithInventory:
|
|
124
319
|
"""Check out wine bottles from the cellar.
|
|
125
320
|
|