Compare commits

...

8 Commits

Author SHA1 Message Date
279e0c6a70 Обновить README.md 2025-12-14 10:22:18 +00:00
5cf9455f76 add logging to mailing error 2025-11-14 14:42:25 +03:00
da3af83b3a mailig skip blocked users 2025-11-14 14:28:18 +03:00
1be3401d09 fix after merge 2025-11-14 14:14:15 +03:00
343dc8fb5f Merge branch 'main' of https://git.shevkunov.space/FascioSchool/FascioSchoolBot 2025-11-14 13:49:37 +03:00
93a425aea9 add mailing function 2025-11-14 13:46:20 +03:00
9b9e81c61d fix typehint: date -> str 2025-11-14 12:51:42 +03:00
27249be467 fix docker-compose 2025-10-03 17:33:09 +03:00
17 changed files with 313 additions and 15 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
__pycache__
.venv
.env
*.db
# Ignore dynaconf secret files
.secrets.*

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# An example of using standalone Python builds with multistage images.
# First, build the application in the `/app` directory
FROM ghcr.io/astral-sh/uv:bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
# Configure the Python directory so it is consistent
ENV UV_PYTHON_INSTALL_DIR=/python
# Only use the managed Python version
ENV UV_PYTHON_PREFERENCE=only-managed
# Install Python before the project for caching
RUN uv python install 3.13
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# Then, use a final image without uv
FROM debian:bookworm-slim
# Setup a non-root user
RUN groupadd --system --gid 999 nonroot \
&& useradd --system --gid 999 --uid 999 --create-home nonroot
# Copy the Python version
COPY --from=builder --chown=python:python /python /python
# Copy the application from the builder
COPY --from=builder --chown=nonroot:nonroot /app /app
# Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"
ENV ENV_FOR_DYNACONF=production
ENV BOT_TOKEN=
ENV DATABASE_URL=
# Use the non-root user to run our application
USER nonroot
# Use `/app` as the working directory
WORKDIR /app
# Run the FastAPI application by default
CMD ["python", "-m", "app"]

View File

@@ -4,6 +4,7 @@
**Администраторы могут:** **Администраторы могут:**
- [x] Редактировать свой профиль - [x] Редактировать свой профиль
- [x] Создавать мероприятия
- [x] Создавать мероприятия - [x] Создавать мероприятия
- (указав дату, время, название, описание) - (указав дату, время, название, описание)
- [ ] Редактировать мероприятия - [ ] Редактировать мероприятия
@@ -11,6 +12,9 @@
- [ ] Удалять мероприятия - [ ] Удалять мероприятия
- [x] Просматривать список существующих мероприятий - [x] Просматривать список существующих мероприятий
- [ ] Просматривать людей записавшихся на мероприятие - [ ] Просматривать людей записавшихся на мероприятие
- [x] Делать рассылку по пользователям
- [x] Исключать из рассылки администраторов
- [ ] Указать дату и время рассылки
--- ---
@@ -20,13 +24,22 @@
- [x] Просматривать информацию о мероприятии - [x] Просматривать информацию о мероприятии
- [ ] Регистрироваться на мероприятие - [ ] Регистрироваться на мероприятие
- [ ] Отменять регистрацию на мероприятие - [ ] Отменять регистрацию на мероприятие
- [x] Просматривать список мероприятий
- [x] Просматривать информацию о мероприятии
- [ ] Регистрироваться на мероприятие
- [ ] Отменять регистрацию на мероприятие
--- ---
## Планируемые возможности ## Планируемые возможности
- Прямая ссылка на мероприятие извне бота - Прямая ссылка на мероприятие извне бота
- Прямая ссылка на мероприятие извне бота
- Подтверждение регистрации администратором - Подтверждение регистрации администратором
- Число записавшихся на мероприятие пользователей у администратора - Число записавшихся на мероприятие пользователей у администратора
- Уведомление пользователям о изменении мероприятия, на которые они подписаны - Уведомление пользователям о изменении мероприятия, на которые они подписаны
- Значок о регистрации на мероприятие в списке мероприятий у пользователя - Значок о регистрации на мероприятие в списке мероприятий у пользователя
- Выгружать людей записавшихся на мероприятие в виде Excel файла
- Число записавшихся на мероприятие пользователей у администратора
- Уведомление пользователям о изменении мероприятия, на которые они подписаны
- Значок о регистрации на мероприятие в списке мероприятий у пользователя
- Выгружать людей записавшихся на мероприятие в виде Excel файла - Выгружать людей записавшихся на мероприятие в виде Excel файла

View File

