diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..da78a4b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +__pycache__ +.venv +.env +*.db +# Ignore dynaconf secret files +.secrets.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..561a0e7 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md index 823d545..22574ca 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,32 @@ -# Бот для записи на курсы и события от школы фасциопатии +# Бот для записи на курсы и мероприятия от школы фасциопатии ## Текущие возможности **Администраторы могут:** - [x] Редактировать свой профиль -- [x] Создавать события +- [x] Создавать мероприятия - (указав дату, время, название, описание) -- [ ] Редактировать события - - Редактировать отдельно любую из характеристик события -- [ ] Удалять события -- [x] Просматривать список существующих событий -- [ ] Просматривать людей записавшихся на событие +- [ ] Редактировать мероприятия + - Редактировать отдельно любую из характеристик мероприятия +- [ ] Удалять мероприятия +- [x] Просматривать список существующих мероприятий +- [ ] Просматривать людей записавшихся на мероприятие --- **Пользователи могут:** - [x] Редактировать свой профиль -- [x] Просматривать список событий -- [x] Просматривать информацию о событии -- [ ] Регистрироваться на событие -- [ ] Отменять регистрацию на событие +- [x] Просматривать список мероприятий +- [x] Просматривать информацию о мероприятии +- [ ] Регистрироваться на мероприятие +- [ ] Отменять регистрацию на мероприятие --- ## Планируемые возможности -- Прямая ссылка на событие извне бота +- Прямая ссылка на мероприятие извне бота - Подтверждение регистрации администратором -- Число записавшихся на событие пользователей у администратора -- Уведомление пользователям о изменении события, на которые они подписаны -- Значок о регистрации на событие в списке событий у пользователя -- Выгружать людей записавшихся на событие в виде Excel файла \ No newline at end of file +- Число записавшихся на мероприятие пользователей у администратора +- Уведомление пользователям о изменении мероприятия, на которые они подписаны +- Значок о регистрации на мероприятие в списке мероприятий у пользователя +- Выгружать людей записавшихся на мероприятие в виде Excel файла \ No newline at end of file diff --git a/app/bot/dialogs/flows/events/dialogs.py b/app/bot/dialogs/flows/events/dialogs.py index e4cfa15..6a022ec 100644 --- a/app/bot/dialogs/flows/events/dialogs.py +++ b/app/bot/dialogs/flows/events/dialogs.py @@ -1,16 +1,16 @@ from aiogram_dialog import Dialog, Window -from aiogram_dialog.widgets.kbd import Back, Cancel, Column, Select -from aiogram_dialog.widgets.text import Const, Format, Jinja +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Column, Select +from aiogram_dialog.widgets.text import Case, Const, Format, Jinja from app.bot.dialogs.templates import event_template -from .getters import event_getter, events_list_getter -from .handlers import on_event_selected +from .getters import event_getter, events_list_getter, registration_getter +from .handlers import change_registration, on_event_selected from .states import EventsSG events_dialog = Dialog( Window( - Const("События"), + Const("Мероприятия"), Column( Cancel(Const("Назад")), Select( @@ -26,8 +26,16 @@ events_dialog = Dialog( ), Window( Jinja(event_template), + Button( + Case( + [Const("зарегистрироваться"), Const("отменить регистрацию")], + selector="is_registered_to_event", + ), + id="change_registration_btn", + on_click=change_registration, + ), Back(Const("Назад")), - getter=event_getter, + getter=[event_getter, registration_getter], parse_mode="HTML", state=EventsSG.event, ), diff --git a/app/bot/dialogs/flows/events/getters.py b/app/bot/dialogs/flows/events/getters.py index cac1516..7be4163 100644 --- a/app/bot/dialogs/flows/events/getters.py +++ b/app/bot/dialogs/flows/events/getters.py @@ -1,7 +1,12 @@ from aiogram.types import User from aiogram_dialog import DialogManager -from app.infrastructure.database.crud import get_event_by_id, get_events_list +from app.infrastructure.database.crud import ( + get_event_by_id, + get_events_list, +) +from app.infrastructure.database.enums import UserEventStatus +from app.infrastructure.database.models import Event async def events_list_getter( @@ -11,12 +16,21 @@ async def events_list_getter( return {"events": [{"title": event.title, "id": event.id} for event in events]} -async def event_getter( - dialog_manager: DialogManager, **kwargs -) -> dict[str, str]: +async def event_getter(dialog_manager: DialogManager, **kwargs) -> dict[str, Event]: return { "event_obj": await get_event_by_id( 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 ) } diff --git a/app/bot/dialogs/flows/events/handlers.py b/app/bot/dialogs/flows/events/handlers.py index 7d78bd1..6eac0dc 100644 --- a/app/bot/dialogs/flows/events/handlers.py +++ b/app/bot/dialogs/flows/events/handlers.py @@ -1,6 +1,13 @@ from aiogram.types import CallbackQuery 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( @@ -9,5 +16,26 @@ async def on_event_selected( manager: DialogManager, item_id: str, ): - manager.dialog_data["selected_event"] = item_id + manager.dialog_data["selected_event"] = int(item_id) await manager.next() + + +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 + ) diff --git a/app/bot/dialogs/flows/start/dialogs.py b/app/bot/dialogs/flows/start/dialogs.py index ada6ca1..3abf0fe 100644 --- a/app/bot/dialogs/flows/start/dialogs.py +++ b/app/bot/dialogs/flows/start/dialogs.py @@ -12,9 +12,9 @@ from .states import StartSG start_dialog = Dialog( Window( Format("Привет, {user.fullname}"), - Start(Const("📃 события"), id="events", state=EventsSG.events_list), + Start(Const("📃 мероприятия"), id="events", state=EventsSG.events_list), Start( - Const("✏️ создать событие"), + Const("✏️ создать мероприятие"), id="create_event", state=NewEventSG.input_title, when="is_admin", diff --git a/app/bot/middlewares/get_user.py b/app/bot/middlewares/get_user.py index fdddc1a..e6807ef 100644 --- a/app/bot/middlewares/get_user.py +++ b/app/bot/middlewares/get_user.py @@ -20,7 +20,7 @@ class GetUserMiddleware(BaseMiddleware): user = await create_user( session, tg_user.id, - tg_user.username, + tg_user.full_name, ) data["user"] = user return await handler(event, data) diff --git a/app/infrastructure/database/crud.py b/app/infrastructure/database/crud.py index a65cbcf..f7f07a3 100644 --- a/app/infrastructure/database/crud.py +++ b/app/infrastructure/database/crud.py @@ -2,12 +2,16 @@ from datetime import datetime from sqlalchemy import select 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: - 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() @@ -94,4 +98,33 @@ async def update_event( if end_date: event.end_date = end_date await session.commit() - return event \ No newline at end of file + 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() diff --git a/app/infrastructure/database/enums.py b/app/infrastructure/database/enums.py index 104996c..54d73e9 100644 --- a/app/infrastructure/database/enums.py +++ b/app/infrastructure/database/enums.py @@ -7,7 +7,7 @@ class UserRole(Enum): TEACHER = "teacher" -class EventUserStatus(Enum): +class UserEventStatus(Enum): NOT_CONFIRMED = "not confirmed" CONFIRMED = "confirmed" CANCELLED = "cancelled" diff --git a/app/infrastructure/database/models.py b/app/infrastructure/database/models.py index 0d548d1..5c135c4 100644 --- a/app/infrastructure/database/models.py +++ b/app/infrastructure/database/models.py @@ -3,7 +3,7 @@ import datetime as dt from sqlalchemy import BigInteger, DateTime, ForeignKey, UniqueConstraint, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship -from .enums import EventUserStatus, UserRole +from .enums import UserEventStatus, UserRole class Base(DeclarativeBase): @@ -45,7 +45,7 @@ class UserEvent(Base): date: Mapped[dt.datetime] = mapped_column( 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") event_rel = relationship("Event", back_populates="user_events") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e3fd822 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/ui.md b/ui.md deleted file mode 100644 index 487d471..0000000 --- a/ui.md +++ /dev/null @@ -1,30 +0,0 @@ - Пользователь -### Приветствие -Здрвствуйте, {Username}\ -Вот текущие события - -- ✅ Видим руками уровень 1 -- Видим руками уровень 2 -- Видим руками уровень 3 - ---- -### Информация о событии - -**Видим руками уровень 1** -На этом курсе вы научитесь... - -[Вы записаны] / [Вы не записаны] -- Записаться -- Отменить запись -- Назад ---- -## Регистарация в системе - -### Регистрация шаг 1/2 -- Укажите свой номер телефона - -### Регистрация шаг 2/2 -- Укажите свое ФИО - -## Редактировать профиль -// Запускаем регистрацию заново \ No newline at end of file