This commit is contained in:
Gustavo Henrique Miranda 2025-07-24 16:24:31 +00:00 committed by GitHub
commit b13103de9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1863 additions and 51 deletions

View File

@ -1,36 +1,32 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pilgrim"
version = "0.0.5"
authors = [
{ name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" }
]
description = "Pilgrim's Travel Log"
readme = "README.md"
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
]
dependencies = [
"sqlalchemy",
"textual",
"tomli",
"tomli_w",
"unidecode"
]
[project]
name = "Pilgrim"
version = "0.0.4"
authors = [
{ name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" }
]
description = "Pilgrim's Travel Log"
readme = "README.md"
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
]
dependencies = [
"sqlalchemy",
"textual",
"tomli",
"tomli_w"
[project.urls]
Homepage = "https://github.com/gmbrax/Pilgrim/"
Issues = "https://github.com/gmbrax/Pilgrim/issues"
]
[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:main"
[project.scripts]
pilgrim = "pilgrim.command:main"

View File

@ -1,5 +1,5 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import sessionmaker
from pathlib import Path
import os

View File

@ -4,15 +4,16 @@ from pilgrim.models.photo_in_entry import photo_entry_association
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from ..database import Base
from pilgrim.database import Base
class Entry(Base):
__tablename__ = "entries"
id = Column(Integer, primary_key=True)
title = Column(String)
title = Column(String,nullable=False)
text = Column(String)
date = Column(DateTime)
date = Column(DateTime,nullable=False)
photos = relationship(
"Photo",
secondary=photo_entry_association,

View File

@ -7,7 +7,8 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql.schema import Index
from pilgrim.models.photo_in_entry import photo_entry_association
from ..database import Base
from pilgrim.database import Base
class Photo(Base):
@ -24,7 +25,8 @@ class Photo(Base):
back_populates="photos"
)
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False)
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'),
)

View File

@ -1,6 +1,6 @@
from sqlalchemy import Table, Column, Integer, ForeignKey
from ..database import Base
from pilgrim.database import Base
photo_entry_association = Table('photo_entry_association', Base.metadata,
Column('id', Integer, primary_key=True, autoincrement=True),

View File

@ -3,15 +3,17 @@ from typing import Any
from sqlalchemy import Column, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from .. import database
from pilgrim.database import Base
class TravelDiary(database.Base):
class TravelDiary(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'),

View File

@ -1,9 +1,10 @@
import re
from datetime import datetime
from typing import List
from ..models.entry import Entry
from ..models.travel_diary import TravelDiary
from ..models.photo import Photo # ✨ Importe o modelo Photo
from pilgrim.models.entry import Entry
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.photo import Photo # ✨ Importe o modelo Photo
class EntryService:
@ -54,3 +55,24 @@ class EntryService:
self.session.commit()
return excluded
return None
def delete_references_for_specific_photo(self, entry: Entry, photo_hash: str) -> Entry:
regex = r"\[\[photo::" + re.escape(photo_hash) + r"\]\]"
entry.text = re.sub(regex, lambda match: ' ' * len(match.group(0)), entry.text)
self.session.commit()
self.session.refresh(entry)
return entry
def delete_all_photo_references(self, entry: Entry, commit=True) -> Entry:
if not entry.photos:
return entry
photo_hashes = {photo.photo_hash[:8] for photo in entry.photos}
regex = r"\[\[photo::(" + "|".join(re.escape(h) for h in photo_hashes) + r")\]\]"
entry.text = re.sub(regex, lambda match: ' ' * len(match.group(0)), entry.text)
if commit:
self.session.commit()
self.session.refresh(entry)
return entry

View File

@ -141,7 +141,7 @@ class PhotoService:
return original
return None
def delete(self, photo_src: Photo) -> Photo | None:
def delete(self, photo_src: Photo, commit=True) -> Photo | None:
excluded = self.read_by_id(photo_src.id)
if excluded:
# Store photo data before deletion
@ -162,7 +162,8 @@ class PhotoService:
file_path.unlink()
self.session.delete(excluded)
self.session.commit()
if commit:
self.session.commit()
return deleted_photo
return None

View File

@ -3,11 +3,15 @@ import re
import shutil
from pathlib import Path
from pilgrim.models.entry import Entry
from pilgrim.utils import DirectoryManager
from sqlalchemy.exc import IntegrityError
from ..models.travel_diary import TravelDiary
from pilgrim.models.travel_diary import TravelDiary
from unidecode import unidecode
from pilgrim.service.photo_service import PhotoService
from pilgrim.service.entry_service import EntryService
class TravelDiaryService:
def __init__(self, session):
@ -20,8 +24,10 @@ 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-]', '', name)
safe_name = re.sub(r'[^\w\s-]', '', transliterated_name)
safe_name = safe_name.strip().replace(' ', '_').lower()
# Ensure we have a valid name
@ -145,3 +151,32 @@ class TravelDiaryService:
self.session.rollback()
raise ValueError(f"Could not delete diary: {str(e)}")
return None
def delete_all_entries(self,travel_diary: TravelDiary):
diary = self.read_by_id(travel_diary.id)
if diary is not None:
diary.entries = []
self.session.commit()
return True
return False
def delete_all_photos(self,travel_diary: TravelDiary):
diary = self.read_by_id(travel_diary.id)
photo_service = PhotoService(self.session)
entry_service = EntryService(self.session)
if diary is not None:
for entry in list(diary.entries):
entry_service.delete_all_photo_references(entry,commit=False)
for photo in list(diary.photos):
photo_service.delete(photo,commit=False)
self.session.commit()
return True
return False

View File

@ -9,6 +9,7 @@ from textual.containers import Vertical, Container, Horizontal
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.ui.screens.about_screen import AboutScreen
from pilgrim.ui.screens.diary_settings_screen import SettingsScreen
from pilgrim.ui.screens.edit_diary_modal import EditDiaryModal
from pilgrim.ui.screens.new_diary_modal import NewDiaryModal
from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen
@ -23,6 +24,7 @@ class DiaryListScreen(Screen):
Binding("enter", "open_selected_diary", "Open diary"),
Binding("e", "edit_selected_diary", "Edit diary"),
Binding("r", "force_refresh", "Force refresh"),
Binding("s", "diary_settings", "Open The Selected Diary Settings"),
]
def __init__(self):
@ -285,4 +287,14 @@ class DiaryListScreen(Screen):
def action_quit(self):
"""Action to quit the application"""
self.app.exit()
self.app.exit()
def action_diary_settings(self):
if self.selected_diary_index is not None:
diary_id = self.diary_id_map.get(self.selected_diary_index)
if diary_id:
self.app.push_screen(SettingsScreen(diary_id=diary_id))
else:
self.notify("Invalid diary ID")
else:
self.notify("Select a diary to open the settings")

