add scheduler

parent 8f31b07f
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]]
name = "aiodns"
......@@ -165,6 +165,34 @@ files = [
]
[[package]]
name = "apscheduler"
version = "3.11.0"
description = "In-process task scheduler with Cron-like capabilities"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"},
{file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"},
]
[package.dependencies]
tzlocal = ">=3.0"
[package.extras]
doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"]
etcd = ["etcd3", "protobuf (<=3.21.0)"]
gevent = ["gevent"]
mongodb = ["pymongo (>=3.0)"]
redis = ["redis (>=3.0)"]
rethinkdb = ["rethinkdb (>=2.4.0)"]
sqlalchemy = ["sqlalchemy (>=1.4)"]
test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""]
tornado = ["tornado (>=4.3)"]
twisted = ["twisted"]
zookeeper = ["kazoo"]
[[package]]
name = "attrs"
version = "25.3.0"
description = "Classes Without Boilerplate"
......@@ -1252,6 +1280,37 @@ files = [
typing-extensions = ">=4.12.0"
[[package]]
name = "tzdata"
version = "2025.2"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
{file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
]
[[package]]
name = "tzlocal"
version = "5.3.1"
description = "tzinfo object for the local timezone"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"},
{file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"},
]
[package.dependencies]
tzdata = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
[[package]]
name = "vbml"
version = "1.1.post1"
description = "Way to check, match & resist. Sofisticated object oriented regex-based text parser"
......@@ -1388,4 +1447,4 @@ propcache = ">=0.2.1"
[metadata]
lock-version = "2.1"
python-versions = ">=3.12,<4.0"
content-hash = "c7b24e6d1d4d94badff2a8b624692df36529a3352bfc1b0abc818c7cd1844eac"
content-hash = "5a66b54314c31d01aeef743fd5afdaed663664fe167af068f8cb016f0e95f591"
......@@ -14,7 +14,8 @@ dependencies = [
"pydantic-settings (>=2.10.1,<3.0.0)",
"peewee (>=3.18.2,<4.0.0)",
"bs4 (>=0.0.2,<0.0.3)",
"lxml (>=6.0.0,<7.0.0)"
"lxml (>=6.0.0,<7.0.0)",
"apscheduler (>=3.11.0,<4.0.0)"
]
[project.urls]
......
......@@ -9,14 +9,76 @@ profile_kb = (
profile_settings_kb = (
InlineKeyboard()
.add(InlineButton("Рассылка", callback_data="profile/settings/mailing"))
.row()
.add(InlineButton("Сменить сопровождающего", callback_data="profile/settings/maintainer"))
.row()
.add(InlineButton("Сменить репозиторий", callback_data="profile/settings/branch"))
).get_markup()
def profile_settings_branch_kb():
kb = InlineKeyboard()
for branch in DEFAUIL_BRANCHES:
kb.add(InlineButton(
f"{branch}", callback_data=f"profile/settings/branch/{branch}"))
return kb.get_markup()
\ No newline at end of file
return kb.get_markup()
def settings_tasks_kb(watch_task, bugs_task):
kb = InlineKeyboard()
if watch_task:
kb.add(InlineButton(
"Редактировать отслеживание",
callback_data=f"profile/settings/mailing/watch/edit"
))
else:
kb.add(InlineButton(
"Подписаться на отслеживание",
callback_data=f"profile/settings/mailing/watch/add"
))
kb.row()
if bugs_task:
kb.add(InlineButton(
"Редактировать баги",
callback_data=f"profile/settings/mailing/bugs/edit"
))
else:
kb.add(InlineButton(
"Подписаться на баги",
callback_data=f"profile/settings/mailing/bugs/add"
))
return kb.get_markup()
def set_task_days_kb(task_type, days, selected_days):
kb = InlineKeyboard()
for i, day_name in enumerate(days):
emoji = "🟢" if i in selected_days else "🔴"
new_days = selected_days.copy()
if i in new_days:
new_days.remove(i)
else:
new_days.append(i)
new_days_str = ",".join(map(str, sorted(new_days)))
kb.add(InlineButton(
f"{emoji} {day_name}",
callback_data=f"profile/settings/mailing/{task_type}/set-day/{new_days_str}"
))
if i == 3:
kb.row()
kb.row()
kb.add(InlineButton(
f"Продолжить",
callback_data=f"profile/settings/mailing/{task_type}/set-time"
))
return kb.get_markup()
import json
from .models import Maintainer, User, Package
from .models import Maintainer, User, Package, ScheduledTask
class MaintainerMethod:
......@@ -17,7 +17,7 @@ class MaintainerMethod:
maintainer.save()
return True
return False
@classmethod
def get(cls, nickname: str) -> Maintainer | None:
"""получение записи сопровождающего"""
......@@ -64,7 +64,7 @@ class UserMethod:
user.save()
return True
return False
@classmethod
def add_role(cls, user_id: int, role: str):
"""добавление ролей пользователя"""
......@@ -84,12 +84,12 @@ class UserMethod:
"""получение записи пользователя"""
user: User | None = User.get_or_none(User.user_id == user_id)
return user
@classmethod
def get_all(cls):
"""Получение записей всех пользователей"""
return [user for user in User.select()]
@classmethod
def get_roles(cls, user_id: int):
"""получение ролей пользователя"""
......@@ -106,7 +106,7 @@ class UserMethod:
return False
user.delete_instance()
return True
@classmethod
def remove_role(cls, user_id: int, role: str):
"""удаление ролей пользователя"""
......@@ -154,7 +154,7 @@ class PackageMethod:
package.save()
return True
return False
@classmethod
def get(cls, name: str) -> Package | None:
"""получение записи пакета"""
......@@ -162,7 +162,42 @@ class PackageMethod:
return package
class SchedulerMethod:
@classmethod
def add(cls, user: User, task_type: str, days: str, time: str):
task = ScheduledTask(
user=user, task_type=task_type, days=days, send_time=time)
task.save()
@classmethod
def get(cls, user: User, task_type: str) -> ScheduledTask | None:
return ScheduledTask.get_or_none(ScheduledTask.user == user, ScheduledTask.task_type == task_type)
@classmethod
def get_by_id(cls, task_id: int):
return ScheduledTask.get_or_none(ScheduledTask.id == task_id)
@classmethod
def update(cls, user: User, task_type: str, **kwargs):
ScheduledTask.update(**kwargs).where(
(ScheduledTask.user == user) & (
ScheduledTask.task_type == task_type)
).execute()
@classmethod
def all(cls):
return list(ScheduledTask.select())
@classmethod
def delete(cls, user: User, task_type: str):
ScheduledTask.delete().where(
(ScheduledTask.user == user) & (
ScheduledTask.task_type == task_type)
).execute()
class DB:
maintainer = MaintainerMethod
user = UserMethod
package = PackageMethod
scheduler = SchedulerMethod
......@@ -2,8 +2,10 @@ from peewee import (
SqliteDatabase,
Model,
AutoField,
CharField,
IntegerField,
TextField,
TimeField,
ForeignKeyField
)
......@@ -21,8 +23,7 @@ class BaseModel(Model):
class Maintainer(BaseModel):
"""модель сопровождающего"""
name = TextField()
name_ru = TextField(null=True)
nickname = TextField()
......@@ -35,7 +36,7 @@ class User(BaseModel):
"""модель пользователя"""
user_id = IntegerField() # id пользователя
maintainer: Maintainer = ForeignKeyField( # сопровождающий
maintainer: Maintainer = ForeignKeyField( # сопровождающий
Maintainer,
to_field="nickname"
)
......@@ -57,3 +58,18 @@ class Package(BaseModel):
class Meta:
table_name = "packages"
class ScheduledTask(BaseModel):
"""Модель запланированной задачи"""
user = ForeignKeyField(User, on_delete="CASCADE", to_field="user_id")
task_type = CharField()
days = CharField()
send_time = TimeField()
class Meta:
table_name = "scheduled_tasks"
indexes = (
(('user', 'task_type'), True),
)
from telegrinder import Dispatch, Message
from telegrinder.rules import Command, Argument, Text, IsPrivate
from telegrinder.tools.formatting import HTMLFormatter
from altrepo import altrepo
from database.models import User
from database.func import DB
from data.keyboards import bugs_keyboards
from services.utils import _bold
from config import BUGS_URL
from services.bugs import bugs
dp = Dispatch()
......@@ -17,44 +11,6 @@ dp = Dispatch()
@dp.message(Command("bugs", Argument("maintainer", optional=True)))
@dp.message(Text(["bugs", "ошибки"], ignore_case=True), IsPrivate())
async def bugs_handler(m: Message, user: User | None, maintainer: str | None = None) -> None:
if maintainer:
_maintainer = maintainer.lower()
maintainer = DB.maintainer.get(_maintainer)
if not maintainer:
await m.answer(f"Сопровождающий не найден.")
return
else:
if user:
maintainer = user.maintainer
else:
return
bugs_data = await altrepo.api.bug.bugzilla_by_maintainer(maintainer.nickname)
if bugs_data:
unresolved_bugs = [
bug for bug in bugs_data.bugs if bug.status not in ["RESOLVED", "CLOSED"]]
else:
unresolved_bugs = []
if not len(unresolved_bugs):
await m.answer(
"Нет открытых багов"
)
return
bugs_message = _bold("Ошибки:\n\n")
for i, bug in enumerate(unresolved_bugs):
if i == 20:
break
bugs_message += HTMLFormatter(f"<a href='{BUGS_URL}{bug.id}'>#{bug.id}</a>") + \
f" | {bug.product} - {bug.component} | {bug.severity}\n"
bugs_message += bug.summary + "\n\n"
markup = None
if len(unresolved_bugs) > 20:
bugs_message += "..."
markup = bugs_keyboards.bugs_more_kb(maintainer.nickname)
await m.answer(bugs_message, reply_markup=markup)
await bugs(
user, m.chat_id, maintainer
)
from telegrinder import Dispatch, Message, CallbackQuery, MESSAGE_FROM_USER, WaiterMachine
from telegrinder.rules import Command, PayloadEqRule, PayloadMarkupRule, Text, IsPrivate, HasText
from telegrinder.rules import Command, PayloadEqRule, PayloadMarkupRule, Text, IsPrivate, HasText, Argument
from asyncio import sleep
import re
from datetime import datetime
from database.func import DB
from data.keyboards import profile_keyboards
from modules import scheduler
from altrepo import altrepo
from services.menu import send_menu
from services.utils import _bold
from database.models import User
DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
dp = Dispatch()
wm = WaiterMachine(dp)
......@@ -109,6 +115,85 @@ async def callback_confirm_handler(cb: CallbackQuery, branch: str) -> None:
await cb.delete()
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing"))
async def mailing_handler(cb: CallbackQuery) -> None:
user = DB.user.get(cb.from_user.id)
if user is None:
return
watch_task = DB.scheduler.get(user, 'watch')
bugs_task = DB.scheduler.get(user, 'bugs')
markup = profile_keyboards.settings_tasks_kb(watch_task, bugs_task)
message = (
f"{_bold('Рассылка:\n\n')}"
f"{format_task(watch_task, 'Отслеживание')}"
f"{format_task(bugs_task, 'Баги')}"
)
await cb.answer()
await cb.ctx_api.send_message(
chat_id=cb.from_user.id,
text=message,
reply_markup=markup
)
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing/<task_type>/set-day/<days>"))
async def mailing_set_days_handler(cb: CallbackQuery, task_type: str, days: str):
user = DB.user.get(cb.from_user.id)
if not user:
return
if not days:
await scheduler.remove_task(user, task_type)
await cb.edit_text(
"Задача удалена — ни один день не выбран."
)
await sleep(3.0)
await cb.delete()
return
task = scheduler.get_task(user, task_type)
if task:
await scheduler.update_task(user, task_type, days=days)
else:
DB.scheduler.add(user, task_type, days, "12:00")
await edit_days_message(cb, task_type)
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing/<task_type>/set-time"))
async def mailing_action_handler(cb: CallbackQuery, task_type: str):
user = DB.user.get(cb.from_user.id)
if not user:
return
await cb.edit_text("Введите время в формате ЧЧ:ММ (например, 09:30)")
while True:
msg, _ = await wm.wait(MESSAGE_FROM_USER, cb.from_user.id, release=HasText())
time_str = msg.text.unwrap().lower()
if re.match(r"^(?:[01]\d|2[0-3]):[0-5]\d$", time_str):
parsed_time = datetime.strptime(time_str, "%H:%M").time()
await scheduler.update_task(user, task_type, send_time=time_str)
await cb.edit_text(f"Время для задачи '{task_type}' установлено: {parsed_time.strftime('%H:%M')}")
await msg.delete()
await sleep(3.0)
await cb.delete()
break
else:
await msg.delete()
await cb.edit_text("Неверный формат. Введите время в формате ЧЧ:ММ, например 08:45.")
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing/<task_type>/<action>"))
async def mailing_action_handler(cb: CallbackQuery, task_type: str, action: str):
await edit_days_message(cb, task_type)
@dp.callback_query(PayloadEqRule("command/menu"))
async def menu_handler(cb: CallbackQuery):
await send_menu(cb=cb)
......@@ -117,3 +202,33 @@ async def menu_handler(cb: CallbackQuery):
@dp.message(Command(["menu", "меню"]) | Text(["меню", "menu"]), IsPrivate())
async def menu_handler(m: Message):
await send_menu(m=m)
def format_task(task, title):
if not task:
return f"{title}: (Неактивна)\n"
days = ", ".join(DAYS[int(i)] for i in task.days.split(","))
time_str = f"{task.send_time.hour}:{task.send_time.minute:02d}"
return (
f"{title}: (Активна)\n"
f" Время: {time_str}\n"
f" Дни: {days}\n\n"
)
async def edit_days_message(cb: CallbackQuery, task_type: str):
user = DB.user.get(cb.from_user.id)
if not user:
return
task = DB.scheduler.get(user, task_type)
selected_days = list(map(int, task.days.split(","))
) if task and task.days else []
await cb.edit_text(
text='Выберите дни и нажмите "Продолжить".',
reply_markup=profile_keyboards.set_task_days_kb(
task_type, DAYS, selected_days
)
)
from telegrinder import Dispatch, Message
from telegrinder.rules import Command, Argument, Text, IsPrivate
from telegrinder.tools.formatting import HTMLFormatter
from altrepo import altrepo
from database.models import User
from database.func import DB
from data.keyboards import watch_keyboards
from services.utils import _bold
from services.watch import watch
dp = Dispatch()
......@@ -18,52 +14,9 @@ dp = Dispatch()
Argument("acl", optional=True))
)
@dp.message(Text(["watch", "отслеживание"], ignore_case=True), IsPrivate())
async def watch_test_handler(
async def watch_handler(
m: Message, user: User | None, maintainer: str | None = None, acl: str | None = None
) -> None:
if maintainer:
maintainer = maintainer.lower()
_maintainer = DB.maintainer.get(maintainer)
if not _maintainer:
await m.answer(f"Сопровождающий не найден.")
return
else:
if user:
maintainer = user.maintainer.nickname
else:
return
acl = acl or "by-acl"
watch_data = await altrepo.parser.packages.watch_by_maintainer(maintainer, acl)
if not len(watch_data):
await m.answer("Нет устаревших пакетов")
return
watch_message = _bold("Отслеживание:\n\n")
packages = {}
for package in watch_data:
name = package.pkg_name
if name not in packages or "src.rpm" in packages[name].url:
packages[name] = package
packages = list(packages.values())
for i, package in enumerate(packages):
if i == 30:
break
watch_message += (
f"{package.pkg_name}: {package.old_version} -> "
+ HTMLFormatter(
f"<a href='{package.url.replace(".git/", "/")}'>{package.new_version}</a>"
)
+ "\n"
)
markup = None
if len(packages) > 30:
watch_message += "\n..."
markup = watch_keyboards.watch_more_kb(maintainer)
await m.answer(watch_message, reply_markup=markup)
await watch(
user, m.chat_id, maintainer, acl
)
......@@ -4,9 +4,11 @@ from telegrinder.types import BotCommand
from telegrinder.node import Error
from config import tg_api
from modules import scheduler
from altrepo import altrepo
from altrepo.api.errors import TooManyRequests
from database.models import db, Maintainer, User, Package
from database.models import db, Maintainer, User, Package, ScheduledTask
from middlewares import UserMiddleware
from services.test_api_version import test_api_version
......@@ -21,12 +23,23 @@ bot.on.message.register_middleware(UserMiddleware)
@bot.loop_wrapper.lifespan.on_startup
async def startup():
db.create_tables([Maintainer, User, Package])
db.create_tables([Maintainer, User, Package, ScheduledTask])
logger.info("initializing ALTRepo")
await altrepo.init()
await test_api_version()
await update_maintainers()
await update_appstream_data()
logger.info("initializing Scheduler")
scheduler.register_handler(
"watch", print
)
scheduler.register_handler(
"bugs", print
)
await scheduler.init()
await bot.api.set_my_commands(
commands=[
BotCommand("watch", "Отслеживание по пакетам"),
......
from .scheduler import CustomScheduler
scheduler = CustomScheduler()
from datetime import time as dtime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from database.func import DB
from services.watch import watch
from services.bugs import bugs
class CustomScheduler:
def __init__(self):
self.scheduler = AsyncIOScheduler()
self.handlers = {}
def register_handler(self, task_type: str, func):
self.handlers[task_type] = func
async def init(self):
self.scheduler.start()
await self.reload_tasks()
async def add_task(self, user_id: int, task_type: str, days: str, time: str):
h, m = map(int, time.split(":"))
DB.scheduler.add(
user_id,
task_type,
days,
dtime(h, m)
)
await self.reload_tasks()
async def update_task(self, user_id: int, task_type: str, **kwargs):
DB.scheduler.update(user_id, task_type, **kwargs)
await self.reload_tasks()
async def remove_task(self, user_id: int, task_type: str):
DB.scheduler.delete(
user_id, task_type
)
await self.reload_tasks()
def get_task(self, user_id: int, task_type: str):
return DB.scheduler.get(
user_id, task_type
)
async def reload_tasks(self):
self.scheduler.remove_all_jobs()
for task in DB.scheduler.all():
hour = task.send_time.hour
minute = task.send_time.minute
for day in task.days.split(","):
self.scheduler.add_job(
self._job_runner,
CronTrigger(
day_of_week=int(day),
hour=int(hour),
minute=int(minute)
),
args=[task.id],
id=f"{task.user.id}_{task.task_type}_{day}"
)
async def _job_runner(self, task_id: int):
"""Вызывается планировщиком"""
task = DB.scheduler.get_by_id(task_id)
if not task:
return
match task.task_type:
case "watch": await watch(task.user)
case "bugs": await bugs(task.user)
from telegrinder.tools.formatting import HTMLFormatter
from config import tg_api
from altrepo import altrepo
from database.models import User
from database.func import DB
from data.keyboards import bugs_keyboards
from services.utils import _bold
from config import BUGS_URL
async def bugs(
user: User | None, chat_id: int | None = None,
maintainer: str | None = None
) -> None:
chat_id = chat_id or user.user_id
if maintainer:
_maintainer = maintainer.lower()
maintainer = DB.maintainer.get(_maintainer)
if not maintainer:
await tg_api.send_message(
chat_id=chat_id,
text="Сопровождающий не найден."
)
return
else:
if user:
maintainer = user.maintainer
else:
return
bugs_data = await altrepo.api.bug.bugzilla_by_maintainer(maintainer.nickname)
if bugs_data:
unresolved_bugs = [
bug for bug in bugs_data.bugs if bug.status not in ["RESOLVED", "CLOSED"]]
else:
unresolved_bugs = []
if not len(unresolved_bugs):
await tg_api.send_message(
chat_id=chat_id,
text="Нет открытых багов."
)
return
bugs_message = _bold("Ошибки:\n\n")
for i, bug in enumerate(unresolved_bugs):
if i == 20:
break
bugs_message += HTMLFormatter(f"<a href='{BUGS_URL}{bug.id}'>#{bug.id}</a>") + \
f" | {bug.product} - {bug.component} | {bug.severity}\n"
bugs_message += bug.summary + "\n\n"
markup = None
if len(unresolved_bugs) > 20:
bugs_message += "..."
markup = bugs_keyboards.bugs_more_kb(maintainer.nickname)
await tg_api.send_message(
chat_id=chat_id,
text=bugs_message,
reply_markup=markup
)
from telegrinder.tools.formatting import HTMLFormatter
from config import tg_api
from altrepo import altrepo
from database.models import User
from database.func import DB
from data.keyboards import watch_keyboards
from services.utils import _bold
async def watch(
user: User | None, chat_id: int | None = None,
maintainer: str | None = None, acl: str | None = None
) -> None:
chat_id = chat_id or user.user_id
if maintainer:
maintainer = maintainer.lower()
_maintainer = DB.maintainer.get(maintainer)
if not _maintainer:
await tg_api.send_message(
chat_id=chat_id,
text=f"Сопровождающий не найден."
)
return
else:
if user:
maintainer = user.maintainer.nickname
else:
return
acl = acl or "by-acl"
watch_data = await altrepo.parser.packages.watch_by_maintainer(maintainer, acl)
if not len(watch_data):
await tg_api.send_message(
chat_id=chat_id,
text=f"Нет устаревших пакетов."
)
return
watch_message = _bold("Отслеживание:\n\n")
packages = {}
for package in watch_data:
name = package.pkg_name
if name not in packages or "src.rpm" in packages[name].url:
packages[name] = package
packages = list(packages.values())
for i, package in enumerate(packages):
if i == 30:
break
watch_message += (
f"{package.pkg_name}: {package.old_version} -> "
+ HTMLFormatter(
f"<a href='{package.url.replace(".git/", "/")}'>{package.new_version}</a>"
)
+ "\n"
)
markup = None
if len(packages) > 30:
watch_message += "\n..."
markup = watch_keyboards.watch_more_kb(maintainer)
await tg_api.send_message(
chat_id=chat_id,
text=watch_message,
reply_markup=markup
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment