The first phase-11 file operation. Lightroom-style inline rename
of a single photo, in place, in its current directory.
Backend (PATCH /photos/{id})
- PhotoUpdate schema accepts an optional `filename`.
- When set, the handler validates: non-empty, no path separators,
no `..`/`.`, target name doesn't already exist in the directory,
source file exists on disk.
- os.renames the file inside its current directory, then updates
photo.filename + photo.filepath atomically. The DB only changes
after a successful rename — a filesystem failure leaves the
rest of the row untouched.
- Other PhotoUpdate fields still apply afterwards in the same
request.
Frontend (RightSidebar)
- Filename is now an editable monospace input above the Title
input. Same draft + commit pattern as title/notes (local draft,
resync on photo.id change, on-blur or Enter commits).
- Esc reverts to the server value.
- Client-side validation mirrors the backend (rejects path
separators and dot-segments) and shows a toast on backend
errors with the FastAPI detail message, then rolls the draft
back so the input matches the still-on-disk filename.
- Removed the old read-only Filename Field from the Basic Info
section to avoid showing the same value twice.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
69 lines
2.1 KiB
Python
69 lines
2.1 KiB
Python
"""
|
|
Pydantic schemas for photos
|
|
"""
|
|
from pydantic import BaseModel, Field
|
|
from typing import Optional, List, Dict, Any
|
|
from datetime import datetime
|
|
|
|
class PhotoBase(BaseModel):
|
|
"""Base photo schema"""
|
|
filename: str
|
|
media_type: str
|
|
original_format: Optional[str] = None
|
|
width: Optional[int] = None
|
|
height: Optional[int] = None
|
|
file_size: Optional[int] = None
|
|
taken_at: Optional[datetime] = None
|
|
taken_at_source: Optional[str] = None
|
|
user_title: Optional[str] = None
|
|
user_notes: Optional[str] = None
|
|
rating: int = 0
|
|
color_label: Optional[str] = None
|
|
|
|
class PhotoResponse(PhotoBase):
|
|
"""Photo response schema"""
|
|
id: str
|
|
filepath: str
|
|
folder_id: Optional[str] = None
|
|
file_hash: Optional[str] = None
|
|
added_at: datetime
|
|
updated_at: Optional[datetime] = None
|
|
is_discarded: bool = False
|
|
discarded_at: Optional[datetime] = None
|
|
thumb_small: Optional[str] = None
|
|
thumb_medium: Optional[str] = None
|
|
thumb_large: Optional[str] = None
|
|
processing_status: str = 'pending'
|
|
processing_error: Optional[str] = None
|
|
exif_json: Optional[str] = None
|
|
is_duplicate: bool = False
|
|
live_photo_video_id: Optional[str] = None
|
|
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading
|
|
|
|
class Config:
|
|
orm_mode = True
|
|
from_attributes = True
|
|
|
|
class PhotoUpdate(BaseModel):
|
|
"""Photo update schema"""
|
|
filename: Optional[str] = None
|
|
user_title: Optional[str] = None
|
|
user_notes: Optional[str] = None
|
|
rating: Optional[int] = Field(None, ge=0, le=5)
|
|
color_label: Optional[str] = None
|
|
is_discarded: Optional[bool] = None
|
|
taken_at: Optional[datetime] = None
|
|
|
|
class PhotoListResponse(BaseModel):
|
|
"""Photo list response with pagination"""
|
|
photos: List[PhotoResponse]
|
|
total: int
|
|
page: int
|
|
per_page: int
|
|
pages: int
|
|
|
|
class BulkAction(BaseModel):
|
|
"""Bulk action on photos"""
|
|
ids: List[str]
|
|
action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color'
|
|
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id) |