Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ The MVP is complete. The current repository baseline is the finished admin-opera
- Async Telegram bot skeleton with polling
- Button-first Telegram navigation (reply keyboard + inline actions)
- Admin-only repertoire CRUD flow
- Rich song metadata fields: capo, time signature, and arrangement notes
- Rich song metadata fields: capo and time signature
- Stored `arrangement_notes` field for backup/domain compatibility (currently non-user-facing)
- Admin-only chart upload flow with one active chart per song
- Chart retrieval by song ID
- Admin-only repertoire backup export/import (ZIP with chart binaries)
Expand Down
28 changes: 28 additions & 0 deletions docs/features/button-navigation-cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,31 @@ Stage 3 hardens callback-driven navigation without changing service contracts or
first message keeps reply-keyboard reset behavior, second message offers inline return actions.
- Upload flow now keeps return context (`return_mode`, `return_page`) in upload state so post-upload
navigation can reopen the upload target list page.

## Implementation note: existing flow copy consistency

Stage 4 aligns existing Ukrainian bot copy without adding capabilities or changing service
contracts:

- Admin-only rejections now share one user-facing message across menu, command-helper, callback,
upload, archive, and backup entry points.
- Empty states are explicit for repertoire, search, and tag flows.
- Add-song prompts use consistent "Надішліть..." wording while preserving the current fields and
skip/cancel behavior.
- Chart upload/view text now uses "гармонія" consistently to match the reply-keyboard menu label.
- Archive, backup export, and backup import outcomes use consistent success text and keep the
Stage 3 next-action buttons.

## Implementation note: docs and final verification

Stage 5 finalizes documentation and verification for this UX cleanup:

- README/MVP docs now explicitly describe the current userspace surface as button-first with
`/start` as the only typed entry/reset command.
- `arrangement_notes` is now explicitly documented as persisted for domain/backup compatibility but
currently non-user-facing in bot flows.
- Final quality gates were run after Stage 5 updates:
- `uv run ruff check .`
- `uv run ruff format --check .`
- `uv run mypy`
- `uv run pytest`
21 changes: 11 additions & 10 deletions docs/mvp.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ The MVP is finished. This document records the delivered scope of the first usab

## Included

- Start and help commands
- Button-first navigation with `/start` as the only typed entry/reset command
- List active songs
- Search songs by title, artist, and tag text
- Guided add-song flow
- Guided edit-song flow
- Soft-archive command
- Soft-archive flow
- Tag listing
- Rich song metadata fields (capo, time signature, arrangement notes)
- Rich song metadata fields (capo, time signature)
- Stored `arrangement_notes` field for backups/domain compatibility (currently non-user-facing)
- One current chart image per song, managed by admins
- Admin-only chart upload command
- Song chart retrieval command for all users
- Admin-only chart upload flow
- Song chart retrieval flow for all users
- S3-compatible chart storage for local/dev via MinIO
- Admin-only repertoire backup export/import (ZIP with songs + charts)

Expand All @@ -28,7 +29,7 @@ Charts shipped in the MVP as admin-managed image attachments.
- Chart binaries live in S3-compatible object storage, with MinIO used in Docker Compose for local development.
- Chart metadata is stored separately from the song record so future arrangement support remains possible.
- Chart metadata includes optional `source_url` and optional chart key in the musical sense.
- Chart upload happens in a dedicated step after song creation rather than inside `/addsong`.
- Chart upload happens in a dedicated step after song creation.
- Chart retrieval is available to all bot users in the delivered MVP.

## Out of scope
Expand All @@ -46,12 +47,12 @@ Charts shipped in the MVP as admin-managed image attachments.

- [x] Establish project baseline with `uv`, CI, linting, formatting, and pre-commit
- [x] Define the initial song data model and migration flow
- [x] Implement admin-only repertoire CRUD command flows
- [x] Implement admin-only repertoire CRUD flows
- [x] Add Postgres-backed integration tests for migrations and persistence
- [x] Improve guided edit flows with field previews and validation feedback
- [x] Add pagination or compact summaries for long `/songs` and `/search` results
- [x] Add chart attachment storage, metadata persistence, and admin commands
- [x] Support richer song metadata such as capo, time signature, and arrangement notes
- [x] Add pagination or compact summaries for long list/search result sets
- [x] Add chart attachment storage, metadata persistence, and admin upload/view flows
- [x] Support richer song metadata such as capo, time signature, and persisted arrangement notes
- [x] Add import/export support for repertoire backups

