diff --git a/src/bot/plugins/week_routine.py b/src/bot/plugins/week_routine.py index a1c4c64..ee3edd2 100644 --- a/src/bot/plugins/week_routine.py +++ b/src/bot/plugins/week_routine.py @@ -1,4 +1,5 @@ import re +from typing import Any from apscheduler.schedulers.asyncio import AsyncIOScheduler from dependency_injector.wiring import Provide, inject @@ -8,6 +9,7 @@ from src.bot.schemas import Actions, Attachment, Context, Integration from src.bot.services.matching import MatchingService from src.bot.services.notify_service import NotifyService +from src.core.db.models import MatchReviewAnswerEnum from src.depends import Container from src.endpoints import Endpoints @@ -17,6 +19,8 @@ DAY_OF_WEEK_FRIDAY = "fri" SUNDAY_TIME_SENDING_MESSAGE = 11 DAY_OF_WEEK_SUNDAY = "sun" +WEDNESDAY_TIME_SENDING_MESSAGE = 11 +DAY_OF_WEEK_WEDNESDAY = "wed" class WeekRoutine(Plugin): @@ -65,14 +69,15 @@ def on_start( matching_service: MatchingService = Provide[Container.matching_service], scheduler: AsyncIOScheduler = Provide[Container.scheduler], ) -> None: - attachments = self.direct_friday_message() + friday_attachments = self.direct_friday_message() + wednesday_attachments = self.direct_wednesday_message() scheduler.add_job( notify_service.notify_all_users, "cron", day_of_week=DAY_OF_WEEK_FRIDAY, hour=FRIDAY_TIME_SENDING_MESSAGE, - kwargs=dict(plugin=self, attachments=attachments, title="Еженедельный пятничный опрос"), + kwargs=dict(plugin=self, attachments=friday_attachments, title="Еженедельный пятничный опрос"), ) scheduler.add_job( matching_service.run_matching, @@ -87,6 +92,13 @@ def on_start( hour=MONDAY_TIME_SENDING_MESSAGE, kwargs=dict(plugin=self), ) + scheduler.add_job( + notify_service.match_review_notifications, + "cron", + day_of_week=DAY_OF_WEEK_WEDNESDAY, + hour=WEDNESDAY_TIME_SENDING_MESSAGE, + kwargs=dict(plugin=self, attachments=wednesday_attachments), + ) scheduler.start() @listen_to("/stop_jobs", re.IGNORECASE) @@ -122,3 +134,81 @@ async def no(self, event: ActionEvent) -> None: "update": {"message": "На следующей неделе отправлю новое предложение.", "props": {}}, }, ) + + @inject + def direct_wednesday_message(self, endpoints: Endpoints = Provide[Container.endpoints]) -> Attachment: + action_yes = Actions( + id="yes", + name="Да", + type="button", + integration=Integration(url=endpoints.answer_yes, context=Context(action="yes")), + ) + + action_no = Actions( + id="No", + name="Нет", + type="button", + integration=Integration(url=endpoints.answer_no, context=Context(action="no")), + ) + + every_wednesday_message = Attachment(text="Удалось ли вам встретиться?", actions=[action_yes, action_no]) + return every_wednesday_message + + @listen_webhook("match_review_answer_yes") + async def answer_yes( + self, + event: ActionEvent, + ) -> None: + await self._save_user_answer(event.user_id, MatchReviewAnswerEnum.IS_COMPLETE) + self.driver.respond_to_web( + event, + { + "update": { + "message": "Поделитесь итогами вашей встречи в канале " + '"Coffe на этой неделе", отправьте фото и ' + "краткие эмоции, чтобы мотивировать других " + "поучаствовать в Random Coffee!", + "props": {}, + }, + }, + ) + + @listen_webhook("match_review_answer_no") + async def answer_no( + self, + event: ActionEvent, + ) -> None: + await self._save_user_answer(event.user_id, MatchReviewAnswerEnum.IS_NOT_COMPLETE) + user_nickname = await self._get_pair_nickname(event.user_id) + self.driver.respond_to_web( + event, + { + "update": { + "message": f"Неделя скоро закончится, не забудь " + f"познакомиться с новым человеком и провести " + f"время за классным разговором, напиши " + f"{user_nickname} точно ждёт вашей встречи", + "props": {}, + }, + }, + ) + + @inject + async def _save_user_answer( + self, user_id: str, answer: str, notify_service: NotifyService = Provide[Container.week_routine_service,] + ) -> None: + await notify_service.set_match_review_answer(user_id, answer) + + @inject + async def _get_pair_nickname( + self, user_id: str, matching_service: MatchingService = Provide[Container.matching_service,] + ) -> Any: + return await matching_service.get_match_pair_nickname(user_id) + + @listen_to("/wednesday_message", re.IGNORECASE) + @inject + async def test_wednesday_message( + self, message: str, notify_service: NotifyService = Provide[Container.week_routine_service,] + ) -> None: + attachments = self.direct_wednesday_message() + await notify_service.match_review_notifications(plugin=self, attachments=attachments) diff --git a/src/bot/services/matching.py b/src/bot/services/matching.py index cb5f8b1..e6bcce9 100644 --- a/src/bot/services/matching.py +++ b/src/bot/services/matching.py @@ -1,4 +1,4 @@ -from typing import Sequence +from typing import Any, Sequence import structlog @@ -38,3 +38,13 @@ async def run_closing_meetings(self) -> Sequence[UsersMatch]: user.status = StatusEnum.NOT_INVOLVED await self._user_repository.update(user.id, user) return await self._match_repository.closing_meetings() + + async def get_match_pair_nickname(self, user_id: str) -> Any: + """Возвращает никнейм второго пользователя + по user_id первого пользователя""" + match = await self._match_repository.get_by_user_id(user_id) + if user_id == match.object_user_one.user_id: + user = match.object_user_two + elif user_id == match.object_user_two.user_id: + user = match.object_user_one + return user.username diff --git a/src/bot/services/notify_service.py b/src/bot/services/notify_service.py index efd96ab..6e55b03 100644 --- a/src/bot/services/notify_service.py +++ b/src/bot/services/notify_service.py @@ -4,6 +4,7 @@ from src.bot.schemas import Attachment from src.core.db.models import MatchStatusEnum, User +from src.core.db.repository.match_review import MatchReviewRepository from src.core.db.repository.user import UserRepository from src.core.db.repository.usersmatch import UsersMatchRepository @@ -11,9 +12,15 @@ class NotifyService: - def __init__(self, user_repository: UserRepository, match_repository: UsersMatchRepository) -> None: + def __init__( + self, + user_repository: UserRepository, + match_repository: UsersMatchRepository, + match_review_repository: MatchReviewRepository, + ) -> None: self._user_repository = user_repository self._match_repository = match_repository + self._match_review_repository = match_review_repository async def notify_all_users( self, plugin: Plugin, attachments: Attachment, title: str = "Еженедельный опрос" @@ -47,3 +54,23 @@ async def meeting_notifications(self, plugin: Plugin) -> None: ) except InvalidOrMissingParameters as error: logger.error(str(error)) + + async def set_match_review_answer(self, user_id: str, answer: str) -> None: + match = await self._match_repository.get_by_user_id(user_id) + await self._match_review_repository.set_match_review_answer(match, user_id, answer) + + async def match_review_notifications( + self, + plugin: Plugin, + attachments: Attachment, + title: str = "Опрос по результатам встречи", + ) -> None: + for match in await self._match_repository.get_by_status(status=MatchStatusEnum.ONGOING): + pair: list[User] = [match.object_user_one, match.object_user_two] + for user_one, user_two in zip(pair, pair[::-1]): + try: + plugin.driver.direct_message( + receiver_id=user_one.user_id, message=title, props={"attachments": [attachments.model_dump()]} + ) + except InvalidOrMissingParameters as error: + logger.error(str(error)) diff --git a/src/core/db/migrations/versions/abb6775f7512_add_match_review.py b/src/core/db/migrations/versions/abb6775f7512_add_match_review.py new file mode 100644 index 0000000..998db75 --- /dev/null +++ b/src/core/db/migrations/versions/abb6775f7512_add_match_review.py @@ -0,0 +1,47 @@ +"""add match_review + +Revision ID: abb6775f7512 +Revises: 6d584f53f8cf +Create Date: 2023-12-04 20:54:36.381591 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "abb6775f7512" +down_revision = "6d584f53f8cf" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "match_review", + sa.Column("usersmatch_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "user_answer", sa.Enum("IS_COMPLETE", "IS_NOT_COMPLETE", name="matchreviewanswerenum"), nullable=True + ), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.Date(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.Date(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["usersmatch_id"], + ["usersmatch.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("match_review") + op.execute("drop type matchreviewanswerenum") + # ### end Alembic commands ### diff --git a/src/core/db/models.py b/src/core/db/models.py index 50c09c3..7bb05aa 100644 --- a/src/core/db/models.py +++ b/src/core/db/models.py @@ -1,7 +1,8 @@ from datetime import date from enum import StrEnum +from typing import Optional -from sqlalchemy import ForeignKey, String, func +from sqlalchemy import ForeignKey, Integer, String, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship SERVER_DEFAULT_TIME = func.current_timestamp() @@ -18,6 +19,11 @@ class MatchStatusEnum(StrEnum): CLOSED = "CLOSED" +class MatchReviewAnswerEnum(StrEnum): + IS_COMPLETE = "IS_COMPLETE" + IS_NOT_COMPLETE = "IS_NOT_COMPLETE" + + class Base(DeclarativeBase): """Base class for models""" @@ -58,3 +64,12 @@ class UsersMatch(Base): object_user_one = relationship("User", foreign_keys=[matched_user_one], backref="matches_as_user_one") object_user_two = relationship("User", foreign_keys=[matched_user_two], backref="matches_as_user_two") + match_review: Mapped["MatchReview"] = relationship("MatchReview", backref="usersmatch") + + +class MatchReview(Base): + __tablename__ = "match_review" + + usersmatch_id: Mapped[int] = mapped_column(Integer(), ForeignKey("usersmatch.id"), nullable=False) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + user_answer: Mapped[Optional[MatchReviewAnswerEnum]] = mapped_column(nullable=True) diff --git a/src/core/db/repository/match_review.py b/src/core/db/repository/match_review.py new file mode 100644 index 0000000..b7c014b --- /dev/null +++ b/src/core/db/repository/match_review.py @@ -0,0 +1,31 @@ +from sqlalchemy import select + +from src.core.db.models import MatchReview, UsersMatch +from src.core.db.repository.base import AbstractRepository +from src.core.exceptions.exceptions import ObjectAlreadyExistsError + + +class MatchReviewRepository(AbstractRepository[MatchReview]): + _model = MatchReview + + async def set_match_review_answer(self, match: UsersMatch, user_id: str, answer: str) -> MatchReview: + if user_id == match.object_user_one.user_id: + user = match.object_user_one + elif user_id == match.object_user_two.user_id: + user = match.object_user_two + if await self.get_match_review_if_already_exists(usersmatch_id=match.id, user_id=user.id): + raise ObjectAlreadyExistsError(self._model) # type: ignore[arg-type] + return await self.create(MatchReview(usersmatch_id=match.id, user_id=user.id, user_answer=answer)) + + async def get_match_review_if_already_exists( + self, + usersmatch_id: int, + user_id: int, + ) -> MatchReview | None: + async with self._sessionmaker() as session: + match_review = await session.scalar( + select(self._model).where( + (self._model.usersmatch_id == usersmatch_id) & (self._model.user_id == user_id) + ) + ) + return match_review diff --git a/src/core/db/repository/usersmatch.py b/src/core/db/repository/usersmatch.py index a5fe2ff..9b4045d 100644 --- a/src/core/db/repository/usersmatch.py +++ b/src/core/db/repository/usersmatch.py @@ -1,11 +1,11 @@ from typing import Sequence from sqlalchemy import select, update -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import aliased, selectinload from src.core.db.models import MatchStatusEnum, User, UsersMatch from src.core.db.repository.base import AbstractRepository -from src.core.exceptions.exceptions import ObjectAlreadyExistsError +from src.core.exceptions.exceptions import MatchNotFoundError, ObjectAlreadyExistsError, TooManyMatchesError class UsersMatchRepository(AbstractRepository[UsersMatch]): @@ -51,3 +51,25 @@ async def get_by_status(self, status: str | MatchStatusEnum) -> Sequence[UsersMa .where(self._model.status == status) ) return meetings.all() + + async def get_by_user_id(self, user_id: str) -> UsersMatch: + """Получает текущую встречу по user_id участника""" + async with self._sessionmaker() as session: + matched_user_one = aliased(User) + matched_user_two = aliased(User) + result = await session.scalars( + select(self._model) + .options(selectinload(self._model.object_user_one), selectinload(self._model.object_user_two)) + .join(matched_user_one, self._model.object_user_one) + .join(matched_user_two, self._model.object_user_two) + .where( + (self._model.status == MatchStatusEnum.ONGOING) + & ((matched_user_one.user_id == user_id) | (matched_user_two.user_id == user_id)) + ) + ) + matches = result.all() + if not matches: + raise MatchNotFoundError(user_id) + elif len(matches) > 1: + raise TooManyMatchesError(user_id) + return matches[0] diff --git a/src/core/exceptions/exceptions.py b/src/core/exceptions/exceptions.py index afb5994..26cd59a 100644 --- a/src/core/exceptions/exceptions.py +++ b/src/core/exceptions/exceptions.py @@ -15,3 +15,19 @@ def __init__(self, obj: DatabaseModel) -> None: def __str__(self) -> str: return self.detail + + +class MatchNotFoundError(Exception): + def __init__(self, user_id: str) -> None: + self.detail = f"Встреча пользователя с user_id {user_id} не найдена" + + def __str__(self) -> str: + return self.detail + + +class TooManyMatchesError(Exception): + def __init__(self, user_id: str) -> None: + self.detail = f"У пользователя с user_id {user_id} " f"найдено несколько встреч на этой неделе" + + def __str__(self) -> str: + return self.detail diff --git a/src/depends.py b/src/depends.py index b837858..ab333c6 100644 --- a/src/depends.py +++ b/src/depends.py @@ -7,6 +7,7 @@ from src.bot.services.notify_service import NotifyService from src.bot.services.registration import RegistrationService from src.core.db.repository.admin import AdminRepository +from src.core.db.repository.match_review import MatchReviewRepository from src.core.db.repository.user import UserRepository from src.core.db.repository.usersmatch import UsersMatchRepository from src.endpoints import Endpoints @@ -24,6 +25,7 @@ class Container(containers.DeclarativeContainer): admin_repository = providers.Factory(AdminRepository, sessionmaker=sessionmaker) user_repository = providers.Factory(UserRepository, sessionmaker=sessionmaker) match_repository = providers.Factory(UsersMatchRepository, sessionmaker=sessionmaker) + match_review_repository = providers.Factory(MatchReviewRepository, sessionmaker=sessionmaker) # Services admin_service = providers.Factory( AdminService, admin_repository=admin_repository, admin_username=settings.provided.ADMIN_USERNAME @@ -33,7 +35,10 @@ class Container(containers.DeclarativeContainer): MatchingService, user_repository=user_repository, match_repository=match_repository ) week_routine_service = providers.Factory( - NotifyService, user_repository=user_repository, match_repository=match_repository + NotifyService, + user_repository=user_repository, + match_repository=match_repository, + match_review_repository=match_review_repository, ) # Scheduler scheduler: AsyncIOScheduler = providers.Singleton(AsyncIOScheduler) diff --git a/src/endpoints.py b/src/endpoints.py index cd5a99d..b7ae140 100644 --- a/src/endpoints.py +++ b/src/endpoints.py @@ -16,3 +16,11 @@ def add_to_meeting(self) -> str: @property def not_meeting(self) -> str: return self.host_append("/hooks/not_meeting") + + @property + def answer_yes(self) -> str: + return self.host_append("/hooks/match_review_answer_yes") + + @property + def answer_no(self) -> str: + return self.host_append("/hooks/match_review_answer_no")