View File

@ -0,0 +1,274 @@
from textual.widgets import Static
from textual.containers import Container
from textual.widgets import Header, Footer, Label, Button,Checkbox,Input
from textual.screen import Screen
from textual.reactive import reactive
from textual.binding import Binding
from textual import on
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.ui.screens.modals.delete_all_entries_from_diary_modal import DeleteAllEntriesModal
from pilgrim.ui.screens.modals.delete_all_photos_from_diary_modal import DeleteAllPhotosModal
from pilgrim.ui.screens.modals.delete_diary_modal import DeleteDiaryModal
class SettingsScreen(Screen):
is_changed = reactive(False)
BINDINGS = [
Binding("escape","cancel","Cancel"),
]
def __init__(self,diary_id:int):
super().__init__()
self.current_diary = self.app.service_manager.get_travel_diary_service().read_by_id(diary_id)
self.header = Header()
self.footer = Footer()
self.title = "Settings"
self.diary_name = Static(self.current_diary.name,id="DiarySettingsScreen-DiaryName")
self.notify(str(self.app.config_manager))
self.is_the_diary_set_to_auto_open = self.app.config_manager.get_auto_open_diary() == self.current_diary.name
self.diary_entry_count = Static(str(len(self.current_diary.entries)))
self.diary_photo_count = Static(str(len(self.current_diary.photos)))
self.save_button = Button("Save",id="DiarySettingsScreen-SaveButton" )
self.cancel_button = Button("Cancel",id="DiarySettingsScreen-cancel_button")
self.apply_button = Button("Apply",id="DiarySettingsScreen-ApplyButton")
self.delete_diary_button = Button("Delete Diary",id="DiarySettingsScreen-DeleteDiaryButton")
self.delete_all_entries_button = Button("Delete All Entries",id="DiarySettingsScreen-DeleteAllEntriesButton")
self.delete_all_photos_button = Button("Delete All Photos",id="DiarySettingsScreen-DeleteAllPhotosButton")
self.set_auto_open_to_this_diary = Checkbox(id="set_auto_open_to_this_diary",value=self.is_the_diary_set_to_auto_open)
self.delete_diary_button_container = Container(
Label("Delete Diary:"),
self.delete_diary_button,
id="DiarySettingsScreen-DeleteDiaryButtonContainer",
classes="DiarySettingsScreen-DeleteDiaryButtonContainer Button_Container"
)
self.delete_all_entries_button_container = Container(
Label("Delete All Entries:"),
self.delete_all_entries_button,
id="DiarySettingsScreen-DeleteAllEntriesButtonContainer",
classes="DiarySettingsScreen-DeleteAllEntriesButtonContainer Button_Container"
)
self.delete_all_photos_button_container = Container(
Label("Delete All Photos:"),
self.delete_all_photos_button,
id="DiarySettingsScreen-DeleteAllPhotosButtonContainer",
classes="DiarySettingsScreen-DeleteAllPhotosButtonContainer Button_Container"
)
self.diary_name_container = Container(
Label("Diary Name:"),
self.diary_name,
id="DiarySettingsScreen-DiaryNameContainer",
classes="DiarySettingsScreen-DiaryNameContainer Data_Container"
)
self.diary_entry_count_container = Container(
Label("Diary Entries:"),
self.diary_entry_count,
id="DiarySettingsScreen-DiaryEntryCountContainer",
classes="DiarySettingsScreen-DiaryEntryCountContainer Data_Container"
)
self.set_auto_open_to_this_diary_container = Container(
Label("Set Open This Diary On App Start?:"),
self.set_auto_open_to_this_diary,
id="DiarySettingsScreen-SetAutoOpenToThisDiaryContainer",
classes="DiarySettingsScreen-SetAutoOpenToThisDiaryContainer Data_Container"
)
self.diary_photo_count_container = Container(
Label("Diary Photos:"),
self.diary_photo_count,
id="DiarySettingsScreen-DiaryPhotoCountContainer",
classes="DiarySettingsScreen-DiaryPhotoCountContainer Data_Container"
)
self.diary_info_container = Container(
self.diary_name_container,
self.diary_entry_count_container,
self.diary_photo_count_container,
self.set_auto_open_to_this_diary_container,
id="DiarySettingsScreen-DiaryInfoContainer",
classes="DiarySettingsScreen-DiaryInfoContainer",
)
self.diary_denger_zone_container = Container(
self.backup_diary_button_container,
self.delete_diary_button_container,
self.delete_all_entries_button_container,
self.delete_all_photos_button_container,
id="DiarySettingsScreen-DiaryDengerZoneContainer",
classes="DiarySettingsScreen-DiaryDengerZoneContainer"
)
self.button_container = Container(
self.save_button,
self.apply_button,
self.cancel_button,
id="DiarySettingsScreen-ButtonContainer",
classes="DiarySettingsScreen-ButtonContainer"
)
self.main = Container(
self.diary_info_container,
self.diary_denger_zone_container,
self.button_container,
id="DiarySettingsScreen-MainContainer",
classes="DiarySettingsScreen-MainContainer"
)
self.diary_info_container.border_title = "Diary Info"
self.diary_denger_zone_container.border_title = "Denger Zone"
@on(Checkbox.Changed, "#set_auto_open_to_this_diary")
def on_checkbox_changed(self, event):
self.is_changed = not self.is_changed
@on(Button.Pressed, "#DiarySettingsScreen-cancel_button")
def on_cancel_button_pressed(self, event):
self.action_cancel()
@on(Button.Pressed, "#DiarySettingsScreen-DeleteDiaryButton")
def on_delete_diary_button_pressed(self, event):
self.app.push_screen(DeleteDiaryModal(diary_id=self.current_diary.id,diary_name=self.current_diary.name))
@on(Button.Pressed, "#DiarySettingsScreen-DeleteAllEntriesButton")
def on_delete_all_entries_button_pressed(self, event):
self.app.push_screen(DeleteAllEntriesModal(diary_id=self.current_diary.id))
@on(Button.Pressed, "#DiarySettingsScreen-DeleteAllPhotosButton")
def on_delete_all_photos_button_pressed(self, event):
self.app.push_screen(DeleteAllPhotosModal(diary_id=self.current_diary.id))
def action_cancel(self):
if self.is_changed:
self.notify("Cancel button pressed, but changes are not saved",severity="error")
return
self.dismiss()
@on(Button.Pressed, "#DiarySettingsScreen-SaveButton")
def on_save_button_pressed(self, event):
self.action_save()
@on(Button.Pressed, "#DiarySettingsScreen-ApplyButton")
def on_apply_button_pressed(self, event):
self.action_apply()
def watch_is_changed(self, value):
label = self.set_auto_open_to_this_diary_container.query_one(Label)
if value:
label.add_class("DiarySettingsScreen-SetAutoOpenToThisDiaryContainer-Not-Saved-Label")
else:
label.remove_class("DiarySettingsScreen-SetAutoOpenToThisDiaryContainer-Not-Saved-Label")
def compose(self):
yield Header()
yield self.main
yield Footer()
def on_mount(self):
if self.app.config_manager.get_auto_open_diary() == self.current_diary.name:
self.call_after_refresh(self.set_checkbox_state)
def set_checkbox_state(self):
self.set_auto_open_to_this_diary.value = True
def _set_auto_open_diary(self,value):
self.app.config_manager.set_auto_open_diary(value)
self.app.config_manager.save_config()
self.is_changed = False
def _get_auto_open_diary(self):
return self.app.config_manager.get_auto_open_diary()
def _make_auto_open_diary_value(self):
value = None
if self.set_auto_open_to_this_diary.value:
value = self.current_diary.name
return value
def action_save(self):
if not self.is_changed:
self.dismiss()
return
value = self._make_auto_open_diary_value()
current_auto_open = self._get_auto_open_diary()
if current_auto_open is None:
self._set_auto_open_diary(value)
self.notify("Settings saved")
self.dismiss()
return
if current_auto_open == self.current_diary.name:
if value is None:
self._set_auto_open_diary(None)
self.notify("Auto-open disabled")
else:
self.is_changed = False
self.notify("No changes made")
self.dismiss()
return
if value is not None:
self._set_auto_open_diary(value)
self.notify(f"Auto-open changed from '{current_auto_open}' to '{self.current_diary.name}'")
self.dismiss()
else:
self.is_changed = False
self.notify("No changes made")
self.dismiss()
def action_apply(self):
if not self.is_changed:
return
value = self._make_auto_open_diary_value()
current_auto_open = self._get_auto_open_diary()
if current_auto_open is None:
self._set_auto_open_diary(value)
self.notify("Settings applied")
return
if current_auto_open == self.current_diary.name:
if value is None:
self._set_auto_open_diary(None)
self.notify("Auto-open disabled")
else:
self.is_changed = False
self.notify("No changes made")
return
if value is not None:
self._set_auto_open_diary(value)
self.notify(f"Auto-open changed from '{current_auto_open}' to '{self.current_diary.name}'")
else:
self.is_changed = False
self.notify("No changes made")