@@ -1,6 +1,7 @@
from aiogram import Router from aiogram import Router
from .events.dialogs import events_dialog from .events.dialogs import events_dialog
from .mailing.dialogs import mailing_dialog
from .new_event.dialogs import new_event_dialog from .new_event.dialogs import new_event_dialog
from .profile.dialogs import profile_dialog from .profile.dialogs import profile_dialog
from .start.dialogs import start_dialog from .start.dialogs import start_dialog
@@ -10,3 +11,4 @@ dialogs_router.include_router(start_dialog)
dialogs_router.include_router(events_dialog) dialogs_router.include_router(events_dialog)
dialogs_router.include_router(profile_dialog) dialogs_router.include_router(profile_dialog)
dialogs_router.include_router(new_event_dialog) dialogs_router.include_router(new_event_dialog)
dialogs_router.include_router(mailing_dialog)

View File

@@ -4,7 +4,7 @@ from aiogram_dialog.widgets.text import Case, Const, Format, Jinja
from app.bot.dialogs.templates import event_template from app.bot.dialogs.templates import event_template
from .getters import event_getter, events_list_getter from .getters import event_getter, events_list_getter, registration_getter
from .handlers import change_registration, on_event_selected from .handlers import change_registration, on_event_selected
from .states import EventsSG from .states import EventsSG
@@ -31,10 +31,11 @@ events_dialog = Dialog(
[Const("зарегистрироваться"), Const("отменить регистрацию")], [Const("зарегистрироваться"), Const("отменить регистрацию")],
selector="is_registered_to_event", selector="is_registered_to_event",
), ),
id="change_registration_btn",
on_click=change_registration, on_click=change_registration,
), ),
Back(Const("Назад")), Back(Const("Назад")),
getter=event_getter, getter=[event_getter, registration_getter],
parse_mode="HTML", parse_mode="HTML",
state=EventsSG.event, state=EventsSG.event,
), ),

View File