## Next planning location
Expand Down
25 changes: 13 additions & 12 deletions src/handlers/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
home_or_remove_markup,
user_state,
)
from handlers.messages import NEXT_ACTIONS_MESSAGE
from handlers.ui import cancel_markup
from services.repertoire_backup_service import BackupValidationError
from storage.chart_storage import ChartStorageError
Expand All @@ -39,24 +40,24 @@ async def export_backup_command(update: Update, context: ContextTypes.DEFAULT_TY
archive = await service.export_backup()
except ChartStorageError:
await update.effective_message.reply_text(
"Не вдалося експортувати резервну копію під час читання файлів акордів."
"Не вдалося експортувати резервну копію під час читання файлів гармонії."
)
return

await update.effective_message.reply_document(
document=InputFile(BytesIO(archive.content), filename=archive.filename),
caption=(
"Експорт резервної копії завершено.\n"
"Резервну копію експортовано.\n"
f"Пісень: {archive.song_count}\n"
f"Акордів: {archive.chart_count}"
f"Файлів гармонії: {archive.chart_count}"
),
)
await update.effective_message.reply_text(
"Експорт резервної копії завершено.",
"Резервну копію експортовано.",
reply_markup=home_or_remove_markup(update, context),
)
await update.effective_message.reply_text(
"Що далі?",
NEXT_ACTIONS_MESSAGE,
reply_markup=backup_outcome_keyboard(),
)

Expand All @@ -69,7 +70,7 @@ async def import_backup_start(update: Update, context: ContextTypes.DEFAULT_TYPE

user_state(context)[IMPORT_BACKUP_STATE_KEY] = {}
await update.effective_message.reply_text(
"Надішліть .zip файл резервної копії для імпорту або натисніть «Скасувати».",
"Надішліть .zip файл резервної копії або натисніть «Скасувати».",
reply_markup=cancel_markup(update),
)
return IMPORT_BACKUP_UPLOAD
Expand All @@ -92,11 +93,11 @@ async def import_backup_file(update: Update, context: ContextTypes.DEFAULT_TYPE)

document = update.effective_message.document
if document is None:
await update.effective_message.reply_text("Надішліть .zip файл документом.")
await update.effective_message.reply_text("Надішліть .zip файл резервної копії документом.")
return IMPORT_BACKUP_UPLOAD
if not _looks_like_zip(document.file_name, document.mime_type):
await update.effective_message.reply_text(
"Імпорт резервної копії очікує .zip файл документом."
"Потрібен .zip файл резервної копії, надісланий документом."
)
return IMPORT_BACKUP_UPLOAD

Expand All @@ -107,20 +108,20 @@ async def import_backup_file(update: Update, context: ContextTypes.DEFAULT_TYPE)
try:
summary = await service.import_backup(content)
except (BackupValidationError, ChartStorageError, ValueError) as error:
await update.effective_message.reply_text(f"Помилка імпорту резервної копії: {error}")
await update.effective_message.reply_text(f"Не вдалося імпортувати резервну копію: {error}")
return ConversationHandler.END

user_state(context).pop(IMPORT_BACKUP_STATE_KEY, None)
await update.effective_message.reply_text(
(
"Імпорт резервної копії завершено.\n"
"Резервну копію імпортовано.\n"
f"Відновлено пісень: {summary.song_count}\n"
f"Відновлено акордів: {summary.chart_count}"
f"Відновлено файлів гармонії: {summary.chart_count}"
),
reply_markup=home_or_remove_markup(update, context),
)
await update.effective_message.reply_text(
"Що далі?",
NEXT_ACTIONS_MESSAGE,
reply_markup=backup_outcome_keyboard(),
)
return ConversationHandler.END
Expand Down
23 changes: 14 additions & 9 deletions src/handlers/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
song_outcome_keyboard,
user_state,
)
from handlers.messages import NEXT_ACTIONS_MESSAGE
from handlers.ui import BUTTON_SKIP, cancel_markup
from services.chart_service import ChartFile, ChartUpload, SongChartNotFoundError
from services.song_service import SongNotFoundError
Expand Down Expand Up @@ -58,10 +59,14 @@ async def send_chart_for_song_id(
await update.effective_message.reply_text(str(error))
return
except SongChartNotFoundError:
await update.effective_message.reply_text(f"Для пісні #{song_id} ще не завантажено акорди.")
await update.effective_message.reply_text(
f"Для пісні #{song_id} ще не завантажено гармонію."
)
return
except ChartStorageError:
await update.effective_message.reply_text("Не вдалося завантажити файл акордів зі сховища.")
await update.effective_message.reply_text(
"Не вдалося завантажити файл гармонії зі сховища."
)
return

caption = _chart_caption(chart_file)
Expand Down Expand Up @@ -122,7 +127,7 @@ async def _begin_upload_for_song_id(
"return_page": _browser_return_page(context),
}
await update.effective_message.reply_text(
f"Ціль завантаження: пісня #{song_id}.\nНадішліть зображення акордів як фото або файл.",
f"Ціль завантаження: пісня #{song_id}.\nНадішліть зображення гармонії як фото або файл.",
reply_markup=cancel_markup(update),
)
return UPLOAD_MEDIA
Expand Down Expand Up @@ -159,15 +164,15 @@ async def upload_chart_media(update: Update, context: ContextTypes.DEFAULT_TYPE)
filename = document.file_name or f"song-{song_id}-chart"
else:
await update.effective_message.reply_text(
"Надішліть фото або зображення-документ (наприклад, image/png)."
"Надішліть фото або зображення-документ гармонії (наприклад, image/png)."
)
return UPLOAD_MEDIA

state["content"] = content
state["content_type"] = content_type
state["filename"] = filename
await update.effective_message.reply_text(
"Тональність акордів необов'язкова. Надішліть текст або «Пропустити».",
"Тональність гармонії необов'язкова. Надішліть текст або «Пропустити».",
reply_markup=cancel_markup(update),
)
return UPLOAD_CHART_KEY
Expand Down Expand Up @@ -221,11 +226,11 @@ async def upload_chart_chart_key(update: Update, context: ContextTypes.DEFAULT_T

user_state(context).pop(UPLOAD_CHART_STATE_KEY, None)
await update.effective_message.reply_text(
f"Акорди #{chart.id} для пісні #{song_id} завантажено.",
f"Гармонію #{chart.id} для пісні #{song_id} завантажено.",
reply_markup=home_or_remove_markup(update, context),
)
await update.effective_message.reply_text(
"Що далі?",
NEXT_ACTIONS_MESSAGE,
reply_markup=song_outcome_keyboard(
song_id=song_id,
page=return_page,
Expand Down Expand Up @@ -290,7 +295,7 @@ def _chart_caption(chart_file: ChartFile) -> str:
f"Пісня #{chart_file.song_id}: {chart_file.song_title}",
]
if chart_file.chart_key:
lines.append(f"Тональність акордів: {chart_file.chart_key}")
lines.append(f"Тональність гармонії: {chart_file.chart_key}")
if chart_file.source_url:
lines.append(f"Джерело акордів: {chart_file.source_url}")
lines.append(f"Джерело гармонії: {chart_file.source_url}")
return "\n".join(lines)
3 changes: 2 additions & 1 deletion src/handlers/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from telegram.ext import ContextTypes

from bot.runtime import get_settings
from handlers.messages import ADMIN_REQUIRED_MESSAGE
from handlers.ui import home_menu_markup

logger = logging.getLogger(__name__)
Expand All @@ -28,7 +29,7 @@ async def ensure_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bo
user = update.effective_user
if user is None or user.id not in settings.admin_telegram_user_ids:
if update.effective_message is not None:
await update.effective_message.reply_text("Для цієї дії потрібні права адміністратора.")
await update.effective_message.reply_text(ADMIN_REQUIRED_MESSAGE)
return False
return True

Expand Down
5 changes: 5 additions & 0 deletions src/handlers/messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ADMIN_REQUIRED_MESSAGE = "Ця дія доступна лише адміністраторам."
EMPTY_ACTIVE_SONGS_MESSAGE = "У репертуарі ще немає активних пісень."
EMPTY_SEARCH_RESULTS_MESSAGE = "За цим запитом пісень не знайдено."
EMPTY_TAGS_MESSAGE = "Теги ще не додано."
NEXT_ACTIONS_MESSAGE = "Що далі?"
Loading
Loading