View File

@ -0,0 +1,33 @@
from textual.widgets import Button
from textual import on
from pilgrim.ui.screens.modals.delete_yes_confirmation_modal import DeleteYesConfirmationModal
class DeleteAllEntriesModal(DeleteYesConfirmationModal):
def __init__(self,diary_id:int):
super().__init__(diary_id)
self.head_text.update("Are you sure you want to delete all entries from this diary?")
self.delete_button.add_class("DeleteDiaryModal-DeleteButton")
@on(Button.Pressed, ".DeleteDiaryModal-DeleteButton")
def on_delete_button_pressed(self, event):
from pilgrim.ui.screens.diary_list_screen import DiaryListScreen
self.result = True
self._delete_entries()
self.dismiss()
self.app.push_screen(DiaryListScreen())
def _delete_entries(self):
diary = self.app.service_manager.get_travel_diary_service().read_by_id(self.diary_id)
if self.app.service_manager.get_travel_diary_service().delete_all_entries(diary):
self.notify("All entries deleted successfully")
else:
self.notify("Failed to delete all entries")

View File

@ -0,0 +1,32 @@
from textual.widgets import Button
from textual import on
from pilgrim.ui.screens.modals.delete_yes_confirmation_modal import DeleteYesConfirmationModal
class DeleteAllPhotosModal(DeleteYesConfirmationModal):
def __init__(self,diary_id:int):
super().__init__(diary_id)
self.head_text.update("Are you sure you want to delete all photos from this diary?")
self.delete_button.add_class("DeleteDiaryModal-DeleteButton")
@on(Button.Pressed, ".DeleteDiaryModal-DeleteButton")
def on_delete_button_pressed(self, event):
from pilgrim.ui.screens.diary_list_screen import DiaryListScreen
self.result = True
self._delete_all_photo()
self.dismiss()
self.app.push_screen(DiaryListScreen())
def _delete_all_photo(self):
diary = self.app.service_manager.get_travel_diary_service().read_by_id(self.diary_id)
if self.app.service_manager.get_travel_diary_service().delete_all_photos(diary):
self.notify("All photos deleted successfully")
else:
self.notify("Failed to delete all photos")

