From fe01ff51b8dea902d49df3b03e2638a78972639f Mon Sep 17 00:00:00 2001 From: Tanya Date: Mon, 5 Jan 2026 15:05:03 -0500 Subject: [PATCH] feat: Add PrismaCompatibleDate type and enhance date validation in photo extraction This commit introduces a new `PrismaCompatibleDate` type to ensure compatibility with Prisma's SQLite driver by storing dates in a DateTime format. Additionally, the `extract_exif_date`, `extract_video_date`, and `extract_photo_date` functions are updated to include validation checks that reject future dates and dates prior to 1900, enhancing data integrity during photo and video metadata extraction. --- backend/db/models.py | 85 ++++++++++++++++++++++++++++++- backend/services/photo_service.py | 36 +++++++++++-- 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/backend/db/models.py b/backend/db/models.py index 6b85d01..d449f7a 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -89,6 +89,89 @@ class PrismaCompatibleDateTime(TypeDecorator): return value +class PrismaCompatibleDate(TypeDecorator): + """ + Date type that stores in DateTime format for Prisma compatibility. + + Prisma's SQLite driver expects DateTime format (YYYY-MM-DD HH:MM:SS) even for dates. + This type stores dates with a time component (00:00:00) so Prisma can read them correctly, + while still using Python's date type in the application. + + Uses String as the underlying type for SQLite to have full control over the format. + """ + impl = String + cache_ok = True + + def process_bind_param(self, value, dialect): + """Convert Python date to space-separated DateTime format for Prisma compatibility.""" + if value is None: + return None + if isinstance(value, date): + # Store date in space-separated format: YYYY-MM-DD HH:MM:SS (matching date_added format) + return value.strftime('%Y-%m-%d 00:00:00') + if isinstance(value, datetime): + # If datetime is passed, extract date and format with time component + return value.date().strftime('%Y-%m-%d 00:00:00') + if isinstance(value, str): + # If it's already a string, ensure it's in space-separated format + try: + # Try to parse and convert to space-separated format + if 'T' in value: + # ISO format with T - convert to space-separated + date_part, time_part = value.split('T', 1) + time_part = time_part.split('+')[0].split('-')[0].split('Z')[0].split('.')[0] + if len(time_part.split(':')) == 3: + return f"{date_part} {time_part}" + else: + return f"{date_part} 00:00:00" + elif ' ' in value: + # Already space-separated - ensure it has time component + parts = value.split(' ', 1) + if len(parts) == 2: + date_part, time_part = parts + time_part = time_part.split('.')[0] # Remove microseconds if present + if len(time_part.split(':')) == 3: + return f"{date_part} {time_part}" + # Missing time component - add it + return f"{parts[0]} 00:00:00" + else: + # Just date (YYYY-MM-DD) - add time component + d = datetime.strptime(value, '%Y-%m-%d').date() + return d.strftime('%Y-%m-%d 00:00:00') + except (ValueError, TypeError): + # If parsing fails, return as-is + return value + return value + + def process_result_value(self, value, dialect): + """Convert SQL string back to Python date.""" + if value is None: + return None + if isinstance(value, str): + # Extract date part from ISO 8601 or space-separated DateTime string + try: + if 'T' in value: + # ISO format with T + return datetime.fromisoformat(value.split('T')[0]).date() + elif ' ' in value: + # Space-separated format - extract date part + return datetime.strptime(value.split()[0], '%Y-%m-%d').date() + else: + # Just date (YYYY-MM-DD) + return datetime.strptime(value, '%Y-%m-%d').date() + except ValueError: + # Fallback to ISO format parser + try: + return datetime.fromisoformat(value.split('T')[0]).date() + except: + return datetime.strptime(value.split()[0], '%Y-%m-%d').date() + if isinstance(value, (date, datetime)): + if isinstance(value, datetime): + return value.date() + return value + return value + + class Photo(Base): """Photo model - matches desktop schema exactly.""" @@ -98,7 +181,7 @@ class Photo(Base): path = Column(Text, unique=True, nullable=False, index=True) filename = Column(Text, nullable=False) date_added = Column(PrismaCompatibleDateTime, default=datetime.utcnow, nullable=False) - date_taken = Column(Date, nullable=True, index=True) + date_taken = Column(PrismaCompatibleDate, nullable=True, index=True) processed = Column(Boolean, default=False, nullable=False, index=True) file_hash = Column(Text, nullable=True, index=True) # Nullable to support existing photos without hashes media_type = Column(Text, default="image", nullable=False, index=True) # "image" or "video" diff --git a/backend/services/photo_service.py b/backend/services/photo_service.py index cdc919a..3bfccef 100644 --- a/backend/services/photo_service.py +++ b/backend/services/photo_service.py @@ -100,12 +100,20 @@ def extract_exif_date(image_path: str) -> Optional[date]: # Parse EXIF date format (YYYY:MM:DD HH:MM:SS) try: dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S") - return dt.date() + extracted_date = dt.date() + # Validate date before returning (reject future dates) + if extracted_date > date.today() or extracted_date < date(1900, 1, 1): + continue # Skip invalid dates + return extracted_date except ValueError: # Try alternative format try: dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S") - return dt.date() + extracted_date = dt.date() + # Validate date before returning (reject future dates) + if extracted_date > date.today() or extracted_date < date(1900, 1, 1): + continue # Skip invalid dates + return extracted_date except ValueError: continue except (KeyError, TypeError): @@ -123,11 +131,19 @@ def extract_exif_date(image_path: str) -> Optional[date]: if date_str: try: dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S") - return dt.date() + extracted_date = dt.date() + # Validate date before returning (reject future dates) + if extracted_date > date.today() or extracted_date < date(1900, 1, 1): + continue # Skip invalid dates + return extracted_date except ValueError: try: dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S") - return dt.date() + extracted_date = dt.date() + # Validate date before returning (reject future dates) + if extracted_date > date.today() or extracted_date < date(1900, 1, 1): + continue # Skip invalid dates + return extracted_date except ValueError: continue except Exception: @@ -216,7 +232,11 @@ def extract_video_date(video_path: str) -> Optional[date]: else: # Try other common formats dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S") - return dt.date() + extracted_date = dt.date() + # Validate date before returning (reject future dates) + if extracted_date > date.today() or extracted_date < date(1900, 1, 1): + continue # Skip invalid dates + return extracted_date except (ValueError, AttributeError): continue except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError, Exception) as e: @@ -230,6 +250,9 @@ def extract_video_date(video_path: str) -> Optional[date]: if os.path.exists(video_path): mtime = os.path.getmtime(video_path) mtime_date = datetime.fromtimestamp(mtime).date() + # Validate date before returning (reject future dates) + if mtime_date > date.today() or mtime_date < date(1900, 1, 1): + return None # Skip invalid dates return mtime_date except Exception as e: # Log error for debugging (but don't fail the import) @@ -260,6 +283,9 @@ def extract_photo_date(image_path: str) -> Optional[date]: if os.path.exists(image_path): mtime = os.path.getmtime(image_path) mtime_date = datetime.fromtimestamp(mtime).date() + # Validate date before returning (reject future dates) + if mtime_date > date.today() or mtime_date < date(1900, 1, 1): + return None # Skip invalid dates return mtime_date except Exception as e: # Log error for debugging (but don't fail the import)