Compare commits

..
Author SHA1 Message Date
coderabbitai[bot] 335d06bca1 📝 Add docstrings to staging
Docstrings generation was requested by @gmbrax.

* https://github.com/gmbrax/Pilgrim/pull/48#issuecomment-3076555726

The following files were modified:

* `src/pilgrim/ui/screens/diary_list_screen.py`
* `src/pilgrim/ui/screens/edit_diary_modal.py`
* `src/pilgrim/ui/screens/new_diary_modal.py`
2025-07-16 03:06:05 +00:00
30 changed files with 274 additions and 948 deletions
+24
View File
@@ -0,0 +1,24 @@
name: Pylint
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
pip install pylint
- name: Analysing the code with pylint
run: |
pylint --disable=C0114,C0115,C0116 --exit-zero $(git ls-files '*.py')
-63
View File
@@ -1,63 +0,0 @@
name: Pylint and SonarCloud
on:
push:
branches: [ main, master, staging ]
pull_request:
branches: [ main, master, staging ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessário para SonarCloud
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
pip install pylint ruff coverage pytest
- name: Analysing the code with pylint (console output)
run: |
pylint --disable=C0114,C0115,C0116 --exit-zero $(git ls-files '*.py') || true
- name: Generate Pylint report for SonarCloud
run: |
pylint --disable=C0114,C0115,C0116 --output-format=json --exit-zero $(git ls-files '*.py') > pylint-report.json || true
- name: Run Ruff
run: |
ruff check --output-format=json . > ruff-report.json || true
- name: Run tests with coverage (if you have tests)
run: |
if [ -d "tests" ] || [ -f "test_*.py" ] || [ -f "*_test.py" ]; then
coverage run -m pytest || true
coverage xml || true
else
echo "No tests found, skipping coverage"
fi
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@v5.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: >
-Dsonar.projectKey=gmbrax_Pilgrim
-Dsonar.organization=gmbrax
-Dsonar.python.pylint.reportPaths=pylint-report.json
-Dsonar.python.ruff.reportPaths=ruff-report.json
-Dsonar.python.coverage.reportPaths=coverage.xml
+30 -47
View File
@@ -1,68 +1,51 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
## Unreleased
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Planned
* Installation Method 1 (repository compilation)
* Organization of trips by date, location, or theme
* Enhanced photo management features
* Search functionality
* Export features
* Testing implementation
## [Unreleased]
## [0.0.4] - 2025-07-19
### Planned
### Added
* Support for creating new diaries asynchronously, with an option to automatically open the newly created diary
* Unified "Enter" key support for saving or creating diaries across relevant modals
* Automatic diary list refresh when returning to the diary screen
* Application configuration management with a new centralized config system
* Database location and initialization now configurable via the new config manager
* Automatic migration of database file to the configuration directory
* Display of database URL on application startup for transparency
* Duplicate photo detection before photo creation to prevent redundant entries
* Photo hash indexing to improve photo lookup performance
### Changed
* Enhanced feedback and validation when editing or creating diary names
* Streamlined and unified save logic for diary modals, reducing duplicated behavior
* About screen now displays the actual installed application version dynamically
* Sidebar and photo-related UI text updated to remove emoji icons for a cleaner appearance
* Sidebar layout and scrolling behavior improved for better usability
* Photo hash generation now relies on existing service-provided hashes instead of local computation
### Improved
* Enhanced feedback and validation when editing or creating diary names
* Streamlined and unified save logic for diary modals, reducing duplicated behavior
* Sidebar layout and scrolling behavior for better usability
- Installation Method 1 (repository compilation)
- Organization of trips by date, location, or theme
- Enhanced photo management features
- Search functionality
- Export features
- Testing implementation
## [0.0.3] - 2025-07-07
### Changed
* Removed the dependency on textual-dev from pyproject.toml
- Removed the dependency on textual-dev from pyproject.toml
## [0.0.2] - 2025-07-07
### Changed
* Changed the license in pyproject.toml to BSD
- Changed the license in pyproject.toml to BSD
## [0.0.1] - 2025-07-06
### Added
* Initial alpha release of Pilgrim travel diary application
* Create and edit travel diaries
* Create and edit diary entries
* Photo ingestion system
* Photo addition and reference via sidebar
* Text User Interface (TUI) built with Textual framework
* Pre-compiled binary installation method (Method 2)
* Support for Linux operating systems
* Basic project documentation (README)
- Initial alpha release of Pilgrim travel diary application
- Create and edit travel diaries
- Create and edit diary entries
- Photo ingestion system
- Photo addition and reference via sidebar
- Text User Interface (TUI) built with Textual framework
- Pre-compiled binary installation method (Method 2)
- Support for Linux operating systems
- Basic project documentation (README)
### Known Issues
* Installation Method 1 not yet implemented
* No testing suite implemented yet
* Some features may be unstable in an alpha version
- Installation Method 1 not yet implemented
- No testing suite implemented yet
- Some features may be unstable in an alpha version
[Unreleased]: https://github.com/username/pilgrim/compare/v0.0.1...HEAD
[0.0.1]: https://github.com/username/pilgrim/releases/tag/v0.0.1
+9 -8
View File
@@ -2,9 +2,10 @@
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pilgrim"
version = "0.0.4"
name = "Pilgrim"
version = "0.0.3"
authors = [
{ name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" }
]
@@ -19,14 +20,14 @@ classifiers = [
dependencies = [
"sqlalchemy",
"textual",
"tomli",
"tomli_w",
"unidecode"
]
]
[template.plugins.default]
src-layout = true
[project.urls]
Homepage = "https://github.com/gmbrax/Pilgrim/"
Issues = "https://github.com/gmbrax/Pilgrim/issues"
[tool.hatch.build.targets.wheel]
packages = ["src/pilgrim"]
[project.scripts]
pilgrim = "pilgrim.command:main"
pilgrim = "pilgrim:main"
+4 -6
View File
@@ -1,21 +1,19 @@
from pilgrim.database import Database
from pilgrim.service.servicemanager import ServiceManager
from pilgrim.ui.ui import UIApp
from pilgrim.utils import ConfigManager
from pilgrim.utils import DirectoryManager
class Application:
def __init__(self):
self.config_manager = ConfigManager()
self.config_manager.read_config() # Chamar antes de criar o Database
self.database = Database(self.config_manager)
self.config_dir = DirectoryManager.get_config_directory()
self.database = Database()
session = self.database.session()
session_manager = ServiceManager()
session_manager.set_session(session)
self.ui = UIApp(session_manager, self.config_manager)
self.ui = UIApp(session_manager)
def run(self):
print(f"URL do banco: {self.config_manager.database_url}")
self.database.create()
self.ui.run()
+27 -13
View File
@@ -1,28 +1,42 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from pathlib import Path
import os
import shutil
from pilgrim.utils import ConfigManager
Base = declarative_base()
def get_database_path() -> Path:
"""
Get the database file path following XDG Base Directory specification.
Creates the directory if it doesn't exist.
"""
# Get home directory
home = Path.home()
# Create .pilgrim directory if it doesn't exist
pilgrim_dir = home / ".pilgrim"
pilgrim_dir.mkdir(exist_ok=True)
# Database file path
db_path = pilgrim_dir / "database.db"
# If database doesn't exist in new location but exists in current directory,
# migrate it
if not db_path.exists():
current_db = Path("database.db")
if current_db.exists():
shutil.copy2(current_db, db_path)
print(f"Database migrated from {current_db} to {db_path}")
return db_path
class Database:
def __init__(self, config_manager: ConfigManager):
self.db_path = config_manager.database_url
# Garantir que o diretório existe
db_dir = os.path.dirname(self.db_path)
if not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True)
def __init__(self):
db_path = get_database_path()
self.engine = create_engine(
f"sqlite:///{self.db_path}",
f"sqlite:///{db_path}",
echo=False,
connect_args={"check_same_thread": False},
)
+3 -4
View File
@@ -4,16 +4,15 @@ from pilgrim.models.photo_in_entry import photo_entry_association
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from pilgrim.database import Base
from ..database import Base
class Entry(Base):
__tablename__ = "entries"
id = Column(Integer, primary_key=True)
title = Column(String,nullable=False)
title = Column(String)
text = Column(String)
date = Column(DateTime,nullable=False)
date = Column(DateTime)
photos = relationship(
"Photo",
secondary=photo_entry_association,
+1 -7
View File
@@ -4,11 +4,9 @@ from pathlib import Path
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql.schema import Index
from pilgrim.models.photo_in_entry import photo_entry_association
from pilgrim.database import Base
from ..database import Base
class Photo(Base):
@@ -26,10 +24,6 @@ class Photo(Base):
)
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False)
travel_diary = relationship("TravelDiary", back_populates="photos")
__table_args__ = (
Index('idx_photo_hash_diary', 'hash', 'fk_travel_diary_id'),
)
def __init__(self, filepath, name, photo_hash, addition_date=None, caption=None, entries=None, fk_travel_diary_id=None, **kw: Any):
super().__init__(**kw)
+1 -1
View File
@@ -1,6 +1,6 @@
from sqlalchemy import Table, Column, Integer, ForeignKey
from pilgrim.database import Base
from ..database import Base
photo_entry_association = Table('photo_entry_association', Base.metadata,
Column('id', Integer, primary_key=True, autoincrement=True),
+2 -4
View File
@@ -3,17 +3,15 @@ from typing import Any
from sqlalchemy import Column, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from pilgrim.database import Base
from .. import database
class TravelDiary(Base):
class TravelDiary(database.Base):
__tablename__ = "travel_diaries"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
directory_name = Column(String, nullable=False, unique=True)
entries = relationship("Entry", back_populates="travel_diary", cascade="all, delete-orphan")
photos = relationship("Photo", back_populates="travel_diary", cascade="all, delete-orphan")
__table_args__ = (
UniqueConstraint('directory_name', name='uq_travel_diary_directory_name'),
+3 -3
View File
@@ -1,9 +1,9 @@
from datetime import datetime
from typing import List
from pilgrim.models.entry import Entry
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.photo import Photo # ✨ Importe o modelo Photo
from ..models.entry import Entry
from ..models.travel_diary import TravelDiary
from ..models.photo import Photo # ✨ Importe o modelo Photo
class EntryService:
+5 -12
View File
@@ -14,9 +14,8 @@ class PhotoService:
def __init__(self, session):
self.session = session
@staticmethod
def hash_file(filepath: Path) -> str:
"""Calculate the hash of a file using SHA3-384."""
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):
@@ -65,18 +64,10 @@ class PhotoService:
return dest_path
def check_photo_by_hash(self, photohash:str, traveldiaryid:int):
photo = (self.session.query(Photo).filter(Photo.photo_hash == photohash,Photo.fk_travel_diary_id == traveldiaryid)
.first())
return photo
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
photo_hash = self.hash_file(filepath)
if self.check_photo_by_hash(photo_hash, travel_diary_id):
return None
# Copy photo to diary's images directory
copied_path = self._copy_photo_to_diary(filepath, travel_diary)
@@ -88,6 +79,8 @@ class PhotoService:
except ValueError:
addition_date = None
# Calculate hash from the copied file
photo_hash = self._hash_file(copied_path)
new_photo = Photo(
filepath=str(copied_path), # Store the path to the copied file
@@ -125,7 +118,7 @@ class PhotoService:
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.photo_hash = self._hash_file(new_path)
original.name = photo_dst.name
original.addition_date = photo_dst.addition_date
+3 -5
View File
@@ -6,8 +6,8 @@ from pathlib import Path
from pilgrim.utils import DirectoryManager
from sqlalchemy.exc import IntegrityError
from pilgrim.models.travel_diary import TravelDiary
from unidecode import unidecode
from ..models.travel_diary import TravelDiary
class TravelDiaryService:
def __init__(self, session):
@@ -20,10 +20,8 @@ class TravelDiaryService:
- Replaces spaces with underscores
- Ensures name is unique by adding a suffix if needed
"""
transliterated_name = unidecode(name)
# Remove special characters and replace spaces
safe_name = re.sub(r'[^\w\s-]', '', transliterated_name)
safe_name = re.sub(r'[^\w\s-]', '', name)
safe_name = safe_name.strip().replace(' ', '_').lower()
# Ensure we have a valid name
+2 -2
View File
@@ -3,7 +3,7 @@ from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import Header, Footer, Button, Label, TextArea
from textual.containers import Container
from importlib.metadata import version
class AboutScreen(Screen[bool]):
"""Screen to display application information."""
@@ -20,7 +20,7 @@ class AboutScreen(Screen[bool]):
self.app_title = Label("Pilgrim", id="AboutScreen_AboutTitle",classes="AboutScreen_AboutTitle")
self.content = Label("A TUI Based Travel Diary Application", id="AboutScreen_AboutContent",
classes="AboutScreen_AboutContent")
self.version = Label(f"Version: {version('Pilgrim')}", id="AboutScreen_AboutVersion",
self.version = Label("Version: 0.0.1", id="AboutScreen_AboutVersion",
classes="AboutScreen_AboutVersion")
self.developer = Label("Developed By: Gustavo Henrique Miranda ", id="AboutScreen_AboutAuthor")
self.contact = Label("git.gustavomiranda.xyz", id="AboutScreen_AboutContact",
+16 -4
View File
@@ -203,11 +203,17 @@ class DiaryListScreen(Screen):
self.action_open_diary()
def action_new_diary(self):
"""Action to create new diary"""
"""
Opens the modal dialog to create a new diary and registers a callback for submission.
"""
self.app.push_screen(NewDiaryModal(),self._on_new_diary_submitted)
def _on_new_diary_submitted(self, result):
"""Callback after diary creation"""
"""
Handles the callback after a new diary is submitted.
If a diary was created (non-empty result), notifies the user and refreshes the diary list; otherwise, notifies that creation was canceled.
"""
if result: # Se result não é string vazia, o diário foi criado
self.notify(f"Returning to diary list...")
# Atualiza a lista de diários
@@ -216,11 +222,17 @@ class DiaryListScreen(Screen):
self.notify(f"Creation canceled...")
def _on_screen_resume(self) -> None:
super()._on_screen_resume()
"""
Refreshes the diary list when the screen resumes.
"""
self.refresh_diaries()
def action_edit_selected_diary(self):
"""Action to edit selected diary"""
"""
Opens the edit dialog for the currently selected diary.
If a diary is selected, displays the edit modal and registers a callback for when the edit is submitted. Notifies the user if no diary is selected.
"""
if self.selected_diary_index is not None:
diary_id = self.diary_id_map.get(self.selected_diary_index)
if diary_id:
@@ -12,6 +12,9 @@ class EditDiaryModal(ModalScreen[tuple[int,str]]):
]
def __init__(self, diary_id: int):
"""
Initialize the EditDiaryModal with the specified diary ID, pre-filling the input field with the current diary name.
"""
super().__init__()
self.diary_id = diary_id
self.current_diary_name = self.app.service_manager.get_travel_diary_service().read_by_id(self.diary_id).name
@@ -32,16 +35,29 @@ class EditDiaryModal(ModalScreen[tuple[int,str]]):
self.name_input.cursor_position = len(self.name_input.value)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""
Handle button press events in the modal.
Triggers the save action if the "Save" button is pressed, or cancels and dismisses the modal if the "Cancel" button is pressed.
"""
if event.button.id == "save_diary_button":
self.action_edit_diary()
elif event.button.id == "cancel_button":
self.dismiss(None)
def on_key(self, event):
"""
Handles the Enter key press event by triggering the diary save action and preventing the default behavior.
"""
if event.key == "enter":
self.action_edit_diary()
event.prevent_default()
def action_edit_diary(self) -> None:
"""
Attempts to save the edited diary name, validating input and providing user feedback.
If the new name is non-empty and different from the current name, dismisses the modal and returns the diary ID and new name. If the name is unchanged, notifies the user and dismisses without changes. If the input is empty, notifies the user and refocuses the input field.
"""
new_diary_name = self.name_input.value.strip()
if new_diary_name and new_diary_name != self.current_diary_name:
self.dismiss((self.diary_id, new_diary_name))
@@ -53,8 +69,14 @@ class EditDiaryModal(ModalScreen[tuple[int,str]]):
self.name_input.focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""
Handles submission of the diary name input field by triggering the save action if the relevant input is submitted.
"""
if event.input.id == "edit_diary_name_input":
self.action_edit_diary()
def action_cancel(self) -> None:
"""
Dismisses the modal without saving changes or returning any data.
"""
self.dismiss(None)
+18 -15
View File
@@ -87,7 +87,7 @@ class EditEntryScreen(Screen):
self.text_entry = TextArea(id="text_entry", classes="EditEntryScreen-text-entry")
# Sidebar widgets
self.sidebar_title = Static("Photos", classes="EditEntryScreen-sidebar-title")
self.sidebar_title = Static("📸 Photos", classes="EditEntryScreen-sidebar-title")
self.photo_list = OptionList(id="photo_list", classes="EditEntryScreen-sidebar-photo-list")
self.photo_info = Static("", classes="EditEntryScreen-sidebar-photo-info")
self.help_text = Static("", classes="EditEntryScreen-sidebar-help")
@@ -296,28 +296,30 @@ class EditEntryScreen(Screen):
if not self.cached_photos:
self.photo_info.update("No photos found for this diary")
self.help_text.update("No photos available\n\nUse Photo Manager to add photos")
self.help_text.update("📸 No photos available\n\nUse Photo Manager to add photos")
return
# Add photos to the list with hash
for photo in self.cached_photos:
# Show name and hash in the list
photo_hash = str(photo.photo_hash)[:8]
self.photo_list.add_option(f"{photo.name} \\[{photo_hash}\]")
self.photo_list.add_option(f"📷 {photo.name} \\[{photo_hash}\]")
self.photo_info.update(f"{len(self.cached_photos)} photos in diary")
self.photo_info.update(f"📸 {len(self.cached_photos)} photos in diary")
# Updated help a text with hash information
help_text = (
"[b]Sidebar Shortcuts[/b]\n"
"[b]⌨️ Sidebar Shortcuts[/b]\n"
"[b][green]i[/green][/b]: Insert photo into entry\n"
"[b][green]n[/green][/b]: Add new photo\n"
"[b][green]d[/green][/b]: Delete selected photo\n"
"[b][green]e[/green][/b]: Edit selected photo\n"
"[b][yellow]Tab[/yellow][/b]: Back to editor\n"
"[b][yellow]F8[/yellow][/b]: Show/hide sidebar\n"
"[b][yellow]F9[/yellow][/b]: Switch focus (if needed)\n\n"
"[b]📝 Photo References[/b]\n"
"\\[\\[photo::hash\\]\\]"
"Use: \\[\\[photo:name:hash\\]\\]\n"
"Or: \\[\\[photo::hash\\]\\]"
)
self.help_text.update(help_text)
except Exception as e:
@@ -427,7 +429,9 @@ class EditEntryScreen(Screen):
photo_details += f"🔗 {photo_hash}\n"
photo_details += f"📅 {selected_photo.addition_date}\n"
photo_details += f"💬 {selected_photo.caption or 'No caption'}\n"
photo_details += f"📁 {selected_photo.filepath}\n\n"
photo_details += f"[b]Reference formats:[/b]\n"
photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n"
photo_details += f"\\[\\[photo::{photo_hash}\\]\\]"
self.photo_info.update(photo_details)
@@ -611,8 +615,7 @@ class EditEntryScreen(Screen):
addition_date=original_photo.addition_date,
caption=photo_data["caption"],
entries=original_photo.entries if original_photo.entries is not None else [],
id=original_photo.id,
photo_hash=original_photo.photo_hash,
id=original_photo.id
)
result = photo_service.update(original_photo, updated_photo)
@@ -739,15 +742,15 @@ class EditEntryScreen(Screen):
self.notify(f"Selected photo: {selected_photo.name} \\[{photo_hash}\\]")
# Update photo info with details including hash
photo_details = f"Name: {selected_photo.name}\n"
photo_details += f"Hash: {photo_hash}\n"
photo_details += f"Date: {selected_photo.addition_date}\n"
photo_details = f"📷 {selected_photo.name}\n"
photo_details += f"🔗 {photo_hash}\n"
photo_details += f"📅 {selected_photo.addition_date}\n"
if selected_photo.caption:
photo_details += f"Caption: {selected_photo.caption}\n"
else:
photo_details += f"Caption: No Caption\n"
photo_details += f"💬 {selected_photo.caption}\n"
photo_details += f"📁 {selected_photo.filepath}\n\n"
photo_details += f"[b]Reference formats:[/b]\n"
photo_details += f"\\[\\[photo::{photo_hash}]]"
photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n"
photo_details += f"\\[\\[photo::{photo_hash}\\]\\]"
self.photo_info.update(photo_details)
@@ -15,6 +15,12 @@ class AddPhotoModal(Screen):
self.result = None
self.created_photo = None
def _generate_photo_hash(self, photo_data: dict) -> str:
"""Generate a short, unique hash for a photo"""
# Use temporary data for hash generation
unique_string = f"{photo_data['name']}_{photo_data.get('photo_id', 0)}_new"
hash_object = hashlib.md5(unique_string.encode())
return hash_object.hexdigest()[:8]
def compose(self) -> ComposeResult:
yield Container(
@@ -63,16 +69,10 @@ class AddPhotoModal(Screen):
async def _async_create_photo(self, photo_data: dict):
"""Creates a new photo asynchronously using PhotoService"""
try:
service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service()
if photo_service.check_photo_by_hash(photo_service.hash_file(photo_data["filepath"]),self.diary_id):
self.notify("Photo already exists in database", severity="error")
return
new_photo = photo_service.create(
filepath=Path(photo_data["filepath"]),
name=photo_data["name"],
@@ -82,9 +82,13 @@ class AddPhotoModal(Screen):
if new_photo:
self.created_photo = new_photo
# Generate hash for the new photo
photo_hash = self._generate_photo_hash({
"name": new_photo.name,
"photo_id": new_photo.id
})
self.notify(f"Photo '{new_photo.name}' added successfully!\nHash: {new_photo.photo_hash[:8]}\nReference: \\[\\[photo:{new_photo.name}:{new_photo.photo_hash[:8]}\\]\\]",
self.notify(f"Photo '{new_photo.name}' added successfully!\nHash: {photo_hash}\nReference: \\[\\[photo:{new_photo.name}:{photo_hash}\\]\\]",
severity="information", timeout=5)
# Return the created photo data to the calling screen
@@ -93,7 +97,7 @@ class AddPhotoModal(Screen):
"name": photo_data["name"],
"caption": photo_data["caption"],
"photo_id": new_photo.id,
"hash": new_photo.photo_hash
"hash": photo_hash
}
self.dismiss(self.result)
else:
@@ -3,7 +3,7 @@ from textual.screen import Screen
from textual.widgets import Static, Input, Button
from textual.containers import Container, Horizontal
from pilgrim.models.photo import Photo
import hashlib
class EditPhotoModal(Screen):
"""Modal for editing an existing photo (name and caption only)"""
@@ -12,11 +12,15 @@ class EditPhotoModal(Screen):
self.photo = photo
self.result = None
def _generate_photo_hash(self, photo: Photo) -> str:
"""Generate a short, unique hash for a photo"""
unique_string = f"{photo.name}_{photo.id}_{photo.addition_date}"
hash_object = hashlib.md5(unique_string.encode())
return hash_object.hexdigest()[:8]
def compose(self) -> ComposeResult:
# Generate hash for this photo
photo_hash = self._generate_photo_hash(self.photo)
yield Container(
Static("✏️ Edit Photo", classes="EditPhotoModal-Title"),
@@ -41,9 +45,10 @@ class EditPhotoModal(Screen):
id="caption-input",
classes="EditPhotoModal-Input"
),
Static(f"🔗 Photo Hash: {self.photo.photo_hash[:8]}", classes="EditPhotoModal-Hash"),
Static(f"🔗 Photo Hash: {photo_hash}", classes="EditPhotoModal-Hash"),
Static("Reference formats:", classes="EditPhotoModal-Label"),
Static(f"\\[\\[photo::{self.photo.photo_hash[:8]}\\]\\]", classes="EditPhotoModal-Reference"),
Static(f"\\[\\[photo:{self.photo.name}:{photo_hash}\\]\\]", classes="EditPhotoModal-Reference"),
Static(f"\\[\\[photo::{photo_hash}\\]\\]", classes="EditPhotoModal-Reference"),
Horizontal(
Button("Save Changes", id="save-button", classes="EditPhotoModal-Button"),
Button("Cancel", id="cancel-button", classes="EditPhotoModal-Button"),
+35 -4
View File
@@ -12,13 +12,25 @@ class NewDiaryModal(ModalScreen[str]):
Binding("escape", "cancel", "Cancel"),
Binding("enter", "create_diary", "Create",priority=True),
]
def __init__(self):
def __init__(self,autoopen=True):
"""
Initialize the NewDiaryModal with optional automatic opening of the created diary.
Parameters:
autoopen (bool): If True, the newly created diary will open automatically after creation. Defaults to True.
"""
super().__init__()
self.auto_open = self.app.config_manager.auto_open_new_diary
self.auto_open = autoopen
self.name_input = Input(id="NewDiaryModal-NameInput",classes="NewDiaryModal-NameInput") # This ID is fine, it's specific to the input
def compose(self) -> ComposeResult:
"""
Constructs and yields the UI layout for the new diary modal dialog.
Returns:
ComposeResult: An iterable of widgets forming the modal, including title, input field, and action buttons.
"""
with Vertical(id="new_diary_dialog",classes="NewDiaryModal-Dialog"):
yield Label("Create a new diary", classes="NewDiaryModal-Title")
yield Label("Diary Name:")
@@ -33,17 +45,28 @@ class NewDiaryModal(ModalScreen[str]):
self.name_input.focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handles button clicks."""
"""
Handle button press events for the modal.
Triggers diary creation when the "Create" button is pressed, or dismisses the modal when the "Cancel" button is pressed.
"""
if event.button.id == "create_diary_button":
self.action_create_diary()
elif event.button.id == "cancel_button":
self.dismiss()
def action_cancel(self) -> None:
"""Action to cancel the modal."""
"""
Cancels the modal dialog and dismisses it without returning a diary name.
"""
self.dismiss("")
def action_create_diary(self) -> None:
"""
Validates the diary name input and initiates asynchronous diary creation if valid.
If the input is empty, displays a warning notification and refocuses the input field.
"""
diary_name = self.name_input.value.strip()
if diary_name:
@@ -54,11 +77,19 @@ class NewDiaryModal(ModalScreen[str]):
self.name_input.focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""
Handles submission of the diary name input field and initiates diary creation if triggered from the correct input.
"""
if event.input.id == "NewDiaryModal-NameInput":
self.action_create_diary()
async def _async_create_diary(self, name: str):
"""
Asynchronously creates a new diary entry with the given name and handles post-creation actions.
If creation succeeds, dismisses the modal with the diary name, optionally opens the diary for editing, and shows a success notification. If creation fails or an exception occurs, displays an error notification.
"""
try:
service = self.app.service_manager.get_travel_diary_service()
created_diary = await service.async_create(name)
+20 -17
View File
@@ -390,9 +390,21 @@ Screen.-modal {
}
.EditEntryScreen-sidebar {
background: $primary-darken-1;
width: 45%;
width: 40;
min-height: 10;
border-left: solid green;
padding: 1;
background: $surface-darken-2;
color: $primary;
text-style: bold;
content-align: left top;
}
.EditEntryScreen-content-container {
layout: horizontal;
height: 1fr;
}
.EditEntryScreen-sidebar-title {
text-align: center;
text-style: bold;
@@ -405,39 +417,30 @@ Screen.-modal {
.EditEntryScreen-sidebar-content {
height: 1fr;
layout: vertical;
margin-left: 1;
margin-right: 1;
}
.EditEntryScreen-sidebar-photo-list {
height: 2fr; /* Pega o espaço restante disponível */
min-height: 5; /* Garante altura mínima para não sumir */
height: 1fr;
border: solid $accent;
margin-bottom: 1;
overflow-y: auto; /* Adiciona scroll vertical se necessário */
}
.EditEntryScreen-sidebar-photo-info {
height: 1fr;
max-height: 15; /* Limita altura máxima */
min-height: 13;
padding: 1;
height: auto;
min-height: 3;
border: solid $warning;
padding: 1;
margin-bottom: 1;
background: $surface-darken-1;
overflow-y: auto; /* Adiciona scroll se exceder max-height */
}
.EditEntryScreen-sidebar-help {
height: 1fr;
max-height: 10; /* Altura máxima menor que info */
min-height: 8; /* Altura mínima menor */
height: auto;
min-height: 8;
border: solid $success;
padding: 1;
background: $surface-darken-1;
text-style: italic;
overflow-y: auto; /* Adiciona scroll se exceder max-height */
}
/* Photo Modal Styles */
+1 -3
View File
@@ -9,7 +9,6 @@ from pilgrim.service.servicemanager import ServiceManager
from pilgrim.ui.screens.about_screen import AboutScreen
from pilgrim.ui.screens.diary_list_screen import DiaryListScreen
from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen
from pilgrim.utils import ConfigManager
CSS_FILE_PATH = Path(__file__).parent / "styles" / "pilgrim.css"
@@ -17,10 +16,9 @@ CSS_FILE_PATH = Path(__file__).parent / "styles" / "pilgrim.css"
class UIApp(App):
CSS_PATH = CSS_FILE_PATH
def __init__(self,service_manager: ServiceManager, config_manager: ConfigManager, **kwargs):
def __init__(self,service_manager: ServiceManager, **kwargs):
super().__init__(**kwargs)
self.service_manager = service_manager
self.config_manager = config_manager
def on_mount(self) -> None:
+1 -2
View File
@@ -1,4 +1,3 @@
from .directory_manager import DirectoryManager
from .config_manager import ConfigManager
__all__ = ['DirectoryManager', 'ConfigManager']
__all__ = ['DirectoryManager']
-107
View File
@@ -1,107 +0,0 @@
import os.path
from os import PathLike
from threading import Lock
import tomli
import tomli_w
from pilgrim.utils import DirectoryManager
class SingletonMeta(type):
_instances = {}
_lock: Lock = Lock()
def __call__(cls, *args, **kwargs):
with cls._lock:
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
class ConfigManager(metaclass=SingletonMeta):
def __init__(self):
self.database_url = None
self.database_type = None
self.auto_open_diary = None
self.auto_open_new_diary = None
self.config_dir = DirectoryManager.get_config_directory()
self.__data = None
def read_config(self):
if os.path.exists(f"{DirectoryManager.get_config_directory()}/config.toml"):
try:
with open(f"{DirectoryManager.get_config_directory()}/config.toml", "rb") as f:
data = tomli.load(f)
except tomli.TOMLDecodeError as e:
raise ValueError(f"Invalid TOML configuration: {e}")
except Exception as e:
raise RuntimeError(f"Error reading configuration: {e}")
self.__data = data
self.database_url = self.__data["database"]["url"]
self.database_type = self.__data["database"]["type"]
if self.__data["settings"]["diary"]["auto_open_diary_on_startup"] == "":
self.auto_open_diary = None
self.auto_open_new_diary = self.__data["settings"]["diary"]["auto_open_on_creation"]
else:
print("Error: config.toml not found.")
self.create_config()
self.read_config()
def create_config(self, config: dict = None):
# Garantir que o diretório de configuração existe
config_dir = DirectoryManager.get_config_directory()
if not os.path.exists(config_dir):
os.makedirs(config_dir, exist_ok=True)
default = {
"database": {
"url": f"{config_dir}/database.db",
"type": "sqlite"
},
"settings": {
"diary": {
"auto_open_diary_on_startup": "",
"auto_open_on_creation": False
}
}
}
if config is None:
config = default
try:
with open(f"{config_dir}/config.toml", "wb") as f:
tomli_w.dump(config, f)
except Exception as e:
raise RuntimeError(f"Error creating configuration: {e}")
def save_config(self):
if self.__data is None:
self.read_config()
if self.__data is None:
raise RuntimeError("Error reading configuration.")
self.__data["database"]["url"] = self.database_url
self.__data["database"]["type"] = self.database_type
self.__data["settings"]["diary"]["auto_open_diary_on_startup"] = self.auto_open_diary or ""
self.__data["settings"]["diary"]["auto_open_on_creation"] = self.auto_open_new_diary
try:
self.create_config(self.__data)
except Exception as e:
raise RuntimeError(f"Error saving configuration: {e}")
def set_config_dir(self, value):
self.config_dir = value
def set_database_url(self, value: str):
self.database_url = value
def set_auto_open_diary(self, value: str):
self.auto_open_diary = value
def set_auto_open_new_diary(self, value: bool):
self.auto_open_new_diary = value
-24
View File
@@ -1,5 +1,4 @@
import os
import shutil
from pathlib import Path
@@ -38,26 +37,3 @@ class DirectoryManager:
def get_diary_images_directory(directory_name: str) -> Path:
"""Returns the images directory path for a specific diary."""
return DirectoryManager.get_diary_data_directory(directory_name) / "images"
@staticmethod
def get_database_path() -> Path:
"""
Get the database file path following XDG Base Directory specification.
Creates the directory if it doesn't exist.
"""
pilgrim_dir = DirectoryManager.get_config_directory()
db_path = pilgrim_dir / "database.db"
# If database doesn't exist in new location but exists in current directory,
# migrate it
if not db_path.exists():
current_db = Path("database.db")
if current_db.exists():
try:
shutil.copy2(current_db, db_path)
# Consider using logging instead of print
print(f"Database migrated from {current_db} to {db_path}")
except (OSError, shutil.Error) as e:
raise RuntimeError(f"Failed to migrate database: {e}")
return db_path
-36
View File
@@ -1,36 +0,0 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from pilgrim.database import Base
from pilgrim.models.travel_diary import TravelDiary
# Todos os imports necessários para as fixtures devem estar aqui
# ...
@pytest.fixture(scope="function")
def db_session():
"""Esta fixture agora está disponível para TODOS os testes."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
Base.metadata.drop_all(engine)
@pytest.fixture
def populated_db_session(db_session):
"""Esta também fica disponível para todos."""
travel_diary = TravelDiary(name="My Travel Diary", directory_name="viagem-teste")
db_session.add(travel_diary)
db_session.commit()
return db_session
@pytest.fixture
def session_with_one_diary(db_session):
diary = TravelDiary(name="Diário de Teste", directory_name="diario_de_teste")
db_session.add(diary)
db_session.commit()
db_session.refresh(diary)
return db_session, diary
-269
View File
@@ -1,269 +0,0 @@
from re import search
import pytest
from datetime import datetime
from unittest.mock import Mock
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from pilgrim.database import Base
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.entry import Entry
from pilgrim.models.photo import Photo
from pilgrim.service.entry_service import EntryService
@pytest.fixture
def session_with_an_entry(populated_db_session):
session = populated_db_session
initial_entry = Entry(
title="Título Original",
text="Texto original.",
date=datetime(2025, 1, 1),
travel_diary_id=1
)
session.add(initial_entry)
session.commit()
return session, initial_entry.id
@pytest.fixture
def session_with_multiple_entries(populated_db_session):
"""Fixture que cria um diário e duas entradas para ele."""
session = populated_db_session
entry1 = Entry(title="Entrada 1", text="Texto 1", date=datetime(2025, 1, 1), travel_diary_id=1)
entry2 = Entry(title="Entrada 2", text="Texto 2", date=datetime(2025, 1, 2), travel_diary_id=1)
session.add_all([entry1, entry2])
session.commit()
return session
def test_create_entry_successfully(populated_db_session):
session = populated_db_session
service = EntryService(session)
diary_id = 1 # Sabemos que o ID é 1 por causa da nossa fixture
title = "Primeiro Dia na Praia"
text = "O dia foi ensolarado e o mar estava ótimo."
date = datetime(2025, 7, 20)
photos = [Photo(filepath="/path/to/photo1.jpg",name="Photo 1",photo_hash="hash_12345678",fk_travel_diary_id=diary_id), Photo(filepath="/path/to/photo2.jpg",name="Photo 1",photo_hash="hash_87654321",fk_travel_diary_id=diary_id)]
created_entry = service.create(
travel_diary_id=diary_id,
title=title,
text=text,
date=date,
photos=photos
)
assert created_entry is not None
assert created_entry.id is not None # Garante que foi salvo no BD e tem um ID
assert created_entry.title == title
assert created_entry.text == text
assert len(created_entry.photos) == 2
assert created_entry.photos[0].filepath == "/path/to/photo1.jpg"
entry_in_db = session.query(Entry).filter_by(id=created_entry.id).one()
assert entry_in_db.title == "Primeiro Dia na Praia"
def test_create_entry_fails_when_diary_id_is_invalid(db_session):
session = db_session
service = EntryService(session)
invalid_id = 666
result = service.create(
travel_diary_id=invalid_id,
title="Título de Teste",
text="Texto de Teste",
date=datetime(2025, 7, 20),
photos=[]
)
assert result is None
def test_create_entry_successfully_without_photo(populated_db_session):
session = populated_db_session
service = EntryService(session)
diary_id = 1 # Sabemos que o ID é 1 por causa da nossa fixture
title = "Primeiro Dia na Praia"
text = "O dia foi ensolarado e o mar estava ótimo."
date = datetime(2025, 7, 20)
photos = []
created_entry = service.create(
travel_diary_id=diary_id,
title=title,
text=text,
date=date,
photos=photos
)
assert created_entry is not None
assert created_entry.id is not None # Garante que foi salvo no BD e tem um ID
assert created_entry.title == title
assert created_entry.text == text
assert len(created_entry.photos) == 0
entry_in_db = session.query(Entry).filter_by(id=created_entry.id).one()
assert entry_in_db.title == "Primeiro Dia na Praia"
def test_create_entry_fails_with_null_title(populated_db_session):
session = populated_db_session
service = EntryService(session)
diary_id = 1
with pytest.raises(IntegrityError):
service.create(
travel_diary_id=diary_id,
title=None,
text="Um texto qualquer.",
date=datetime.now(),
photos=[]
)
def test_create_entry_fails_with_null_date(populated_db_session):
session = populated_db_session
service = EntryService(session)
diary_id = 1
with pytest.raises(IntegrityError):
service.create(
travel_diary_id=diary_id,
title="Sabado de sol",
text="Um texto qualquer.",
date=None,
photos=[]
)
def test_create_entry_fails_with_null_diary_id(populated_db_session):
session = populated_db_session
service = EntryService(session)
diary_id = 1
result = service.create(
travel_diary_id=None,
title="Sabado de sol",
text="Um texto qualquer.",
date=datetime.now(),
photos=[]
)
assert result is None
def test_ready_by_id_successfully(session_with_an_entry):
session,_ = session_with_an_entry
service = EntryService(session)
search_id = 1
result = service.read_by_id(search_id)
assert result is not None
def test_ready_by_id_fails_when_id_is_invalid(db_session):
session = db_session
service = EntryService(session)
invalid_id = 666
result = service.read_by_id(invalid_id)
assert result is None
def test_read_all_returns_all_entries(session_with_multiple_entries):
session = session_with_multiple_entries
service = EntryService(session)
all_entries = service.read_all()
assert isinstance(all_entries, list)
assert len(all_entries) == 2
assert all_entries[0].title == "Entrada 1"
assert all_entries[1].title == "Entrada 2"
def test_read_all_returns_empty_list_on_empty_db(db_session):
session = db_session
service = EntryService(session)
all_entries = service.read_all()
assert isinstance(all_entries, list)
assert len(all_entries) == 0
def test_update_entry_successfully(session_with_an_entry):
session, entry_id = session_with_an_entry
service = EntryService(session)
entry_src = session.query(Entry).filter_by(id=entry_id).one()
new_date = datetime(2025, 1, 2)
entry_dst = Entry(
title="Título Atualizado",
text="Texto atualizado.",
date=new_date,
travel_diary_id=1, # Mantemos o mesmo travel_diary_id
photos=[]
)
updated_entry = service.update(entry_src, entry_dst)
assert updated_entry is not None
assert updated_entry.id == entry_id
assert updated_entry.title == "Título Atualizado"
assert updated_entry.text == "Texto atualizado."
entry_in_db = session.query(Entry).filter_by(id=entry_id).one()
assert entry_in_db.title == "Título Atualizado"
def test_update_entry_fails_if_entry_does_not_exist(db_session):
service = EntryService(db_session)
non_existent_entry = Entry(
title="dummy",
text="dummy",
date=datetime.now(),
travel_diary_id=1)
non_existent_entry.id = 999
entry_with_new_data = Entry(title="Novo Título", text="Novo Texto", date=datetime.now(), travel_diary_id=1)
result = service.update(non_existent_entry, entry_with_new_data)
assert result is None
def test_update_fails_with_null_title(session_with_an_entry):
session, entry_id = session_with_an_entry
service = EntryService(session)
entry_src = session.query(Entry).filter_by(id=entry_id).one()
entry_dst = Entry(
title=None,
text="Texto atualizado.",
date=datetime.now(),
travel_diary_id=1,
photos=[]
)
with pytest.raises(IntegrityError):
service.update(entry_src, entry_dst)
def test_update_fails_with_null_date(session_with_an_entry):
session, entry_id = session_with_an_entry
service = EntryService(session)
entry_src = session.query(Entry).filter_by(id=entry_id).one()
entry_dst = Entry(
title=entry_src.title,
text="Texto atualizado.",
date=None,
travel_diary_id=1,
photos=[]
)
with pytest.raises(IntegrityError):
service.update(entry_src, entry_dst)
def test_update_fails_with_null_diary_id(session_with_an_entry):
session, entry_id = session_with_an_entry
service = EntryService(session)
entry_src = session.query(Entry).filter_by(id=entry_id).one()
entry_dst = Entry(
title=entry_src.title,
text="Texto atualizado.",
date=datetime.now(),
travel_diary_id=None,
photos=[]
)
with pytest.raises(IntegrityError):
service.update(entry_src, entry_dst)
def test_delete_successfully_removes_entry(session_with_an_entry):
session, entry_id = session_with_an_entry
service = EntryService(session)
entry_to_delete = service.read_by_id(entry_id)
assert entry_to_delete is not None
deleted_entry = service.delete(entry_to_delete)
assert deleted_entry is not None
assert deleted_entry.id == entry_id
entry_in_db = service.read_by_id(entry_id)
assert entry_in_db is None
def test_delete_returns_none_if_entry_does_not_exist(db_session):
service = EntryService(db_session)
non_existent_entry = Entry(
title="dummy",
text="dummy",
date=datetime.now(),
travel_diary_id=1)
non_existent_entry.id = 999
result = service.delete(non_existent_entry)
assert result is None
-143
View File
@@ -1,143 +0,0 @@
from unittest.mock import patch, MagicMock
from pathlib import Path
import pytest
from pilgrim import TravelDiary
from pilgrim.service.travel_diary_service import TravelDiaryService
@patch.object(TravelDiaryService, '_ensure_diary_directory')
@pytest.mark.asyncio # Marca o teste para rodar código assíncrono
async def test_create_diary_successfully(mock_ensure_dir, db_session):
service = TravelDiaryService(db_session)
new_diary = await service.async_create("Viagem para a Serra")
assert new_diary is not None
assert new_diary.id is not None
assert new_diary.name == "Viagem para a Serra"
assert new_diary.directory_name == "viagem_para_a_serra"
@patch.object(TravelDiaryService, '_ensure_diary_directory')
@patch.object(TravelDiaryService, '_sanitize_directory_name', return_value="nome_existente")
@pytest.mark.asyncio
async def test_create_diary_handles_integrity_error(mock_sanitize, mock_ensure_dir, db_session):
existing_diary = TravelDiary(name="Diário Antigo", directory_name="nome_existente")
db_session.add(existing_diary)
db_session.commit()
service = TravelDiaryService(db_session)
with pytest.raises(ValueError, match="Could not create diary"):
await service.async_create("Qualquer Nome Novo")
mock_ensure_dir.assert_not_called()
@patch.object(TravelDiaryService, '_ensure_diary_directory')
def test_read_by_id_successfully(mock_ensure_dir, session_with_one_diary):
session, diary_to_find = session_with_one_diary
service = TravelDiaryService(session)
found_diary = service.read_by_id(diary_to_find.id)
assert found_diary is not None
assert found_diary.id == diary_to_find.id
assert found_diary.name == "Diário de Teste"
mock_ensure_dir.assert_called_once_with(found_diary)
@patch.object(TravelDiaryService, '_ensure_diary_directory')
def test_read_by_id_returns_none_for_invalid_id(mock_ensure_dir, db_session):
service = TravelDiaryService(db_session)
result = service.read_by_id(999)
assert result is None
mock_ensure_dir.assert_not_called()
@patch.object(TravelDiaryService, '_ensure_diary_directory')
def test_read_all_returns_all_diaries(mock_ensure_dir, db_session):
d1 = TravelDiary(name="Diário 1", directory_name="d1")
d2 = TravelDiary(name="Diário 2", directory_name="d2")
db_session.add_all([d1, d2])
db_session.commit()
service = TravelDiaryService(db_session)
diaries = service.read_all()
assert isinstance(diaries, list)
assert len(diaries) == 2
assert mock_ensure_dir.call_count == 2
@patch.object(TravelDiaryService, '_ensure_diary_directory')
def test_read_all_returns_empty_list_for_empty_db(mock_ensure_dir, db_session):
service = TravelDiaryService(db_session)
diaries = service.read_all()
assert isinstance(diaries, list)
assert len(diaries) == 0
mock_ensure_dir.assert_not_called()
@patch.object(TravelDiaryService, '_ensure_diary_directory')
@patch('pathlib.Path.rename')
@patch.object(TravelDiaryService, '_get_diary_directory')
def test_update_diary_successfully(mock_get_dir, mock_path_rename, mock_ensure, session_with_one_diary):
session, diary_to_update = session_with_one_diary
service = TravelDiaryService(session)
old_path = MagicMock(spec=Path) # Um mock que se parece com um objeto Path
old_path.exists.return_value = True # Dizemos que o diretório antigo "existe"
new_path = Path("/fake/path/diario_atualizado")
mock_get_dir.side_effect = [old_path, new_path]
updated_diary = service.update(diary_to_update.id, "Diário Atualizado")
assert updated_diary is not None
assert updated_diary.name == "Diário Atualizado"
assert updated_diary.directory_name == "diario_atualizado"
old_path.rename.assert_called_once_with(new_path)
def test_update_returns_none_for_invalid_id(db_session):
service = TravelDiaryService(db_session)
result = service.update(travel_diary_id=999, name="Nome Novo")
assert result is None
@patch.object(TravelDiaryService, '_cleanup_diary_directory')
def test_delete_diary_successfully(mock_cleanup, session_with_one_diary):
session, diary_to_delete = session_with_one_diary
service = TravelDiaryService(session)
result = service.delete(diary_to_delete)
assert result is not None
assert result.id == diary_to_delete.id
mock_cleanup.assert_called_once_with(diary_to_delete)
diary_in_db = service.read_by_id(diary_to_delete.id)
assert diary_in_db is None
@patch.object(TravelDiaryService, '_cleanup_diary_directory')
def test_delete_returns_none_for_non_existent_diary(mock_cleanup, db_session):
service = TravelDiaryService(db_session)
non_existent_diary = TravelDiary(name="dummy", directory_name="dummy")
non_existent_diary.id = 999
result = service.delete(non_existent_diary)
assert result is None
mock_cleanup.assert_not_called()
@patch.object(TravelDiaryService, '_sanitize_directory_name')
def test_update_raises_value_error_on_name_collision(mock_sanitize, db_session):
d1 = TravelDiary(name="Diário A", directory_name="diario_a")
d2 = TravelDiary(name="Diário B", directory_name="diario_b")
db_session.add_all([d1, d2])
db_session.commit()
db_session.refresh(d1)
mock_sanitize.return_value = "diario_b"
service = TravelDiaryService(db_session)
with pytest.raises(ValueError, match="Could not update diary"):
service.update(d1.id, "Diário B")
def test_sanitize_directory_name_formats_string_correctly(db_session):
service = TravelDiaryService(db_session)
name1 = "Minha Primeira Viagem"
assert service._sanitize_directory_name(name1) == "minha_primeira_viagem"
name2 = "Viagem para o #Rio de Janeiro! @2025"
assert service._sanitize_directory_name(name2) == "viagem_para_o_rio_de_janeiro_2025"
name3 = " Mochilão na Europa "
assert service._sanitize_directory_name(name3) == "mochilao_na_europa"
def test_sanitize_directory_name_handles_uniqueness(db_session):
existing_diary = TravelDiary(name="Viagem para a Praia", directory_name="viagem_para_a_praia")
db_session.add(existing_diary)
db_session.commit()
service = TravelDiaryService(db_session)
new_sanitized_name = service._sanitize_directory_name("Viagem para a Praia")
assert new_sanitized_name == "viagem_para_a_praia_1"
another_diary = TravelDiary(name="Outra Viagem", directory_name="viagem_para_a_praia_1")
db_session.add(another_diary)
db_session.commit()
third_sanitized_name = service._sanitize_directory_name("Viagem para a Praia")
assert third_sanitized_name == "viagem_para_a_praia_2"
-40
View File
@@ -1,40 +0,0 @@
import pytest
from unittest.mock import Mock # A ferramenta para criar nosso "dublê"
from pathlib import Path
from sqlalchemy import inspect, Column, Integer, String
from sqlalchemy.orm import Session
from pilgrim.database import Database,Base
class MockUser(Base):
__tablename__ = 'mock_users'
id = Column(Integer, primary_key=True)
name = Column(String)
@pytest.fixture
def db_instance(tmp_path: Path):
fake_db_path = tmp_path / "test_pilgrim.db"
mock_config_manager = Mock()
mock_config_manager.database_url = str(fake_db_path)
db = Database(mock_config_manager)
return db, fake_db_path
def test_create_database(db_instance):
db, fake_db_path = db_instance
db.create()
assert fake_db_path.exists()
def test_table_creation(db_instance):
db, _ = db_instance
db.create()
inspector = inspect(db.engine)
assert "mock_users" in inspector.get_table_names()
def test_session_returned_corectly(db_instance):
db, _ = db_instance
session = db.session()
assert isinstance(session, Session)
session.close()
-71
View File
@@ -1,71 +0,0 @@
import shutil
from pathlib import Path
from unittest.mock import patch
import pytest
from pilgrim.utils.directory_manager import DirectoryManager
@patch('os.chmod')
@patch('pathlib.Path.home')
def test_get_config_directory_creates_dir_in_fake_home(mock_home, mock_chmod, tmp_path: Path):
mock_home.return_value = tmp_path
expected_config_dir = tmp_path / ".pilgrim"
assert not expected_config_dir.exists()
result_path = DirectoryManager.get_config_directory()
assert result_path == expected_config_dir
assert expected_config_dir.exists()
mock_chmod.assert_called_once_with(expected_config_dir, 0o700)
@patch('shutil.copy2')
@patch('pathlib.Path.home')
def test_get_database_path_no_migration(mock_home, mock_copy, tmp_path: Path):
mock_home.return_value = tmp_path
expected_db_path = tmp_path / ".pilgrim" / "database.db"
result_path = DirectoryManager.get_database_path()
assert result_path == expected_db_path
mock_copy.assert_not_called()
@patch('shutil.copy2')
@patch('pathlib.Path.home')
def test_get_database_path_with_migration(mock_home, mock_copy, tmp_path: Path, monkeypatch):
fake_home_dir = tmp_path / "home"
fake_project_dir = tmp_path / "project"
fake_home_dir.mkdir()
fake_project_dir.mkdir()
(fake_project_dir / "database.db").touch()
mock_home.return_value = fake_home_dir
monkeypatch.chdir(fake_project_dir)
result_path = DirectoryManager.get_database_path()
expected_db_path = fake_home_dir / ".pilgrim" / "database.db"
assert result_path == expected_db_path
mock_copy.assert_called_once_with(
Path("database.db"),
expected_db_path
)
@patch('os.chmod')
@patch('pathlib.Path.home')
def test_diary_path_methods_construct_correctly(mock_home, mock_chmod, tmp_path: Path):
mock_home.return_value = tmp_path
images_path = DirectoryManager.get_diary_images_directory("minha-viagem")
expected_path = tmp_path / ".pilgrim" / "diaries" / "minha-viagem" / "data" / "images"
assert images_path == expected_path
assert (tmp_path / ".pilgrim" / "diaries").exists()
@patch('shutil.copy2')
@patch('pathlib.Path.home')
def test_get_database_path_handles_migration_error(mock_home, mock_copy, tmp_path: Path, monkeypatch):
fake_home_dir = tmp_path / "home"
fake_project_dir = tmp_path / "project"
fake_home_dir.mkdir()
fake_project_dir.mkdir()
(fake_project_dir / "database.db").touch()
mock_home.return_value = fake_home_dir
mock_copy.side_effect = shutil.Error("O disco está cheio!")
monkeypatch.chdir(fake_project_dir)
with pytest.raises(RuntimeError, match="Failed to migrate database"):
DirectoryManager.get_database_path()
mock_copy.assert_called_once()