View File

@ -0,0 +1,77 @@
from textual.containers import Container
from textual.widgets import Header, Footer, Label, Button,Input
from textual.screen import Screen
from textual.binding import Binding
from textual import on
class DeleteDiaryModal(Screen):
BINDINGS = [
Binding("escape","cancel","Cancel"),
]
def __init__(self, diary_id: int,diary_name:str):
super().__init__()
self.diary_id = diary_id
self.diary_name = diary_name
self.user_input = Input(placeholder=f"Type diary name to confirm: ({self.diary_name})",id="DeleteDiaryModal-UserInput")
self.delete_button = Button("Delete Diary",id="DeleteDiaryModal-DeleteButton",disabled=True)
self.cancel_button = Button("Cancel",id="DeleteDiaryModal-CancelButton")
self.result = None
def compose(self):
yield Header()
yield Container(
Label("Are you sure you want to delete this diary?"),
self.user_input,
Container(
self.delete_button,
self.cancel_button,
id="DeleteDiaryModal-ButtonContainer",
classes="DeleteDiaryModal-ButtonContainer"
),
id="DeleteDiaryModal-MainContainer",
classes="DeleteDiaryModal-MainContainer"
)
yield Footer()
@on(Input.Changed,"#DeleteDiaryModal-UserInput")
def on_user_input_changed(self, event):
input_text = event.value.strip()
if input_text == self.diary_name:
self.delete_button.disabled = False
else:
self.delete_button.disabled = True
@on(Button.Pressed,"#DeleteDiaryModal-DeleteButton")
def on_delete_button_pressed(self, event):
self.result = True
self._delete_diary()
self.dismiss()
from pilgrim.ui.screens.diary_list_screen import DiaryListScreen
self.app.push_screen(DiaryListScreen())
@on(Button.Pressed,"#DeleteDiaryModal-CancelButton")
def on_cancel_button_pressed(self, event):
self.action_cancel()
def action_cancel(self):
self.dismiss()
def _delete_diary(self):
diary = self.app.service_manager.get_travel_diary_service().read_by_id(self.diary_id)
self.app.service_manager.get_travel_diary_service().delete(diary)
if self.app.config_manager.get_auto_open_diary() == self.diary_name:
self.app.config_manager.set_auto_open_diary(None)
self.app.config_manager.save_config()

View File

@ -0,0 +1,57 @@
from textual.containers import Container
from textual.widgets import Header, Footer, Label, Button,Input
from textual.screen import Screen
from textual.binding import Binding
from textual import on
class DeleteYesConfirmationModal(Screen):
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
]
def __init__(self,diary_id:int):
super().__init__()
self.diary_id = diary_id
self.user_input = Input(placeholder="Type 'Yes, I do ' to confirm",id="DeleteYesConfirmationModal-UserInput")
self.delete_button = Button("Delete",id="DeleteYesConfirmationModal-DeleteButton",disabled=True)
self.cancel_button = Button("Cancel",id="DeleteYesConfirmationModal-CancelButton")
self.head_text = Label("Are you sure you want to delete this diary?",id="DeleteYesConfirmationModal-HeadText")
self.second_head_text = Label("This action cannot be undone.",id="DeleteYesConfirmationModal-SecondHeadText")
self.delete_modal_container = Container(
self.head_text,
self.second_head_text,
self.user_input,
Container(
self.delete_button,
self.cancel_button,
id="DeleteYesConfirmationModal-DeleteButtonContainer",
classes="DeleteYesConfirmationModal-DeleteButtonContainer"
),
id="DeleteYesConfirmationModal-DeleteModalContainer",
classes="DeleteYesConfirmationModal-DeleteModalContainer"
)
self.result = None
@on(Input.Changed,"#DeleteYesConfirmationModal-UserInput")
def on_user_input_changed(self, event):
input_text = event.value.strip()
if input_text == "Yes, I do":
self.delete_button.disabled = False
else:
self.delete_button.disabled = True
@on(Button.Pressed,"#DeleteYesConfirmationModal-CancelButton")
def on_cancel_button_pressed(self, event):
self.action_cancel()
def action_cancel(self):
from pilgrim.ui.screens.diary_settings_screen import SettingsScreen
self.dismiss()
self.app.push_screen(SettingsScreen(self.diary_id))
def compose(self):
yield Header()
yield Footer()
yield self.delete_modal_container

View File

