Repository: vsuh/reminder-tgm
Pull request #1: Another view
Author: login: vsuh
Created: 2025-04-27T12:44:07Z | Merged: 2025-04-27T12:44:55Z

## Обзор от Sourcery

Рефакторинг структуры приложения и добавление поддержки нескольких Telegram-чатов.

Новые возможности:
- Разрешить связывание расписаний с конкретными Telegram-чатами.
- Добавить веб-интерфейс для управления Telegram-чатами.
- Отправлять уведомления в чат, связанный с расписанием.
- Использовать multiprocessing для одновременной проверки расписаний в скрипте runner.
- Добавить поддержку Docker для упрощения развертывания.
- Добавить shell-скрипты для запуска приложения локально и в production.
- Добавить файлы конфигурации VSCode.
- Добавить файл `.gitignore`.
- Разделить requirements на файлы, специфичные для компонентов (`web`, `rund`).

Улучшения:
- Рефакторинг веб-приложения в структуру на основе классов в `web/app.py`.
- Разделить логику запуска расписания в `rund.py`.
- Обновить основной веб-интерфейс для отображения и выбора целевых чатов для расписаний.
- Добавить favicon в веб-интерфейс.
- Обновить схему базы данных, чтобы включить таблицу `chats` и связать расписания с чатами.
- Отрегулировать количество резервных копий ротации логов.

<details>
<summary>Original summary in English</summary>

## Summary by Sourcery

Refactor the application structure and add support for multiple Telegram chats.

New Features:
- Allow associating schedules with specific Telegram chats.
- Add a web interface to manage Telegram chats.
- Send notifications to the chat associated with the schedule.
- Use multiprocessing to check schedules concurrently in the runner script.
- Add Docker support for easier deployment.
- Add shell scripts for running the application locally and in production.
- Add VSCode configuration files.
- Add `.gitignore` file.
- Split requirements into component-specific files (`web`, `rund`).

Enhancements:
- Refactor the web application into a class-based structure within `web/app.py`.
- Separate the schedule runner logic into `rund.py`.
- Update the main web view to display and select target chats for schedules.
- Add a favicon to the web interface.
- Update database schema to include a `chats` table and link schedules to chats.
- Adjust log rotation backup count.

</details>


Diff:
diff --git a/.gitignore b/.gitignore
index a769ae6..0a19790 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,174 @@
-venv/
+# Byte-compiled / optimized / DLL files
 __pycache__/
