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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """WineBox - Wine Cellar Management Application."""
2
2
 
3
- __version__ = "0.1.3"
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"
@@ -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
- 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
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 = 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
@@ -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
- _: RequireAuth,
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
- # Read image data
58
- front_data = await front_label.read()
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.read()
64
- await back_label.seek(0)
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
- _: RequireAuth,
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
- Uses Claude Vision for intelligent label analysis when available.
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
- # Try Claude Vision first
169
- parsed_data = {}
170
- front_text = ""
171
- back_text = None
172
-
173
- if vision_service.is_available():
174
- logger.info("Using Claude Vision for checkin analysis")
175
- try:
176
- front_media_type = get_media_type(front_label.filename)
177
- back_media_type = get_media_type(back_label.filename if back_label else None)
178
-
179
- result = await vision_service.analyze_labels(
180
- front_image_data=front_data,
181
- back_image_data=back_data,
182
- front_media_type=front_media_type,
183
- back_media_type=back_media_type,
184
- )
185
- parsed_data = result
186
- front_text = result.get("raw_text", "")
187
- back_text = result.get("back_label_text")
188
- except Exception as e:
189
- logger.warning(f"Claude Vision failed, falling back to Tesseract: {e}")
190
-
191
- # Fall back to Tesseract if needed
192
- if not parsed_data.get("name"):
193
- logger.info("Using Tesseract OCR for checkin analysis")
194
- front_text = await ocr_service.extract_text(front_image_path)
195
- if back_image_path:
196
- back_text = await ocr_service.extract_text(back_image_path)
197
-
198
- combined_text = front_text
199
- if back_text:
200
- combined_text = f"{front_text}\n{back_text}"
201
- parsed_data = wine_parser.parse(combined_text)
202
-
203
- # Use provided values or fall back to parsed values
204
- 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"
205
264
 
206
265
  # Create wine record
207
266
  wine = Wine(
208
267
  name=wine_name,
209
- winery=winery or parsed_data.get("winery"),
210
- vintage=vintage or parsed_data.get("vintage"),
211
- grape_variety=grape_variety or parsed_data.get("grape_variety"),
212
- region=region or parsed_data.get("region"),
213
- country=country or parsed_data.get("country"),
214
- 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,
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