@ -1,7 +1,8 @@
Screen {
layout: vertical;
background: $surface-darken-1;
background: $primary-background-darken-3;
align: center middle;
hatch: right $secondary-background-darken-3;
}
.EditEntryScreen-sub-header {
@ -623,4 +624,265 @@ Screen.-modal {
.ConfirmDeleteModal-Button {
margin: 0 1;
width: 1fr;
}
.DeleteYesConfirmationModal-DeleteModalContainer,
.DeleteDiaryModal-MainContainer {
align: center middle;
layout: vertical;
margin: 2;
padding: 2;
background: $primary-background;
height: auto;
width: auto;
min-width: 80%;
max-width: 95%;
border: solid $primary;
}
/* Labels de texto */
.DeleteYesConfirmationModal-DeleteModalContainer > Label,
.DeleteDiaryModal-MainContainer > Label {
margin: 1 0;
padding: 0 1;
color: $error;
text-align: center;
width: 100%;
}
/* Input fields */
.DeleteYesConfirmationModal-DeleteModalContainer > Input,
.DeleteDiaryModal-MainContainer > Input {
margin: 1 0;
width: 100%;
height: 3;
}
/* Container dos botões */
.DeleteYesConfirmationModal-DeleteButtonContainer,
.DeleteDiaryModal-ButtonContainer {
layout: horizontal;
align: center middle;
margin: 2 0 0 0;
height: auto;
width: 100%;
padding: 0;
}
/* Botões individuais */
.DeleteYesConfirmationModal-DeleteButtonContainer > Button,
.DeleteDiaryModal-ButtonContainer > Button {
width: 45%;
margin: 0 1;
height: 3;
}
/* Botão de delete (primeiro botão) */
.DeleteYesConfirmationModal-DeleteButtonContainer > Button:first-child,
.DeleteDiaryModal-ButtonContainer > Button:first-child {
background: $error-darken-1;
}
/* Botão de cancel */
.DeleteYesConfirmationModal-DeleteButtonContainer > Button:last-child,
.DeleteDiaryModal-ButtonContainer > Button:last-child {
background: $surface;
}
/* Estados disabled para botões de delete */
.DeleteYesConfirmationModal-DeleteButtonContainer > Button:first-child:disabled,
.DeleteDiaryModal-ButtonContainer > Button:first-child:disabled {
background: $surface-lighten-1;
color: $text-muted;
}
/* Espaçamento específico para labels secundários */
#DeleteYesConfirmationModal-SecondHeadText {
margin: 1 0;
padding: 0 1;
color: $warning;
text-align: center;
text-style: italic;
}
.Data_Container{
width: 100%;
layout: grid;
grid-size: 2 1; /* 2 colunas, 1 linha */
height:auto;
}
.Button_Container{
width: 100%;
layout: grid;
grid-size: 2 2;
height: auto;
}
.DiarySettingsScreen-DeleteDiaryButtonContainer{
grid-size: 2 1;
padding: 0 1;
content-align: center middle;
padding-bottom:1;
}
.DiarySettingsScreen-DeleteAllEntriesButtonContainer{
margin:0;
padding: 0 1;
height:auto;
padding-bottom:1;
}
.DiarySettingsScreen-DeleteAllPhotosButtonContainer{
margin:0;
padding: 0 1;
height:auto;
padding-bottom:1;
}
.DiarySettingsScreen-DeleteAllPhotosButtonContainer > Label,
.DiarySettingsScreen-DeleteAllEntriesButtonContainer > Label{
color: $error-lighten-3;
padding:1;
}
.DiarySettingsScreen-BackupDiaryButtonContainer{
margin:0;
padding:0 1;
padding-bottom:1
}
.DiarySettingsScreen-BackupDiaryButtonContainer > Label{
padding:1;
color: $success-darken-1;
}
.DiarySettingsScreen-BackupDiaryButtonContainer > Button{
background: $success;
}
.DiarySettingsScreen-DeleteDiaryButtonContainer > Label{
color: $error-lighten-3;
content-align: left middle;
padding:1
}
.DiarySettingsScreen-DeleteAllPhotosButtonContainer > Button,
.DiarySettingsScreen-DeleteAllEntriesButtonContainer > Button,
.DiarySettingsScreen-DeleteDiaryButtonContainer Button{
background: $error;
}
.DiarySettingsScreen-MainContainer{
align: center top;
layout: vertical;
margin:1;
padding:1;
background: $primary-background
}
.DiarySettingsScreen-DiaryInfoContainer{
padding:1 2;
border:round grey;
height:auto;
width: 90%;
padding-bottom: 0;
}
.DiarySettingsScreen-ButtonContainer{
height:auto;
layout: grid;
grid-size: 3 1;
grid-gutter:2;
dock:bottom;
}
.DiarySettingsScreen-ButtonContainer > Button:first-child {
margin-left:2
}
.DiarySettingsScreen-ButtonContainer > Button:last-child {
margin-right:2
}
.DiarySettingsScreen-ButtonContainer > Button {
width: 100%;
}
#DiarySettingsScreen-DiaryPhotoCountContainer > Static:first-child,
#DiarySettingsScreen-DiaryEntryCountContainer > Static:first-child,
#DiarySettingsScreen-DiaryNameContainer > Static:first-child{
text-align: left;
padding: 0 1;
padding-bottom:1
}
#DiarySettingsScreen-DiaryPhotoCountContainer > Static:last-child,
#DiarySettingsScreen-DiaryEntryCountContainer > Static:last-child,
#DiarySettingsScreen-DiaryNameContainer > Static:last-child{
text-align: right;
padding:0 1
}
.DiarySettingsScreen-SetAutoOpenToThisDiaryContainer > Checkbox{
margin:0;
padding:1;
background: $primary-background;
border:none;
}
.DiarySettingsScreen-SetAutoOpenToThisDiaryContainer-Not-Saved-Label{
text-style:bold;
color:$warning-lighten-2;
}
.DiarySettingsScreen-DiaryDengerZoneContainer{
border: round $error-darken-1;
width: 90%;
padding: 0 1;
height: auto;
padding-bottom: 0;
}

View File

@ -46,6 +46,8 @@ class ConfigManager(metaclass=SingletonMeta):
if self.__data["settings"]["diary"]["auto_open_diary_on_startup"] == "":
self.auto_open_diary = None
else:
self.auto_open_diary = self.__data["settings"]["diary"]["auto_open_diary_on_startup"]
self.auto_open_new_diary = self.__data["settings"]["diary"]["auto_open_on_creation"]
else:
print("Error: config.toml not found.")
@ -103,5 +105,8 @@ class ConfigManager(metaclass=SingletonMeta):
def set_auto_open_diary(self, value: str):
self.auto_open_diary = value
def get_auto_open_diary(self):
return self.auto_open_diary
def set_auto_open_new_diary(self, value: bool):
self.auto_open_new_diary = value

64
tests/conftest.py Normal file
View File

