Compare commits

..
Author SHA1 Message Date
Gustavo Henrique Miranda 9a5d112a26 Merge branch 'staging' into test/backup-service-unit-test 2025-07-25 00:20:35 -03:00
Gustavo Henrique Miranda 4af2a47606 Merge pull request #85 from gmbrax/test/traveldiaryservice-unit-test
Add the tests for the new methods of deleting
2025-07-25 00:17:47 -03:00
Gustavo Henrique Santos Souza de Miranda 3d926993e8 Add the tests for the new methods of deleting 2025-07-25 00:13:08 -03:00
Gustavo Henrique Miranda ed55d33bc6 Merge pull request #84 from gmbrax/test/entryservice-unit-test
Add the tests for the new methods of deleting
2025-07-25 00:01:00 -03:00
Gustavo Henrique Santos Souza de Miranda 819a2c7b6b Add the tests for the new methods of deleting 2025-07-24 23:36:02 -03:00
Gustavo Henrique Miranda 92ee47cff3 Merge pull request #83 from gmbrax/fix/add-photos-list-to-entry-model
Fix to add a photo list to the constructor of the entry model
2025-07-24 23:20:01 -03:00
Gustavo Henrique Mirandaandcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> 8d45b4361e Update src/pilgrim/models/entry.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-07-24 23:18:34 -03:00
Gustavo Henrique Santos Souza de Miranda 3db39eac71 Fix to add a photo list to the constructor of the entry model 2025-07-24 23:13:01 -03:00
Gustavo Henrique Santos Souza de Miranda 53e8b36a42 Add fixture and tests for the backup service 2025-07-24 22:56:43 -03:00
Gustavo Henrique Miranda 7faab93dfe Update CHANGELOG.md
Add the 0.0.5 changes into the changelog
2025-07-24 21:52:28 -03:00
Gustavo Henrique Miranda 34821bfedb Merge pull request #82 from gmbrax/refactor/remove-mocks
Remove the unused mock services
2025-07-24 21:12:32 -03:00
Gustavo Henrique Santos Souza de Miranda b32a068fb9 Remove the unused mock services 2025-07-24 21:08:28 -03:00
Gustavo Henrique Miranda 447dffd7df Merge pull request #81 from gmbrax/refactor/clean-the-ruff-errors
Fix all the Ruff errors
2025-07-24 20:26:17 -03:00
Gustavo Henrique Santos Souza de Miranda 2b79b08df0 Fix all the Ruff errors 2025-07-24 20:19:09 -03:00
Gustavo Henrique Miranda 48b3ec5fd1 Merge pull request #80 from gmbrax/feat/backup-database
add the backup feature
2025-07-24 19:59:15 -03:00
Gustavo Henrique Santos Souza de Miranda c3397ab982 Remove the wrong error message and made the program exit if no session is found 2025-07-24 19:36:12 -03:00
Gustavo Henrique Mirandaandcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> bb9457978c Update src/pilgrim/service/backup_service.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-07-24 19:31:13 -03:00
Gustavo Henrique Santos Souza de Miranda c09425cd48 Merge remote-tracking branch 'origin/feat/backup-database' into feat/backup-database 2025-07-24 19:23:59 -03:00
Gustavo Henrique Mirandaandcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> 7c950b93ab Update src/pilgrim/ui/screens/diary_list_screen.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-07-24 19:23:40 -03:00
Gustavo Henrique Santos Souza de Miranda 17371088da Changed the way it uses the database connection to be more robust 2025-07-24 19:21:47 -03:00
Gustavo Henrique Santos Souza de Miranda 209099cc5e Add a error handling on backup operation 2025-07-24 19:05:55 -03:00
Gustavo Henrique Santos Souza de Miranda 890fc470bb Change the filename variable to conform to the proper pathlike use 2025-07-24 19:00:04 -03:00
Gustavo Henrique Santos Souza de Miranda 8cf5f7e465 Fix moved the backup service initialization inside the check for the session 2025-07-24 18:39:08 -03:00
Gustavo Henrique Santos Souza de Miranda f62ecc18f7 add the backup feature 2025-07-24 18:15:26 -03:00
Gustavo Henrique Miranda 4aca171ce3 Merge pull request #79 from gmbrax/fix/remove-the-remaining-backup-feature
Remove the backup container in the diary settings screen
2025-07-24 17:10:33 -03:00
22 changed files with 320 additions and 329 deletions
+55 -34
View File
@@ -6,63 +6,84 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
## Unreleased ## Unreleased
## Planned ## Planned
* Installation Method 1 (repository compilation) * Restore from backup functionality
* Organization of trips by date, location, or theme * Organization of trips by date, location, or theme
* Enhanced photo management features * Enhanced photo management features
* Search functionality * Search functionality
* Export features * Export features
* Testing implementation * UI Testing with Textual Pilot
## [0.0.5] - 2025-07-24
### Added
* **Backup Feature:** Introduced a full backup feature to export the database and all photos to a single ZIP file.
* **Diary Settings Screen:** Added a dedicated settings screen per diary, accessible with the 's' key from the diary list.
* **Advanced Deletion Options:** The new settings screen allows for deleting the entire diary, all of its entries, or all of its photos.
* **Confirmation Modals:** Added confirmation modals that require the user to type the diary's name to proceed with critical delete operations, preventing accidental data loss.
* **Comprehensive Test Suite:** Implemented a robust unit and integration test suite for the entire backend (services, models, utils, application), achieving high code coverage and ensuring stability.
### Changed
* **Architectural Refactor:** Service methods were refactored to be transactional, allowing for more complex operations to be executed safely and atomically.
* **Configuration:** The "auto-open diary on startup" setting is now managed and persisted via the `config.toml` file.
* **Project Structure:** Standardized project metadata and build configuration in `pyproject.toml` and converted all imports to use a consistent absolute path structure.
### Fixed
* **Directory Name Sanitization:** Fixed a bug where directory names were not correctly sanitized, now properly handling accented and special characters by using transliteration.
* **Data Integrity:** Enforced `NOT NULL` constraints on `title` and `date` fields for entries in the database, preventing the creation of entries with invalid data.
### Removed
* **Obsolete Mocks:** Removed old and unused mock service files from the production codebase.
## [0.0.4] - 2025-07-19 ## [0.0.4] - 2025-07-19
### Added ### Added
* Support for creating new diaries asynchronously, with an option to automatically open the newly created diary * 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 * Unified "Enter" key support for saving or creating diaries across relevant modals.
* Automatic diary list refresh when returning to the diary screen * Automatic diary list refresh when returning to the diary screen.
* Application configuration management with a new centralized config system * Application configuration management with a new centralized config system.
* Database location and initialization now configurable via the new config manager * Database location and initialization now configurable via the new config manager.
* Automatic migration of database file to the configuration directory * Automatic migration of database file to the configuration directory.
* Display of database URL on application startup for transparency * Display of database URL on application startup for transparency.
* Duplicate photo detection before photo creation to prevent redundant entries * Duplicate photo detection before photo creation to prevent redundant entries.
* Photo hash indexing to improve photo lookup performance * Photo hash indexing to improve photo lookup performance.
### Changed ### Changed
* Enhanced feedback and validation when editing or creating diary names * Enhanced feedback and validation when editing or creating diary names.
* Streamlined and unified save logic for diary modals, reducing duplicated behavior * Streamlined and unified save logic for diary modals, reducing duplicated behavior.
* About screen now displays the actual installed application version dynamically * 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 and photo-related UI text updated to remove emoji icons for a cleaner appearance.
* Sidebar layout and scrolling behavior improved for better usability * Sidebar layout and scrolling behavior improved for better usability.
* Photo hash generation now relies on existing service-provided hashes instead of local computation * Photo hash generation now relies on existing service-provided hashes instead of local computation.
### Improved ### Improved
* Enhanced feedback and validation when editing or creating diary names * Enhanced feedback and validation when editing or creating diary names.
* Streamlined and unified save logic for diary modals, reducing duplicated behavior * Streamlined and unified save logic for diary modals, reducing duplicated behavior.
* Sidebar layout and scrolling behavior for better usability * Sidebar layout and scrolling behavior for better usability.
## [0.0.3] - 2025-07-07 ## [0.0.3] - 2025-07-07
### Changed ### 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 ## [0.0.2] - 2025-07-07
### Changed ### Changed
* Changed the license in pyproject.toml to BSD * Changed the license in pyproject.toml to BSD.
## [0.0.1] - 2025-07-06 ## [0.0.1] - 2025-07-06
### Added ### Added
* Initial alpha release of Pilgrim travel diary application * Initial alpha release of Pilgrim travel diary application.
* Create and edit travel diaries * Create and edit travel diaries.
* Create and edit diary entries * Create and edit diary entries.
* Photo ingestion system * Photo ingestion system.
* Photo addition and reference via sidebar * Photo addition and reference via sidebar.
* Text User Interface (TUI) built with Textual framework * Text User Interface (TUI) built with Textual framework.
* Pre-compiled binary installation method (Method 2) * Pre-compiled binary installation method (Method 2).
* Support for Linux operating systems * Support for Linux operating systems.
* Basic project documentation (README) * Basic project documentation (README).
### Known Issues ### Known Issues
* Installation Method 1 not yet implemented * Installation Method 1 not yet implemented.
* No testing suite implemented yet * No testing suite implemented yet.
* Some features may be unstable in an alpha version * Some features may be unstable in an alpha version.
-2
View File
@@ -1,9 +1,7 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from pathlib import Path
import os import os
import shutil
from pilgrim.utils import ConfigManager from pilgrim.utils import ConfigManager
+6 -2
View File
@@ -1,5 +1,6 @@
from typing import Any from typing import Any, List
from pilgrim.models.photo import Photo
from pilgrim.models.photo_in_entry import photo_entry_association from pilgrim.models.photo_in_entry import photo_entry_association
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -21,9 +22,12 @@ class Entry(Base):
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="entries") travel_diary = relationship("TravelDiary", back_populates="entries")
def __init__(self, title: str, text: str, date: str, travel_diary_id: int, **kw: Any): def __init__(self, title: str, text: str, date: Any, travel_diary_id: int, photos: List[Photo] = None, **kw: Any):
super().__init__(**kw) super().__init__(**kw)
self.title = title self.title = title
self.text = text self.text = text
self.date = date self.date = date
self.fk_travel_diary_id = travel_diary_id self.fk_travel_diary_id = travel_diary_id
if photos is not None:
self.photos = photos
+34
View File
@@ -0,0 +1,34 @@
import zipfile
from pilgrim.utils.directory_manager import DirectoryManager
class BackupService:
def __init__(self, session):
self.session = session
def create_backup(self):
db_path = DirectoryManager.get_database_path()
if not db_path.exists():
raise FileNotFoundError("No Database Found")
with self.session.connection() as conn:
raw_conn = conn.connection
dump = "\n".join(line for line in raw_conn.iterdump())
filename = DirectoryManager.get_config_directory() / "backup.zip"
diaries_root_path = DirectoryManager.get_diaries_root()
try:
with zipfile.ZipFile(filename, "w",zipfile.ZIP_DEFLATED) as zipf:
zipf.writestr("database.sql", dump)
if diaries_root_path.exists():
for file_path in diaries_root_path.rglob('*'):
if file_path.is_file():
arcname = file_path.relative_to(diaries_root_path.parent)
zipf.write(file_path, arcname=arcname)
return True, filename
except Exception as e:
return False, str(e)
@@ -1,123 +0,0 @@
from typing import List, Tuple
import asyncio
from pilgrim.service.entry_service import EntryService
from pilgrim.models.entry import Entry
class EntryServiceMock(EntryService):
def __init__(self):
super().__init__(None)
self.mock_data = {
1: Entry(title="The Adventure Begins", text="I'm hopping in the Plane to finally visit canadian lands",
date="26/07/2025", travel_diary_id=1, id=1,
photos=[]),
2: Entry(title="The Landing", text="Finally on Canadian Soil", date="27/07/2025",
travel_diary_id=1, id=2,photos=[]),
3: Entry(title="The Mount Royal", text="The Mount Royal is fucking awesome", date="28/07/2025",
travel_diary_id=1, id=3, photos=[]),
4: Entry(title="Old Montreal", text="Exploring the historic district", date="29/07/2025",
travel_diary_id=1, id=4, photos=[]),
5: Entry(title="Notre-Dame Basilica", text="Beautiful architecture", date="30/07/2025",
travel_diary_id=1, id=5, photos=[]),
6: Entry(title="Parc Jean-Drapeau", text="Great views of the city", date="31/07/2025",
travel_diary_id=1, id=6, photos=[]),
7: Entry(title="La Ronde", text="Amusement park fun", date="01/08/2025",
travel_diary_id=1, id=7, photos=[]),
8: Entry(title="Biodome", text="Nature and science", date="02/08/2025",
travel_diary_id=1, id=8, photos=[]),
9: Entry(title="Botanical Gardens", text="Peaceful walk", date="03/08/2025",
travel_diary_id=1, id=9, photos=[]),
10: Entry(title="Olympic Stadium", text="Historic venue", date="04/08/2025",
travel_diary_id=1, id=10, photos=[]),
}
self._next_id = 11
# Synchronous methods (kept for compatibility)
def create(self, travel_diary_id: int, title: str, text: str, date: str) -> Entry:
"""Synchronous version"""
new_entry = Entry(title, text, date, travel_diary_id, id=self._next_id)
self.mock_data[self._next_id] = new_entry
self._next_id += 1
return new_entry
def read_by_id(self, entry_id: int) -> Entry | None:
"""Synchronous version"""
return self.mock_data.get(entry_id)
def read_all(self) -> List[Entry]:
"""Synchronous version"""
return list(self.mock_data.values())
def read_by_travel_diary_id(self, travel_diary_id: int) -> List[Entry]:
"""Synchronous version - reads entries by diary"""
return [entry for entry in self.mock_data.values() if entry.fk_travel_diary_id == travel_diary_id]
def read_paginated(self, travel_diary_id: int, page: int = 1, page_size: int = 5) -> Tuple[List[Entry], int, int]:
"""Synchronous version - reads paginated entries by diary"""
entries = self.read_by_travel_diary_id(travel_diary_id)
entries.sort(key=lambda x: x.id, reverse=True) # Most recent first
total_entries = len(entries)
total_pages = (total_entries + page_size - 1) // page_size
start_index = (page - 1) * page_size
end_index = start_index + page_size
page_entries = entries[start_index:end_index]
return page_entries, total_pages, total_entries
def update(self, entry_src: Entry, entry_dst: Entry) -> Entry | None:
"""Synchronous version"""
item_to_update = self.mock_data.get(entry_src.id)
if item_to_update:
item_to_update.title = entry_dst.title if entry_dst.title is not None else item_to_update.title
item_to_update.text = entry_dst.text if entry_dst.text is not None else item_to_update.text
item_to_update.date = entry_dst.date if entry_dst.date is not None else item_to_update.date
item_to_update.fk_travel_diary_id = entry_dst.fk_travel_diary_id if (entry_dst.fk_travel_diary_id
is not None) else entry_dst.id
item_to_update.photos.extend(entry_dst.photos)
return item_to_update
return None
def delete(self, entry_src: Entry) -> Entry | None:
"""Synchronous version"""
return self.mock_data.pop(entry_src.id, None)
# Async methods (main)
async def async_create(self, travel_diary_id: int, title: str, text: str, date: str) -> Entry:
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.create(travel_diary_id, title, text, date)
async def async_read_by_id(self, entry_id: int) -> Entry | None:
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_by_id(entry_id)
async def async_read_all(self) -> List[Entry]:
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_all()
async def async_read_by_travel_diary_id(self, travel_diary_id: int) -> List[Entry]:
"""Async version - reads entries by diary"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_by_travel_diary_id(travel_diary_id)
async def async_read_paginated(self, travel_diary_id: int, page: int = 1, page_size: int = 5) -> Tuple[List[Entry], int, int]:
"""Async version - reads paginated entries by diary"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_paginated(travel_diary_id, page, page_size)
async def async_update(self, entry_src: Entry, entry_dst: Entry) -> Entry | None:
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.update(entry_src, entry_dst)
async def async_delete(self, entry_src: Entry) -> Entry | None:
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.delete(entry_src)
@@ -1,48 +0,0 @@
from pathlib import Path
from typing import List
from pilgrim.models.photo import Photo
from pilgrim.service.photo_service import PhotoService
class PhotoServiceMock(PhotoService):
def __init__(self):
super().__init__(None)
self.mock_data = {}
self._next_id = 1
def create(self, filepath: Path, name: str, travel_diary_id, addition_date=None, caption=None) -> Photo | None:
new_photo = Photo(
filepath=filepath,
name=name,
addition_date=addition_date,
caption=caption,
fk_travel_diary_id=travel_diary_id
)
new_photo.id = self._next_id
self.mock_data[self._next_id] = new_photo
self._next_id += 1
return new_photo
def read_by_id(self, photo_id: int) -> Photo:
return self.mock_data.get(photo_id)
def read_all(self) -> List[Photo]:
return list(self.mock_data.values())
def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
item_to_update: Photo = self.mock_data.get(photo_src.id)
if item_to_update:
item_to_update.filepath = photo_dst.filepath if photo_dst.filepath else item_to_update.filepath
item_to_update.name = photo_dst.name if photo_dst.name else item_to_update.name
item_to_update.caption = photo_dst.caption if photo_dst.caption else item_to_update.caption
item_to_update.addition_date = photo_dst.addition_date if photo_dst.addition_date else item_to_update.addition_date
item_to_update.fk_travel_diary_id = photo_dst.fk_travel_diary_id if photo_dst.fk_travel_diary_id else item_to_update.fk_travel_diary_id
if photo_dst.entries:
item_to_update.entries = photo_dst.entries
return item_to_update
return None
def delete(self, photo_src: Photo) -> Photo | None:
return self.mock_data.pop(photo_src.id, None)
@@ -1,23 +0,0 @@
from pilgrim.service.mocks.entry_service_mock import EntryServiceMock
from pilgrim.service.mocks.photo_service_mock import PhotoServiceMock
from pilgrim.service.mocks.travel_diary_service_mock import TravelDiaryServiceMock
from pilgrim.service.photo_service import PhotoService
from pilgrim.service.servicemanager import ServiceManager
class ServiceManagerMock(ServiceManager):
def __init__(self):
super().__init__()
# Cria instâncias únicas para manter estado consistente
self._travel_diary_service = TravelDiaryServiceMock()
self._entry_service = EntryServiceMock()
self._photo_service = PhotoServiceMock()
def get_entry_service(self):
return self._entry_service
def get_travel_diary_service(self):
return self._travel_diary_service
def get_photo_service(self):
return self._photo_service
@@ -1,67 +0,0 @@
from pilgrim.service.travel_diary_service import TravelDiaryService
from pilgrim.models.travel_diary import TravelDiary
import asyncio
class TravelDiaryServiceMock(TravelDiaryService):
def __init__(self):
super().__init__(None)
self.mock_data = {
1: TravelDiary(id=1, name="Montreal"),
2: TravelDiary(id=2, name="Rio de Janeiro"),
}
self._next_id = 3
# Synchronous methods (original)
def create(self, name: str):
"""Synchronous version"""
new_travel_diary = TravelDiary(id=self._next_id, name=name)
self.mock_data[self._next_id] = new_travel_diary
self._next_id += 1
return new_travel_diary
def read_by_id(self, travel_id: int):
"""Synchronous version"""
return self.mock_data.get(travel_id)
def read_all(self):
"""Synchronous version"""
return list(self.mock_data.values())
def update(self, travel_diary_id: int, name: str):
"""Synchronous version"""
item_to_update = self.mock_data.get(travel_diary_id)
if item_to_update:
item_to_update.name = name
return item_to_update
return None
def delete(self, travel_diary_id: int):
"""Synchronous version"""
return self.mock_data.pop(travel_diary_id, None)
# Async methods (new)
async def async_create(self, name: str):
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.create(name)
async def async_read_by_id(self, travel_id: int):
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_by_id(travel_id)
async def async_read_all(self):
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_all()
async def async_update(self, travel_diary_id: int, name: str):
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.update(travel_diary_id, name)
async def async_delete(self, travel_diary_id: int):
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.delete(travel_diary_id)
@@ -3,7 +3,6 @@ import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from pilgrim.models.entry import Entry
from pilgrim.utils import DirectoryManager from pilgrim.utils import DirectoryManager
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
+21 -6
View File
@@ -1,19 +1,19 @@
from typing import Optional, Tuple from typing import Optional, Tuple
import asyncio
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Label, Static, OptionList, Button from textual.widgets import Header, Footer, Static, OptionList, Button
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Vertical, Container, Horizontal from textual.containers import Container, Horizontal
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.ui.screens.about_screen import AboutScreen from pilgrim.ui.screens.about_screen import AboutScreen
from pilgrim.ui.screens.diary_settings_screen import SettingsScreen from pilgrim.ui.screens.diary_settings_screen import SettingsScreen
from pilgrim.ui.screens.edit_diary_modal import EditDiaryModal from pilgrim.ui.screens.edit_diary_modal import EditDiaryModal
from pilgrim.ui.screens.new_diary_modal import NewDiaryModal from pilgrim.ui.screens.new_diary_modal import NewDiaryModal
from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen
from pilgrim.service.backup_service import BackupService
class DiaryListScreen(Screen): class DiaryListScreen(Screen):
TITLE = "Pilgrim - Main" TITLE = "Pilgrim - Main"
@@ -211,11 +211,11 @@ class DiaryListScreen(Screen):
def _on_new_diary_submitted(self, result): def _on_new_diary_submitted(self, result):
"""Callback after diary creation""" """Callback after diary creation"""
if result: # Se result não é string vazia, o diário foi criado if result: # Se result não é string vazia, o diário foi criado
self.notify(f"Returning to diary list...") self.notify("Returning to diary list...")
# Atualiza a lista de diários # Atualiza a lista de diários
self.refresh_diaries() self.refresh_diaries()
else: else:
self.notify(f"Creation canceled...") self.notify("Creation canceled...")
def _on_screen_resume(self) -> None: def _on_screen_resume(self) -> None:
super()._on_screen_resume() super()._on_screen_resume()
@@ -298,3 +298,18 @@ class DiaryListScreen(Screen):
self.notify("Invalid diary ID") self.notify("Invalid diary ID")
else: else:
self.notify("Select a diary to open the settings") self.notify("Select a diary to open the settings")
def action_backup(self):
session = self.app.service_manager.get_session()
if session:
backup_service = BackupService(session)
result_operation, result_data = backup_service.create_backup()
if result_operation:
self.notify(f"Backup result: {result_data}")
else:
self.notify(f"Error performing backup: {result_data}")
else:
self.notify("Error: Session not found",severity="error")
self.app.exit()
@@ -1,13 +1,12 @@
from textual.widgets import Static from textual.widgets import Static
from textual.containers import Container from textual.containers import Container
from textual.widgets import Header, Footer, Label, Button,Checkbox,Input from textual.widgets import Header, Footer, Label, Button,Checkbox
from textual.screen import Screen from textual.screen import Screen
from textual.reactive import reactive from textual.reactive import reactive
from textual.binding import Binding from textual.binding import Binding
from textual import on 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_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_all_photos_from_diary_modal import DeleteAllPhotosModal
from pilgrim.ui.screens.modals.delete_diary_modal import DeleteDiaryModal from pilgrim.ui.screens.modals.delete_diary_modal import DeleteDiaryModal
+4 -6
View File
@@ -5,11 +5,9 @@ from typing import Optional, List
from pilgrim.models.entry import Entry from pilgrim.models.entry import Entry
from pilgrim.models.photo import Photo from pilgrim.models.photo import Photo
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.ui.screens.modals.add_photo_modal import AddPhotoModal from pilgrim.ui.screens.modals.add_photo_modal import AddPhotoModal
from pilgrim.ui.screens.modals.confirm_delete_modal import ConfirmDeleteModal from pilgrim.ui.screens.modals.confirm_delete_modal import ConfirmDeleteModal
from pilgrim.ui.screens.modals.edit_photo_modal import EditPhotoModal from pilgrim.ui.screens.modals.edit_photo_modal import EditPhotoModal
from pilgrim.ui.screens.modals.file_picker_modal import FilePickerModal
from pilgrim.ui.screens.rename_entry_modal import RenameEntryModal from pilgrim.ui.screens.rename_entry_modal import RenameEntryModal
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding from textual.binding import Binding
@@ -189,7 +187,7 @@ class EditEntryScreen(Screen):
"""Ensures the diary info widget is always updated with current diary name""" """Ensures the diary info widget is always updated with current diary name"""
try: try:
self.diary_info.update(f"Diary: {self.diary_name}") self.diary_info.update(f"Diary: {self.diary_name}")
except Exception as e: except Exception:
self.diary_info.update(f"Diary: {self.diary_id}") self.diary_info.update(f"Diary: {self.diary_id}")
def refresh_entries(self): def refresh_entries(self):
@@ -427,7 +425,7 @@ class EditEntryScreen(Screen):
photo_details += f"🔗 {photo_hash}\n" photo_details += f"🔗 {photo_hash}\n"
photo_details += f"📅 {selected_photo.addition_date}\n" photo_details += f"📅 {selected_photo.addition_date}\n"
photo_details += f"💬 {selected_photo.caption or 'No caption'}\n" photo_details += f"💬 {selected_photo.caption or 'No caption'}\n"
photo_details += f"[b]Reference formats:[/b]\n" photo_details += "[b]Reference formats:[/b]\n"
photo_details += f"\\[\\[photo::{photo_hash}\\]\\]" photo_details += f"\\[\\[photo::{photo_hash}\\]\\]"
self.photo_info.update(photo_details) self.photo_info.update(photo_details)
@@ -745,8 +743,8 @@ class EditEntryScreen(Screen):
if selected_photo.caption: if selected_photo.caption:
photo_details += f"Caption: {selected_photo.caption}\n" photo_details += f"Caption: {selected_photo.caption}\n"
else: else:
photo_details += f"Caption: No Caption\n" photo_details += "Caption: No Caption\n"
photo_details += f"[b]Reference formats:[/b]\n" photo_details += "[b]Reference formats:[/b]\n"
photo_details += f"\\[\\[photo::{photo_hash}]]" photo_details += f"\\[\\[photo::{photo_hash}]]"
self.photo_info.update(photo_details) self.photo_info.update(photo_details)
@@ -1,11 +1,9 @@
import os
from pathlib import Path from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Static, Input, Button from textual.widgets import Static, Input, Button
from textual.containers import Horizontal, Container from textual.containers import Horizontal, Container
from .file_picker_modal import FilePickerModal from .file_picker_modal import FilePickerModal
import hashlib
class AddPhotoModal(Screen): class AddPhotoModal(Screen):
"""Modal for adding a new photo""" """Modal for adding a new photo"""
+5
View File
@@ -37,6 +37,11 @@ class UIApp(App):
"Open About Pilgrim", "Open About Pilgrim",
screen.action_about_cmd screen.action_about_cmd
) )
yield SystemCommand(
"Backup Database",
"Backup the Database",
screen.action_backup
)
elif isinstance(screen, AboutScreen): elif isinstance(screen, AboutScreen):
yield SystemCommand( yield SystemCommand(
-1
View File
@@ -1,5 +1,4 @@
import os.path import os.path
from os import PathLike
from threading import Lock from threading import Lock
import tomli import tomli
+76 -1
View File
@@ -1,10 +1,18 @@
from pathlib import Path
from unittest.mock import patch
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from datetime import datetime
from pilgrim.models.entry import Entry
from pilgrim.database import Base from pilgrim.database import Base
from pilgrim.models.travel_diary import TravelDiary from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.entry import Entry
from pilgrim.models.photo import Photo from pilgrim.models.photo import Photo
from pilgrim.utils import DirectoryManager
# Todos os imports necessários para as fixtures devem estar aqui # Todos os imports necessários para as fixtures devem estar aqui
# ... # ...
@@ -61,4 +69,71 @@ def session_with_photos(session_with_one_diary):
session.add_all([photo1, photo2]) session.add_all([photo1, photo2])
session.commit() session.commit()
return session, [photo1, photo2] return session, [photo1, photo2]
@pytest.fixture
def backup_test_env_files_only(tmp_path):
fake_config_dir = tmp_path / "config"
fake_diaries_root = tmp_path / "diaries"
fake_db_path = fake_config_dir / "database.db"
fake_config_dir.mkdir()
fake_diaries_root.mkdir()
with patch.object(DirectoryManager, 'get_database_path', return_value=fake_db_path), \
patch.object(DirectoryManager, 'get_config_directory', return_value=fake_config_dir), \
patch.object(DirectoryManager, 'get_diaries_root', return_value=fake_diaries_root):
engine = create_engine(f"sqlite:///{fake_db_path}")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
diary = TravelDiary(name="Viagem de Teste", directory_name="viagem_de_teste")
session.add(diary)
session.commit()
photo_file_path_str = str(fake_diaries_root / "viagem_de_teste" / "images" / "foto1.jpg")
photo = Photo(filepath=photo_file_path_str, name="Foto 1", photo_hash="hash123", fk_travel_diary_id=diary.id)
session.add(photo)
session.commit()
photo_file_path = Path(photo_file_path_str)
photo_file_path.parent.mkdir(parents=True)
photo_file_path.touch()
yield {
"session": session,
"db_path": fake_db_path,
"config_dir": fake_config_dir,
"diaries_root": fake_diaries_root,
}
session.close()
@pytest.fixture
def entry_with_photo_references(session_with_one_diary):
session, diary = session_with_one_diary
photo1 = Photo(filepath="p1.jpg", name="P1", photo_hash="aaaaaaaa", fk_travel_diary_id=diary.id)
photo2 = Photo(filepath="p2.jpg", name="P2", photo_hash="bbbbbbbb", fk_travel_diary_id=diary.id)
session.add_all([photo1, photo2])
session.flush()
entry = Entry(
title="Entrada com Fotos",
text="Texto com a foto A [[photo::aaaaaaaa]] e também a foto B [[photo::bbbbbbbb]].",
date=datetime.now(),
travel_diary_id=diary.id,
photos=[photo1, photo2]
)
session.add(entry)
session.commit()
session.refresh(entry)
return session, entry
@pytest.fixture
def session_with_multiple_entries(session_with_one_diary):
session, diary = session_with_one_diary
session.query(Entry).delete()
entry1 = Entry(title="Entrada 1", text="Texto 1", date=datetime.now(), travel_diary_id=diary.id)
entry2 = Entry(title="Entrada 2", text="Texto 2", date=datetime.now(), travel_diary_id=diary.id)
session.add_all([entry1, entry2])
session.commit()
return session, diary
+39
View File
@@ -0,0 +1,39 @@
import zipfile
from pathlib import Path
from unittest.mock import patch, MagicMock
from pilgrim.service.backup_service import BackupService
from pilgrim.utils.directory_manager import DirectoryManager
import pytest
@patch.object(DirectoryManager, 'get_diaries_root')
@patch.object(DirectoryManager, 'get_config_directory')
@patch.object(DirectoryManager, 'get_database_path')
def test_create_backup_success(mock_get_db_path, mock_get_config_dir, mock_get_diaries_root, backup_test_env_files_only):
env = backup_test_env_files_only
session = env["session"]
mock_get_db_path.return_value = env["db_path"]
mock_get_config_dir.return_value = env["config_dir"]
mock_get_diaries_root.return_value = env["diaries_root"]
service = BackupService(session)
backup_zip_path = env["config_dir"] / "backup.zip"
success, returned_path = service.create_backup()
assert success is True
assert returned_path == backup_zip_path
assert backup_zip_path.exists()
with zipfile.ZipFile(backup_zip_path, 'r') as zf:
file_list = zf.namelist()
assert "database.sql" in file_list
assert "diaries/viagem_de_teste/images/foto1.jpg" in file_list
sql_dump = zf.read("database.sql").decode('utf-8')
assert "Viagem de Teste" in sql_dump
@patch.object(DirectoryManager, 'get_database_path')
def test_create_backup_fails_if_db_not_found(mock_get_db_path, tmp_path: Path):
non_existent_db_path = tmp_path / "non_existent.db"
mock_get_db_path.return_value = non_existent_db_path
mock_session = MagicMock()
service = BackupService(mock_session)
with pytest.raises(FileNotFoundError, match="No Database Found"):
service.create_backup()
+43 -6
View File
@@ -1,14 +1,8 @@
from re import search
import pytest import pytest
from datetime import datetime from datetime import datetime
from unittest.mock import Mock
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError 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.entry import Entry
from pilgrim.models.photo import Photo from pilgrim.models.photo import Photo
@@ -267,3 +261,46 @@ def test_delete_returns_none_if_entry_does_not_exist(db_session):
non_existent_entry.id = 999 non_existent_entry.id = 999
result = service.delete(non_existent_entry) result = service.delete(non_existent_entry)
assert result is None assert result is None
def test_delete_references_for_specific_photo(entry_with_photo_references):
session, entry = entry_with_photo_references
service = EntryService(session)
updated_entry = service.delete_references_for_specific_photo(entry, "aaaaaaaa")
assert "[[photo::aaaaaaaa]]" not in updated_entry.text
assert "[[photo::bbbbbbbb]]" in updated_entry.text
def test_delete_specific_photo_reference_does_nothing_if_no_match(entry_with_photo_references):
session, entry = entry_with_photo_references
service = EntryService(session)
original_text = entry.text
updated_entry = service.delete_references_for_specific_photo(entry, "cccccccc")
assert updated_entry.text == original_text
def test_delete_all_photo_references_removes_all_refs(entry_with_photo_references):
session, entry = entry_with_photo_references
service = EntryService(session)
updated_entry = service.delete_all_photo_references(entry)
assert "[[photo::aaaaaaaa]]" not in updated_entry.text
assert "[[photo::bbbbbbbb]]" not in updated_entry.text
def test_delete_all_photo_references_uses_truncated_hash(session_with_one_diary):
session, diary = session_with_one_diary
service = EntryService(session)
long_hash_photo = Photo(
filepath="long.jpg", name="Long",
photo_hash="1234567890abcdef", # Hash com 16 caracteres
fk_travel_diary_id=diary.id
)
entry = Entry(
title="Teste de Hash Curto",
text="Referência com hash truncado [[photo::12345678]].", # Texto usa só os 8 primeiros
date=datetime.now(),
travel_diary_id=diary.id
)
entry.photos.append(long_hash_photo)
session.add_all([long_hash_photo, entry])
session.commit()
updated_entry = service.delete_all_photo_references(entry)
expected_text = "Referência com hash truncado ."
assert "[[photo::12345678]]" not in updated_entry.text
-1
View File
@@ -1,4 +1,3 @@
import pytest
from pathlib import Path from pathlib import Path
from pilgrim import TravelDiary from pilgrim import TravelDiary
+36 -3
View File
@@ -1,9 +1,12 @@
from unittest.mock import patch, MagicMock
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest import pytest
from pilgrim import TravelDiary from pilgrim.models.photo import Photo
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.entry import Entry
from pilgrim.service.travel_diary_service import TravelDiaryService from pilgrim.service.travel_diary_service import TravelDiaryService
@patch.object(TravelDiaryService, '_ensure_diary_directory') @patch.object(TravelDiaryService, '_ensure_diary_directory')
@@ -140,4 +143,34 @@ def test_sanitize_directory_name_handles_uniqueness(db_session):
db_session.commit() db_session.commit()
third_sanitized_name = service._sanitize_directory_name("Viagem para a Praia") third_sanitized_name = service._sanitize_directory_name("Viagem para a Praia")
assert third_sanitized_name == "viagem_para_a_praia_2" assert third_sanitized_name == "viagem_para_a_praia_2"
def test_delete_all_entries_successfully(session_with_multiple_entries):
session, diary = session_with_multiple_entries
service = TravelDiaryService(session)
diary_id = 1
assert session.query(Entry).filter_by(fk_travel_diary_id=diary_id).count() == 2
result = service.delete_all_entries(diary)
assert result is True
assert session.query(Entry).filter_by(fk_travel_diary_id=diary_id).count() == 0
@patch.object(TravelDiaryService, '_ensure_diary_directory')
@patch('pathlib.Path.unlink')
@patch('pathlib.Path.exists', return_value=True)
@patch('pilgrim.utils.DirectoryManager.get_diaries_root', return_value=Path("/fake/diaries_root"))
def test_delete_all_photos_orchestration(
mock_ensure_dir, mock_unlink, mock_exists, mock_get_root, entry_with_photo_references
):
session, entry = entry_with_photo_references
diary_id = entry.fk_travel_diary_id
service = TravelDiaryService(session)
assert session.query(Photo).filter_by(fk_travel_diary_id=diary_id).count() == 2
assert "[[photo::" in entry.text
diary = session.get(TravelDiary, diary_id)
result = service.delete_all_photos(diary)
assert result is True
photos_after_delete = session.query(Photo).filter_by(fk_travel_diary_id=diary_id).all()
assert len(photos_after_delete) == 0
session.refresh(entry)
assert "[[photo::" not in entry.text
assert mock_unlink.call_count == 2
-1
View File
@@ -4,7 +4,6 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from pilgrim.utils.config_manager import ConfigManager, SingletonMeta from pilgrim.utils.config_manager import ConfigManager, SingletonMeta
from pilgrim.utils.directory_manager import DirectoryManager
@pytest.fixture @pytest.fixture
def clean_singleton(): def clean_singleton():