-log/
-.vscode/  
-*.db
-env/*
-dataschedules.json 
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# UV
+#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#uv.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+#   in version control.
+#   https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#  and can be added to the global gitignore or merged into this file.  For a more nuclear
+#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..a584047
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,30 @@
+{
+    // Используйте IntelliSense, чтобы узнать о возможных атрибутах.
+    // Наведите указатель мыши, чтобы просмотреть описания существующих атрибутов.
+    // Для получения дополнительной информации посетите: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "Отладчик Python: Текущий файл",
+            "type": "debugpy",
+            "request": "launch",
+            "program": "${file}",
+            "console": "integratedTerminal"
+        },
+        {
+            "name": "Python Debugger: Flask",
+            "type": "debugpy",
+            "request": "launch",
+            "module": "flask",
+            "env": {
+                "FLASK_DEBUG": "1"
+            },
+            "args": [
+                "run",
+                "--no-debugger",
+                "--no-reload"
+            ],
+            "jinja": true
+        }
+    ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..42211ce
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,8 @@
+{
+    "cSpell.language": "ru,en",
+    "cSpell.enabled": false,
+    "python.venvPath": ["${workspaceFolder}/.venv"],
+    "python.terminal.activateEnvironment": true
+  
+
+}
\ No newline at end of file
diff --git a/API.md b/API.md
deleted file mode 100644
index e54d738..0000000
--- a/API.md
+++ /dev/null
@@ -1,140 +0,0 @@
-# API
-
-Приложение предоставляет REST API для управления расписаниями.
-
-## Получение списка расписаний
-
-```text
-GET /schedules
-```
-
-Ответ:
-
-```json
-[
-  {
-    "id": 1,
-    "cron": "0 12 * * *",
-    "message": "test message 1",
-    "modifier": "",
-    "last_fired": "2025-03-23T12:00:00"
-  },
-  {
-    "id": 2,
-    "cron": "0 13 * * *",
-    "message": "test message 2",
-    "modifier": "d/2",
-    "last_fired": null
-  },
-  ...
-]
-```
-
-## Создание расписания
-
-```text
-POST /schedules
-```
-
-- Заголовки:
-
-```http
-Content-Type: application/json;
-charset: utf-8
-```
-
-Тело запроса (JSON):
-
-```json
-{
-  "cron": "0 15 * * *",
-  "message": "new schedule message",
-  "modifier": "w/2"
-}
-```
-
-Ответ:
-
-```json
-{
-  "status": "success"
-}
-```
-
-## Создание нескольких расписаний
-
-```text
-POST /schedules_all
-```
-
-Заголовки:
-
-```http
-Content-Type: application/json;
-charset: utf-8
-```
-
-Тело запроса (JSON):
-
-```json
-[
-    {
-        "cron": "1 13 * * *",
-        "message": "event 1",
-        "modifier": "20250303>d/60"
-    },
-    {
-        "cron": "2 13 * * *",
-        "message": "event 2",
-        "modifier": "20250303>w/2"
-    }
-]
-```
-
-Ответ:
-
-```json
-{
-  "status": "success"
-}
-```
-
-## Удаление расписания
-
-```text
-DELETE /schedules/<id>
-```
-
-Ответ:
-
-```json
-{
-  "status": "deleted"
-}
-```
-
-## Редактирование расписания
-
-```text
-POST /schedules/<id>
-```
-
-Заголовки:
-
-```http
-Content-Type: application/json;
-charset: utf-8
-```
-
-Тело запроса (JSON):
-
-```json
-{
-  "cron": "0 16 * * *",
-  "message": "updated schedule message",
-  "modifier": "d/3"
-}
-```
-
-Ответ: В случае успеха возвращается код 302 (редирект на главную страницу). В случае ошибки возвращается код 500 и страница с описанием ошибки.
-
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..cd4899b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,10 @@
+FROM python:3.9-slim-buster
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+CMD ["python", "wsgi.py"] 
diff --git a/Readme.md b/Readme.md
deleted file mode 100644
index 264a111..0000000
--- a/Readme.md
+++ /dev/null
@@ -1,114 +0,0 @@
-# Telegram Cron Reminder
-
-Этот проект представляет собой простое приложение для отправки напоминаний в Telegram на основе расписания `cron` и дополнительных модификаторов.
-
-## Возможности
-
-- **Гибкое расписание:** Поддержка `cron` выражений и дополнительных модификаторов для точной настройки времени отправки напоминаний.
-- **Модификаторы:** Расширенные модификаторы d/n (каждые n дней) и w/n (каждые n недель) с поддержкой начальной даты (YYYYMMDD>).
-- **Веб-интерфейс:** Удобный веб-интерфейс для управления расписаниями, добавления, редактирования и удаления напоминаний.
-- **Экспорт/импорт расписаний:** Возможность экспорта и импорта расписаний в формате JSON.
-- **Запуск как сервис:** Поддержка запуска приложения как сервиса systemd в Linux.
-
-## Установка и запуск
-### Зависимости
-
-[Список зависимостей](requirements.txt)
-
-### Установка
-
-- Клонируйте репозиторий:
-
-```sh
-git clone https://github.com/ваш_репозиторий/cron-reminder.git
-```
-
-- Создайте виртуальное окружение и установите зависимости:
-
-```sh
-python3 -m venv venv
-source venv/bin/activate
-pip install -r requirements.txt
-```
-
-- Настройте переменные окружения (токен бота, ID чата и т.д.) в файле env/.env.prod (Linux) или env/.env.dev (Windows).  
-Пример файла .env:
-
-```ini
-TELEGRAM_TOKEN=ваш_токен
-TELEGRAM_CHAT_ID=ваш_id_чата
-reminderTZ=Europe/Moscow
-DB_PATH=settings.db
-LOGPATH=log
-LOG_LEVEL=INFO
-PORT=7878
-SECRET_KEY=ваш_секретный_ключ
-```
-
-### Запуск сервера расписаний
-
-#### Разработка
-
-```sh
-./palmface.sh
-```
-
-### Продакшн (Linux)
-
-- Создайте файл службы systemd:
-
-```sh
-sudo nano /etc/systemd/system/cron-telegram-reminder.service
-```
-
-Вставьте следующее содержимое, заменив USER и WORKDIR на ваши значения:
-[Unit]
-Description=Cron Reminder Web Server
-After=network.target
-
-[Service]
-User=USER
-Group=GROUP
-WorkingDirectory=WORKDIR
-ExecStart=/bin/bash -c "source WORKDIR/venv/bin/activate; gunicorn --workers 2 --bind 0.0.0.0:PORT app:app"
-Restart=always
-
-[Install]
-WantedBy=multi-user.target
-
-
-
-- Обновите systemd, включите и запустите службу:
-
-```sh
-sudo systemctl daemon-reload
-sudo systemctl enable cron-telegram-reminder
-sudo systemctl start cron-telegram-reminder
-sudo systemctl status cron-telegram-reminder
-```
-
-### Использование
-
-#### Веб-интерфейс
-
-Доступ к веб-интерфейсу осуществляется по адресу http://localhost:7878. Вы можете добавлять, редактировать и удалять расписания через веб-интерфейс.
-
-#### API
-
-Приложение предоставляет [REST API для управления расписаниями](API.md).
-
-#### Модификаторы
-
-Модификаторы позволяют уточнить время срабатывания `cron` выражения. Поддерживаются следующие модификаторы:
-
-- d/n: Срабатывает каждые n дней.
-- w/n: Срабатывает каждые n недель.
-- YYYYMMDD>: Указывает начальную дату для отсчета. Например, 20240301>d/3 будет срабатывать каждые 3 дня, начиная с 1 марта 2024 года.
-
-#### Пример импорта данных
-
-для первоначального заполнения расписаний можно использовать `json` [файл dataschedules.json](dataschedules.json.SAMPLE) 
-
-```sh
-curl -X POST -H "Content-Type: application/json;charset=utf-8" --data-binary @dataschedules.json http://localhost:7878/schedules_all
-```
diff --git a/app.py b/app.py
deleted file mode 100644
index 7dcecc2..0000000
--- a/app.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from flask import Flask
-from datetime import datetime
-
-from lib.utils import MyError, get_environment_name, load_env
-from wsgi import create_app
-from wsgi import application
-
-if __name__ == "__main__":
-    from waitress import serve
-    from lib.utils import get_environment_name, load_env
-    import os
-
-    environment = get_environment_name()
-    load_env(environment)
-    APIPORT = os.getenv("PORT", "7878")
-
-    if environment == 'dev':
-        app = create_app()
-        app.logger.info(f"Запуск Flask-сервера: http://{app.config['SERVER_NAME']}")
-        app.run(host="0.0.0.0", port=int(APIPORT))
-    else:
-        serve(application, host='0.0.0.0', port=int(APIPORT))
-
diff --git a/dataschedules.json.SAMPLE b/dataschedules.json.SAMPLE
deleted file mode 100644
index 540f31d..0000000
--- a/dataschedules.json.SAMPLE
+++ /dev/null
@@ -1,32 +0,0 @@
-[
-    {
-        "cron": "1 13 * * *",
-        "message": "🤹 event 1",
-        "modifier": "20250303>d/60"
-    },
-    {
-        "cron": "2 13 * * *",
-        "message": "🌊 event 2",
-        "modifier": "20250303>w/2"
-    },
-    {
-        "cron": "3 14 * * *",
-        "message": "🛒 событие 3",
-        "modifier": "20240301>d/3"
-    },
-    {
-        "cron": "4 14 22 * *",
-        "message": "🧺событие 4",
-        "modifier": ""
-    },
-    {
-        "cron": "5 14 * * *",
-        "message": "📲 событие 5",
-        "modifier": "20240301>d/9"
-    },
-    {
-        "cron": "6 14 * * *",
-        "message": "💊 тестовое событие",
-        "modifier": "20240320>d/3"
-    }
-]
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..19713dc
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,29 @@
+version: "3.9"
+services:
+  web:
+    build: .
+    ports:
+      - "7878:7878"
+    volumes:
+      - ./env:/app/env
+      - ./lib:/app/lib
+      - ./templates:/app/templates
+      - ./settings.db:/app/settings.db # Или другой путь к вашей БД
+      - ./log:/app/log
+    environment:
+      - FLASK_APP=wsgi:app
+      - PYTHONUNBUFFERED=1 # Для корректного вывода логов
+    depends_on:
+      - rund
+
+  rund:
+    build: .
+    volumes:
+      - ./env:/app/env
+      - ./lib:/app/lib
+      - ./settings.db:/app/settings.db # Или другой путь к вашей БД
+      - ./log:/app/log
+    environment:
+      - PYTHONUNBUFFERED=1
+    command: python rund.py
+
diff --git a/env/.gitkeep b/env/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/lib/db_utils.py b/lib/db_utils.py
index 309488b..17b04b9 100644
--- a/lib/db_utils.py
+++ b/lib/db_utils.py
@@ -9,7 +9,7 @@
 environment = get_environment_name()
 load_env(environment)
 
-# DB_PATH = os.getenv("DB_PATH", "settings.db")
+DB_PATH = os.getenv("DB_PATH", "settings.db")
 LOGPATH = os.getenv("LOGPATH", ".")
 LOGLEVEL = os.getenv("LOG_LEVEL", 'INFO').upper()
 
@@ -29,7 +29,7 @@ def init_db(db_path=DB_PATH, drop_table=True):
     try:
         with sqlite3.connect(db_path) as conn:
             run_initialization(conn, drop_table, db_path)
-        log.info(f"Таблица базы данных '{db_path}.schedules' успешно {'пересоздана' if drop_table else 'установлена'}")
+        log.info(f"Таблицы базы данных '{db_path}.schedules,chats' успешно {'пересозданы' if drop_table else 'установлены'}")
         init_db._initialized = True # Mark as initialized
     except sqlite3.Error as e:
         log.error(f"Ошибка инициализации БД '{db_path}': %s", str(e))
@@ -56,10 +56,19 @@ def run_initialization(conn, drop_table, db_path):
                                 cron TEXT NOT NULL,
                                 message TEXT NOT NULL,
                                 modifier TEXT,
-                                last_fired TIMESTAMP
+                                last_fired TIMESTAMP,
+                                chat_id INTEGER,
+                                FOREIGN KEY (chat_id) REFERENCES chats(id)
                             )'''
     cursor.execute(sql)
     conn.commit()
+    sql_chats = f'''CREATE TABLE {'' if drop_table else 'IF NOT EXISTS'}  chats (
+                                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                                name TEXT NOT NULL UNIQUE,
+                                chat_id INTEGER NOT NULL UNIQUE
+                            )'''
+    cursor.execute(sql_chats)
+    conn.commit()
 
 def get_schedules(db_path) -> list:
     """
@@ -74,8 +83,8 @@ def get_schedules(db_path) -> list:
     try:
         with sqlite3.connect(db_path) as conn:
             cursor = conn.cursor()
-            cursor.execute("SELECT id, cron, message, modifier, last_fired FROM schedules")
-            return [{"id": row[0], "cron": row[1], "message": row[2], "modifier": row[3], "last_fired": row[4]} for row in cursor.fetchall()]
+            cursor.execute("SELECT id, cron, message, modifier, last_fired, chat_id FROM schedules")
+            return [{"id": row[0], "cron": row[1], "message": row[2], "modifier": row[3], "last_fired": row[4], "chat_id": row[5]} for row in cursor.fetchall()]
     except sqlite3.Error as e:
         log.error("Ошибка при получении расписаний: %s", str(e))
         return []
@@ -94,17 +103,17 @@ def get_schedule(schedule_id, db_path) -> dict or None:
     try:
         with sqlite3.connect(db_path) as conn:
             cursor = conn.cursor()
-            cursor.execute("SELECT id, cron, message, modifier, last_fired FROM schedules WHERE id = ?", (schedule_id,))
+            cursor.execute("SELECT id, cron, message, modifier, last_fired, chat_id FROM schedules WHERE id = ?", (schedule_id,))
             row = cursor.fetchone()
             if row:
-                return {"id": row[0], "cron": row[1], "message": row[2], "modifier": row[3], "last_fired": row[4]}
+                return {"id": row[0], "cron": row[1], "message": row[2], "modifier": row[3], "last_fired": row[4], "chat_id": row[5]}
             else:
                 return None
     except sqlite3.Error as e:
         log.error("Ошибка при получении расписания: %s", str(e))
         return []
 
-def add_schedule(cron, message, modifier, db_path):
+def add_schedule(cron, message, modifier, chat_id, db_path):
     """
     Добавляет новое расписание в базу данных.
 
@@ -112,14 +121,15 @@ def add_schedule(cron, message, modifier, db_path):
         cron (str): CRON выражение.
         message (str): Сообщение.
         modifier (str): Модификатор.
+        chat_id (int): ID чата.
         db_path (str): Путь к файлу базы данных.
     """
     try:
         with sqlite3.connect(db_path) as conn:
             cursor = conn.cursor()
-            cursor.execute("INSERT INTO schedules (cron, message, modifier) VALUES (?, ?, ?)", (cron, message, modifier))
+            cursor.execute("INSERT INTO schedules (cron, message, modifier, chat_id) VALUES (?, ?, ?, ?)", (cron, message, modifier, chat_id))
             conn.commit()
-            log.info("Добавлено новое расписание: %s, %s, %s", cron, message, modifier)
+            log.info("Добавлено новое расписание: %s, %s, %s, %s", cron, message, modifier, chat_id)
     except sqlite3.Error as e:
         log.error("Ошибка при добавлении расписания: %s", str(e))
 
@@ -140,6 +150,26 @@ def delete_schedule(schedule_id, db_path):
     except sqlite3.Error as e:
         log.error("Ошибка при удалении расписания: %s", str(e))
 
+def get_chats(db_path) -> list:
+    try:
+        with sqlite3.connect(db_path) as conn:
+            cursor = conn.cursor()
+            cursor.execute("SELECT id, name, chat_id FROM chats")
+            return [{"id": row[0], "name": row[1], "chat_id": row[2]} for row in cursor.fetchall()]
+    except sqlite3.Error as e:
+        log.error("Ошибка при получении чатов: %s", str(e))
+        return []
+
+def add_chat(name, chat_id, db_path):
+    try:
+        with sqlite3.connect(db_path) as conn:
+            cursor = conn.cursor()
+            cursor.execute("INSERT INTO chats (name, chat_id) VALUES (?, ?)", (name, chat_id))
+            conn.commit()
+            log.info("Добавлен новый чат: %s, %s", name, chat_id)
+    except sqlite3.Error as e:
+        log.error("Ошибка при добавлении чата: %s", str(e))
+
 def update_last_fired(schedule_id, db_path):
     """
     Обновляет поле last_fired для расписания с id=schedule_id.
diff --git a/lib/utils.py b/lib/utils.py
index 6c0e2ac..ed1281c 100644
--- a/lib/utils.py
+++ b/lib/utils.py
@@ -31,7 +31,7 @@ def init_log(name: str = None, log_path: str = ".", log_level: str = "INFO") ->
     except AttributeError:
         level = logging.INFO
 
-    handler2file = RotatingFileHandler(pt(log_path).joinpath(f'{name}.log'), encoding="utf-8", maxBytes=50000, backupCount=5)
+    handler2file = RotatingFileHandler(pt(log_path).joinpath(f'{name}.log'), encoding="utf-8", maxBytes=50000, backupCount=3)
     handler2file.setLevel(level)
     formatter = logging.Formatter('%(asctime)s - %(name)s@(%(module)s:%(lineno)d)- %(levelname)s - %(message)s')
     handler2file.setFormatter(formatter)
diff --git a/log/.gitkeep b/log/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/palmface.sh b/palmface.sh
deleted file mode 100755
index c705736..0000000
--- a/palmface.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/bin/bash
-myDIR=/home/vsuh/my/projects/reminder
-
-cd ${myDIR}
-source ./venv/bin/activate
-pip install --upgrade pip
-pip install -q -r requirements.txt
-gunicorn --workers 2 --bind=0.0.0.0:7878 \
-	 --access-logfile /var/log/tg_reminder/access.log \
-	 --error-logfile /var/log/tg_reminder/error.log \
-	   app:app 
-
diff --git a/requirements.txt b/requirements.txt
index b13db7c..f1dbf1a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,21 +1,2 @@
-blinker==1.9.0
-certifi==2025.1.31
-charset-normalizer==3.4.1
-click==8.1.8
-colorama==0.4.6
-croniter==6.0.0
-Flask==3.1.0
-gunicorn==23.0.0
-idna==3.10
-itsdangerous==2.2.0
-Jinja2==3.1.5
-MarkupSafe==3.0.2
-packaging==24.2
-python-dateutil==2.9.0.post0
-python-dotenv==1.0.1
-pytz==2025.1
-requests==2.32.3
-six==1.17.0
-urllib3==2.3.0
-waitress==3.0.2
-Werkzeug==3.1.3
+-r requirements/web.txt
+-r requirements/rund.txt
diff --git a/requirements/base.txt b/requirements/base.txt
new file mode 100644
index 0000000..5f0f638
--- /dev/null
+++ b/requirements/base.txt
@@ -0,0 +1,5 @@
+python-dotenv
+pytz
+python-dateutil
+croniter
+requests
diff --git a/requirements/rund.txt b/requirements/rund.txt
new file mode 100644
index 0000000..a3e81b8
--- /dev/null
+++ b/requirements/rund.txt
@@ -0,0 +1 @@
+-r base.txt
diff --git a/requirements/web.txt b/requirements/web.txt
new file mode 100644
index 0000000..ae50b1a
--- /dev/null
+++ b/requirements/web.txt
@@ -0,0 +1,4 @@
+-r base.txt
+Flask
+gunicorn
+waitress
diff --git a/run.py b/run.py
index 83a88e4..f9edf85 100644
--- a/run.py
+++ b/run.py
@@ -10,7 +10,7 @@
 # from croniter import croniter
 
 from lib.cron_utils import VCron
-from lib.db_utils import update_last_fired
+from lib.db_utils import update_last_fired, get_chats
 from lib.utils import get_environment_name, init_log, load_env
 
 # Load environment variables
@@ -24,6 +24,7 @@
 SCHEDULES_URL = "http://localhost:7878/schedules"
 LOGPATH = os.getenv("LOGPATH", ".")
 LOGLEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
+DB_PATH = os.getenv("DB_PATH", "settings.db")
 
 # Initialize logger
 log = init_log('rmndr', LOGPATH, LOGLEVEL)
@@ -31,7 +32,7 @@
 # Initialize VCron
 myVCron = VCron(TIMEZONE)
 
-def send_telegram_message(message):
+def send_telegram_message(message, chat_id):
     """
     Отправляет сообщение в Telegram.
 
@@ -39,11 +40,11 @@ def send_telegram_message(message):
         message (str): Текст сообщения.
     """
     url = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage"
-    data = {"chat_id": CHAT_ID, "text": message}
+    data = {"chat_id": chat_id, "text": message}
     try:
         response = requests.post(url, data=data)
         response.raise_for_status()  # Raise HTTPError for bad responses (4xx or 5xx)
-        log.debug("Сообщение успешно отправлено: %s", message)
+        log.debug(f"Сообщение успешно отправлено chat_id={chat_id}: {message}")
     except requests.exceptions.RequestException as e:
         log.error("Ошибка при отправке сообщения: %s, ошибка: %s", message, e)
 
@@ -64,6 +65,17 @@ def get_schedules() -> list:
         log.error(f"Ошибка при запросе к серверу расписаний: {e}")
         return []
 
+def get_chat_id(chat_id_from_schedule):
+    chats = get_chats(DB_PATH)
+    return next(
+        (
+            chat['chat_id']
+            for chat in chats
+            if chat['id'] == chat_id_from_schedule
+        ),
+        None,
+    )
+
 # Main script logic
 timezone = pytz.timezone(TIMEZONE)
 now = datetime.now(timezone)
@@ -79,8 +91,13 @@ def get_schedules() -> list:
 
         if myVCron.check_cron(cron_expr, now) and myVCron.check_modifier(modifier, now):
             log.info("Телеграфирую: %s", message)
-            send_telegram_message(message)
-            update_last_fired(record_key)
+            chat_id = get_chat_id(schedule["chat_id"])
+            if chat_id is None:
+                log.error(f"Не найден chat_id для schedule_id={record_key}")
+                continue # Skip to the next schedule
+
+            send_telegram_message(message, chat_id)
+            update_last_fired(record_key, DB_PATH)
             print(f"{now} уведомление по расписанию № {record_key}")
         else:
             log.debug(f"Сообщение не отправлено: {message} (не соответствует условиям CRON:{cron_expr}({modifier})")
diff --git a/rund.py b/rund.py
new file mode 100644
index 0000000..e8aad8a
--- /dev/null
+++ b/rund.py
@@ -0,0 +1,110 @@
+import os
+import time
+import requests
+import pytz
+from datetime import datetime
+import multiprocessing
+
+from lib.cron_utils import VCron
+from lib.db_utils import update_last_fired, get_chats, get_schedules as db_get_schedules, DB_PATH, LOGPATH, LOGLEVEL
+from lib.utils import get_environment_name, init_log, load_env
+
+# Load environment variables
+environment = get_environment_name()
+load_env(environment)
+
+# Переменные окружения
+TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN")
+CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
+TIMEZONE = os.getenv("reminderTZ", "UTC")
+LOGPATH = os.getenv("LOGPATH", ".")
+LOGLEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
+
+# Initialize logger
+log = init_log('rmndr', LOGPATH, LOGLEVEL)
+
+# Initialize VCron
+myVCron = VCron(TIMEZONE)
+
+def send_telegram_message(message, chat_id):
+    """
+    Отправляет сообщение в Telegram.
+
+    Args:
+        message (str): Текст сообщения.
+        chat_id (int): ID чата Telegram.
+    """
+    url = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage"
+    data = {"chat_id": chat_id, "text": message}
+    try:
+        response = requests.post(url, data=data)
+        response.raise_for_status()  # Raise HTTPError for bad responses (4xx or 5xx)
+        log.debug(f"Сообщение успешно отправлено chat_id={chat_id}: {message}")
+    except requests.exceptions.RequestException as e:
+        log.error("Ошибка при отправке сообщения: %s, ошибка: %s", message, e)
+
+
+def get_chat_id(chat_id_from_schedule):
+    chats = get_chats(DB_PATH)
+    return next(
+        (
+            chat['chat_id']
+            for chat in chats
+            if chat['id'] == chat_id_from_schedule
+        ),
+        None,
+    )
+
+
+def check_and_send(schedule, myVCron, timezone):
+    """
+    Проверяет расписание и отправляет уведомление, если необходимо.
+    Эта функция предназначена для запуска в отдельном процессе.
+    """
+    now = datetime.now(timezone)
+    cron_expr = schedule["cron"]
+    message = schedule["message"]
+    modifier = schedule.get("modifier", "")
+    record_key = schedule["id"]
+
+    if myVCron.check_cron(cron_expr, now) and myVCron.check_modifier(modifier, now):
+        log.info("Телеграфирую: %s", message)
+        chat_id = get_chat_id(schedule["chat_id"])
+        if chat_id is None:
+            log.error(f"Не найден chat_id для schedule_id={record_key}")
+            return
+
+        send_telegram_message(message, chat_id)
+        update_last_fired(record_key, DB_PATH)
+        print(f"{now} уведомление по расписанию № {record_key}")
+    else:
+        log.debug(f"Сообщение не отправлено: {message} (не соответствует условиям CRON:{cron_expr}({modifier})")
+
+
+def main():
+    """Основная функция скрипта."""
+    timezone = pytz.timezone(TIMEZONE)
+    myVCron = VCron(TIMEZONE)
+
+    while True:
+        now = datetime.now(timezone)
+        log.info(f"Проверка расписаний ({now.strftime('%d-%m-%Y %H:%M:%S')})")
+
+        if schedules := db_get_schedules(DB_PATH):
+            processes = []
+            for schedule in schedules:
+                p = multiprocessing.Process(target=check_and_send, args=(schedule, myVCron, timezone))
+                log.debug(f"Выполнение расписания {schedule}")
+                processes.append(p)
+                p.start()
+
+            # Ожидание завершения всех процессов
+            for p in processes:
+                p.join()
+
+        log.info(f"Следующая проверка через {CHECK_INTERVAL} секунд")
+        time.sleep(CHECK_INTERVAL)
+
+if __name__ == "__main__":
+    CHECK_INTERVAL = 60 * 60  # 1 час в секундах
+    main()
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000..8f67463
Binary files /dev/null and b/static/favicon.ico differ
diff --git a/templates/chats.html b/templates/chats.html
new file mode 100644
index 0000000..8dd0eeb
--- /dev/null
+++ b/templates/chats.html
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<html lang="ru">
+
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Управление чатами</title>
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
+    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v6.1.0/css/all.css" crossorigin="anonymous">
+</head>
+
+<body>
+    <div class="container mt-5">
+        <h1>Чаты</h1>
+
+        <table class="table table-striped">
+            <thead>
+                <tr>
+                    <th scope="col">ID</th>
+                    <th scope="col">Название</th>
+                    <th scope="col">Chat ID</th>
+                </tr>
+            </thead>
+            <tbody>
+                {% for chat in chats %}
+                <tr>
+                    <td>{{ chat.id }}</td>
+                    <td>{{ chat.name }}</td>
+                    <td>{{ chat.chat_id }}</td>
+                </tr>
+                {% endfor %}
+            </tbody>
+        </table>
+
+        <h2>Добавить чат</h2>
+        <form method="POST">
+            <div class="mb-3">
+                <label for="name" class="form-label">Название:</label>
+                <input type="text" class="form-control" id="name" name="name" required>
+            </div>
+            <div class="mb-3">
+                <label for="chat_id" class="form-label">Chat ID:</label>
+                <input type="text" class="form-control" id="chat_id" name="chat_id" required>
+            </div>
+            <button type="submit" class="btn btn-primary"><i class="fas fa-plus"></i> Добавить</button>
+        </form>
+    </div>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+</body>
+
+</html>
diff --git a/templates/index.html b/templates/index.html
index e90b722..b8290e2 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -2,6 +2,7 @@
 <html lang="ru">
     <head>
         <meta charset="UTF-8">
+	<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon.ico">
         <meta name="viewport" content="width=device-width, initial-scale=1.0">
         <title>Управление расписаниями</title>
         <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" crossorigin="anonymous">
@@ -17,6 +18,7 @@ <h1 title="Используется БД: {{ db_path }}">Расписания</h
                 <th scope="col">CRON</th>
                 <th scope="col">Сообщение</th>
                 <th scope="col">Модификатор</th>
+                <th scope="col">Кому</th>
                 <th scope="col">Следующий</th>
                 <th scope="col">&nbsp;</th>
                 <th scope="col">id</th>
@@ -32,6 +34,8 @@ <h1 title="Используется БД: {{ db_path }}">Расписания</h
                 <td>{{ schedule.cron }} </td>
                 <td>{{ schedule.message }}</td>
                 <td>{{ schedule.modifier }}</td>
+                <td>{{ schedule.chat_name }}</td>
+
                 <td>{{ schedule.next | fromisoformat | format_datetime }}</td>
                 <td>
                     <a href="/edit/{{ schedule.id }}" title="Редактировать"><i class="fas fa-edit text-primary mr-2"></i></a>
@@ -63,12 +67,22 @@ <h2>Добавить расписание</h2>
             <label for="message">Сообщение:</label>
             <textarea class="form-control" id="message" name="message" rows="3" required></textarea>
         </div>
+        <div class="form-group">
+            <label for="chat_id">Chat:</label>
+            <select class="form-control" id="chat_id" name="chat_id">
+                {% for chat in chats %}
+                    <option value="{{ chat.id }}">{{ chat.name }} (CHAT_ID: {{ chat.chat_id }})</option>
+                {% endfor %}
+            </select>
+        </div>
+
         <div class="form-group">
             <label for="modifier">Модификатор:</label>
             <input type="text" class="form-control" id="modifier" name="modifier">
         </div>
         <button type="submit" class="btn btn-success"><i class="fas fa-plus"></i>&nbsp; Добавить</button>
-    <button type="button" class="btn btn-warning" onclick="if (confirm('БД {{ db_path }} будет УДАЛЕНА и создана вновь пустой.\nЭто действие необратимо. Вы уверены?')) { location.href='/drop_db'; } return false;"><i class="fas fa-trash-alt" style="color:rgb(81, 48, 136);"></i>&nbsp; Очистить базу данных</button>
+        <a href="/chats" class="btn btn-primary"><i class="far fa-edit"></i>&nbsp; Управление чатами</a>
+        <button type="button" class="btn btn-warning" onclick="if (confirm('БД {{ db_path }} будет УДАЛЕНА и создана вновь пустой.\nЭто действие необратимо. Вы уверены?')) { location.href='/drop_db'; } return false;"><i class="fas fa-trash-alt" style="color:rgb(81, 48, 136);"></i>&nbsp; Очистить базу данных</button>
     </form>
 
     <hr>
diff --git a/tests/test_cron_utils.py b/tests/test_cron_utils.py
deleted file mode 100644
index 5f35b47..0000000
--- a/tests/test_cron_utils.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import unittest
-from datetime import datetime, date
-
-import pytz
-from lib.cron_utils import VCron
-
-class TestVCron(unittest.TestCase):
-
-    def setUp(self):
-        """Настройка перед каждым тестом."""
-        self.timezone_name = "Europe/Moscow"
-        self.vcron = VCron(self.timezone_name)
-
-    def test_check_cron_true(self):
-        """Тест check_cron с совпадением."""
-        tz = pytz.timezone(self.timezone_name)
-        date = tz.localize(datetime(2024, 3, 28, 14, 10))
-        self.assertTrue(self.vcron.check_cron("* * * * *", date))
-
-    def test_check_cron_false(self):
-        """Тест check_cron без совпадения."""
-        tz = pytz.timezone(self.timezone_name)
-        date = tz.localize(datetime(2024, 3, 29, 10, 10))
-        self.assertFalse(self.vcron.check_cron("0 14 * * *", date))
-
-    def test_valid_cron_true(self):
-        """Тест valid с корректным cron."""
-        self.assertTrue(self.vcron.valid("* * * * *"))
-
-    def test_valid_cron_false(self):
-        """Тест valid с некорректным cron."""
-        self.assertFalse(self.vcron.valid("* * * *"))
-
-    def test_is_valid_modifier_true(self):
-        """Тест is_valid_modifier с корректным modifier."""
-        self.assertTrue(self.vcron.is_valid_modifier("20240301>d/3"))
-
-    def test_is_valid_modifier_false(self):
-        """Тест is_valid_modifier с некорректным modifier."""
-        self.assertFalse(self.vcron.is_valid_modifier("20240301>d/"))
-
-    def test_check_modifier_true(self):
-        """Тест check_modifier с совпадением."""
-        tz = pytz.timezone(self.timezone_name)
-        now = tz.localize(datetime(2025, 3, 28, 14, 10))
-        self.assertTrue(self.vcron.check_modifier("20240327>d/1", now))
-
-    def test_check_modifier_false(self):
-        """Тест check_modifier без совпадения."""
-        tz = pytz.timezone(self.timezone_name)
-        now = tz.localize(datetime(2024, 3, 28, 14, 10))
-        self.assertFalse(self.vcron.check_modifier("20240327>d/2", now))
-
-    def test_days_since(self):
-        """Тест days_since."""
-        today_date = date(2024, 3, 28)
-        base_date = date(2024, 3, 27)
-        self.assertEqual(self.vcron.days_since(today_date, base_date), 1)
-
-    def test_get_next_match(self):
-        """Тест get_next_match."""
-        tz = pytz.timezone(self.timezone_name)
-        start_time = tz.localize(datetime(2024, 3, 28, 14, 10))
-        next_match = self.vcron.get_next_match("* * * * *", start_time=start_time)
-        self.assertEqual(next_match.replace(tzinfo=None, microsecond=0), start_time.replace(tzinfo=None, microsecond=0))
-
-
diff --git a/web.sh b/web.sh
new file mode 100644
index 0000000..e296fb5
--- /dev/null
+++ b/web.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+source env/.env.dev
+export FLASK_APP=wsgi:app
+export FLASK_ENV=development
+if [ -z "$FLASK_PORT" ]; then
+    echo "Error: FLASK_PORT is not set. Please set the FLASK_PORT environment variable."
+    exit 1
+fi
+
+flask run -p $FLASK_PORT
\ No newline at end of file
diff --git a/web/app.py b/web/app.py
new file mode 100644
index 0000000..476fc57
--- /dev/null
+++ b/web/app.py
@@ -0,0 +1,286 @@
+import os
+import json
+import tempfile
+from datetime import datetime, timedelta
+
+from dotenv import load_dotenv
+from flask import Flask, request, jsonify, render_template, redirect, url_for, abort, send_file
+
+from lib.cron_utils import VCron
+from lib.db_utils import (DB_PATH, LOGLEVEL, LOGPATH, add_schedule,
+                         delete_schedule, get_schedule, get_schedules, init_db,
+                         init_log, update_schedule, add_chat, get_chats)
+from lib.utils import MyError, get_environment_name, load_env as load_utils_env
+
+
+class WebApp:
+    def __init__(self, env_file=None):
+        self.app = Flask(__name__, template_folder='../templates', static_folder='../static')
+        self.setup_app()
+        self.log = init_log('web_app', LOGPATH, LOGLEVEL)
+        self.load_env(env_file)
+        self.myVCron = VCron(timezone=self.timezone)
+        self.setup_routes()
+        init_db(drop_table=False)
+        
+    def setup_app(self):
+        self.app.config["def_chat_id"] = os.getenv("CHAT_ID")
+        
+    def load_env(self, env_file):
+        env = get_environment_name()
+        load_utils_env(env)
+        if env_file:
+            load_dotenv(dotenv_path=env_file, override=True)
+
+        self.db_path = os.getenv("DB_PATH", DB_PATH)
+        self.timezone = os.getenv("reminderTZ", "UTC")
+
+    def _validate_schedule_data(self, data):
+        if not data or "cron" not in data or "message" not in data:
+            abort(400, description="Неверный формат данных")
+
+        if not self.myVCron.valid(data["cron"]):
+            abort(400, description=f'Invalid CRON expression: "{data["cron"]}"')
+        if not self.myVCron.is_valid_modifier(data.get("modifier", "")):
+            abort(400, description=f'Invalid modifier expression "{data.get("modifier", "")}"')
+
+    def setup_routes(self):
+        @self.app.route('/test')
+        def test_route():
+            return 'ok'
+
+        @self.app.route("/", methods=["GET", "POST"])
+        def schedules_view():
+            """
+            Главная страница приложения. Отображает список расписаний и форму для добавления нового расписания.
+            """
+            if request.method == "POST":
+                cron = request.form.get("cron")
+                message = request.form.get("message")
+                modifier = request.form.get("modifier", "")
+                if not cron or not message:
+                    abort(400, description="Все поля должны быть заполнены")
+                try:
+                    self._validate_schedule_data({"cron": cron, "modifier": modifier, "message": message}) # Validate data
+                    chat_id = request.form.get("chat_id") or self.app.config["def_chat_id"]
+
+                    add_schedule(cron, message, modifier, int(chat_id), self.db_path)
+                except ValueError as e:
+                    return render_template("error.html", text=str(e)), 400
+                return redirect(url_for("schedules_view"))
+
+            sort_by = request.args.get('sort_by', 'next')
+            schedules = get_schedules(self.db_path)
+            chats = get_chats(self.db_path)
+            chat_map = {chat['id']: chat['name'] for chat in chats} # Create a chat ID to name mapping
+
+            for item in schedules:
+                next_match = self.myVCron.get_next_match(item['cron'], item['modifier'])
+                item['next'] = next_match.isoformat() if next_match else None
+                item['chat_name'] = chat_map.get(item['chat_id']) # Add chat name to schedule data
+
+            schedules.sort(
+                key=lambda x: (x[sort_by] is None, x[sort_by]),
+                reverse=(sort_by == 'id')
+            )
+
+            self.log.debug("Рендеринг шаблона index.html")
+            chats = get_chats(self.db_path)
+            return render_template("index.html", schedules=schedules, db_path=self.db_path, sort_by=sort_by, chats=chats)
+
+
+        @self.app.route("/schedules", methods=["GET"])
+        def list_schedules():
+            """
+            Возвращает список расписаний в формате JSON.
+            """
+            return jsonify(get_schedules(self.db_path))
+
+        @self.app.route("/schedules_all", methods=["POST"])
+        def create_schedules_by_list():
+            """
+            Создает несколько расписаний из списка JSON объектов.
+            """
+            list_data = request.get_json()
+            if isinstance(list_data, list):
+                if len(list_data) == 0:
+                    abort(400, description="Получен пустой массив")
+            else:
+                abort(400, description="Ожидался массив расписаний")
+
+            for data in list_data:
+                try:
+                    self._validate_schedule_data(data) # Validate data
+                    add_schedule(data["cron"], data["message"], data.get("modifier", ""), self.db_path)
+                except ValueError as e:
+                    abort(400, description=str(e))
+
+            return jsonify({"status": "success"}), 201
+
+        @self.app.route("/schedules", methods=["POST"])
+        def create_schedule():
+            """
+            Создает новое расписание.
+            """
+            data = request.get_json()
+            try:
+                self._validate_schedule_data(data) # Validate data
+                add_schedule(data["cron"], data["message"], data.get("modifier", ""), self.db_path)
+                return jsonify({"status": "success"}), 201
+            except ValueError as e:
+                abort(400, description=str(e))
+
+
+        @self.app.route("/schedules/<int:schedule_id>", methods=["DELETE"])
+        def remove_schedule(schedule_id: int):
+            delete_schedule(schedule_id, self.db_path)
+            return jsonify({"status": "deleted"})
+
+        @self.app.route('/schedules/<int:schedule_id>/delete', methods=['POST'])
+        def delete_schedule_post(schedule_id: int):
+            """
+            Удаляет расписание по ID (POST запрос).
+            """
+            delete_schedule(schedule_id, self.db_path)
+            return redirect(url_for('schedules_view'))
+
+
+        @self.app.route("/schedules/<int:schedule_id>", methods=["POST"])
+        def edit_schedule(schedule_id: int):
+            """
+            Изменяет расписание с ID=schedule_id.
+            """
+            data = request.get_json()
+            if not data or "cron" not in data or "message" not in data:
+                abort(400, description="Неверный формат данных")
+
+            try:
+                self._validate_schedule_data(data) # Validate data
+                update_schedule(schedule_id, data["cron"], data["message"], data.get("modifier", ""), self.db_path)
+                return jsonify({"status": "success"}), 200
+            except ValueError as e:
+                abort(400, description=str(e))
+
+
+        @self.app.route("/edit/<int:schedule_id>", methods=["GET", "POST"])
+        def edit_schedule_route(schedule_id: int):
+            """
+            Страница редактирования расписания.
+            """
+            schedule = get_schedule(schedule_id, self.db_path)
+            if schedule is None:
+                abort(404)
+
+            if request.method == "GET":
+                return render_template("edit.html", schedule=schedule)
+
+            cron = request.form.get("cron")
+            message = request.form.get("message")
+            modifier = request.form.get("modifier", "")
+            if not cron or not message:
+                abort(400, description="Все поля должны быть заполнены")
+
+            try:
+                self._validate_schedule_data({"cron": cron, "modifier": modifier, "message": message}) # Validate data
+                update_schedule(schedule_id, cron, message, modifier, self.db_path)
+            except ValueError as e:
+                return render_template("error.html", text=str(e)), 400
+            return redirect(url_for("schedules_view"))
+
+
+        @self.app.route('/list/<int:schedule_id>', methods=['GET'])
+        def list_nexts(schedule_id: int):
+            """
+            Отображает список следующих запусков расписания.
+            """
+            NEXT = 10
+            schedule = get_schedule(schedule_id, self.db_path)
+            if schedule is None:
+                abort(404)
+
+            next_dates = []
+            cron_expression = schedule['cron']
+            modifier = schedule.get('modifier')
+            current_time = datetime.now(tz=self.myVCron.timezone)
+
+            for _ in range(NEXT):
+                next_match = self.myVCron.get_next_match(cron_expression, modifier, start_time=current_time)
+                if next_match is None:
+                    break
+                next_dates.append(next_match.isoformat())
+                current_time = next_match + timedelta(seconds=1)
+
+            return render_template("list.html", next_dates=next_dates, schedule=schedule)
+
+        @self.app.route("/chats", methods=["GET", "POST"])
+        def chats_view():
+            if request.method == "POST":
+                name = request.form.get("name")
+                chat_id_str = request.form.get("chat_id")
+                if not name or not chat_id_str:
+                    abort(400, description="Все поля должны быть заполнены")
+                try:
+                    chat_id = int(chat_id_str)
+                    add_chat(name, chat_id, self.db_path)
+                except ValueError as e:
+                    return render_template("error.html", text=str(e)), 400
+                return redirect(url_for("chats_view"))
+
+            chats = get_chats(self.db_path)
+            return render_template("chats.html", chats=chats)
+
+        @self.app.route('/export', methods=['GET'])
+        def export_json():
+            """
+            Экспортирует расписания в JSON файл.
+            """
+            schedules = get_schedules(self.db_path)
+            json_data = json.dumps(schedules, indent=4, ensure_ascii=False)
+            with tempfile.NamedTemporaryFile(delete=False, mode='w+', suffix='.json', encoding='utf-8') as temp_file:
+                temp_file.write(json_data)
+                temp_file.flush()
+
+            return send_file(
+                temp_file.name,
+                mimetype='application/json',
+                as_attachment=True,
+                download_name='schedules_export.json'
+            )
+
+        @self.app.route("/drop_db", methods=["GET"])
+        def reset_db():
+            """
+            Пересоздает базу данных.
+            """
+            try:
+                init_db(self.db_path)
+                return redirect(url_for("schedules_view"))
+            except Exception as e:
+                self.log.error("Ошибка при переинициализации БД: %s", str(e))
+                abort(500, description="Ошибка при переинициализации БД")
+
+        @self.app.template_filter('fromisoformat')
+        def fromisoformat_filter(s: str) -> datetime or None:
+            """
+            Фильтр Jinja2 для преобразования строки ISO 8601 в объект datetime.
+            """
+            try:
+                return datetime.fromisoformat(s)
+            except (ValueError, TypeError):
+                return None
+
+        @self.app.template_filter('format_datetime')
+        def format_datetime_filter(dt: datetime) -> str:
+            """
+            Фильтр Jinja2 для форматирования строки даты и времени.
+            """
+            return dt.strftime('%Y-%m-%d %H:%M') if dt else "##"
+
+
+    def run(self):
+        self.app.run(debug=os.getenv('DEBUG', 'False').lower() == 'true', port=int(os.getenv('PORT', 7878)), use_reloader=True)
+
+web = WebApp()
+
+if __name__ == '__main__':
+    web.run()
diff --git a/web_prod.sh b/web_prod.sh
new file mode 100644
index 0000000..4dfbb70
--- /dev/null
+++ b/web_prod.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+source env/.env.dev
+export FLASK_APP=wsgi:app
+
+if [ -z "$FLASK_PORT" ]; then
+    echo "Error: FLASK_PORT is not set. Please set the FLASK_PORT environment variable."
+    exit 1
+fi
+
+#gunicorn -w 4 -b 0.0.0.0:${FLASK_PORT} ${FLASK_APP}
+# или
+waitress-serve --port=${FLASK_PORT} ${FLASK_APP}
diff --git a/wsgi.py b/wsgi.py
index f3b6a84..dc7908f 100644
--- a/wsgi.py
+++ b/wsgi.py
@@ -1,347 +1,10 @@
-import logging
-import os
-import sys
-import json
-import tempfile
-from datetime import datetime, timedelta
+from web.app import web
+from web.app import WebApp
 
-from lib.utils import init_log, get_environment_name, load_env, MyError
-from lib.cron_utils import VCron
-# from app import app
-from lib.db_utils import init_db, get_schedules, get_schedule, update_schedule, delete_schedule, add_schedule
-from flask import (Flask,jsonify, render_template, redirect, url_for, abort,
-                   send_file, request, g)
-
-APIPORT=7870
-# def create_app(environ=None, start_response=None, environment_name: str = None):
-def create_app(environment_name: str = None):
-    # Load environment variables
-    if environment_name is None:
-        environment = get_environment_name()
-    else:
-        environment = environment_name
-    load_env(environment)
-
-    # Read environment variables
-    TIMEZONE = os.getenv("reminderTZ", "UTC")
-    DB_PATH = os.getenv("DB_PATH", "settings.db")
-    LOGPATH = os.getenv("LOGPATH", ".")
-    LOGLEVEL = os.getenv("LOG_LEVEL", 'INFO').upper()
-    APIPORT = os.getenv("PORT", "7878")
-    SECRET_KEY = os.getenv("SECRET_KEY")
-    ISDEBUGMODE = os.getenv("DEBUG", False).lower() in ('true', 'yes', '1')
-
-    app = Flask(__name__) # Create Flask app instance
-
-    app.config['SECRET_KEY'] = SECRET_KEY
-    app.config['DEBUG'] = ISDEBUGMODE
-    app.config['TIMEZONE'] = TIMEZONE
-    app.config['DB_PATH'] = DB_PATH
-    app.config['SERVER_NAME'] = f"localhost:{APIPORT}" if environment == 'dev' else f"0.0.0.0:{APIPORT}"
-    app.config['myVCron'] = VCron(TIMEZONE)
-    app.config['APIPORT'] = APIPORT
-    app.template_folder = 'templates'
-
-    # Initialize database
-    init_db(DB_PATH, False)
-
-    # Initialize logger
-    log = init_log('svrm', LOGPATH, LOGLEVEL)
-    app.config['log'] = log # Add logger to app.config
-
-    @app.route("/", methods=["GET", "POST"])
-    def schedules_view():
-        """
-        Главная страница приложения. Отображает список расписаний и форму для добавления нового расписания.
-
-        Returns:
-            str: HTML код страницы.
-        """
-        if request.method == "POST":
-            cron = request.form.get("cron")
-            message = request.form.get("message")
-            modifier = request.form.get("modifier", "")
-            if not cron or not message:
-                abort(400, description="Все поля должны быть заполнены")
-            try:
-                add_schedule(cron, message, modifier)
-            except ValueError as e:
-                return render_template("error.html", text=str(e)), 400  # Обработка неверного ввода
-            return redirect(url_for("schedules_view"))
-
-        sort_by = request.args.get('sort_by', 'next')  # Get sort column from query parameter
-        schedules = get_schedules(app.config['DB_PATH'])
-        for item in schedules:
-            next_match = app.config['myVCron'].get_next_match(item['cron'], item['modifier'])
-            item['next'] = next_match.isoformat() if next_match else None
-        # Sort schedules based on the selected column
-        schedules.sort(
-            key=lambda x: (x[sort_by] is None, x[sort_by])
-            , reverse=(sort_by == 'id')
-        )
-
-        db_path = app.config['DB_PATH']
-        app.logger.debug("Рендеринг шаблона index.html")
-        return render_template("index.html", schedules=schedules, db_path=db_path, sort_by=sort_by)
-
-
-
-    @app.route("/schedules", methods=["GET"])
-    def list_schedules():
-        """
-        Возвращает список расписаний в формате JSON.
-
-        Returns:
-            str: JSON представление списка расписаний.
-        """
-        return jsonify(get_schedules(app.config['DB_PATH'])) # Pass DB_PATH
-
-    @app.route("/schedules_all", methods=["POST"])
-    def create_schedules_by_list():
-        """
-        Создает несколько расписаний из списка JSON объектов.
-
-        Returns:
-            tuple: Кортеж, содержащий JSON ответ и код состояния.
-        """
-        list_data = request.get_json()
-        if isinstance(list_data, list):
-            if len(list_data)==0:
-                abort(400, description="Получен пустой массив")
-        else:
-            abort(400, description="Ожидался массив расписаний")
-
-        for data in list_data:
-            if not data or "cron" not in data or "message" not in data:
-                abort(400, description="Неверный формат данных")
-            if not app.config['myVCron'].valid(data["cron"]):
-                raise ValueError(f'Invalid CRON expression: "{data["cron"]}"')
-            if not app.config['myVCron'].is_valid_modifier(data.get("modifier", "")):
-                raise ValueError(f'Invalid modifier expression "{data.get("modifier", "")}"')
-
-            add_schedule(data["cron"], data["message"], data.get("modifier", ""))
-
-        return jsonify({"status": "success"}), 201
-
-    @app.route("/schedules", methods=["POST"])
-    def create_schedule():
-        """
-        Создает новое расписание.
-
-        Returns:
-            tuple: Кортеж, содержащий JSON ответ и код состояния.
-
-        Raises:
-            ValueError: Если cron или modifier некорректны.
-        """
-        data = request.get_json()
-        if not data or "cron" not in data or "message" not in data:
-            abort(400, description="Неверный формат данных")
-
-        if not app.config['myVCron'].valid(data["cron"]):
-            raise ValueError(f'Invalid CRON expression: "{data["cron"]}"')
-        if not app.config['myVCron'].is_valid_modifier(data.get("modifier", "")):
-            raise ValueError(f'Invalid modifier expression "{data.get("modifier", "")}"')
-
-        add_schedule(data["cron"], data["message"], data.get("modifier", ""))
-        return jsonify({"status": "success"}), 201
-
-    @app.route("/schedules/<int:schedule_id>", methods=["DELETE"])
-    def remove_schedule(schedule_id: int):
-        delete_schedule(schedule_id)
-        return jsonify({"status": "deleted"})
-
-    @app.route('/schedules/<int:schedule_id>/delete', methods=['POST'])
-    def delete_schedule_post(schedule_id: int):
-        """
-        Удаляет расписание по ID (POST запрос).
-
-        Args:
-            schedule_id (int): ID расписания.
-
-        Returns:
-            flask.wrappers.Response: Редирект на главную страницу.
-        """
-        delete_schedule(schedule_id)
-        return redirect(url_for('schedules_view'))
-
-
-    @app.route("/schedules/<int:schedule_id>", methods=["POST"])
-    def edit_schedule(schedule_id: int):
-        """
-        Изменяет расписание с ID=schedule_id.
-
-        Args:
-            schedule_id (int): ID расписания.
-
-        Returns:
-            flask.wrappers.Response: Редирект на главную страницу или HTML код страницы с ошибкой.
-
-        Raises:
-            ValueError: Если cron или modifier некорректны.
-        """
-        data = request.get_json()
-        if not data or "cron" not in data or "message" not in data:
-            abort(400, description="Неверный формат данных")
-        cron = data["cron"]
-        message = data["message"]
-        modifier = data.get("modifier", "")
-
-        if not app.config['myVCron'].valid(cron):
-            raise ValueError(f'Invalid CRON expression: "{cron}"')
-
-        if not app.config['myVCron'].is_valid_modifier(modifier):
-            raise ValueError(f'Invalid modifier expression "{modifier}"')
-
-        try:
-            update_schedule(schedule_id, cron, message, modifier)
-        except ValueError as e:
-            return render_template("error.html", text=str(e)), 400
-        return redirect(url_for("schedules_view"))
-
-
-    @app.route("/edit/<int:schedule_id>", methods=["GET", "POST"])
-    def edit_schedule_route(schedule_id: int):
-        """
-        Страница редактирования расписания.
-
-        Args:
-            schedule_id (int): ID расписания.
-
-        Returns:
-            str or flask.wrappers.Response: HTML код страницы редактирования или редирект на главную страницу.
-        """
-        schedule = get_schedule(schedule_id)
-        if schedule is None:
-            abort(404)
-
-        if request.method == "GET":
-            return render_template("edit.html", schedule=schedule)
-
-        cron = request.form.get("cron")
-        message = request.form.get("message")
-        modifier = request.form.get("modifier", "")
-        if not cron or not message:
-            abort(400, description="Все поля должны быть заполнены")
-
-        try:
-            update_schedule(schedule_id, cron, message, modifier)
-        except ValueError as e:
-            return render_template("error.html", text=str(e)), 400
-        return redirect(url_for("schedules_view"))
-
-
-    @app.route('/list/<int:schedule_id>', methods=['GET'])
-    def list_nexts(schedule_id: int):
-        """
-        Отображает список следующих запусков расписания.
-
-        Args:
-            schedule_id (int): ID расписания.
-
-        Returns:
-            str: HTML код страницы со списком следующих запусков.
-        """
-        NEXT = 10
-        schedule = get_schedule(schedule_id)
-        if schedule is None:
-            abort(404)
-
-        next_dates = []
-        cron_expression = schedule['cron']
-        modifier = schedule.get('modifier')
-        current_time = datetime.now(tz=app.config['myVCron'].timezone)
-
-        for _ in range(NEXT):
-            next_match = app.config['myVCron'].get_next_match(cron_expression, modifier, start_time=current_time)
-            if next_match is None:
-                break
-            next_dates.append(next_match.isoformat())
-            current_time = next_match + timedelta(seconds=1)
-
-        return render_template("list.html", next_dates=next_dates, schedule=schedule)
-
-    @app.route('/export', methods=['GET'])
-    def export_json():
-        """
-        Экспортирует расписания в JSON файл.
-
-        Returns:
-            flask.wrappers.Response: JSON файл с расписаниями.
-        """
-        schedules = get_schedules(app.config['DB_PATH']) # Pass DB_PATH
-        json_data = json.dumps(schedules, indent=4, ensure_ascii=False)
-        with tempfile.NamedTemporaryFile(delete=False, mode='w+', suffix='.json', encoding='utf-8') as temp_file:
-            temp_file.write(json_data)
-            temp_file.flush()
-
-        return send_file(
-            temp_file.name,
-            mimetype='application/json',
-            as_attachment=True,
-            download_name='schedules_export.json'
-        )
-
-    @app.route("/drop_db", methods=["GET"])
-    def reset_db():
-        """
-        Пересоздает базу данных.
-
-        Returns:
-            flask.wrappers.Response: Редирект на главную страницу.
-        """
-        try:
-            init_db(app.config['DB_PATH'])
-            return redirect(url_for("schedules_view"))
-
-        except Exception as e:
-            app.logger.error("Ошибка при переинициализации БД: %s", str(e))
-            abort(500, description="Ошибка при переинициализации БД")
-
-    @app.template_filter('fromisoformat')
-    def fromisoformat_filter(s: str) -> datetime or None:
-        """
-        Фильтр Jinja2 для преобразования строки ISO 8601 в объект datetime.
-
-        Args:
-            s (str): Строка ISO 8601.
-
-        Returns:
-            datetime or None: Объект datetime или None, если строка некорректна.
-        """
-        try:
-            return datetime.fromisoformat(s)
-        except (ValueError, TypeError):  # Handle cases where s is None or not a valid ISO string
-            return None
-
-    @app.template_filter('format_datetime')
-    def format_datetime_filter(dt: datetime) -> str:
-        """
-        Фильтр Jinja2 для форматирования строки даты и времени.
-
-        Args:
-            dt (datetime): Объект datetime.
-
-        Returns:
-            str: Отформатированная строка даты и времени.
-        """
-        return dt.strftime('%Y-%m-%d %H:%M') if dt else "##" # Handle None values
-
-    return app
-
-def application(environ, start_response):
-    app = create_app()
-    return app(environ, start_response)
+def create_app():
+    return WebApp().app
 
+app = create_app()
 
 if __name__ == "__main__":
-    from waitress import serve
-    environment = get_environment_name()
-    load_env(environment)
-
-    SERVE_PORT = os.getenv("PORT", 7878)
-    if environment == 'dev':
-        app = create_app()
-        app.run(debug=True, host='0.0.0.0', port=int(SERVE_PORT))
-    else:
-        serve(application, host='0.0.0.0', port=int(SERVE_PORT))
+    app.run()


Reviews:
user: login: sourcery-ai[bot]
body: Привет, @vsuh - я проверил ваши изменения - вот несколько отзывов:

**Общие комментарии**:
- Рассмотрите возможность добавления мониторинга или надежной обработки ошибок для фоновых рабочих процессов, представленных в `rund.py`.
- Рассмотрите возможность разделения крупных изменений рефакторинга, таких как это, на более мелкие запросы на включение в будущем.

<details>
<summary>Вот что я посмотрел во время обзора</summary>

- 🟡 **Общие проблемы**: 1 проблема найдена
- 🟢 **Безопасность**: все выглядит хорошо
- 🟢 **Тестирование**: все выглядит хорошо
- 🟡 **Сложность**: 1 проблема найдена
- 🟢 **Документация**: все выглядит хорошо
</details>

***

<details>
<summary>Sourcery бесплатен для открытого исходного кода - если вам нравятся наши обзоры, пожалуйста, поделитесь ими ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Помогите мне быть более полезным! Пожалуйста, нажмите 👍 или 👎 на каждом комментарии, и я буду использовать отзыв для улучшения ваших обзоров.
</sub>

<details>
<summary>Original comment in English</summary>

Hey @vsuh - I've reviewed your changes - here's some feedback:

**Overall Comments**:
- Consider adding monitoring or robust error handling for the background worker processes introduced in `rund.py`.
- Consider splitting large refactoring changes like this into smaller pull requests in the future.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟡 **General issues**: 1 issue found
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟡 **Complexity**: 1 issue found
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>

</details>
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-27T12:45:20Z

Review comments:
diff_hunk: @@ -0,0 +1,286 @@
+import os
+import json
+import tempfile
+from datetime import datetime, timedelta
+
+from dotenv import load_dotenv
+from flask import Flask, request, jsonify, render_template, redirect, url_for, abort, send_file
+
+from lib.cron_utils import VCron
+from lib.db_utils import (DB_PATH, LOGLEVEL, LOGPATH, add_schedule,
+                         delete_schedule, get_schedule, get_schedules, init_db,
+                         init_log, update_schedule, add_chat, get_chats)
+from lib.utils import MyError, get_environment_name, load_env as load_utils_env
+
+
+class WebApp:
+    def __init__(self, env_file=None):
+        self.app = Flask(__name__, template_folder='../templates', static_folder='../static')
+        self.setup_app()
+        self.log = init_log('web_app', LOGPATH, LOGLEVEL)
+        self.load_env(env_file)
+        self.myVCron = VCron(timezone=self.timezone)
+        self.setup_routes()
+        init_db(drop_table=False)
+        
+    def setup_app(self):
+        self.app.config["def_chat_id"] = os.getenv("CHAT_ID")
+        
+    def load_env(self, env_file):
+        env = get_environment_name()
+        load_utils_env(env)
+        if env_file:
+            load_dotenv(dotenv_path=env_file, override=True)
+
+        self.db_path = os.getenv("DB_PATH", DB_PATH)
+        self.timezone = os.getenv("reminderTZ", "UTC")
+
+    def _validate_schedule_data(self, data):
+        if not data or "cron" not in data or "message" not in data:
+            abort(400, description="Неверный формат данных")
+
+        if not self.myVCron.valid(data["cron"]):
+            abort(400, description=f'Invalid CRON expression: "{data["cron"]}"')
+        if not self.myVCron.is_valid_modifier(data.get("modifier", "")):
+            abort(400, description=f'Invalid modifier expression "{data.get("modifier", "")}"')
+
+    def setup_routes(self):
+        @self.app.route('/test')
+        def test_route():
+            return 'ok'
+
+        @self.app.route("/", methods=["GET", "POST"])
+        def schedules_view():
+            """
+            Главная страница приложения. Отображает список расписаний и форму для добавления нового расписания.
+            """
+            if request.method == "POST":
+                cron = request.form.get("cron")
+                message = request.form.get("message")
+                modifier = request.form.get("modifier", "")
+                if not cron or not message:
+                    abort(400, description="Все поля должны быть заполнены")
+                try:
+                    self._validate_schedule_data({"cron": cron, "modifier": modifier, "message": message}) # Validate data
+                    chat_id = request.form.get("chat_id") or self.app.config["def_chat_id"]
+
+                    add_schedule(cron, message, modifier, int(chat_id), self.db_path)
+                except ValueError as e:
+                    return render_template("error.html", text=str(e)), 400
+                return redirect(url_for("schedules_view"))
+
+            sort_by = request.args.get('sort_by', 'next')
+            schedules = get_schedules(self.db_path)
+            chats = get_chats(self.db_path)
+            chat_map = {chat['id']: chat['name'] for chat in chats} # Create a chat ID to name mapping
+
+            for item in schedules:
+                next_match = self.myVCron.get_next_match(item['cron'], item['modifier'])
+                item['next'] = next_match.isoformat() if next_match else None
+                item['chat_name'] = chat_map.get(item['chat_id']) # Add chat name to schedule data
+
+            schedules.sort(
+                key=lambda x: (x[sort_by] is None, x[sort_by]),
+                reverse=(sort_by == 'id')
+            )
+
+            self.log.debug("Рендеринг шаблона index.html")
+            chats = get_chats(self.db_path)
+            return render_template("index.html", schedules=schedules, db_path=self.db_path, sort_by=sort_by, chats=chats)
+
+
+        @self.app.route("/schedules", methods=["GET"])
+        def list_schedules():
+            """
+            Возвращает список расписаний в формате JSON.
+            """
+            return jsonify(get_schedules(self.db_path))
+
+        @self.app.route("/schedules_all", methods=["POST"])
+        def create_schedules_by_list():
+            """
+            Создает несколько расписаний из списка JSON объектов.
+            """
+            list_data = request.get_json()
+            if isinstance(list_data, list):
+                if len(list_data) == 0:
+                    abort(400, description="Получен пустой массив")
+            else:
+                abort(400, description="Ожидался массив расписаний")
+
+            for data in list_data:
+                try:
+                    self._validate_schedule_data(data) # Validate data
+                    add_schedule(data["cron"], data["message"], data.get("modifier", ""), self.db_path)
path: web/app.py
user: login: sourcery-ai[bot]
body: **issue (bug_risk):** JSON-точки не предоставляют chat_id при вызове add_schedule.

Поскольку рефакторинг add_schedule теперь требует аргумент chat_id, рассмотрите возможность предоставления значения по умолчанию (например, из app.config["def_chat_id"]) для JSON-точек (например, в маршрутах /schedules_all и /schedules), чтобы избежать ошибок во время выполнения.

<details>
<summary>Original comment in English</summary>

**issue (bug_risk):** JSON endpoints do not provide a chat_id when calling add_schedule.

Since the refactored add_schedule now requires a chat_id argument, consider providing a default (e.g. from app.config["def_chat_id"]) for the JSON endpoints (e.g. in the /schedules_all and /schedules routes) to avoid runtime errors.

</details>
created_at: 2025-04-27T12:45:20Z
author_association: NONE
diff_hunk: @@ -0,0 +1,286 @@
+import os
+import json
+import tempfile
+from datetime import datetime, timedelta
+
+from dotenv import load_dotenv
+from flask import Flask, request, jsonify, render_template, redirect, url_for, abort, send_file
+
+from lib.cron_utils import VCron
+from lib.db_utils import (DB_PATH, LOGLEVEL, LOGPATH, add_schedule,
+                         delete_schedule, get_schedule, get_schedules, init_db,
+                         init_log, update_schedule, add_chat, get_chats)
+from lib.utils import MyError, get_environment_name, load_env as load_utils_env
+
+
+class WebApp:
+    def __init__(self, env_file=None):
+        self.app = Flask(__name__, template_folder='../templates', static_folder='../static')
+        self.setup_app()
+        self.log = init_log('web_app', LOGPATH, LOGLEVEL)
+        self.load_env(env_file)
+        self.myVCron = VCron(timezone=self.timezone)
+        self.setup_routes()
+        init_db(drop_table=False)
+        
+    def setup_app(self):
+        self.app.config["def_chat_id"] = os.getenv("CHAT_ID")
+        
+    def load_env(self, env_file):
+        env = get_environment_name()
+        load_utils_env(env)
+        if env_file:
+            load_dotenv(dotenv_path=env_file, override=True)
+
+        self.db_path = os.getenv("DB_PATH", DB_PATH)
+        self.timezone = os.getenv("reminderTZ", "UTC")
+
+    def _validate_schedule_data(self, data):
+        if not data or "cron" not in data or "message" not in data:
+            abort(400, description="Неверный формат данных")
+
+        if not self.myVCron.valid(data["cron"]):
+            abort(400, description=f'Invalid CRON expression: "{data["cron"]}"')
+        if not self.myVCron.is_valid_modifier(data.get("modifier", "")):
+            abort(400, description=f'Invalid modifier expression "{data.get("modifier", "")}"')
+
+    def setup_routes(self):
path: web/app.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Рассмотрите возможность разделения маршрутов на отдельные Blueprint или модули, чтобы уменьшить сложность метода `setup_routes`.

Рассмотрите возможность разделения маршрутов на отдельные Blueprint или модули, чтобы уменьшить сложность единого метода `setup_routes`. Например, вы можете извлечь конечные точки, связанные с расписанием, в выделенный blueprint. Это помогает изолировать обязанности и упрощает обслуживание как основного приложения, так и новых модулей.

**Практические шаги:**

1. **Создайте новый модуль для расписаний:**
 В файле, например `schedules.py`, определите blueprint с маршрутами расписания.

 ```python
 from flask import Blueprint, request, render_template, redirect, url_for, jsonify, abort
 from lib.db_utils import add_schedule, delete_schedule, get_schedule, get_schedules, update_schedule, get_chats
 from datetime import datetime, timedelta
 from lib.cron_utils import VCron

 schedule_bp = Blueprint('schedules', __name__)
 cron_util = VCron(timezone="UTC")  # Adjust as needed

 @schedule_bp.route("/", methods=["GET", "POST"])
 def schedules_view():
 if request.method == "POST":
 cron = request.form.get("cron")
 message = request.form.get("message")
 modifier = request.form.get("modifier", "")
 if not cron or not message:
 abort(400, description="Все поля должны быть заполнены")
 # Validate and add schedule (call your validation and add_schedule here)
 # For brevity, error handling is omitted.
 add_schedule(cron, message, modifier, int(request.form.get("chat_id")), current_app.config["def_chat_id"])
 return redirect(url_for("schedules.schedules_view"))
 # Fetch and render schedules
 schedules = get_schedules(current_app.config["DB_PATH"])
 return render_template("index.html", schedules=schedules)

 # Define additional schedule endpoints similarly.
 ```

2. **Зарегистрируйте blueprint в вашем основном классе:**
 Измените класс `WebApp`, чтобы зарегистрировать blueprint.

 ```python
 from schedules import schedule_bp

 class WebApp:
 def __init__(self, env_file=None):
 self.app = Flask(__name__, template_folder='../templates', static_folder='../static')
 self.setup_app()
 # ... other initializations ...
 self.setup_routes()

 def setup_routes(self):
 self.app.register_blueprint(schedule_bp, url_prefix="/schedules")
 # Register other blueprints similarly.
 ```

3. **Повторите процесс для других маршрутов:**
 Для чата или других конечных точек создайте аналогичные модули blueprint (например, `chats.py`), чтобы еще больше изолировать функциональность.

Эти изменения сохраняют функциональность, уменьшая когнитивную нагрузку и накладные расходы на обслуживание в единой монолитной настройке маршрута.

<details>
<summary>Original comment in English</summary>

**issue (complexity):** Consider splitting the routes into separate Blueprints or modules to reduce the complexity of the `setup_routes` method.

Consider splitting the routes into separate Blueprints or modules to reduce the complexity of the single `setup_routes` method. For example, you could extract schedule-related endpoints into a dedicated blueprint. This helps isolate responsibilities and simplifies the maintenance of both the main application and the new modules.

**Actionable Steps:**

1. **Create a new module for schedules:**  
   In a file like `schedules.py`, define a blueprint with the schedule routes.

   ```python
   from flask import Blueprint, request, render_template, redirect, url_for, jsonify, abort
   from lib.db_utils import add_schedule, delete_schedule, get_schedule, get_schedules, update_schedule, get_chats
   from datetime import datetime, timedelta
   from lib.cron_utils import VCron

   schedule_bp = Blueprint('schedules', __name__)
   cron_util = VCron(timezone="UTC")  # Adjust as needed

   @schedule_bp.route("/", methods=["GET", "POST"])
   def schedules_view():
       if request.method == "POST":
           cron = request.form.get("cron")
           message = request.form.get("message")
           modifier = request.form.get("modifier", "")
           if not cron or not message:
               abort(400, description="Все поля должны быть заполнены")
           # Validate and add schedule (call your validation and add_schedule here)
           # For brevity, error handling is omitted.
           add_schedule(cron, message, modifier, int(request.form.get("chat_id")), current_app.config["def_chat_id"])
           return redirect(url_for("schedules.schedules_view"))
       # Fetch and render schedules
       schedules = get_schedules(current_app.config["DB_PATH"])
       return render_template("index.html", schedules=schedules)

   # Define additional schedule endpoints similarly.
   ```

2. **Register the blueprint in your main class:**  
   Modify the `WebApp` class to register the blueprint.

   ```python
   from schedules import schedule_bp

   class WebApp:
       def __init__(self, env_file=None):
           self.app = Flask(__name__, template_folder='../templates', static_folder='../static')
           self.setup_app()
           # ... other initializations ...
           self.setup_routes()

       def setup_routes(self):
           self.app.register_blueprint(schedule_bp, url_prefix="/schedules")
           # Register other blueprints similarly.
   ```

3. **Repeat the process for other routes:**  
   For chat related or other endpoints, create similar blueprint modules (e.g., `chats.py`) to further isolate functionality.

These changes keep functionality intact while reducing the cognitive load and maintenance overhead in a single monolithic route setup.

</details>
created_at: 2025-04-27T12:45:20Z
author_association: NONE

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-04-27T12:44:16Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Руководство для проверяющего от Sourcery

Этот запрос на внесение изменений значительно реорганизует структуру приложения, разделяя веб-сервер и фоновый планировщик задач на отдельные компоненты. Он также добавляет поддержку управления несколькими чатами Telegram, позволяя связывать расписания с определенными чатами и отправлять их в эти чаты. Схема базы данных обновлена для поддержки этого, а веб-интерфейс улучшен для обеспечения управления чатами и возможностей выбора.

_Диаграммы не сгенерированы, так как изменения выглядят простыми и не нуждаются в визуальном представлении._

### Изменения на уровне файлов

| Изменение | Подробности | Файлы |
| ------ | ------- | ----- |
| Реорганизована структура приложения путем разделения веб-сервера и фонового планировщика задач. | <ul><li>Перемещена основная логика веб-приложения в новый класс WebApp в web/app.py.</li><li>Извлечена логика проверки расписания и отправки в новый автономный скрипт, rund.py.</li><li>Упрощен wsgi.py для первичной инициализации и запуска WebApp.</li><li>Обновлен основной блок выполнения в rund.py для фонового процесса.</li></ul> | `wsgi.py`<br/>`run.py`<br/>`web/app.py`<br/>`rund.py` |
| Изменена схема базы данных для включения таблицы chats и добавлен внешний ключ chat_id в таблицу schedules. Реализованы операции базы данных для управления чатами. | <ul><li>Добавлена новая таблица 'chats' для хранения информации о чатах.</li><li>Добавлен столбец 'chat_id' в таблицу 'schedules', связывающий расписания с определенными чатами.</li><li>Реализованы функции для получения и добавления чатов в базу данных.</li><li>Обновлены существующие функции расписания (get, add, update last fired) для обработки нового поля 'chat_id'.</li></ul> | `lib/db_utils.py`<br/>`rund.py`<br/>`web/app.py` |
| Улучшен веб-интерфейс для поддержки управления несколькими чатами Telegram и связывания расписаний с определенными чатами. | <ul><li>Добавлена новая страница (chats.html) и соответствующие маршруты для управления чатами Telegram.</li><li>Обновлено основное представление расписаний (index.html) для отображения связанного имени чата для каждого расписания.</li><li>Добавлен выпадающий список в форму 'Add Schedule' в index.html для выбора чата.</li><li>Добавлена ссылка на favicon в index.html.</li></ul> | `templates/index.html`<br/>`templates/chats.html`<br/>`web/app.py` |
| Обновлена логика фонового процесса для получения идентификаторов чатов из базы данных и отправки сообщений расписания в правильный чат Telegram. Реализована многопроцессорность для проверки расписания. | <ul><li>Изменена функция send_telegram_message для приема chat_id.</li><li>Реализована функция для получения идентификатора чата Telegram на основе идентификатора чата в базе данных расписания.</li><li>Обновлен основной цикл для использования новой логики получения и отправки идентификатора чата.</li><li>Введена многопроцессорность для одновременной проверки и отправки расписаний.</li></ul> | `rund.py`<br/>`lib/db_utils.py` |
| Реорганизованы зависимости и добавлены конфигурации контейнеризации. Удалены неиспользуемые файлы. | <ul><li>Разделены requirements.txt на отдельные файлы для веб-зависимостей и зависимостей runner.</li><li>Добавлены Dockerfile и docker-compose.yml для контейнеризации приложения.</li><li>Включены вспомогательные скрипты (web.sh, web_prod.sh) для запуска веб-компонента.</li><li>Удалены несколько устаревших или неиспользуемых файлов из проекта.</li></ul> | `requirements.txt`<br/>`requirements/web.txt`<br/>`requirements/rund.txt`<br/>`Dockerfile`<br/>`docker-compose.yml`<br/>`web.sh`<br/>`web_prod.sh`<br/>`API.md`<br/>`Readme.md`<br/>`app.py`<br/>`dataschedules.json.SAMPLE`<br/>`palmface.sh`<br/>`tests/test_cron_utils.py` |
| Скорректировано количество резервных копий файлов журналов. | <ul><li>Изменено максимальное количество резервных файлов журналов с 5 до 3.</li></ul> | `lib/utils.py` |

---

<details>
<summary>Советы и команды</summary>

#### Взаимодействие с Sourcery

- **Запустить новую проверку:** Напишите в комментарии `@sourcery-ai review` к запросу на внесение изменений.
- **Продолжить обсуждения:** Отвечайте непосредственно на комментарии проверки Sourcery.
- **Создать задачу GitHub из комментария проверки:** Попросите Sourcery создать
  задачу из комментария проверки, ответив на него. Вы также можете ответить на
  комментарий проверки с помощью `@sourcery-ai issue`, чтобы создать задачу из него.
- **Сгенерировать заголовок запроса на внесение изменений:** Напишите `@sourcery-ai` в любом месте заголовка
  запроса на внесение изменений, чтобы сгенерировать заголовок в любое время. Вы также можете прокомментировать
  `@sourcery-ai title` в запросе на внесение изменений, чтобы (повторно) сгенерировать заголовок в любое время.
- **Сгенерировать сводку запроса на внесение изменений:** Напишите `@sourcery-ai summary` в любом месте
  тела запроса на внесение изменений, чтобы сгенерировать сводку PR в любое время именно там, где вы
  хотите. Вы также можете прокомментировать `@sourcery-ai summary` в запросе на внесение изменений, чтобы
  (повторно) сгенерировать сводку в любое время.
- **Сгенерировать руководство для проверяющего:** Напишите в комментарии `@sourcery-ai guide` к запросу на
  внесение изменений, чтобы (повторно) сгенерировать руководство для проверяющего в любое время.
- **Разрешить все комментарии Sourcery:** Напишите в комментарии `@sourcery-ai resolve` к запросу на
  внесение изменений, чтобы разрешить все комментарии Sourcery. Полезно, если вы уже
  рассмотрели все комментарии и больше не хотите их видеть.
- **Отклонить все проверки Sourcery:** Напишите в комментарии `@sourcery-ai dismiss` к запросу на
  внесение изменений, чтобы отклонить все существующие проверки Sourcery. Особенно полезно, если вы
  хотите начать все заново с новой проверки - не забудьте прокомментировать
  `@sourcery-ai review`, чтобы запустить новую проверку!

#### Настройка вашего опыта

Перейдите на свою [панель управления](https://app.sourcery.ai), чтобы:
- Включить или отключить функции проверки, такие как сводка запроса на внесение изменений, сгенерированная Sourcery,
  руководство для проверяющего и другие.
- Изменить язык проверки.
- Добавить, удалить или изменить пользовательские инструкции по проверке.
- Настроить другие параметры проверки.

#### Получение помощи

- [Свяжитесь с нашей службой поддержки](mailto:support@sourcery.ai) с вопросами или отзывами.
- Посетите нашу [документацию](https://docs.sourcery.ai) для получения подробных руководств и информации.
- Оставайтесь на связи с командой Sourcery, подписавшись на нас в [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) или [GitHub](https://github.com/sourcery-ai).

</details>

<details>
<summary>Original review guide in English</summary>

## Reviewer's Guide by Sourcery

This pull request significantly refactors the application structure by separating the web server and the background schedule runner into distinct components. It also introduces support for managing multiple Telegram chats, allowing schedules to be associated with and sent to specific chats. The database schema is updated to support this, and the web interface is enhanced to provide chat management and selection capabilities.

_No diagrams generated as the changes look simple and do not need a visual representation._

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Refactored the application structure by separating the web server and the background schedule runner. | <ul><li>Moved the core web application logic into a new WebApp class in web/app.py.</li><li>Extracted the schedule checking and sending logic into a new standalone script, rund.py.</li><li>Simplified wsgi.py to primarily initialize and run the WebApp.</li><li>Updated the main execution block in rund.py for the background process.</li></ul> | `wsgi.py`<br/>`run.py`<br/>`web/app.py`<br/>`rund.py` |
| Modified the database schema to include a chats table and added a foreign key chat_id to the schedules table. Implemented database operations for managing chats. | <ul><li>Added a new 'chats' table to store chat information.</li><li>Added a 'chat_id' column to the 'schedules' table, linking schedules to specific chats.</li><li>Implemented functions to retrieve and add chats to the database.</li><li>Updated existing schedule functions (get, add, update last fired) to handle the new 'chat_id' field.</li></ul> | `lib/db_utils.py`<br/>`rund.py`<br/>`web/app.py` |
| Enhanced the web interface to support managing multiple Telegram chats and associating schedules with specific chats. | <ul><li>Added a new page (chats.html) and corresponding routes for managing Telegram chats.</li><li>Updated the main schedules view (index.html) to display the associated chat name for each schedule.</li><li>Added a dropdown to the 'Add Schedule' form in index.html to select a chat.</li><li>Added a favicon link to index.html.</li></ul> | `templates/index.html`<br/>`templates/chats.html`<br/>`web/app.py` |
| Updated the background runner logic to fetch chat IDs from the database and send schedule messages to the correct Telegram chat. Implemented multiprocessing for schedule checking. | <ul><li>Modified the send_telegram_message function to accept a chat_id.</li><li>Implemented a function to retrieve the Telegram chat ID based on the schedule's database chat ID.</li><li>Updated the main loop to use the new chat ID retrieval and sending logic.</li><li>Introduced multiprocessing to check and send schedules concurrently.</li></ul> | `rund.py`<br/>`lib/db_utils.py` |
| Refactored dependencies and added containerization configurations. Cleaned up unused files. | <ul><li>Split requirements.txt into separate files for web and runner dependencies.</li><li>Added Dockerfile and docker-compose.yml for containerizing the application.</li><li>Included helper scripts (web.sh, web_prod.sh) for running the web component.</li><li>Removed several outdated or unused files from the project.</li></ul> | `requirements.txt`<br/>`requirements/web.txt`<br/>`requirements/rund.txt`<br/>`Dockerfile`<br/>`docker-compose.yml`<br/>`web.sh`<br/>`web_prod.sh`<br/>`API.md`<br/>`Readme.md`<br/>`app.py`<br/>`dataschedules.json.SAMPLE`<br/>`palmface.sh`<br/>`tests/test_cron_utils.py` |
| Adjusted log file backup count. | <ul><li>Changed the maximum number of backup log files from 5 to 3.</li></ul> | `lib/utils.py` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: NONE
user: login: vsuh
created_at: 2025-04-27T12:45:17Z
body: accepted
author_association: OWNER