@@ -5,6 +5,7 @@ from app.infrastructure.database.crud import (
get_event_by_id, get_event_by_id,
get_events_list, get_events_list,
) )
from app.infrastructure.database.enums import UserEventStatus
from app.infrastructure.database.models import Event from app.infrastructure.database.models import Event
@@ -19,7 +20,18 @@ async def event_getter(dialog_manager: DialogManager, **kwargs) -> dict[str, Eve
return { return {
"event_obj": await get_event_by_id( "event_obj": await get_event_by_id(
dialog_manager.middleware_data["session"], dialog_manager.middleware_data["session"],
int(dialog_manager.dialog_data["selected_event"]), dialog_manager.dialog_data["selected_event"],
)
}
async def registration_getter(dialog_manager: DialogManager, **kwargs):
user = dialog_manager.middleware_data["user"]
event_id = dialog_manager.dialog_data["selected_event"]
return {
"is_registered_to_event": any(
(ue.event_id == event_id) and (ue.status != UserEventStatus.CANCELLED.value)
for ue in user.events
) )
} }

View File

@@ -1,6 +1,13 @@
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from aiogram_dialog import DialogManager from aiogram_dialog import DialogManager
from aiogram_dialog.widgets.kbd import Select from aiogram_dialog.widgets.kbd import Button, Select
from app.infrastructure.database.crud import (
register_user_to_event,
unregister_user_to_event,
)
from .getters import registration_getter
async def on_event_selected( async def on_event_selected(
@@ -9,8 +16,26 @@ async def on_event_selected(
manager: DialogManager, manager: DialogManager,
item_id: str, item_id: str,
): ):
manager.dialog_data["selected_event"] = item_id manager.dialog_data["selected_event"] = int(item_id)
await manager.next() await manager.next()
async def change_registration():
... async def change_registration(
callback: CallbackQuery,
widget: Button,
manager: DialogManager,
**kwargs,
):
user = manager.middleware_data["user"]
event_id = manager.dialog_data["selected_event"]
if (await registration_getter(manager))["is_registered_to_event"]:
await unregister_user_to_event(
manager.middleware_data["session"], user.id, event_id
)
else:
if not user.phone:
await callback.answer("Пожалуйста, заполните ваш номер телефона в профиле", show_alert=True)
else:
await register_user_to_event(
manager.middleware_data["session"], user.id, event_id
)

View File

@@ -0,0 +1,43 @@
from aiogram_dialog import Dialog, Window
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import Back, Button, Cancel
from aiogram_dialog.widgets.text import Const
# from .getters import event_getter, events_list_getter, registration_getter
from .handlers import choose_recipients, confirm_mailing, message_data
from .states import MailingSG
mailing_dialog = Dialog(
Window(
Const("Пришлите сообщение которое хотите разослать"),
MessageInput(message_data),
Cancel(Const("Отмена")),
state=MailingSG.message_data,
),
Window(
Const("Кому отправить сообщение?"),
Button(
Const("Всем"),
id="send_all",
on_click=choose_recipients,
),
Button(
Const("Не админам"),
id="send_users",
on_click=choose_recipients,
),
Back(Const("Назад")),
state=MailingSG.recipients,
),
Window(
Const("Начать рассылку?"),
Button(
Const("Разослать"),
id="start_mailing",
on_click=confirm_mailing,
),
Back(Const("Назад")),
Cancel(Const("Отмена")),
state=MailingSG.confirm,
),
)

View File

@@ -0,0 +1,77 @@
import asyncio
from aiogram.types import CallbackQuery, Message
from aiogram_dialog import DialogManager
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import Button
from structlog import get_logger
from app.infrastructure.database.crud import get_users
logger = get_logger()
async def message_data(
message: Message,
widget: MessageInput,
manager: DialogManager,
):
manager.dialog_data["message"] = message
await manager.next()
async def choose_recipients(
callback: CallbackQuery,
button: Button,
manager: DialogManager,
):
manager.dialog_data["recipients"] = button.widget_id
await manager.next()
async def confirm_mailing(
callback: CallbackQuery,
button: Button,
manager: DialogManager,
):
print(manager.dialog_data["recipients"])
users = list(await get_users(
manager.middleware_data["session"],
exclude_admins=manager.dialog_data["recipients"] == "send_users",
))
message = manager.dialog_data["message"]
source_chat_id = message.chat.id
source_message_id = message.message_id
success_count = 0
# Создаём задачу с обработкой ошибок для каждого пользователя
async def send_to_user(user):
nonlocal success_count
try:
await callback.bot.copy_message(
chat_id=user.tg_id,
from_chat_id=source_chat_id,
message_id=source_message_id,
)
success_count += 1
except Exception as e:
logger.warning(
"не удалось отправить сообщение",
user_id=user.tg_id,
error=str(e),
exc_info=True, # добавит traceback
)
pass
await asyncio.gather(*[send_to_user(user) for user in users])
await callback.bot.send_message(
chat_id=callback.from_user.id,
text=f"Рассылка завершена.\nУспешно отправлено: {success_count} из {len(users)}",
)
await manager.done()

View File

@@ -0,0 +1,8 @@
from aiogram.fsm.state import State, StatesGroup
class MailingSG(StatesGroup):
message_data = State()
recipients = State()
# mailing_date = State()
confirm = State()

View File

@@ -12,7 +12,7 @@ async def input_title(
callback: CallbackQuery, callback: CallbackQuery,
widget: ManagedTextInput, widget: ManagedTextInput,
manager: DialogManager, manager: DialogManager,
text: date, text: str,
): ):
if "event_obj" not in manager.dialog_data: if "event_obj" not in manager.dialog_data:
manager.dialog_data["event_obj"] = {} manager.dialog_data["event_obj"] = {}
@@ -24,7 +24,7 @@ async def input_description(
callback: CallbackQuery, callback: CallbackQuery,
widget: ManagedTextInput, widget: ManagedTextInput,
manager: DialogManager, manager: DialogManager,
text: date, text: str,
): ):
manager.dialog_data["event_obj"]["description"] = text manager.dialog_data["event_obj"]["description"] = text
await manager.next() await manager.next()

View File

@@ -3,6 +3,7 @@ from aiogram_dialog.widgets.kbd import Button, Start
from aiogram_dialog.widgets.text import Const, Format from aiogram_dialog.widgets.text import Const, Format
from app.bot.dialogs.flows.events.dialogs import EventsSG from app.bot.dialogs.flows.events.dialogs import EventsSG
from app.bot.dialogs.flows.mailing.dialogs import MailingSG
from app.bot.dialogs.flows.new_event.dialogs import NewEventSG from app.bot.dialogs.flows.new_event.dialogs import NewEventSG
from app.bot.dialogs.flows.profile.dialogs import ProfileSG from app.bot.dialogs.flows.profile.dialogs import ProfileSG
from app.bot.dialogs.widgets.getters import is_admin_getter, user_getter from app.bot.dialogs.widgets.getters import is_admin_getter, user_getter
@@ -19,6 +20,12 @@ start_dialog = Dialog(
state=NewEventSG.input_title, state=NewEventSG.input_title,
when="is_admin", when="is_admin",
), ),
Start(
Const("📧 разослать сообщение"),
id="create_mailing",
state=MailingSG.message_data,
when="is_admin",
),
Start(Const("👤 профиль"), id="profile", state=ProfileSG.profile), Start(Const("👤 профиль"), id="profile", state=ProfileSG.profile),
getter=[user_getter, is_admin_getter], getter=[user_getter, is_admin_getter],
state=StartSG.start, state=StartSG.start,

