Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b906bfa777 | |||
| 8a80af32d8 | |||
| 3c3a2c507c | |||
| 71d56d2491 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ venv/
|
|||||||
data/
|
data/
|
||||||
*.db
|
*.db
|
||||||
.cursor/
|
.cursor/
|
||||||
|
.cursorrules/
|
||||||
# Test and coverage artifacts
|
# Test and coverage artifacts
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
Metadata-Version: 2.4
|
|
||||||
Name: duty-teller
|
|
||||||
Version: 0.1.0
|
|
||||||
Summary: Telegram bot for team duty shift calendar and group reminder
|
|
||||||
Requires-Python: >=3.11
|
|
||||||
Description-Content-Type: text/markdown
|
|
||||||
Requires-Dist: python-telegram-bot[job-queue]<23.0,>=22.0
|
|
||||||
Requires-Dist: python-dotenv<2.0,>=1.0
|
|
||||||
Requires-Dist: fastapi<1.0,>=0.115
|
|
||||||
Requires-Dist: uvicorn[standard]<1.0,>=0.32
|
|
||||||
Requires-Dist: sqlalchemy<3.0,>=2.0
|
|
||||||
Requires-Dist: alembic<2.0,>=1.14
|
|
||||||
Requires-Dist: pydantic<3.0,>=2.0
|
|
||||||
Requires-Dist: icalendar<6.0,>=5.0
|
|
||||||
Provides-Extra: dev
|
|
||||||
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
|
||||||
Requires-Dist: pytest-asyncio<2.0,>=1.0; extra == "dev"
|
|
||||||
Requires-Dist: pytest-cov<7.0,>=6.0; extra == "dev"
|
|
||||||
Requires-Dist: httpx<1.0,>=0.27; extra == "dev"
|
|
||||||
Provides-Extra: docs
|
|
||||||
Requires-Dist: mkdocs<2,>=1.5; extra == "docs"
|
|
||||||
Requires-Dist: mkdocstrings[python]<1,>=0.24; extra == "docs"
|
|
||||||
Requires-Dist: mkdocs-material<10,>=9.0; extra == "docs"
|
|
||||||
|
|
||||||
# Duty Teller (Telegram Bot)
|
|
||||||
|
|
||||||
A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 with the `Application` API. The bot and web UI support **Russian and English** (language from Telegram or `DEFAULT_LANGUAGE`).
|
|
||||||
|
|
||||||
**History of changes:** [CHANGELOG.md](CHANGELOG.md).
|
|
||||||
|
|
||||||
## Get a bot token
|
|
||||||
|
|
||||||
1. Open Telegram and search for [@BotFather](https://t.me/BotFather).
|
|
||||||
2. Send `/newbot` and follow the prompts to create a bot.
|
|
||||||
3. Copy the token BotFather gives you.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. **Clone and enter the project**
|
|
||||||
```bash
|
|
||||||
cd duty-teller
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create a virtual environment (recommended)**
|
|
||||||
```bash
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # Linux/macOS
|
|
||||||
# or: venv\Scripts\activate # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Install dependencies**
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Configure the bot**
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
Edit `.env` and set `BOT_TOKEN` to the token from BotFather.
|
|
||||||
|
|
||||||
5. **Miniapp access (calendar)**
|
|
||||||
Access is controlled by **roles in the DB** (assigned by an admin with `/set_role @username user|admin`). Set `ADMIN_USERNAMES` (and optionally `ADMIN_PHONES`) so that at least one admin can use the bot and assign roles; these also act as a fallback for admin when a user has no role in the DB. See [docs/configuration.md](docs/configuration.md).
|
|
||||||
**Mini App URL:** When configuring the bot's menu button or Web App URL (e.g. in @BotFather or via `setChatMenuButton`), use the URL **with a trailing slash**, e.g. `https://your-domain.com/app/`. A redirect from `/app` to `/app/` can cause the browser to drop the fragment that Telegram sends, which breaks authorization.
|
|
||||||
**How to open:** Users must open the calendar **via the bot's menu button** (⋮ → "Calendar" or the configured label) or a **Web App inline button**. If they use "Open in browser" or a direct link, Telegram may not send user data (`tgWebAppData`), and access will be denied.
|
|
||||||
**BOT_TOKEN:** The server that serves `/api/duties` (e.g. your production host) must have in `.env` the **same** bot token as the bot from which users open the Mini App. If the token differs (e.g. test vs production bot), validation returns "hash_mismatch" and access is denied.
|
|
||||||
|
|
||||||
6. **Other options**
|
|
||||||
Full list of environment variables (types, defaults, examples): **[docs/configuration.md](docs/configuration.md)**.
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Or after `pip install -e .`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
duty-teller
|
|
||||||
```
|
|
||||||
|
|
||||||
The bot runs in polling mode. Send `/start` or `/help` to your bot in Telegram to test.
|
|
||||||
|
|
||||||
## Bot commands
|
|
||||||
|
|
||||||
- **`/start`** — Greeting and user registration in the database.
|
|
||||||
- **`/help`** — Help on available commands.
|
|
||||||
- **`/set_phone [number]`** — Set or clear phone number (private chat only); used for access via `ALLOWED_PHONES` / `ADMIN_PHONES`.
|
|
||||||
- **`/import_duty_schedule`** — Import duty schedule (admin only); see **Duty schedule import** below for the two-step flow.
|
|
||||||
- **`/set_role @username user|admin`** — Set a user’s role (admin only). Alternatively, reply to a message and send `/set_role user|admin`.
|
|
||||||
- **`/pin_duty`** — Pin the current duty message in a group (reply to the bot’s duty message); time/timezone for the pinned message come from `DUTY_DISPLAY_TZ`.
|
|
||||||
|
|
||||||
## Run with Docker
|
|
||||||
|
|
||||||
Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
|
|
||||||
|
|
||||||
- **Dev** (volume mount; code changes apply without rebuild):
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml up --build
|
|
||||||
```
|
|
||||||
Stop with `Ctrl+C` or `docker compose -f docker-compose.dev.yml down`.
|
|
||||||
|
|
||||||
- **Prod** (no volume; runs the built image; restarts on failure):
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
|
||||||
```
|
|
||||||
For production deployments you may use Docker secrets or your orchestrator’s env instead of a `.env` file.
|
|
||||||
|
|
||||||
The image is built from `Dockerfile`; on start, `entrypoint.sh` runs Alembic migrations then starts the app as user `botuser`.
|
|
||||||
|
|
||||||
**Production behind a reverse proxy:** When the app is behind nginx/Caddy etc., `request.client.host` is usually the proxy (e.g. 127.0.0.1). The "private IP" bypass (allowing requests without initData from localhost) then applies to the proxy, not the real client. Either ensure the Mini App always sends initData, or forward the real client IP (e.g. `X-Forwarded-For`) and use it for that check. See `api/app.py` `_is_private_client` for details.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
The HTTP server is FastAPI; the miniapp is served at `/app`.
|
|
||||||
|
|
||||||
**Interactive API documentation (Swagger UI)** is available at **`/docs`**, e.g. `http://localhost:8080/docs` when running locally.
|
|
||||||
|
|
||||||
- **`GET /api/duties`** — List of duties (date params; auth via Telegram initData or, in dev, `MINI_APP_SKIP_AUTH` / private IP).
|
|
||||||
- **`GET /api/calendar-events`** — Calendar events (including external ICS when `EXTERNAL_CALENDAR_ICS_URL` is set).
|
|
||||||
- **`GET /api/calendar/ical/{token}.ics`** — Personal ICS calendar (by secret token; no Telegram auth).
|
|
||||||
|
|
||||||
For production, initData validation is required; see the reverse-proxy paragraph above for proxy/headers.
|
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
High-level architecture (components, data flow, package relationships) is described in [docs/architecture.md](docs/architecture.md).
|
|
||||||
|
|
||||||
- `main.py` – Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
|
|
||||||
- `duty_teller/` – Main package (install with `pip install -e .`). Contains:
|
|
||||||
- `config.py` – Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
|
|
||||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
|
|
||||||
- `db/` – SQLAlchemy models, session (`session_scope`), repository, schemas.
|
|
||||||
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
|
||||||
- `i18n/` – Translations and language detection (ru/en); used by handlers and API.
|
|
||||||
- `services/` – Business logic (group duty pin, import); accept session from caller.
|
|
||||||
- `utils/` – Shared date, user, and handover helpers.
|
|
||||||
- `importers/` – Duty-schedule JSON parser.
|
|
||||||
- `alembic/` – Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
|
|
||||||
- `webapp/` – Miniapp UI (calendar, duty list); served at `/app`.
|
|
||||||
- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests.
|
|
||||||
- `pyproject.toml` – Installable package (`pip install -e .`).
|
|
||||||
|
|
||||||
**Documentation:** The `docs/` folder contains configuration reference, architecture, import format, and runbook. API reference is generated from the code. Build: `mkdocs build` (requires `pip install -e ".[docs]"`). Preview: `mkdocs serve`.
|
|
||||||
|
|
||||||
To add commands, define async handlers in `duty_teller/handlers/commands.py` (or a new module) and register them in `duty_teller/handlers/__init__.py`.
|
|
||||||
|
|
||||||
## Duty schedule import (duty-schedule)
|
|
||||||
|
|
||||||
The **`/import_duty_schedule`** command is available only to users in `ADMIN_USERNAMES` or `ADMIN_PHONES`. Import is done in two steps:
|
|
||||||
|
|
||||||
1. **Handover time** — The bot asks for the shift handover time and optional timezone (e.g. `09:00 Europe/Moscow` or `06:00 UTC`). This is converted to UTC and used as the boundary between duty periods when creating records.
|
|
||||||
2. **JSON file** — Send a file in duty-schedule format.
|
|
||||||
|
|
||||||
Format: at the root of the JSON — a **meta** object with `start_date` (YYYY-MM-DD) and a **schedule** array of objects with `name` (full name) and `duty` (string with separator `;`; characters **в/В/б/Б** = duty, **Н** = unavailable, **О** = vacation). The number of days is given by the length of the `duty` string. On re-import, duties in the same date range for each user are replaced by the new data.
|
|
||||||
|
|
||||||
**Full format description and example JSON:** [docs/import-format.md](docs/import-format.md).
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Run from the repository root (no `src/` directory; package is `duty_teller` at the root). Use `PYTHONPATH=.` if needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config` (is_admin, can_access_miniapp), and the API (date validation, 403/200 with mocked auth, plus an E2E auth test without auth mocks).
|
|
||||||
|
|
||||||
**CI (Gitea Actions):** Lint (ruff), tests (pytest), security (bandit). If the workflow uses `PYTHONPATH: src` or `bandit -r src`, update it to match the repo layout (no `src/`).
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
- **Commits:** Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, etc.
|
|
||||||
- **Branches:** Follow [Gitea Flow](https://docs.gitea.io/en-us/workflow-branching/): main branch `main`, features and fixes in separate branches.
|
|
||||||
- **Changes:** Via **Pull Request** in Gitea; run linters and tests (`ruff check .`, `pytest`) before merge.
|
|
||||||
|
|
||||||
## Logs and rotation
|
|
||||||
|
|
||||||
To meet the 7-day log retention policy, configure log rotation at deploy time: e.g. [logrotate](https://manpages.ubuntu.com/logrotate), systemd logging settings, or Docker (size/time retention limits). Keep application logs for no more than 7 days.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
README.md
|
|
||||||
pyproject.toml
|
|
||||||
duty_teller/__init__.py
|
|
||||||
duty_teller/config.py
|
|
||||||
duty_teller/run.py
|
|
||||||
duty_teller.egg-info/PKG-INFO
|
|
||||||
duty_teller.egg-info/SOURCES.txt
|
|
||||||
duty_teller.egg-info/dependency_links.txt
|
|
||||||
duty_teller.egg-info/entry_points.txt
|
|
||||||
duty_teller.egg-info/requires.txt
|
|
||||||
duty_teller.egg-info/top_level.txt
|
|
||||||
duty_teller/api/__init__.py
|
|
||||||
duty_teller/api/app.py
|
|
||||||
duty_teller/api/calendar_ics.py
|
|
||||||
duty_teller/api/dependencies.py
|
|
||||||
duty_teller/api/personal_calendar_ics.py
|
|
||||||
duty_teller/api/telegram_auth.py
|
|
||||||
duty_teller/db/__init__.py
|
|
||||||
duty_teller/db/models.py
|
|
||||||
duty_teller/db/repository.py
|
|
||||||
duty_teller/db/schemas.py
|
|
||||||
duty_teller/db/session.py
|
|
||||||
duty_teller/handlers/__init__.py
|
|
||||||
duty_teller/handlers/commands.py
|
|
||||||
duty_teller/handlers/common.py
|
|
||||||
duty_teller/handlers/errors.py
|
|
||||||
duty_teller/handlers/group_duty_pin.py
|
|
||||||
duty_teller/handlers/import_duty_schedule.py
|
|
||||||
duty_teller/i18n/__init__.py
|
|
||||||
duty_teller/i18n/core.py
|
|
||||||
duty_teller/i18n/lang.py
|
|
||||||
duty_teller/i18n/messages.py
|
|
||||||
duty_teller/importers/__init__.py
|
|
||||||
duty_teller/importers/duty_schedule.py
|
|
||||||
duty_teller/services/__init__.py
|
|
||||||
duty_teller/services/group_duty_pin_service.py
|
|
||||||
duty_teller/services/import_service.py
|
|
||||||
duty_teller/utils/__init__.py
|
|
||||||
duty_teller/utils/dates.py
|
|
||||||
duty_teller/utils/handover.py
|
|
||||||
duty_teller/utils/user.py
|
|
||||||
tests/test_api_dependencies.py
|
|
||||||
tests/test_app.py
|
|
||||||
tests/test_calendar_ics.py
|
|
||||||
tests/test_calendar_token_repository.py
|
|
||||||
tests/test_config.py
|
|
||||||
tests/test_db_session.py
|
|
||||||
tests/test_duty_schedule_parser.py
|
|
||||||
tests/test_group_duty_pin_service.py
|
|
||||||
tests/test_handlers_commands.py
|
|
||||||
tests/test_handlers_errors.py
|
|
||||||
tests/test_handlers_group_duty_pin.py
|
|
||||||
tests/test_handlers_init.py
|
|
||||||
tests/test_i18n.py
|
|
||||||
tests/test_import_duty_schedule_integration.py
|
|
||||||
tests/test_import_service.py
|
|
||||||
tests/test_package_init.py
|
|
||||||
tests/test_personal_calendar_ics.py
|
|
||||||
tests/test_repository_duty_range.py
|
|
||||||
tests/test_repository_roles.py
|
|
||||||
tests/test_run.py
|
|
||||||
tests/test_telegram_auth.py
|
|
||||||
tests/test_utils.py
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[console_scripts]
|
|
||||||
duty-teller = duty_teller.run:main
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
python-telegram-bot[job-queue]<23.0,>=22.0
|
|
||||||
python-dotenv<2.0,>=1.0
|
|
||||||
fastapi<1.0,>=0.115
|
|
||||||
uvicorn[standard]<1.0,>=0.32
|
|
||||||
sqlalchemy<3.0,>=2.0
|
|
||||||
alembic<2.0,>=1.14
|
|
||||||
pydantic<3.0,>=2.0
|
|
||||||
icalendar<6.0,>=5.0
|
|
||||||
|
|
||||||
[dev]
|
|
||||||
pytest<9.0,>=8.0
|
|
||||||
pytest-asyncio<2.0,>=1.0
|
|
||||||
pytest-cov<7.0,>=6.0
|
|
||||||
httpx<1.0,>=0.27
|
|
||||||
|
|
||||||
[docs]
|
|
||||||
mkdocs<2,>=1.5
|
|
||||||
mkdocstrings[python]<1,>=0.24
|
|
||||||
mkdocs-material<10,>=9.0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
duty_teller
|
|
||||||
@@ -246,7 +246,38 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||||
if message_id is None:
|
if message_id is None:
|
||||||
await update.message.reply_text(t(lang, "pin_duty.no_message"))
|
text = await loop.run_in_executor(
|
||||||
|
None, lambda: _get_duty_message_text_sync(lang)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
msg = await context.bot.send_message(chat_id=chat_id, text=text)
|
||||||
|
except (BadRequest, Forbidden) as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e
|
||||||
|
)
|
||||||
|
await update.message.reply_text(t(lang, "pin_duty.failed"))
|
||||||
|
return
|
||||||
|
pinned = False
|
||||||
|
try:
|
||||||
|
await context.bot.pin_chat_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=msg.message_id,
|
||||||
|
disable_notification=True,
|
||||||
|
)
|
||||||
|
pinned = True
|
||||||
|
except (BadRequest, Forbidden) as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to pin message for pin_duty chat_id=%s: %s", chat_id, e
|
||||||
|
)
|
||||||
|
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||||
|
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||||
|
await _schedule_next_update(context.application, chat_id, next_end)
|
||||||
|
if pinned:
|
||||||
|
await update.message.reply_text(t(lang, "pin_duty.pinned"))
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(
|
||||||
|
t(lang, "pin_duty.could_not_pin_make_admin")
|
||||||
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await context.bot.pin_chat_message(
|
await context.bot.pin_chat_message(
|
||||||
|
|||||||
@@ -306,8 +306,8 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pin_duty_cmd_no_message_id_replies_no_message():
|
async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned():
|
||||||
"""pin_duty_cmd: no pin record (_sync_get_message_id -> None) -> reply pin_duty.no_message."""
|
"""pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned."""
|
||||||
update = MagicMock()
|
update = MagicMock()
|
||||||
update.message = MagicMock()
|
update.message = MagicMock()
|
||||||
update.message.reply_text = AsyncMock()
|
update.message.reply_text = AsyncMock()
|
||||||
@@ -316,14 +316,109 @@ async def test_pin_duty_cmd_no_message_id_replies_no_message():
|
|||||||
update.effective_chat.id = 100
|
update.effective_chat.id = 100
|
||||||
update.effective_user = MagicMock()
|
update.effective_user = MagicMock()
|
||||||
context = MagicMock()
|
context = MagicMock()
|
||||||
|
context.bot = MagicMock()
|
||||||
|
new_msg = MagicMock()
|
||||||
|
new_msg.message_id = 42
|
||||||
|
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||||
|
context.bot.pin_chat_message = AsyncMock()
|
||||||
|
context.application = MagicMock()
|
||||||
|
context.application.job_queue = MagicMock()
|
||||||
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||||
|
context.application.job_queue.run_once = MagicMock()
|
||||||
|
|
||||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
with patch.object(
|
||||||
mock_t.return_value = "No message to pin"
|
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||||
await mod.pin_duty_cmd(update, context)
|
):
|
||||||
update.message.reply_text.assert_called_once_with("No message to pin")
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||||
mock_t.assert_called_with("en", "pin_duty.no_message")
|
with patch.object(
|
||||||
|
mod, "_get_next_shift_end_sync", return_value=None
|
||||||
|
):
|
||||||
|
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||||
|
with patch(
|
||||||
|
"duty_teller.handlers.group_duty_pin.t"
|
||||||
|
) as mock_t:
|
||||||
|
mock_t.return_value = "Pinned"
|
||||||
|
await mod.pin_duty_cmd(update, context)
|
||||||
|
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty text")
|
||||||
|
context.bot.pin_chat_message.assert_called_once_with(
|
||||||
|
chat_id=100, message_id=42, disable_notification=True
|
||||||
|
)
|
||||||
|
mock_save.assert_called_once_with(100, 42)
|
||||||
|
update.message.reply_text.assert_called_once_with("Pinned")
|
||||||
|
mock_t.assert_called_with("en", "pin_duty.pinned")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
|
||||||
|
"""pin_duty_cmd: no pin record, send_message raises BadRequest -> reply pin_duty.failed."""
|
||||||
|
update = MagicMock()
|
||||||
|
update.message = MagicMock()
|
||||||
|
update.message.reply_text = AsyncMock()
|
||||||
|
update.effective_chat = MagicMock()
|
||||||
|
update.effective_chat.type = "group"
|
||||||
|
update.effective_chat.id = 100
|
||||||
|
update.effective_user = MagicMock()
|
||||||
|
context = MagicMock()
|
||||||
|
context.bot = MagicMock()
|
||||||
|
context.bot.send_message = AsyncMock(side_effect=BadRequest("Chat not found"))
|
||||||
|
context.application = MagicMock()
|
||||||
|
|
||||||
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||||
|
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||||
|
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
||||||
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||||
|
with patch.object(
|
||||||
|
mod, "_schedule_next_update", AsyncMock()
|
||||||
|
) as mock_schedule:
|
||||||
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||||
|
mock_t.return_value = "Failed"
|
||||||
|
await mod.pin_duty_cmd(update, context)
|
||||||
|
update.message.reply_text.assert_called_once_with("Failed")
|
||||||
|
mock_t.assert_called_with("en", "pin_duty.failed")
|
||||||
|
mock_save.assert_not_called()
|
||||||
|
mock_schedule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not_pin():
|
||||||
|
"""pin_duty_cmd: no pin record, pin_chat_message raises -> save pin, reply could_not_pin_make_admin."""
|
||||||
|
update = MagicMock()
|
||||||
|
update.message = MagicMock()
|
||||||
|
update.message.reply_text = AsyncMock()
|
||||||
|
update.effective_chat = MagicMock()
|
||||||
|
update.effective_chat.type = "group"
|
||||||
|
update.effective_chat.id = 100
|
||||||
|
update.effective_user = MagicMock()
|
||||||
|
context = MagicMock()
|
||||||
|
context.bot = MagicMock()
|
||||||
|
new_msg = MagicMock()
|
||||||
|
new_msg.message_id = 43
|
||||||
|
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||||
|
context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
|
||||||
|
context.application = MagicMock()
|
||||||
|
context.application.job_queue = MagicMock()
|
||||||
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||||
|
context.application.job_queue.run_once = MagicMock()
|
||||||
|
|
||||||
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||||
|
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||||
|
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
||||||
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||||
|
with patch.object(
|
||||||
|
mod, "_get_next_shift_end_sync", return_value=None
|
||||||
|
):
|
||||||
|
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||||
|
with patch(
|
||||||
|
"duty_teller.handlers.group_duty_pin.t"
|
||||||
|
) as mock_t:
|
||||||
|
mock_t.return_value = "Make me admin to pin"
|
||||||
|
await mod.pin_duty_cmd(update, context)
|
||||||
|
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty")
|
||||||
|
mock_save.assert_called_once_with(100, 43)
|
||||||
|
update.message.reply_text.assert_called_once_with("Make me admin to pin")
|
||||||
|
mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
|
|||||||
? t(lang, "duty.today") + ", " + ddmm
|
? t(lang, "duty.today") + ", " + ddmm
|
||||||
: ddmm;
|
: ddmm;
|
||||||
|
|
||||||
const dutyList = (duties || []).filter((d) => d.event_type === "duty");
|
const dutyList = (duties || [])
|
||||||
|
.filter((d) => d.event_type === "duty")
|
||||||
|
.sort((a, b) => new Date(a.start_at || 0) - new Date(b.start_at || 0));
|
||||||
const unavailableList = (duties || []).filter((d) => d.event_type === "unavailable");
|
const unavailableList = (duties || []).filter((d) => d.event_type === "unavailable");
|
||||||
const vacationList = (duties || []).filter((d) => d.event_type === "vacation");
|
const vacationList = (duties || []).filter((d) => d.event_type === "vacation");
|
||||||
const summaries = eventSummaries || [];
|
const summaries = eventSummaries || [];
|
||||||
|
|||||||
41
webapp/js/dayDetail.test.js
Normal file
41
webapp/js/dayDetail.test.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for buildDayDetailContent.
|
||||||
|
* Verifies dutyList is sorted by start_at before display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
|
import { buildDayDetailContent } from "./dayDetail.js";
|
||||||
|
|
||||||
|
describe("buildDayDetailContent", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
document.body.innerHTML =
|
||||||
|
'<div id="calendar"></div><div id="monthTitle"></div>' +
|
||||||
|
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
|
||||||
|
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
|
||||||
|
'<button id="prevMonth"></button><button id="nextMonth"></button>';
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts duty list by start_at when input order is wrong", () => {
|
||||||
|
const dateKey = "2025-02-25";
|
||||||
|
const duties = [
|
||||||
|
{
|
||||||
|
event_type: "duty",
|
||||||
|
full_name: "Петров",
|
||||||
|
start_at: "2025-02-25T14:00:00",
|
||||||
|
end_at: "2025-02-25T18:00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_type: "duty",
|
||||||
|
full_name: "Иванов",
|
||||||
|
start_at: "2025-02-25T09:00:00",
|
||||||
|
end_at: "2025-02-25T14:00:00",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const html = buildDayDetailContent(dateKey, duties, []);
|
||||||
|
expect(html).toContain("Иванов");
|
||||||
|
expect(html).toContain("Петров");
|
||||||
|
const ivanovPos = html.indexOf("Иванов");
|
||||||
|
const petrovPos = html.indexOf("Петров");
|
||||||
|
expect(ivanovPos).toBeLessThan(petrovPos);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -115,13 +115,25 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLa
|
|||||||
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
||||||
timePrefix += " " + toLabel + sep + endHHMM;
|
timePrefix += " " + toLabel + sep + endHHMM;
|
||||||
}
|
}
|
||||||
|
} else if (startSameDay && startHHMM) {
|
||||||
|
/* First of multiple, but starts today — show full range */
|
||||||
|
timePrefix = fromLabel + sep + startHHMM;
|
||||||
|
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
||||||
|
timePrefix += " " + toLabel + sep + endHHMM;
|
||||||
|
}
|
||||||
} else if (endHHMM) {
|
} else if (endHHMM) {
|
||||||
|
/* Continuation from previous day — only end time */
|
||||||
timePrefix = toLabel + sep + endHHMM;
|
timePrefix = toLabel + sep + endHHMM;
|
||||||
}
|
}
|
||||||
} else if (idx > 0) {
|
} else if (idx > 0) {
|
||||||
if (startHHMM) timePrefix = fromLabel + sep + startHHMM;
|
if (startSameDay && startHHMM) {
|
||||||
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
|
timePrefix = fromLabel + sep + startHHMM;
|
||||||
timePrefix += (timePrefix ? " " : "") + toLabel + sep + endHHMM;
|
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
|
||||||
|
timePrefix += " " + toLabel + sep + endHHMM;
|
||||||
|
}
|
||||||
|
} else if (endHHMM) {
|
||||||
|
/* Continuation from previous day — only end time */
|
||||||
|
timePrefix = toLabel + sep + endHHMM;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return timePrefix;
|
return timePrefix;
|
||||||
|
|||||||
126
webapp/js/hints.test.js
Normal file
126
webapp/js/hints.test.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic.
|
||||||
|
* Covers: sorting order preservation, idx=0 with total>1 and startSameDay.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
|
import { getDutyMarkerRows } from "./hints.js";
|
||||||
|
|
||||||
|
const FROM = "from";
|
||||||
|
const TO = "until";
|
||||||
|
const SEP = "\u00a0";
|
||||||
|
|
||||||
|
describe("getDutyMarkerRows", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
document.body.innerHTML = '<div id="calendar"></div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves input order (caller must sort by start_at before passing)", () => {
|
||||||
|
const hintDay = "2025-02-25";
|
||||||
|
const duties = [
|
||||||
|
{
|
||||||
|
full_name: "Иванов",
|
||||||
|
start_at: "2025-02-25T14:00:00",
|
||||||
|
end_at: "2025-02-25T18:00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
full_name: "Петров",
|
||||||
|
start_at: "2025-02-25T09:00:00",
|
||||||
|
end_at: "2025-02-25T14:00:00",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].fullName).toBe("Иванов");
|
||||||
|
expect(rows[1].fullName).toBe("Петров");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first of multiple with startSameDay shows full range (from HH:MM to HH:MM)", () => {
|
||||||
|
const hintDay = "2025-02-25";
|
||||||
|
const duties = [
|
||||||
|
{
|
||||||
|
full_name: "Иванов",
|
||||||
|
start_at: "2025-02-25T09:00:00",
|
||||||
|
end_at: "2025-02-25T14:00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
full_name: "Петров",
|
||||||
|
start_at: "2025-02-25T14:00:00",
|
||||||
|
end_at: "2025-02-25T18:00:00",
|
||||||
|
},
|
||||||
|
].sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
|
||||||
|
|
||||||
|
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].fullName).toBe("Иванов");
|
||||||
|
expect(rows[0].timePrefix).toContain("09:00");
|
||||||
|
expect(rows[0].timePrefix).toContain("14:00");
|
||||||
|
expect(rows[0].timePrefix).toContain(FROM);
|
||||||
|
expect(rows[0].timePrefix).toContain(TO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first of multiple continuation from previous day shows only end time", () => {
|
||||||
|
const hintDay = "2025-02-25";
|
||||||
|
const duties = [
|
||||||
|
{
|
||||||
|
full_name: "Иванов",
|
||||||
|
start_at: "2025-02-24T22:00:00",
|
||||||
|
end_at: "2025-02-25T06:00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
full_name: "Петров",
|
||||||
|
start_at: "2025-02-25T09:00:00",
|
||||||
|
end_at: "2025-02-25T14:00:00",
|
||||||
|
},
|
||||||
|
].sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
|
||||||
|
|
||||||
|
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].fullName).toBe("Иванов");
|
||||||
|
expect(rows[0].timePrefix).not.toContain(FROM);
|
||||||
|
expect(rows[0].timePrefix).toContain(TO);
|
||||||
|
expect(rows[0].timePrefix).toContain("06:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("second duty continuation from previous day shows only end time (to HH:MM)", () => {
|
||||||
|
const hintDay = "2025-02-23";
|
||||||
|
const duties = [
|
||||||
|
{
|
||||||
|
full_name: "A",
|
||||||
|
start_at: "2025-02-23T00:00:00",
|
||||||
|
end_at: "2025-02-23T09:00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
full_name: "B",
|
||||||
|
start_at: "2025-02-22T09:00:00",
|
||||||
|
end_at: "2025-02-23T09:00:00",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].fullName).toBe("A");
|
||||||
|
expect(rows[0].timePrefix).toContain(FROM);
|
||||||
|
expect(rows[0].timePrefix).toContain("00:00");
|
||||||
|
expect(rows[0].timePrefix).toContain(TO);
|
||||||
|
expect(rows[0].timePrefix).toContain("09:00");
|
||||||
|
expect(rows[1].fullName).toBe("B");
|
||||||
|
expect(rows[1].timePrefix).not.toContain(FROM);
|
||||||
|
expect(rows[1].timePrefix).toContain(TO);
|
||||||
|
expect(rows[1].timePrefix).toContain("09:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple duties in one day — correct order when input is pre-sorted", () => {
|
||||||
|
const hintDay = "2025-02-25";
|
||||||
|
const duties = [
|
||||||
|
{ full_name: "A", start_at: "2025-02-25T09:00:00", end_at: "2025-02-25T12:00:00" },
|
||||||
|
{ full_name: "B", start_at: "2025-02-25T12:00:00", end_at: "2025-02-25T15:00:00" },
|
||||||
|
{ full_name: "C", start_at: "2025-02-25T15:00:00", end_at: "2025-02-25T18:00:00" },
|
||||||
|
].sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
|
||||||
|
|
||||||
|
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
|
||||||
|
expect(rows.map((r) => r.fullName)).toEqual(["A", "B", "C"]);
|
||||||
|
expect(rows[0].timePrefix).toContain("09:00");
|
||||||
|
expect(rows[1].timePrefix).toContain("12:00");
|
||||||
|
expect(rows[2].timePrefix).toContain("15:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
12
webapp/package.json
Normal file
12
webapp/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "duty-teller-webapp",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"happy-dom": "^15.0.0",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
webapp/vitest.config.js
Normal file
6
webapp/vitest.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
test: {
|
||||||
|
environment: "happy-dom",
|
||||||
|
include: ["js/**/*.test.js"],
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user