@ -0,0 +1,64 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from pilgrim.database import Base
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.photo import Photo
# 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
@pytest.fixture
def session_with_photos(session_with_one_diary):
session, diary = session_with_one_diary
# Usamos a mesma raiz de diretório que o mock do teste espera
diaries_root = "/fake/diaries_root"
photo1 = Photo(
# CORREÇÃO: O caminho agora inclui a raiz e a subpasta 'images'
filepath=f"{diaries_root}/{diary.directory_name}/images/p1.jpg",
name="Foto 1",
photo_hash="hash1",
fk_travel_diary_id=diary.id
)
photo2 = Photo(
filepath=f"{diaries_root}/{diary.directory_name}/images/p2.jpg",
name="Foto 2",
photo_hash="hash2",
fk_travel_diary_id=diary.id
)
session.add_all([photo1, photo2])
session.commit()
return session, [photo1, photo2]

View File

@ -0,0 +1,269 @@
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

View File

@ -0,0 +1,249 @@
import pytest
from pathlib import Path
from pilgrim import TravelDiary
from pilgrim.service.photo_service import PhotoService
import hashlib
from unittest.mock import patch
from pilgrim.models.photo import Photo
from pilgrim.utils import DirectoryManager
@patch.object(PhotoService, '_copy_photo_to_diary')
@patch.object(PhotoService, 'hash_file', return_value="fake_hash_123")
def test_create_photo_successfully(mock_hash, mock_copy, session_with_one_diary):
session, diary = session_with_one_diary
service = PhotoService(session)
fake_source_path = Path("/path/original/imagem.jpg")
fake_copied_path = Path(f"~/.pilgrim/diaries/{diary.directory_name}/images/imagem.jpg")
mock_copy.return_value = fake_copied_path
new_photo = service.create(
filepath=fake_source_path,
name="Foto da Praia",
travel_diary_id=diary.id,
caption="Pôr do sol")
mock_hash.assert_called_once_with(fake_source_path)
mock_copy.assert_called_once_with(fake_source_path, diary)
assert new_photo is not None
assert new_photo.name == "Foto da Praia"
assert new_photo.photo_hash == "fake_hash_123"
assert new_photo.filepath == str(fake_copied_path)
def test_hash_file_generates_correct_hash(tmp_path: Path):
original_content_bytes = b"um conteudo de teste para o hash"
file_on_disk = tmp_path / "test.jpg"
file_on_disk.write_bytes(original_content_bytes)
hash_from_file = PhotoService.hash_file(file_on_disk)
expected_hash_func = hashlib.new('sha3_384')
expected_hash_func.update(original_content_bytes)
hash_from_memory = expected_hash_func.hexdigest()
assert hash_from_file == hash_from_memory
@patch.object(PhotoService, '_copy_photo_to_diary')
@patch.object(PhotoService, 'hash_file', return_value="hash_ja_existente")
def test_create_photo_returns_none_if_hash_exists(mock_hash, mock_copy, session_with_one_diary):
session, diary = session_with_one_diary
existing_photo = Photo(
filepath="/path/existente.jpg", name="Foto Antiga",
photo_hash="hash_ja_existente", fk_travel_diary_id=diary.id
)
session.add(existing_photo)
session.commit()
service = PhotoService(session)
new_photo = service.create(
filepath=Path("/path/novo/arquivo.jpg"),
name="Foto Nova",
travel_diary_id=diary.id
)
assert new_photo is None
mock_copy.assert_not_called()
def test_read_by_id_successfully(session_with_photos):
session, photos = session_with_photos
service = PhotoService(session)
photo_to_find_id = photos[0].id
found_photo = service.read_by_id(photo_to_find_id)
assert found_photo is not None
assert found_photo.id == photo_to_find_id
assert found_photo.name == "Foto 1"
def test_read_by_id_returns_none_for_invalid_id(db_session):
service = PhotoService(db_session)
result = service.read_by_id(999)
assert result is None
def test_read_all_returns_all_photos(session_with_photos):
session, _ = session_with_photos
service = PhotoService(session)
all_photos = service.read_all()
assert isinstance(all_photos, list)
assert len(all_photos) == 2
assert all_photos[0].name == "Foto 1"
assert all_photos[1].name == "Foto 2"
def test_read_all_returns_empty_list_for_empty_db(db_session):
service = PhotoService(db_session)
all_photos = service.read_all()
assert isinstance(all_photos, list)
assert len(all_photos) == 0
def test_check_photo_by_hash_finds_existing_photo(session_with_photos):
session, photos = session_with_photos
service = PhotoService(session)
existing_photo = photos[0]
hash_to_find = existing_photo.photo_hash # "hash1"
diary_id = existing_photo.fk_travel_diary_id # 1
found_photo = service.check_photo_by_hash(hash_to_find, diary_id)
assert found_photo is not None
assert found_photo.id == existing_photo.id
assert found_photo.photo_hash == hash_to_find
def test_check_photo_by_hash_returns_none_when_not_found(session_with_photos):
session, photos = session_with_photos
service = PhotoService(session)
existing_hash = photos[0].photo_hash # "hash1"
existing_diary_id = photos[0].fk_travel_diary_id # 1
result1 = service.check_photo_by_hash("hash_inexistente", existing_diary_id)
assert result1 is None
invalid_diary_id = 999
result2 = service.check_photo_by_hash(existing_hash, invalid_diary_id)
assert result2 is None
def test_update_photo_metadata_successfully(session_with_photos):
session, photos = session_with_photos
service = PhotoService(session)
photo_to_update = photos[0]
photo_with_new_data = Photo(
filepath=photo_to_update.filepath,
name="Novo Nome da Foto",
caption="Nova legenda para a foto.",
photo_hash=photo_to_update.photo_hash, # O hash não muda
addition_date=photo_to_update.addition_date,
fk_travel_diary_id=photo_to_update.fk_travel_diary_id
)
updated_photo = service.update(photo_to_update, photo_with_new_data)
assert updated_photo is not None
assert updated_photo.name == "Novo Nome da Foto"
assert updated_photo.caption == "Nova legenda para a foto."
assert updated_photo.photo_hash == photo_to_update.photo_hash
photo_in_db = session.query(Photo).get(photo_to_update.id)
assert photo_in_db.name == "Novo Nome da Foto"
@patch.object(PhotoService, 'hash_file')
@patch('pathlib.Path.unlink')
@patch('pathlib.Path.exists')
@patch.object(PhotoService, '_copy_photo_to_diary')
@patch.object(DirectoryManager, 'get_diaries_root', return_value="/fake/diaries_root")
def test_update_photo_with_new_file_successfully(
mock_get_root, mock_copy, mock_exists, mock_unlink, mock_hash, session_with_photos
):
session, photos = session_with_photos
service = PhotoService(session)
photo_to_update = photos[0]
new_source_path = Path("/path/para/nova_imagem.jpg")
new_copied_path = Path(f"/fake/diaries_root/{photo_to_update.travel_diary.directory_name}/images/nova_imagem.jpg")
mock_copy.return_value = new_copied_path
mock_exists.return_value = True
mock_hash.return_value = "novo_hash_calculado"
photo_with_new_file = Photo(
filepath=new_source_path,
name=photo_to_update.name,
photo_hash="hash_antigo",
fk_travel_diary_id=photo_to_update.fk_travel_diary_id
)
updated_photo = service.update(photo_to_update, photo_with_new_file)
mock_copy.assert_called_once_with(new_source_path, photo_to_update.travel_diary)
mock_unlink.assert_called_once()
mock_hash.assert_called_once_with(new_copied_path)
assert updated_photo.filepath == str(new_copied_path)
assert updated_photo.photo_hash == "novo_hash_calculado"
def test_update_photo_returns_none_if_photo_does_not_exist(db_session):
service = PhotoService(db_session)
non_existent_photo_src = Photo(
filepath="/fake/path.jpg", name="dummy",
photo_hash="dummy", fk_travel_diary_id=1
)
non_existent_photo_src.id = 999
photo_with_new_data = Photo(
filepath="/fake/new.jpg", name="new dummy",
photo_hash="new_dummy", fk_travel_diary_id=1
)
result = service.update(non_existent_photo_src, photo_with_new_data)
assert result is None
@patch.object(PhotoService, 'hash_file')
@patch('pathlib.Path.unlink')
@patch('pathlib.Path.exists')
@patch.object(PhotoService, '_copy_photo_to_diary')
@patch.object(DirectoryManager, 'get_diaries_root', return_value="/fake/diaries_root")
def test_update_photo_with_new_file_successfully(
mock_get_root, mock_copy, mock_exists, mock_unlink, mock_hash, session_with_photos
):
session, photos = session_with_photos
service = PhotoService(session)
photo_to_update = photos[0]
new_source_path = Path("/path/para/nova_imagem.jpg")
new_copied_path = Path(f"/fake/diaries_root/{photo_to_update.travel_diary.directory_name}/images/nova_imagem.jpg")
mock_copy.return_value = new_copied_path
mock_exists.return_value = True
mock_hash.return_value = "novo_hash_calculado"
photo_with_new_file = Photo(
filepath=new_source_path, name=photo_to_update.name,
photo_hash="hash_antigo", fk_travel_diary_id=photo_to_update.fk_travel_diary_id
)
updated_photo = service.update(photo_to_update, photo_with_new_file)
mock_copy.assert_called_once_with(new_source_path, photo_to_update.travel_diary)
mock_unlink.assert_called_once()
mock_hash.assert_called_once_with(new_copied_path)
assert updated_photo.filepath == str(new_copied_path)
assert updated_photo.photo_hash == "novo_hash_calculado"
@patch.object(DirectoryManager, 'get_diary_images_directory')
def test_copy_photo_to_diary_handles_name_collision_with_patch(mock_get_images_dir, db_session, tmp_path: Path):
images_dir = tmp_path / "images"
images_dir.mkdir()
mock_get_images_dir.return_value = images_dir
source_file = tmp_path / "foto.jpg"
source_file.touch()
(images_dir / "foto.jpg").touch()
service = PhotoService(db_session)
fake_diary = TravelDiary(name="test",directory_name="fake_diary")
copied_path = service._copy_photo_to_diary(source_file, fake_diary)
assert copied_path.name == "foto_1.jpg"
assert copied_path.exists()
@patch('pathlib.Path.unlink')
@patch('pathlib.Path.exists')
@patch.object(DirectoryManager, 'get_diaries_root', return_value="/fake/diaries_root")
def test_delete_photo_successfully(mock_get_root, mock_exists, mock_unlink, session_with_photos):
session, photos = session_with_photos
service = PhotoService(session)
photo_to_delete = photos[0]
photo_id = photo_to_delete.id
mock_exists.return_value = True
deleted_photo_data = service.delete(photo_to_delete)
mock_unlink.assert_called_once()
assert deleted_photo_data is not None
assert deleted_photo_data.id == photo_id
photo_in_db = service.read_by_id(photo_id)
assert photo_in_db is None
@patch('pathlib.Path.unlink')
def test_delete_returns_none_for_non_existent_photo(mock_unlink, db_session):
service = PhotoService(db_session)
non_existent_photo = Photo(
filepath="/fake/path.jpg", name="dummy",
photo_hash="dummy_hash", fk_travel_diary_id=1
)
non_existent_photo.id = 999
result = service.delete(non_existent_photo)
assert result is None
mock_unlink.assert_not_called()

