diff --git a/src/api/endpoints/__init__.py b/src/api/endpoints/__init__.py index a1651cfd..71dc15d9 100644 --- a/src/api/endpoints/__init__.py +++ b/src/api/endpoints/__init__.py @@ -6,6 +6,7 @@ from .health_check import health_check_router from .notification import notification_router_by_admin, notification_router_by_token from .tasks import task_read_router, task_response_router, task_write_router, tasks_router +from .tech_messages import tech_message_router from .telegram_webhook import telegram_webhook_router from .users import user_router @@ -27,4 +28,5 @@ "admin_user_router", "feedback_router", "user_router", + "tech_message_router", ) diff --git a/src/api/endpoints/admin/__init__.py b/src/api/endpoints/admin/__init__.py index bbc4ef54..2458792d 100644 --- a/src/api/endpoints/admin/__init__.py +++ b/src/api/endpoints/admin/__init__.py @@ -2,6 +2,7 @@ from src.api.endpoints.analytics import analytic_router from src.api.endpoints.notification import notification_router_by_admin +from src.api.endpoints.tech_messages import tech_message_router from src.api.endpoints.users import user_router from src.api.fastapi_admin_users import auth_backend, auth_cookie_backend, fastapi_admin_users from src.api.permissions import is_active_user @@ -18,7 +19,7 @@ admin_router.include_router(notification_router_by_admin, prefix="/messages", tags=["Messages"]) admin_router.include_router(user_router, prefix="/users", tags=["User"]) admin_router.include_router(admin_user_list_router, prefix="/admins", tags=["Admins"]) - +admin_router.include_router(tech_message_router, prefix="/tech_messages", tags=["Tech messages"]) admin_auth_router = APIRouter() admin_auth_router.include_router(fastapi_admin_users.get_auth_router(auth_backend)) diff --git a/src/api/endpoints/tech_messages.py b/src/api/endpoints/tech_messages.py new file mode 100644 index 00000000..1bd32ef7 --- /dev/null +++ b/src/api/endpoints/tech_messages.py @@ -0,0 +1,75 @@ +from dependency_injector.wiring import Provide, inject +from fastapi import APIRouter, Depends, Query, Request, status + +from src.api.pagination import TechMessagePaginator +from src.api.permissions import is_active_superuser +from src.api.schemas import TechMessagePaginateResponse, TechMessageRequest, TechMessageResponce +from src.core.depends import Container +from src.core.services import TechMessageService + +tech_message_router = APIRouter() + + +@tech_message_router.get( + "", + response_model=TechMessagePaginateResponse, + response_model_exclude_none=True, + description="Получает список технических сообщений.", +) +@inject +async def get_all_tech_messages( + request: Request, + was_read: bool | None = None, + page: int = Query(default=1, ge=1), + limit: int = Query(default=20, ge=1), + tech_message_service: TechMessageService = Depends(Provide[Container.core_services_container.tech_message]), + tech_message_paginate: TechMessagePaginator = Depends( + Provide[Container.api_paginate_container.tech_message_paginate] + ), +) -> TechMessagePaginateResponse: + filter_by = {"was_read": was_read} + tech_messages = await tech_message_service.get_filtered_tech_messages_by_page(filter_by, page, limit) + return await tech_message_paginate.paginate(tech_messages, page, limit, request.url.path, filter_by) + + +@tech_message_router.get( + "/{message_id}", + response_model=TechMessageResponce, + response_model_exclude_none=True, + description="Получает техническое сообщение.", +) +@inject +async def get_tech_message( + message_id: int, + tech_message_service: TechMessageService = Depends(Provide[Container.core_services_container.tech_message]), +) -> TechMessageResponce: + return await tech_message_service.get(message_id) + + +@tech_message_router.patch( + "/{message_id}", + response_model=TechMessageResponce, + response_model_exclude_none=True, + description="Обновляет данные технического сообщения.", +) +@inject +async def patch_tech_message( + message_id: int, + tech_message_request: TechMessageRequest, + tech_message_service: TechMessageService = Depends(Provide[Container.core_services_container.tech_message]), +) -> TechMessageResponce: + return await tech_message_service.partial_update(message_id, tech_message_request.model_dump()) + + +@tech_message_router.delete( + "/{message_id}", + status_code=status.HTTP_204_NO_CONTENT, + description="Архивирует техническое сообщение.", + dependencies=[Depends(is_active_superuser)], +) +@inject +async def delete_tech_message( + message_id: int, + tech_message_service: TechMessageService = Depends(Provide[Container.core_services_container.tech_message]), +) -> None: + return await tech_message_service.archive(message_id) diff --git a/src/api/pagination.py b/src/api/pagination.py index 6811f094..94cbb721 100644 --- a/src/api/pagination.py +++ b/src/api/pagination.py @@ -1,8 +1,8 @@ import math from typing import Any, Generic, TypeVar -from src.core.db.models import AdminUser, User -from src.core.db.repository import AbstractRepository, AdminUserRepository, UserRepository +from src.core.db.models import AdminUser, TechMessage, User +from src.core.db.repository import AbstractRepository, AdminUserRepository, TechMessageRepository, UserRepository from src.core.db.repository.base import FilterableRepository DatabaseModel = TypeVar("DatabaseModel") @@ -67,3 +67,10 @@ class UserPaginator(FilterablePaginator[User]): def __init__(self, user_repository: UserRepository) -> None: super().__init__(user_repository) + + +class TechMessagePaginator(FilterablePaginator[TechMessage]): + """Класс для пагинации и фильтрации данных из модели TechMessage.""" + + def __init__(self, repository: TechMessageRepository) -> None: + super().__init__(repository) diff --git a/src/api/schemas/__init__.py b/src/api/schemas/__init__.py index 9750ebbe..a3674769 100644 --- a/src/api/schemas/__init__.py +++ b/src/api/schemas/__init__.py @@ -21,6 +21,7 @@ TelegramNotificationUsersRequest, ) from .tasks import TaskRequest, TaskResponse, TasksRequest, UserResponseToTaskRequest +from .tech_messages import TechMessagePaginateResponse, TechMessageRequest, TechMessageResponce from .token_schemas import TokenCheckResponse from .users import UserResponse, UsersPaginatedResponse @@ -59,4 +60,7 @@ "UserResponse", "UserResponseToTaskRequest", "UsersPaginatedResponse", + "TechMessageResponce", + "TechMessageRequest", + "TechMessagePaginateResponse", ) diff --git a/src/api/schemas/tech_messages.py b/src/api/schemas/tech_messages.py new file mode 100644 index 00000000..6ba1324d --- /dev/null +++ b/src/api/schemas/tech_messages.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from src.api.schemas.base import PaginateBase, RequestBase, ResponseBase + + +class TechMessageResponce(ResponseBase): + """Класс схемы ответа.""" + + id: int + text: str + was_read: bool + created_at: datetime + + +class TechMessageRequest(RequestBase): + """Класс схемы запроса.""" + + was_read: bool + + +class TechMessagePaginateResponse(PaginateBase): + """Класс схемы постраничного ответа.""" + + result: list[TechMessageResponce] | None diff --git a/src/bot/handlers/categories.py b/src/bot/handlers/categories.py index dc60f9da..6b63cbaf 100644 --- a/src/bot/handlers/categories.py +++ b/src/bot/handlers/categories.py @@ -18,6 +18,18 @@ from src.core.logging.utils import logger_decor from src.core.services.procharity_api import ProcharityAPI +text_chose_category = ( + "Чтобы мне было понятнее, с какими задачами ты готов помогать фондам, " + "отметь свои профессиональные компетенции (можно выбрать несколько). " + 'После этого нажми "Готово 👌"' +) + +text_chose_subcategory = ( + "Чтобы мне было понятнее, с какими задачами ты готов помогать фондам, " + "отметь свои профессиональные компетенции (можно выбрать несколько). " + 'После этого нажми "Назад ⬅️"' +) + @logger_decor @registered_user_required @@ -34,9 +46,7 @@ async def categories_callback( selected_categories_with_parents = await user_service.get_user_categories_with_parents(update.effective_user.id) await context.bot.send_message( chat_id=update.effective_chat.id, - text="Чтобы я знал, с какими задачами ты готов помогать, " - "выбери свои профессиональные компетенции (можно выбрать " - 'несколько). После этого, нажми на пункт "Готово 👌"', + text=text_chose_category, reply_markup=await get_checked_categories_keyboard(categories, selected_categories_with_parents), ) @@ -123,9 +133,7 @@ async def subcategories_callback( selected_categories = await user_service.get_user_categories(update.effective_user.id) await query.message.edit_text( - "Чтобы я знал, с какими задачами ты готов помогать, " - "выбери свои профессиональные компетенции (можно выбрать " - 'несколько). После этого, нажми на пункт "Готово 👌"', + text_chose_subcategory, reply_markup=await get_subcategories_keyboard(parent_id, subcategories, selected_categories), ) @@ -158,9 +166,7 @@ async def select_subcategory_callback( parent_id = context.user_data["parent_id"] subcategories = await category_service.get_unarchived_subcategories(parent_id) await query.message.edit_text( - "Чтобы я знал, с какими задачами ты готов помогать, " - "выбери свои профессиональные компетенции (можно выбрать " - 'несколько). После этого, нажми на пункт "Готово 👌"', + text_chose_subcategory, reply_markup=await get_subcategories_keyboard(parent_id, subcategories, selected_categories), ) @@ -179,9 +185,7 @@ async def back_subcategory_callback( selected_categories_with_parents = await user_service.get_user_categories_with_parents(update.effective_user.id) await query.message.edit_text( - "Чтобы я знал, с какими задачами ты готов помогать, " - "выбери свои профессиональные компетенции (можно выбрать " - 'несколько). После этого, нажми на пункт "Готово 👌"', + text_chose_category, reply_markup=await get_checked_categories_keyboard(categories, selected_categories_with_parents), ) diff --git a/src/bot/handlers/registration.py b/src/bot/handlers/registration.py index 8b870264..9e2676d4 100644 --- a/src/bot/handlers/registration.py +++ b/src/bot/handlers/registration.py @@ -63,11 +63,19 @@ async def on_chat_member_update( if my_chat_member.new_chat_member.status == my_chat_member.new_chat_member.BANNED: await user_service.bot_banned(user) await procharity_api.send_user_bot_status(user) - return user - if my_chat_member.new_chat_member.status == my_chat_member.new_chat_member.MEMBER: + elif my_chat_member.new_chat_member.status == my_chat_member.new_chat_member.MEMBER: await user_service.bot_unbanned(user) await procharity_api.send_user_bot_status(user) - return user + unblock_text = ( + "Ты разблокировал бот ProCharity" if user.is_volunteer else "Вы разблокировали бот ProCharity" + ) + await context.bot.send_message( + chat_id=effective_user.id, + text=unblock_text, + parse_mode=ParseMode.HTML, + disable_web_page_preview=True, + ) + return user def registration_handlers(app: Application): diff --git a/src/core/db/repository/base.py b/src/core/db/repository/base.py index 245023ee..f32dce67 100644 --- a/src/core/db/repository/base.py +++ b/src/core/db/repository/base.py @@ -146,6 +146,13 @@ async def get(self, id: int, *, is_archived: bool | None = False) -> DatabaseMod raise NotFoundException(object_name=self._model.__name__, object_id=id) return db_obj + async def archive(self, id: int) -> DatabaseModel: + """Архивирует объект модели""" + db_obj = await self.get(id) + db_obj.is_archived = True + db_obj = await self.update(id, db_obj) + return db_obj + class ContentRepository(ArchivableRepository): """Абстрактный класс, для контента.""" diff --git a/src/core/db/repository/category.py b/src/core/db/repository/category.py index 23be6944..9050e391 100644 --- a/src/core/db/repository/category.py +++ b/src/core/db/repository/category.py @@ -31,6 +31,7 @@ async def get_unarchived_parents_with_children_count(self): select(Category.name, Category.id, parent_and_children_count_subquery.c.children_count) .select_from(Category) .join(parent_and_children_count_subquery, Category.id == parent_and_children_count_subquery.c.parent_id) + .order_by(Category.name) ) return parents_with_children_count.all() diff --git a/src/core/db/repository/tech_message.py b/src/core/db/repository/tech_message.py index 99f4e3d4..21a878b9 100644 --- a/src/core/db/repository/tech_message.py +++ b/src/core/db/repository/tech_message.py @@ -1,3 +1,7 @@ +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import Select, desc, false, func, select, true from sqlalchemy.ext.asyncio import AsyncSession from src.core.db.models import TechMessage @@ -9,3 +13,45 @@ class TechMessageRepository(ArchivableRepository): def __init__(self, session: AsyncSession) -> None: super().__init__(session, TechMessage) + + async def partial_update(self, id: int, data: dict) -> TechMessage: + """Обновляет данные/часть данных технического сообщения.""" + tech_message = await self.get(id) + + for attr, value in data.items(): + setattr(tech_message, attr, value) + + return await self.update(id, tech_message) + + def _add_filter_by_was_read(self, statement: Select, was_read: bool | None) -> Select: + """Добавляет к оператору SELECT проверку статуса технического сообщения (was_read).""" + if was_read is True: + return statement.where(TechMessage.was_read == true()) + elif was_read is False: + return statement.where(TechMessage.was_read == false()) + return statement + + async def count_by_filter(self, filter_by: dict) -> int: + """Возвращает количество не архивных данных, удовлетворяющих фильтру.""" + statement = self._add_filter_by_was_read( + select(func.count()).select_from(TechMessage), + filter_by.get("was_read"), + ) + return await self._session.scalar(statement.where(TechMessage.is_archived == false())) + + async def get_filtered_tech_messages_by_page( + self, filter_by: dict[str:Any], page: int, limit: int, column_name: str = "created_at" + ) -> Sequence[TechMessage]: + """ + Получает отфильтрованные не архивные данные, ограниченные параметрами page и limit + и отсортированные по полю column_name в порядке убывания. + """ + offset = (page - 1) * limit + statement = self._add_filter_by_was_read( + select(TechMessage), + filter_by.get("was_read"), + ) + objects = await self._session.scalars( + statement.where(TechMessage.is_archived == false()).limit(limit).offset(offset).order_by(desc(column_name)) + ) + return objects.all() diff --git a/src/core/depends/pagination.py b/src/core/depends/pagination.py index 19b75f45..b74ddd76 100644 --- a/src/core/depends/pagination.py +++ b/src/core/depends/pagination.py @@ -1,6 +1,6 @@ from dependency_injector import containers, providers -from src.api.pagination import AdminUserPaginator, UserPaginator +from src.api.pagination import AdminUserPaginator, TechMessagePaginator, UserPaginator class PaginateContainer(containers.DeclarativeContainer): @@ -17,3 +17,8 @@ class PaginateContainer(containers.DeclarativeContainer): AdminUserPaginator, repository=repositories.admin_repository, ) + + tech_message_paginate = providers.Factory( + TechMessagePaginator, + repository=repositories.tech_message_repository, + ) diff --git a/src/core/services/tech_message.py b/src/core/services/tech_message.py index 26c7c8cf..567f74aa 100644 --- a/src/core/services/tech_message.py +++ b/src/core/services/tech_message.py @@ -1,3 +1,5 @@ +from typing import Any + from src.core.db.models import TechMessage from src.core.db.repository import TechMessageRepository @@ -10,3 +12,17 @@ def __init__(self, repository: TechMessageRepository): async def create(self, message: str) -> TechMessage: return await self._repository.create(TechMessage(text=message)) + + async def get_filtered_tech_messages_by_page( + self, filter_by: dict[str:Any], page: int, limit: int + ) -> list[TechMessage]: + return await self._repository.get_filtered_tech_messages_by_page(filter_by, page, limit) + + async def get(self, id: int) -> TechMessage: + return await self._repository.get(id) + + async def partial_update(self, id: int, data: dict) -> TechMessage: + return await self._repository.partial_update(id, data) + + async def archive(self, id: int) -> None: + await self._repository.archive(id)