mirror of
https://github.com/EDeev/y.calendarkin.git
synced 2026-10-08 00:39:27 +03:00
Зависимости по рабочему окружению, тесты, CI, Docker и лицензия MIT
Тесты разбора календаря (события на сегодня, повторения, UNTIL, проверка ссылки, текст уведомления). Образ по тегу v* публикуется в GHCR и dcr.deev.su.
This commit is contained in:
parent
15bd36580e
commit
73690afd8f
11 changed files with 225 additions and 18 deletions
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Токен бота от @BotFather
|
||||||
|
BOT_TOKEN=123456:your-token
|
||||||
23
.github/workflows/ci.yml
vendored
Normal file
23
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- run: pip install -r requirements-dev.txt
|
||||||
|
- run: ruff check --select E9,F code tests
|
||||||
|
- run: pytest -q
|
||||||
|
- name: Бот импортируется
|
||||||
|
working-directory: code
|
||||||
|
env:
|
||||||
|
BOT_TOKEN: "123456:TEST"
|
||||||
|
run: python -c "import bot"
|
||||||
42
.github/workflows/docker.yml
vendored
Normal file
42
.github/workflows/docker.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: dcr.deev.su
|
||||||
|
username: ${{ secrets.ZOT_USERNAME }}
|
||||||
|
password: ${{ secrets.ZOT_PASSWORD }}
|
||||||
|
- id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/edeev/y.calendarkin
|
||||||
|
dcr.deev.su/edeev/y.calendarkin
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
db/*.db
|
||||||
|
data/icals/*.ics
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY code/ code/
|
||||||
|
COPY data/photo_edit_alarm.jpg data/
|
||||||
|
RUN useradd --create-home --uid 1000 app \
|
||||||
|
&& mkdir -p data/icals db \
|
||||||
|
&& chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
VOLUME ["/app/data/icals", "/app/db"]
|
||||||
|
|
||||||
|
# пути к данным в коде — относительно папки code/ (../data, ../db)
|
||||||
|
WORKDIR /app/code
|
||||||
|
CMD ["python", "bot.py"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Egor Deev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
15
compose.yaml
Normal file
15
compose.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
image: ghcr.io/edeev/y.calendarkin:latest
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
volumes:
|
||||||
|
- icals:/app/data/icals
|
||||||
|
- db:/app/db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
icals:
|
||||||
|
db:
|
||||||
3
requirements-dev.txt
Normal file
3
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-r requirements.txt
|
||||||
|
pytest==8.4.2
|
||||||
|
ruff==0.14.0
|
||||||
|
|
@ -1,18 +1,5 @@
|
||||||
# Core bot framework
|
aiogram==3.23.0
|
||||||
aiogram>=3.0.0
|
aiohttp==3.13.2
|
||||||
|
icalendar==6.3.2
|
||||||
# Calendar processing
|
python-dateutil==2.9.0.post0
|
||||||
icalendar>=5.0.0
|
pytz==2025.2
|
||||||
python-dateutil>=2.8.0
|
|
||||||
|
|
||||||
# Timezone support
|
|
||||||
pytz>=2023.3
|
|
||||||
|
|
||||||
# HTTP client for downloading calendar files
|
|
||||||
wget>=3.2
|
|
||||||
|
|
||||||
# Database (built-in with Python)
|
|
||||||
# sqlite3 - included in Python standard library
|
|
||||||
|
|
||||||
# Additional dependencies for date/time handling
|
|
||||||
typing-extensions>=4.0.0
|
|
||||||
|
|
|
||||||
4
tests/conftest.py
Normal file
4
tests/conftest.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))
|
||||||
84
tests/test_script.py
Normal file
84
tests/test_script.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
import script
|
||||||
|
|
||||||
|
TZ = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
|
||||||
|
def make_ics(events):
|
||||||
|
body = "\r\n".join(events)
|
||||||
|
return ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" + body + "\r\nEND:VCALENDAR\r\n").encode()
|
||||||
|
|
||||||
|
|
||||||
|
def vevent(uid, start, end, summary, extra=""):
|
||||||
|
return (f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTART;TZID=Europe/Moscow:{start:%Y%m%dT%H%M%S}\r\n"
|
||||||
|
f"DTEND;TZID=Europe/Moscow:{end:%Y%m%dT%H%M%S}\r\nSUMMARY:{summary}\r\n{extra}END:VEVENT")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ical_url_accepts_only_yandex_export():
|
||||||
|
ok = "https://calendar.yandex.ru/export/ics.xml?private_token=abc&tz_id=Asia/Krasnoyarsk"
|
||||||
|
assert script.parse_ical_url(ok) == "Asia/Krasnoyarsk"
|
||||||
|
assert script.parse_ical_url("https://calendar.yandex.ru/export/ics.xml?private_token=abc") == "Europe/Moscow"
|
||||||
|
assert script.parse_ical_url("https://calendar.yandex.ru/x?tz_id=Not/AZone") == "Europe/Moscow"
|
||||||
|
for bad in ("http://calendar.yandex.ru/export/ics.xml", "https://127.0.0.1/ics", "file:///etc/passwd",
|
||||||
|
"https://calendar.yandex.ru.evil.com/ics", "https://example.com/?tz_id=Europe/Moscow"):
|
||||||
|
assert script.parse_ical_url(bad) is None, bad
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_ical_today_events_and_recurrence(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(script, "ICAL_DIR", str(tmp_path))
|
||||||
|
now = datetime.now(TZ).replace(tzinfo=None, second=0, microsecond=0)
|
||||||
|
today = now.replace(hour=10, minute=0)
|
||||||
|
|
||||||
|
events = [
|
||||||
|
vevent("once", today, today + timedelta(hours=1), "Разовое <важное> & срочное",
|
||||||
|
"DESCRIPTION:Описание\r\nORGANIZER:mailto:boss@example.com\r\n"),
|
||||||
|
vevent("weekly", today.replace(hour=12) - timedelta(weeks=3),
|
||||||
|
today.replace(hour=13) - timedelta(weeks=3), "Еженедельное", "RRULE:FREQ=WEEKLY\r\n"),
|
||||||
|
vevent("tomorrow", today + timedelta(days=1), today + timedelta(days=1, hours=1), "Завтра"),
|
||||||
|
]
|
||||||
|
(tmp_path / "1.ics").write_bytes(make_ics(events))
|
||||||
|
|
||||||
|
found = {e[3]["name"]: e for e in script.text_ical(1, TZ)}
|
||||||
|
assert set(found) == {"Разовое <важное> & срочное", "Еженедельное"}
|
||||||
|
assert found["Еженедельное"][1].hour == 12
|
||||||
|
|
||||||
|
text = script.message_form(1, found["Разовое <важное> & срочное"][3])
|
||||||
|
assert "<важное> & срочное" in text
|
||||||
|
assert "boss@example.com" in text and "mailto:" not in text
|
||||||
|
assert "10:00 — 11:00" in text and str(now.year) not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_form_hides_empty_fields():
|
||||||
|
event = {"name": "Встреча", "desc": "", "org": "", "all_day": False,
|
||||||
|
"datetime": [datetime(2026, 1, 1, 9, 0), datetime(2026, 1, 1, 9, 30)]}
|
||||||
|
text = script.message_form(0, event)
|
||||||
|
assert "Описание" not in text and "Организатор" not in text
|
||||||
|
assert "09:00 — 09:30" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_calendar_file_gives_no_events(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(script, "ICAL_DIR", str(tmp_path))
|
||||||
|
assert script.text_ical(42, TZ) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_until_not_in_utc(tmp_path, monkeypatch):
|
||||||
|
# повторяющиеся события с UNTIL местным временем или датой раньше роняли разбор календаря
|
||||||
|
monkeypatch.setattr(script, "ICAL_DIR", str(tmp_path))
|
||||||
|
now = datetime.now(TZ).replace(tzinfo=None, second=0, microsecond=0)
|
||||||
|
first = now.replace(hour=9, minute=0) - timedelta(weeks=5)
|
||||||
|
events = [
|
||||||
|
vevent("ended-local", first, first + timedelta(hours=1), "Закончилось",
|
||||||
|
f"RRULE:FREQ=WEEKLY;UNTIL={first + timedelta(weeks=2):%Y%m%dT%H%M%S}\r\n"),
|
||||||
|
vevent("ended-date", first, first + timedelta(hours=1), "Закончилось датой",
|
||||||
|
f"RRULE:FREQ=WEEKLY;UNTIL={first + timedelta(weeks=2):%Y%m%d}\r\n"),
|
||||||
|
vevent("running", first, first + timedelta(hours=1), "Идёт",
|
||||||
|
f"RRULE:FREQ=WEEKLY;UNTIL={now + timedelta(weeks=4):%Y%m%dT%H%M%S}\r\n"),
|
||||||
|
]
|
||||||
|
(tmp_path / "2.ics").write_bytes(make_ics(events))
|
||||||
|
|
||||||
|
names = {e[3]["name"] for e in script.text_ical(2, TZ)}
|
||||||
|
assert names == {"Идёт"}
|
||||||
|
assert script.until_utc("20260101", TZ) == "20260101T205959Z"
|
||||||
Loading…
Add table
Reference in a new issue