View File

@ -0,0 +1,33 @@
from pilgrim.service.servicemanager import ServiceManager
from unittest.mock import patch, MagicMock
def test_initial_state_is_none():
manager = ServiceManager()
assert manager.get_session() is None
assert manager.get_entry_service() is None
assert manager.get_photo_service() is None
assert manager.get_travel_diary_service() is None
@patch('pilgrim.service.servicemanager.TravelDiaryService')
@patch('pilgrim.service.servicemanager.PhotoService')
@patch('pilgrim.service.servicemanager.EntryService')
def test_get_services_instantiates_with_correct_session(
mock_entry_service, mock_photo_service, mock_travel_diary_service
):
manager = ServiceManager()
mock_session = MagicMock()
manager.set_session(mock_session)
entry_service_instance = manager.get_entry_service()
photo_service_instance = manager.get_photo_service()
travel_diary_service_instance = manager.get_travel_diary_service()
mock_entry_service.assert_called_once()
mock_photo_service.assert_called_once()
mock_travel_diary_service.assert_called_once()
mock_entry_service.assert_called_once_with(mock_session)
mock_photo_service.assert_called_once_with(mock_session)
mock_travel_diary_service.assert_called_once_with(mock_session)
assert entry_service_instance == mock_entry_service.return_value
assert photo_service_instance == mock_photo_service.return_value
assert travel_diary_service_instance == mock_travel_diary_service.return_value

