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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """WineBox - Wine Cellar Management Application."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.4"
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
- def generate_secret_key() -> str:
9
- """Generate a random secret key."""
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
- secret_key: str = generate_secret_key() # Override with WINEBOX_SECRET_KEY env var
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 = 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.model_validate(current_user)
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
- _: RequireAuth,
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
- OCR will extract text and attempt to identify wine details.
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
- # 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"
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 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"),
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