View File

@@ -20,7 +20,7 @@ class GetUserMiddleware(BaseMiddleware):
user = await create_user( user = await create_user(
session, session,
tg_user.id, tg_user.id,
tg_user.username, tg_user.full_name,
) )
data["user"] = user data["user"] = user
return await handler(event, data) return await handler(event, data)

View File

@@ -2,12 +2,16 @@ from datetime import datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from .models import Event, User from .enums import UserEventStatus
from .models import Event, User, UserEvent
async def get_user_by_tg_id(session: AsyncSession, user_tg_id: int) -> User | None: async def get_user_by_tg_id(session: AsyncSession, user_tg_id: int) -> User | None:
result = await session.execute(select(User).where(User.tg_id == user_tg_id)) result = await session.execute(
select(User).options(selectinload(User.events)).where(User.tg_id == user_tg_id)
)
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -37,6 +41,11 @@ async def update_user(
return user return user
async def get_users(session: AsyncSession, exclude_admins=False) -> list[User]:
query = select(User).where(User.role == "user") if exclude_admins else select(User)
return await session.scalars(query)
async def get_events_list(session: AsyncSession) -> list[Event]: async def get_events_list(session: AsyncSession) -> list[Event]:
result = await session.execute( result = await session.execute(
select(Event).where(Event.start_date >= datetime.now()) select(Event).where(Event.start_date >= datetime.now())
@@ -95,3 +104,32 @@ async def update_event(
event.end_date = end_date event.end_date = end_date
await session.commit() await session.commit()
return event return event
async def register_user_to_event(session: AsyncSession, user_id: int, event_id: int):
user_event = (
await session.execute(
select(UserEvent).where(
(UserEvent.user_id == user_id) & (UserEvent.event_id == event_id)
)
)
).scalar_one_or_none()
if user_event is None:
user_event = UserEvent(user_id=user_id, event_id=event_id)
session.add(user_event)
await session.flush()
if user_event.status == UserEventStatus.CANCELLED.value:
user_event.status = UserEventStatus.NOT_CONFIRMED.value
await session.commit()
async def unregister_user_to_event(session: AsyncSession, user_id: int, event_id: int):
user_event = (
await session.execute(
select(UserEvent).where(
(UserEvent.user_id == user_id) & (UserEvent.event_id == event_id)
)
)
).scalar_one()
user_event.status = UserEventStatus.CANCELLED.value
await session.commit()

View File

@@ -7,7 +7,7 @@ class UserRole(Enum):
TEACHER = "teacher" TEACHER = "teacher"
class EventUserStatus(Enum): class UserEventStatus(Enum):
NOT_CONFIRMED = "not confirmed" NOT_CONFIRMED = "not confirmed"
CONFIRMED = "confirmed" CONFIRMED = "confirmed"
CANCELLED = "cancelled" CANCELLED = "cancelled"

View File

@@ -3,7 +3,7 @@ import datetime as dt
from sqlalchemy import BigInteger, DateTime, ForeignKey, UniqueConstraint, func from sqlalchemy import BigInteger, DateTime, ForeignKey, UniqueConstraint, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from .enums import EventUserStatus, UserRole from .enums import UserEventStatus, UserRole
class Base(DeclarativeBase): class Base(DeclarativeBase):
@@ -45,7 +45,7 @@ class UserEvent(Base):
date: Mapped[dt.datetime] = mapped_column( date: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )
status: Mapped[str] = mapped_column(default=EventUserStatus.NOT_CONFIRMED.value) status: Mapped[str] = mapped_column(default=UserEventStatus.NOT_CONFIRMED.value)
user_rel = relationship("User", back_populates="events") user_rel = relationship("User", back_populates="events")
event_rel = relationship("Event", back_populates="user_events") event_rel = relationship("Event", back_populates="user_events")

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
version: "3.9"
services:
fascioschool-bot:
image: git.shevkunov.space/fascioschool/fascioschool-bot:latest
environment:
BOT_TOKENENV_FOR_DYNACONF: production
DATABASE_URL: postgresql+psycopg://admin:password@fascioschool-db:5432/fascioschool
BOT_TOKEN:
networks:
- fascioschool-net
networks:
fascioschool-net:
external: true