View File

@ -0,0 +1,143 @@
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"

56
tests/test_application.py Normal file
View File

@ -0,0 +1,56 @@
from unittest.mock import patch, MagicMock
from pilgrim.application import Application
@patch('pilgrim.application.UIApp')
@patch('pilgrim.application.ServiceManager')
@patch('pilgrim.application.Database')
@patch('pilgrim.application.ConfigManager')
def test_application_initialization_wires_dependencies(
MockConfigManager, MockDatabase, MockServiceManager, MockUIApp
):
mock_config_instance = MockConfigManager.return_value
mock_db_instance = MockDatabase.return_value
mock_session_instance = mock_db_instance.session.return_value
mock_service_manager_instance = MockServiceManager.return_value
app = Application()
MockConfigManager.assert_called_once()
MockDatabase.assert_called_once_with(mock_config_instance)
MockServiceManager.assert_called_once()
MockUIApp.assert_called_once_with(mock_service_manager_instance, mock_config_instance)
mock_config_instance.read_config.assert_called_once()
mock_db_instance.session.assert_called_once()
mock_service_manager_instance.set_session.assert_called_once_with(mock_session_instance)
@patch('pilgrim.application.UIApp')
@patch('pilgrim.application.ServiceManager')
@patch('pilgrim.application.Database')
@patch('pilgrim.application.ConfigManager')
def test_application_run_calls_methods(
MockConfigManager, MockDatabase, MockServiceManager, MockUIApp
):
app = Application()
mock_db_instance = app.database
mock_ui_instance = app.ui
app.run()
mock_db_instance.create.assert_called_once()
mock_ui_instance.run.assert_called_once()
@patch('pilgrim.application.UIApp')
@patch('pilgrim.application.ServiceManager')
@patch('pilgrim.application.Database')
@patch('pilgrim.application.ConfigManager')
def test_get_service_manager_creates_and_configures_new_instance(
MockConfigManager, MockDatabase, MockServiceManager, MockUIApp
):
app = Application()
mock_db_instance = app.database
fake_session = MagicMock()
mock_db_instance.session.return_value = fake_session
mock_db_instance.reset_mock()
MockServiceManager.reset_mock()
returned_manager = app.get_service_manager()
mock_db_instance.session.assert_called_once()
MockServiceManager.assert_called_once()
returned_manager.set_session.assert_called_once_with(fake_session)
assert returned_manager is MockServiceManager.return_value

9
tests/test_command.py Normal file
View File

@ -0,0 +1,9 @@
from unittest.mock import patch
from pilgrim.command import main
@patch('pilgrim.command.Application')
def test_main_function_runs_application(MockApplication):
mock_app_instance = MockApplication.return_value
main()
MockApplication.assert_called_once()
mock_app_instance.run.assert_called_once()

40
tests/test_database.py Normal file
View File

@ -0,0 +1,40 @@
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()

View File

@ -0,0 +1,67 @@
import pytest
import tomli
from pathlib import Path
from unittest.mock import patch
from pilgrim.utils.config_manager import ConfigManager, SingletonMeta
from pilgrim.utils.directory_manager import DirectoryManager
@pytest.fixture
def clean_singleton():
SingletonMeta._instances = {}
@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory')
def test_create_default_config_if_not_exists_with_decorator(mock_get_config_dir, tmp_path: Path, clean_singleton):
mock_get_config_dir.return_value = str(tmp_path)
manager = ConfigManager()
config_file = tmp_path / "config.toml"
assert not config_file.exists()
manager.read_config()
assert config_file.exists()
assert manager.database_type == "sqlite"
@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory')
def test_read_existing_config_with_decorator(mock_get_config_dir, tmp_path: Path, clean_singleton):
mock_get_config_dir.return_value = str(tmp_path)
custom_config_content = """
[database]
url = "/custom/path/to/db.sqlite"
type = "custom_sqlite"
[settings.diary]
auto_open_diary_on_startup = "MyCustomDiary"
auto_open_on_creation = true
"""
config_file = tmp_path / "config.toml"
config_file.write_text(custom_config_content)
manager = ConfigManager()
manager.read_config()
assert manager.database_url == "/custom/path/to/db.sqlite"
assert manager.database_type == "custom_sqlite"
@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory')
def test_save_config_writes_changes_to_file_with_decorator(mock_get_config_dir, tmp_path: Path, clean_singleton):
mock_get_config_dir.return_value = str(tmp_path)
manager = ConfigManager()
manager.read_config()
manager.set_database_url("/novo/caminho.db")
manager.set_auto_open_new_diary(True)
manager.save_config()
config_file = tmp_path / "config.toml"
with open(config_file, "rb") as f:
data = tomli.load(f)
assert data["database"]["url"] == "/novo/caminho.db"
assert data["settings"]["diary"]["auto_open_on_creation"] is True
@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory')
def test_read_config_raises_error_on_malformed_toml(mock_get_config_dir, tmp_path: Path, clean_singleton):
mock_get_config_dir.return_value = str(tmp_path)
invalid_toml_content = """
[database]
url = /caminho/sem/aspas
"""
config_file = tmp_path / "config.toml"
config_file.write_text(invalid_toml_content)
manager = ConfigManager()
with pytest.raises(ValueError, match="Invalid TOML configuration"):
manager.read_config()

View File

@ -0,0 +1,71 @@
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()