Source code for cloud_server.db.files_metadata_database_manager
"""Files metadata database manager."""
import logging
import mimetypes
from pathlib import Path
from python_template_server.db import BaseDatabaseManager
from sqlmodel import Field, Session, SQLModel, select
from cloud_server.models import DatabaseAction, FileMetadata, ServerDatabaseConfig, current_timestamp_int
logger = logging.getLogger(__name__)
# Database table models
[docs]
class FileMetadataDB(SQLModel, table=True):
"""Files metadata table."""
__tablename__ = "files_metadata"
id: int | None = Field(default=None, primary_key=True)
filename: str = Field(..., description="Original filename of the uploaded file.")
parent_directory: str = Field(..., description="Path to parent directory relative to server storage directory.")
mime_type: str = Field(..., description="MIME type of the file.")
size: int = Field(..., description="File size in bytes.")
uploaded_at: int = Field(
default_factory=current_timestamp_int, description="Unix timestamp when the file was uploaded."
)
updated_at: int = Field(
default_factory=current_timestamp_int, description="Unix timestamp when the file was last updated."
)
[docs]
@classmethod
def from_file_metadata(cls, file_metadata: FileMetadata) -> "FileMetadataDB":
"""Create a FileMetadataDB instance from a FileMetadata."""
return cls(
id=file_metadata.id,
filename=file_metadata.filename,
parent_directory=str(file_metadata.parent_directory),
mime_type=file_metadata.mime_type,
size=file_metadata.size,
uploaded_at=file_metadata.uploaded_at,
updated_at=file_metadata.updated_at,
)
[docs]
def to_file_metadata(self) -> FileMetadata:
"""Convert the database model to a FileMetadata."""
return FileMetadata(
id=self.id,
filename=self.filename,
parent_directory=Path(self.parent_directory),
mime_type=self.mime_type,
size=self.size,
uploaded_at=self.uploaded_at,
updated_at=self.updated_at,
)
[docs]
def update_from_file_metadata(self, file_metadata: FileMetadata) -> None:
"""Update the database model fields from a FileMetadata."""
self.filename = file_metadata.filename or self.filename
self.parent_directory = str(file_metadata.parent_directory) or self.parent_directory
self.updated_at = current_timestamp_int()
# Database manager class
[docs]
class FilesMetadataDatabaseManager(BaseDatabaseManager):
"""Manager class for files metadata database operations."""
[docs]
def __init__(self) -> None:
"""Initialize the FilesMetadataDatabaseManager with the given database configuration."""
self.db_config: ServerDatabaseConfig
super().__init__()
@property
def db_url(self) -> str:
"""Get the database URL."""
return self.db_config.db_url(self.db_config.files_metadata_db_filename) # type: ignore[no-any-return]
def _create_file_metadata(self, session: Session, file_metadata: FileMetadata) -> FileMetadata:
"""Add a new file metadata entry to the database."""
if file_metadata.id is not None:
error_msg = f"File metadata ID must be None for new entries, got ID {file_metadata.id}!"
logger.error(error_msg)
raise ValueError(error_msg)
db_entry = FileMetadataDB.from_file_metadata(file_metadata=file_metadata)
session.add(db_entry)
session.commit()
session.refresh(db_entry)
return db_entry.to_file_metadata()
def _read_file_metadata(self, session: Session, file_id: int) -> FileMetadata:
"""Retrieve a file metadata entry from the database."""
if not (db_entry := session.get(FileMetadataDB, file_id)):
error_msg = f"File {file_id} not found!"
logger.error(error_msg)
raise ValueError(error_msg)
return db_entry.to_file_metadata()
def _update_file_metadata(self, session: Session, file_id: int, file_metadata: FileMetadata) -> FileMetadata:
"""Update an existing file metadata entry in the database."""
if not (db_entry := session.get(FileMetadataDB, file_id)):
error_msg = f"File {file_id} not found!"
logger.error(error_msg)
raise ValueError(error_msg)
db_entry.update_from_file_metadata(file_metadata=file_metadata)
session.add(db_entry)
session.commit()
session.refresh(db_entry)
return db_entry.to_file_metadata()
def _delete_file_metadata(self, session: Session, file_id: int) -> FileMetadata:
"""Delete a file metadata entry from the database."""
if not (db_entry := session.get(FileMetadataDB, file_id)):
error_msg = f"File {file_id} not found!"
logger.error(error_msg)
raise ValueError(error_msg)
session.delete(db_entry)
session.commit()
return db_entry.to_file_metadata()
def _list_files_metadata(self, session: Session) -> list[FileMetadata]:
"""List all file metadata entries in the database."""
db_entries = session.exec(select(FileMetadataDB)).all()
return [db_entry.to_file_metadata() for db_entry in db_entries]
[docs]
def list_files(self) -> list[FileMetadata]:
"""Public method to list all file metadata entries."""
with Session(self.engine) as session:
return self._list_files_metadata(session=session)
[docs]
def perform_file_metadata_action(
self, action: DatabaseAction, file_metadata: FileMetadata | None = None, file_id: int | None = None
) -> FileMetadata:
"""Perform a database action (CRUD) on file metadata."""
with Session(self.engine) as session:
match action:
case DatabaseAction.CREATE if file_metadata is not None:
return self._create_file_metadata(session=session, file_metadata=file_metadata)
case DatabaseAction.READ if file_id is not None:
return self._read_file_metadata(session=session, file_id=file_id)
case DatabaseAction.UPDATE if file_id is not None and file_metadata is not None:
return self._update_file_metadata(session=session, file_id=file_id, file_metadata=file_metadata)
case DatabaseAction.DELETE if file_id is not None:
return self._delete_file_metadata(session=session, file_id=file_id)
case _:
error_msg = f"Missing parameters: action={action}, file_id={file_id}, file_metadata={file_metadata}"
logger.error(error_msg)
raise ValueError(error_msg)
[docs]
def synchronize_with_storage(self, storage_directory: Path) -> None:
"""Synchronize the files metadata database with the actual files in the storage directory.
This method ensures that the database entries accurately reflect the files present in the storage directory.
It adds metadata for new files, updates metadata for existing files, and removes metadata for deleted files.
:param Path storage_directory: The path to the storage directory to synchronize with
"""
if not any(storage_directory.iterdir()):
logger.warning("Storage directory is empty, skipping synchronization: %s", storage_directory)
return
existing_metadata = {metadata.filepath: metadata for metadata in self.list_files()}
for filepath in storage_directory.rglob("*"):
if filepath.is_file():
relative_path = filepath.relative_to(storage_directory)
if relative_path.parts[0] == ".thumbnails":
continue
if relative_path in existing_metadata.keys():
del existing_metadata[relative_path]
continue
filename = relative_path.name
parent_directory = relative_path.parent
mime_type, _ = mimetypes.guess_type(filepath)
size = filepath.stat().st_size
file_metadata = FileMetadata(
filename=filename,
parent_directory=parent_directory,
mime_type=mime_type or "application/octet-stream",
size=size,
)
logger.info("Adding new metadata for file: %s", file_metadata.filepath)
self.perform_file_metadata_action(action=DatabaseAction.CREATE, file_metadata=file_metadata)
for remaining_entry in existing_metadata.values():
logger.warning("Removing metadata for deleted file: %s", remaining_entry.filepath)
self.perform_file_metadata_action(action=DatabaseAction.DELETE, file_id=remaining_entry.id)