mailing: unified schedule with per-type toggles

parent e908122d
......@@ -11,7 +11,9 @@ profile_kb = (
profile_settings_kb = (
InlineKeyboard()
.add(InlineButton("Рассылка", callback_data="profile/settings/mailing"))
.add(InlineButton("Рассылки и уведомления", callback_data="profile/mailing"))
.row()
.add(InlineButton("Отслеживаемые пакеты", callback_data="profile/settings/packages"))
.row()
.add(InlineButton("Сменить сопровождающего", callback_data="profile/settings/maintainer"))
.row()
......@@ -31,47 +33,47 @@ def profile_settings_branch_kb():
return kb.get_markup()
def settings_tasks_kb(watch_task, bugs_task, pkg_watch_task, task_events_enabled):
SCHEDULED_TYPES = [
("watch", "Устаревшие пакеты"),
("pkg_watch", "Отслеживаемые пакеты"),
("bugs", "Баги"),
]
kb = InlineKeyboard()
INSTANT_TYPES = [
("task_events", "Таски girar"),
]
label = "Устаревшие пакеты ✓" if watch_task else "Устаревшие пакеты"
action = "edit" if watch_task else "add"
kb.add(InlineButton(label, callback_data=f"profile/settings/mailing/watch/{action}"))
kb.row()
label = "Открытые баги ✓" if bugs_task else "Открытые баги"
action = "edit" if bugs_task else "add"
kb.add(InlineButton(label, callback_data=f"profile/settings/mailing/bugs/{action}"))
kb.row()
def mailing_settings_kb(schedule, enabled: list[str]):
kb = InlineKeyboard()
label = "Устаревшие пакеты (по списку) ✓" if pkg_watch_task else "Устаревшие пакеты (по списку)"
action = "edit" if pkg_watch_task else "add"
kb.add(InlineButton(label, callback_data=f"profile/settings/mailing/pkg_watch/{action}"))
kb.add(InlineButton("Дни недели", callback_data="profile/mailing/days"))
kb.add(InlineButton("Время", callback_data="profile/mailing/set-time"))
kb.row()
if task_events_enabled:
kb.add(InlineButton("Таски girar: вкл", callback_data="profile/settings/mailing/task_events/off"))
else:
kb.add(InlineButton("Таски girar: выкл", callback_data="profile/settings/mailing/task_events/on"))
kb.row()
for type_id, label in SCHEDULED_TYPES:
status = "вкл" if type_id in enabled else "выкл"
kb.add(InlineButton(
f"{label}: {status}",
callback_data=f"profile/mailing/toggle/{type_id}",
))
kb.row()
kb.add(InlineButton("Пакеты для отслеживания", callback_data="profile/settings/packages"))
kb.row()
for type_id, label in INSTANT_TYPES:
status = "вкл" if type_id in enabled else "выкл"
kb.add(InlineButton(
f"{label}: {status}",
callback_data=f"profile/mailing/toggle/{type_id}",
))
kb.row()
kb.add(InlineButton("Назад", callback_data="profile/settings"))
return kb.get_markup()
def user_packages_kb(pkgs: list[str]):
return packages_kb("profile/pkg", pkgs, back_callback="profile/settings/mailing", max_size=MAX_WATCH_LIST_SIZE)
def set_task_days_kb(task_type, days, selected_days):
def schedule_days_kb(days_list, selected_days):
kb = InlineKeyboard()
for i, day_name in enumerate(days):
for i, day_name in enumerate(days_list):
emoji = "🟢" if i in selected_days else "🔴"
new_days = selected_days.copy()
......@@ -84,16 +86,14 @@ def set_task_days_kb(task_type, days, selected_days):
kb.add(InlineButton(
f"{emoji} {day_name}",
callback_data=f"profile/settings/mailing/{task_type}/set-day/{new_days_str}"
callback_data=f"profile/mailing/set-day/{new_days_str}"
))
if i == 3:
kb.row()
kb.row()
kb.add(InlineButton(
"Продолжить",
callback_data=f"profile/settings/mailing/{task_type}/set-time"
))
kb.row()
kb.add(InlineButton("Назад", callback_data="profile/settings/mailing"))
kb.add(InlineButton("Сохранить", callback_data="profile/mailing"))
return kb.get_markup()
def user_packages_kb(pkgs: list[str]):
return packages_kb("profile/pkg", pkgs, back_callback="profile/settings", max_size=MAX_WATCH_LIST_SIZE)
import json
from datetime import time as dtime
from .models import (
Maintainer, User, Package, ScheduledTask,
Maintainer, User, Package,
UserSchedule, UserMailing,
WatchList, ChatSubscription,
)
......@@ -169,39 +172,64 @@ class PackageMethod:
return package
class SchedulerMethod:
class UserScheduleMethod:
@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()
def get(cls, user: User) -> UserSchedule | None:
return UserSchedule.get_or_none(UserSchedule.user == user)
@classmethod
def get(cls, user: User, task_type: str) -> ScheduledTask | None:
return ScheduledTask.get_or_none(ScheduledTask.user == user, ScheduledTask.task_type == task_type)
def set(cls, user: User, days: str, send_time):
schedule, created = UserSchedule.get_or_create(
user=user, defaults={"days": days, "send_time": send_time})
if not created:
schedule.days = days
schedule.send_time = send_time
schedule.save()
@classmethod
def get_by_id(cls, task_id: int):
return ScheduledTask.get_or_none(ScheduledTask.id == task_id)
def update(cls, user: User, **kwargs):
UserSchedule.update(**kwargs).where(UserSchedule.user == user).execute()
@classmethod
def update(cls, user: User, task_type: str, **kwargs):
ScheduledTask.update(**kwargs).where(
(ScheduledTask.user == user) & (
ScheduledTask.task_type == task_type)
).execute()
def ensure_exists(cls, user: User):
UserSchedule.get_or_create(
user=user, defaults={"days": "0,1,2,3,4", "send_time": dtime(12, 0)})
@classmethod
def all(cls):
return list(ScheduledTask.select())
def all_with_mailings(cls):
return list(
UserSchedule.select()
.where(UserSchedule.days != "")
.join(UserMailing, on=(UserSchedule.user == UserMailing.user))
.switch(UserSchedule)
.distinct()
)
class UserMailingMethod:
@classmethod
def is_enabled(cls, user: User, mailing_type: str) -> bool:
return UserMailing.get_or_none(
(UserMailing.user == user) & (UserMailing.mailing_type == mailing_type)
) is not None
@classmethod
def delete(cls, user: User, task_type: str):
ScheduledTask.delete().where(
(ScheduledTask.user == user) & (
ScheduledTask.task_type == task_type)
def enable(cls, user: User, mailing_type: str):
UserMailing.get_or_create(user=user, mailing_type=mailing_type)
@classmethod
def disable(cls, user: User, mailing_type: str):
UserMailing.delete().where(
(UserMailing.user == user) & (UserMailing.mailing_type == mailing_type)
).execute()
@classmethod
def get_enabled(cls, user: User) -> list[str]:
return [
row.mailing_type
for row in UserMailing.select().where(UserMailing.user == user)
]
class WatchListMethod:
@classmethod
......@@ -271,6 +299,7 @@ class DB:
maintainer = MaintainerMethod
user = UserMethod
package = PackageMethod
scheduler = SchedulerMethod
schedule = UserScheduleMethod
mailing = UserMailingMethod
watch_list = WatchListMethod
chat_subscription = ChatSubscriptionMethod
......@@ -88,16 +88,25 @@ class ChatSubscription(BaseModel):
)
class ScheduledTask(BaseModel):
"""Модель запланированной задачи"""
class UserSchedule(BaseModel):
"""Расписание рассылок пользователя"""
user = ForeignKeyField(User, on_delete="CASCADE", to_field="user_id")
task_type = CharField()
days = CharField()
user = ForeignKeyField(User, on_delete="CASCADE", to_field="user_id", unique=True)
days = CharField(default="")
send_time = TimeField()
class Meta:
table_name = "scheduled_tasks"
table_name = "user_schedules"
class UserMailing(BaseModel):
"""Включённые рассылки пользователя"""
user = ForeignKeyField(User, on_delete="CASCADE", to_field="user_id")
mailing_type = CharField()
class Meta:
table_name = "user_mailings"
indexes = (
(('user', 'task_type'), True),
(('user', 'mailing_type'), True),
)
......@@ -18,22 +18,40 @@ dp = Dispatch()
wm = WaiterMachine(dp)
DESCRIPTIONS = {
"watch": "Новые версии пакетов текущего сопровождающего.",
"bugs": "Открытые баги текущего сопровождающего.",
"pkg_watch": "Новые версии отслеживаемых пакетов.",
"task_events": "Изменения статуса тасков текущего сопровождающего.",
}
def _mailing_text_and_markup(user):
watch_task = DB.scheduler.get(user, 'watch')
bugs_task = DB.scheduler.get(user, 'bugs')
pkg_watch_task = DB.scheduler.get(user, 'pkg_watch')
task_events = DB.scheduler.get(user, 'task_events')
text = (
f"{_bold('Уведомления:\n\n')}"
f"{format_task(watch_task, 'Устаревшие пакеты')}"
f"{format_task(bugs_task, 'Открытые баги')}"
f"{format_task(pkg_watch_task, 'Устаревшие пакеты (по списку)')}"
f"Таски girar: {'включено' if task_events else 'выключено'}\n"
)
markup = profile_keyboards.settings_tasks_kb(
watch_task, bugs_task, pkg_watch_task, bool(task_events)
)
schedule = DB.schedule.get(user)
enabled = DB.mailing.get_enabled(user)
text = _bold("Рассылки:\n\n")
if schedule and schedule.days:
days = ", ".join(DAYS[int(i)] for i in schedule.days.split(","))
time_str = f"{schedule.send_time.hour}:{schedule.send_time.minute:02d}"
text += f"Расписание: {days} в {time_str}\n\n"
else:
text += "Расписание: не настроено\n\n"
for type_id, label in profile_keyboards.SCHEDULED_TYPES:
status = "вкл" if type_id in enabled else "выкл"
text += _bold(f"{label}: {status}\n")
text += f"{DESCRIPTIONS[type_id]}\n\n"
text += _bold("Уведомления:\n\n")
for type_id, label in profile_keyboards.INSTANT_TYPES:
status = "вкл" if type_id in enabled else "выкл"
text += _bold(f"{label}: {status}\n")
text += f"{DESCRIPTIONS[type_id]}\n\n"
markup = profile_keyboards.mailing_settings_kb(schedule, enabled)
return text, markup
......@@ -148,10 +166,12 @@ async def branch_select_handler(cb: CallbackQuery, branch: str) -> None:
await cb.answer()
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing"))
# --- Mailing ---
@dp.callback_query(PayloadEqRule("profile/mailing"))
async def mailing_handler(cb: CallbackQuery) -> None:
user = DB.user.get(cb.from_user.id)
if user is None:
if not user:
return
text, markup = _mailing_text_and_markup(user)
......@@ -159,35 +179,46 @@ async def mailing_handler(cb: CallbackQuery) -> None:
await cb.answer()
@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):
@dp.callback_query(PayloadEqRule("profile/mailing/days"))
async def mailing_days_handler(cb: CallbackQuery) -> None:
user = DB.user.get(cb.from_user.id)
if not user:
return
if not days:
await scheduler.remove_task(user, task_type)
text, markup = _mailing_text_and_markup(user)
await cb.edit_text(text, reply_markup=markup)
await cb.answer("Задача удалена")
schedule = DB.schedule.get(user)
selected = list(map(int, schedule.days.split(","))) if schedule and schedule.days else []
await cb.edit_text(
"Выберите дни рассылки:",
reply_markup=profile_keyboards.schedule_days_kb(DAYS, selected),
)
await cb.answer()
@dp.callback_query(PayloadMarkupRule("profile/mailing/set-day/<days>"))
async def mailing_set_day_handler(cb: CallbackQuery, days: str) -> None:
user = DB.user.get(cb.from_user.id)
if not user:
return
task = scheduler.get_task(user, task_type)
if task:
await scheduler.update_task(user, task_type, days=days)
else:
await scheduler.add_task(user, task_type, days, "12:00")
DB.schedule.ensure_exists(user)
DB.schedule.update(user, days=days)
await edit_days_message(cb, task_type)
selected = list(map(int, days.split(","))) if days else []
await cb.edit_text(
"Выберите дни рассылки:",
reply_markup=profile_keyboards.schedule_days_kb(DAYS, selected),
)
await cb.answer()
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing/<task_type>/set-time"))
async def mailing_time_handler(cb: CallbackQuery, task_type: str):
@dp.callback_query(PayloadEqRule("profile/mailing/set-time"))
async def mailing_set_time_handler(cb: CallbackQuery) -> None:
user = DB.user.get(cb.from_user.id)
if not user:
return
cancel = cancel_kb("profile/settings/mailing")
cancel = cancel_kb("profile/mailing")
await cb.edit_text("Введите время в формате ЧЧ:ММ (например, 09:30)", reply_markup=cancel)
try:
......@@ -204,39 +235,31 @@ async def mailing_time_handler(cb: CallbackQuery, task_type: str):
except (asyncio.CancelledError, LookupError):
return
await scheduler.update_task(user, task_type, send_time=time_str)
DB.schedule.ensure_exists(user)
await scheduler.update_schedule(user, send_time=time_str)
text, markup = _mailing_text_and_markup(user)
await cb.edit_text(text, reply_markup=markup)
await cb.answer(f"Время: {time_str}")
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing/task_events/on"))
async def task_events_on_handler(cb: CallbackQuery):
@dp.callback_query(PayloadMarkupRule("profile/mailing/toggle/<mailing_type>"))
async def mailing_toggle_handler(cb: CallbackQuery, mailing_type: str) -> None:
user = DB.user.get(cb.from_user.id)
if not user:
return
if not DB.scheduler.get(user, "task_events"):
DB.scheduler.add(user, "task_events", "", "00:00")
text, markup = _mailing_text_and_markup(user)
await cb.edit_text(text, reply_markup=markup)
await cb.answer("Уведомления включены")
if DB.mailing.is_enabled(user, mailing_type):
await scheduler.disable_mailing(user, mailing_type)
else:
await scheduler.enable_mailing(user, mailing_type)
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing/task_events/off"))
async def task_events_off_handler(cb: CallbackQuery):
user = DB.user.get(cb.from_user.id)
if not user:
return
DB.scheduler.delete(user, "task_events")
text, markup = _mailing_text_and_markup(user)
await cb.edit_text(text, reply_markup=markup)
await cb.answer("Уведомления выключены")
await cb.answer()
@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)
# --- Packages ---
@dp.callback_query(PayloadEqRule("profile/settings/packages"))
async def user_packages_handler(cb: CallbackQuery) -> None:
......@@ -296,6 +319,8 @@ async def user_pkg_remove_handler(cb: CallbackQuery, package: str) -> None:
await cb.answer(f"{package} удалён")
# --- Menu ---
@dp.callback_query(PayloadEqRule("command/menu"))
async def menu_handler(cb: CallbackQuery):
await send_menu(cb=cb)
......@@ -304,29 +329,3 @@ 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}: {days} в {time_str}\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
)
)
......@@ -9,9 +9,16 @@ from modules import scheduler
from altrepo.api.errors import TooManyRequests
from database.models import (
db, Maintainer, User, Package, ScheduledTask,
WatchList, ChatSubscription,
db,
Maintainer,
User,
Package,
UserSchedule,
UserMailing,
WatchList,
ChatSubscription,
)
from database.func import DB
from middlewares import UserMiddleware
from services.test_api_version import test_api_version
......@@ -25,12 +32,44 @@ bot.dispatch.load_from_dir("src/handlers")
bot.on.message.register_middleware(UserMiddleware)
def _migrate_scheduled_tasks():
if not db.table_exists("scheduled_tasks"):
return
logger.info("Migrating scheduled_tasks to new schema")
cursor = db.execute_sql(
"SELECT user_id, task_type, days, send_time FROM scheduled_tasks"
)
for user_id, task_type, days, send_time in cursor.fetchall():
user = DB.user.get(user_id)
if not user:
continue
DB.mailing.enable(user, task_type)
if days:
from datetime import time as dtime
parts = send_time.split(":")
DB.schedule.set(user, days, dtime(int(parts[0]), int(parts[1])))
db.execute_sql("DROP TABLE scheduled_tasks")
logger.info("Migration complete, old table dropped")
@bot.loop_wrapper.lifespan.on_startup
async def startup():
db.create_tables([
Maintainer, User, Package, ScheduledTask,
WatchList, ChatSubscription,
])
db.create_tables(
[
Maintainer,
User,
Package,
UserSchedule,
UserMailing,
WatchList,
ChatSubscription,
]
)
_migrate_scheduled_tasks()
logger.info("initializing ALTRepo")
await altrepo.init()
......@@ -41,14 +80,8 @@ async def startup():
start_task_events_listener()
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", "Отслеживание по пакетам"),
......
......@@ -9,44 +9,52 @@ from services.bugs import bugs
from services.pkg_watch import pkg_watch
MAILING_HANDLERS = {
"watch": lambda user: watch(user),
"bugs": lambda user: bugs(user),
"pkg_watch": lambda user: pkg_watch(
DB.watch_list.get_list(user.user_id), user.user_id
),
}
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)
)
# --- User schedule ---
async def set_schedule(self, user, days: str, send_time: str):
h, m = map(int, send_time.split(":"))
DB.schedule.set(user, 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)
async def update_schedule(self, user, **kwargs):
DB.schedule.update(user, **kwargs)
await self.reload_tasks()
async def remove_task(self, user_id: int, task_type: str):
DB.scheduler.delete(
user_id, task_type
)
def get_schedule(self, user):
return DB.schedule.get(user)
# --- Mailing toggles ---
async def enable_mailing(self, user, mailing_type: str):
DB.mailing.enable(user, mailing_type)
DB.schedule.ensure_exists(user)
await self.reload_tasks()
async def disable_mailing(self, user, mailing_type: str):
DB.mailing.disable(user, mailing_type)
await self.reload_tasks()
def get_task(self, user_id: int, task_type: str):
return DB.scheduler.get(
user_id, task_type
)
def is_mailing_enabled(self, user, mailing_type: str) -> bool:
return DB.mailing.is_enabled(user, mailing_type)
# --- Chat subscription methods ---
# --- Chat subscription ---
async def add_chat_task(self, chat_id: int, task_type: str, days: str, time: str):
h, m = map(int, time.split(":"))
......@@ -69,12 +77,12 @@ class CustomScheduler:
async def reload_tasks(self):
self.scheduler.remove_all_jobs()
for task in DB.scheduler.all():
if not task.days:
for schedule in DB.schedule.all_with_mailings():
if not schedule.days:
continue
hour = task.send_time.hour
minute = task.send_time.minute
for day in task.days.split(","):
hour = schedule.send_time.hour
minute = schedule.send_time.minute
for day in schedule.days.split(","):
self.scheduler.add_job(
self._job_runner,
CronTrigger(
......@@ -82,11 +90,13 @@ class CustomScheduler:
hour=int(hour),
minute=int(minute)
),
args=[task.id],
id=f"{task.user.id}_{task.task_type}_{day}"
args=[schedule.user.user_id],
id=f"user_{schedule.user.user_id}_{day}"
)
for sub in DB.chat_subscription.all():
if not sub.days:
continue
hour = sub.send_time.hour
minute = sub.send_time.minute
for day in sub.days.split(","):
......@@ -101,20 +111,16 @@ class CustomScheduler:
id=f"chat_{sub.chat_id}_{sub.task_type}_{day}"
)
async def _job_runner(self, task_id: int):
"""Вызывается планировщиком для пользовательских задач"""
task = DB.scheduler.get_by_id(task_id)
if not task:
async def _job_runner(self, user_id: int):
user = DB.user.get(user_id)
if not user:
return
match task.task_type:
case "watch": await watch(task.user)
case "bugs": await bugs(task.user)
case "pkg_watch":
packages = DB.watch_list.get_list(task.user.user_id)
await pkg_watch(packages, task.user.user_id)
for mailing_type in DB.mailing.get_enabled(user):
handler = MAILING_HANDLERS.get(mailing_type)
if handler:
await handler(user)
async def _chat_job_runner(self, sub_id: int):
"""Вызывается планировщиком для чат-подписок"""
sub = DB.chat_subscription.get_by_id(sub_id)
if not sub:
return
......
......@@ -37,7 +37,7 @@ async def _listen():
users = DB.user.get_by_maintainer(data.owner)
for user in users:
if not DB.scheduler.get(user, "task_events"):
if not DB.mailing.is_enabled(user, "task_events"):
continue
try:
await tg_api.send_message(chat_id=user.user_id, text=message)
......
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