Implemented directory management and improved TravelDiary and Photo handling logic.

- Added `DirectoryManager` utility for consistent directory operations.
- Introduced directory name sanitization and uniqueness enforcement for `TravelDiary`.
- Updated `TravelDiaryService` with enhanced creation, update, and deletion workflows, including filesystem management.
- Improved `PhotoService` to hash files, copy photos to specific diary directories, and manage updates safely.
- Refined error handling and cascaded changes across related entities (e.g., `Entry` relationships).
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda
2025-07-06 01:07:25 -03:00
parent b1e83aabbb
commit 090bbeda1a
8 changed files with 284 additions and 78 deletions
+82 -9
View File
@@ -1,26 +1,76 @@
import hashlib
import os
import shutil
from datetime import datetime
from pathlib import Path
from typing import List
from datetime import datetime
import hashlib
from pilgrim.models.photo import Photo
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.utils import DirectoryManager
class PhotoService:
def __init__(self, session):
self.session = session
def _hash_file(self,filepath):
def _hash_file(self, filepath: Path) -> str:
"""Calculate hash of a file using SHA3-384."""
hash_func = hashlib.new('sha3_384')
with open(filepath, 'rb') as f:
while chunk := f.read(8192):
hash_func.update(chunk)
return hash_func.hexdigest()
def _ensure_images_directory(self, travel_diary: TravelDiary) -> Path:
"""
Ensures the images directory exists for the given diary.
Returns the path to the images directory.
"""
images_dir = DirectoryManager.get_diary_images_directory(travel_diary.directory_name)
if not images_dir.exists():
images_dir.mkdir(parents=True)
os.chmod(images_dir, 0o700) # Ensure correct permissions
return images_dir
def _copy_photo_to_diary(self, source_path: Path, travel_diary: TravelDiary) -> Path:
"""
Copies a photo to the diary's images directory.
Returns the path to the copied file.
"""
images_dir = self._ensure_images_directory(travel_diary)
# Get original filename and extension
original_name = Path(source_path).name
# Create destination path
dest_path = images_dir / original_name
# If file with same name exists, add a number
counter = 1
while dest_path.exists():
name_parts = original_name.rsplit('.', 1)
if len(name_parts) > 1:
dest_path = images_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
else:
dest_path = images_dir / f"{original_name}_{counter}"
counter += 1
# Copy the file
shutil.copy2(source_path, dest_path)
os.chmod(dest_path, 0o600) # Read/write for owner only
return dest_path
def create(self, filepath: Path, name: str, travel_diary_id: int, caption=None, addition_date=None) -> Photo | None:
travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first()
if not travel_diary:
return None
# Copy photo to diary's images directory
copied_path = self._copy_photo_to_diary(filepath, travel_diary)
# Convert addition_date string to datetime if needed
if isinstance(addition_date, str):
@@ -28,20 +78,24 @@ class PhotoService:
addition_date = datetime.strptime(addition_date, "%Y-%m-%d %H:%M:%S")
except ValueError:
addition_date = None
# Calculate hash from the copied file
photo_hash = self._hash_file(copied_path)
new_photo = Photo(
filepath=filepath,
filepath=str(copied_path), # Store the path to the copied file
name=name,
caption=caption,
fk_travel_diary_id=travel_diary_id,
addition_date=addition_date,
photo_hash=self._hash_file(filepath)
photo_hash=photo_hash
)
self.session.add(new_photo)
self.session.commit()
self.session.refresh(new_photo)
return new_photo
def read_by_id(self, photo_id:int) -> Photo:
return self.session.query(Photo).get(photo_id)
@@ -51,15 +105,30 @@ class PhotoService:
def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
original: Photo = self.read_by_id(photo_src.id)
if original:
original.filepath = photo_dst.filepath
# If filepath changed, need to copy new file
if str(photo_dst.filepath) != str(original.filepath):
travel_diary = self.session.query(TravelDiary).filter(
TravelDiary.id == original.fk_travel_diary_id).first()
if travel_diary:
# Copy new photo
new_path = self._copy_photo_to_diary(Path(photo_dst.filepath), travel_diary)
# Delete old photo if it exists in our images directory
old_path = Path(original.filepath)
if old_path.exists() and str(DirectoryManager.get_diaries_root()) in str(old_path):
old_path.unlink()
original.filepath = str(new_path)
# Update hash based on the new copied file
original.photo_hash = self._hash_file(new_path)
original.name = photo_dst.name
original.addition_date = photo_dst.addition_date
original.caption = photo_dst.caption
original.photo_hash = original.photo_hash
if photo_dst.entries and len(photo_dst.entries) > 0:
if original.entries is None:
original.entries = []
original.entries = photo_dst.entries # Replace instead of extend
self.session.commit()
self.session.refresh(original)
return original
@@ -78,8 +147,12 @@ class PhotoService:
id=excluded.id,
photo_hash=excluded.photo_hash,
entries=excluded.entries,
)
# Delete the physical file if it exists in our images directory
file_path = Path(excluded.filepath)
if file_path.exists() and str(DirectoryManager.get_diaries_root()) in str(file_path):
file_path.unlink()
self.session.delete(excluded)
self.session.commit()
+127 -18
View File
@@ -1,30 +1,132 @@
import os
import re
import shutil
from pathlib import Path
from pilgrim.utils import DirectoryManager
from sqlalchemy.exc import IntegrityError
from ..models.travel_diary import TravelDiary
import asyncio
class TravelDiaryService:
def __init__(self,session):
def __init__(self, session):
self.session = session
async def async_create(self, name:str):
new_travel_diary = TravelDiary(name)
self.session.add(new_travel_diary)
self.session.commit()
self.session.refresh(new_travel_diary)
return new_travel_diary
def _sanitize_directory_name(self, name: str) -> str:
"""
Sanitizes a diary name for use as a directory name.
- Removes special characters
- Replaces spaces with underscores
- Ensures name is unique by adding a suffix if needed
"""
# Remove special characters and replace spaces
safe_name = re.sub(r'[^\w\s-]', '', name)
safe_name = safe_name.strip().replace(' ', '_').lower()
def read_by_id(self, travel_id:int):
return self.session.query(TravelDiary).get(travel_id)
# Ensure we have a valid name
if not safe_name:
safe_name = "unnamed_diary"
# Check if name is already used in database
base_name = safe_name
counter = 1
while self.session.query(TravelDiary).filter_by(directory_name=safe_name).first() is not None:
safe_name = f"{base_name}_{counter}"
counter += 1
return safe_name
def _get_diary_directory(self, diary: TravelDiary) -> Path:
"""Returns the directory path for a diary."""
return DirectoryManager.get_diary_directory(diary.directory_name)
def _get_diary_data_directory(self, diary: TravelDiary) -> Path:
"""Returns the data directory path for a diary."""
return DirectoryManager.get_diary_data_directory(diary.directory_name)
def _ensure_diary_directory(self, diary: TravelDiary) -> Path:
"""
Creates and returns the directory structure for a diary:
~/.pilgrim/diaries/{directory_name}/data/
"""
# Create diary directory
diary_dir = self._get_diary_directory(diary)
diary_dir.mkdir(exist_ok=True)
os.chmod(diary_dir, 0o700)
# Create data subdirectory
data_dir = self._get_diary_data_directory(diary)
data_dir.mkdir(exist_ok=True)
os.chmod(data_dir, 0o700)
return data_dir
def _cleanup_diary_directory(self, diary: TravelDiary):
"""Removes the diary directory and all its contents."""
diary_dir = self._get_diary_directory(diary)
if diary_dir.exists():
shutil.rmtree(diary_dir)
async def async_create(self, name: str):
# Generate safe directory name
directory_name = self._sanitize_directory_name(name)
# Create diary with directory name
new_travel_diary = TravelDiary(name=name, directory_name=directory_name)
try:
self.session.add(new_travel_diary)
self.session.commit()
self.session.refresh(new_travel_diary)
# Create directory structure for the new diary
self._ensure_diary_directory(new_travel_diary)
return new_travel_diary
except IntegrityError:
self.session.rollback()
raise ValueError(f"Could not create diary: directory name '{directory_name}' already exists")
def read_by_id(self, travel_id: int):
diary = self.session.query(TravelDiary).get(travel_id)
if diary:
# Ensure directory exists when reading
self._ensure_diary_directory(diary)
return diary
def read_all(self):
return self.session.query(TravelDiary).all()
diaries = self.session.query(TravelDiary).all()
# Ensure directories exist for all diaries
for diary in diaries:
self._ensure_diary_directory(diary)
return diaries
def update(self, travel_diary_id: int, name: str):
original = self.read_by_id(travel_diary_id)
if original is not None:
original.name = name
self.session.commit()
self.session.refresh(original)
return original
try:
# Generate new directory name
new_directory_name = self._sanitize_directory_name(name)
old_directory = self._get_diary_directory(original)
# Update diary
original.name = name
original.directory_name = new_directory_name
self.session.commit()
self.session.refresh(original)
# Rename directory if it exists
new_directory = self._get_diary_directory(original)
if old_directory.exists() and old_directory != new_directory:
old_directory.rename(new_directory)
return original
except IntegrityError:
self.session.rollback()
raise ValueError(f"Could not update diary: directory name '{new_directory_name}' already exists")
return None
async def async_update(self, travel_diary_id: int, name: str):
return self.update(travel_diary_id, name)
@@ -32,7 +134,14 @@ class TravelDiaryService:
def delete(self, travel_diary_id: TravelDiary):
excluded = self.read_by_id(travel_diary_id.id)
if excluded is not None:
self.session.delete(travel_diary_id)
self.session.commit()
return excluded
try:
# First delete the directory
self._cleanup_diary_directory(excluded)
# Then delete from database
self.session.delete(travel_diary_id)
self.session.commit()
return excluded
except Exception as e:
self.session.rollback()
raise ValueError(f"Could not delete diary: {str(e)}")
return None