From 30791d7f762d778df434b68cdd8fef602084dbc8 Mon Sep 17 00:00:00 2001 From: ivannoskov Date: Wed, 7 Jan 2026 17:32:20 +0300 Subject: [PATCH] feat: delete & archive projects --- src/adapter/postgres.py | 28 +++++++++++++-- src/controller/http_v1/projects.py | 34 +++++++++++++++++-- src/dto/__init__.py | 2 ++ src/dto/project.py | 7 ++++ src/usecase/__init__.py | 10 +++++- src/usecase/project/archive_project.py | 32 +++++++++++++++++ src/usecase/project/delete_project.py | 17 ++++++++++ src/usecase/project/get_workspace_projects.py | 4 ++- telegram_bot/screens/main_menu.go | 2 +- 9 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 src/usecase/project/archive_project.py create mode 100644 src/usecase/project/delete_project.py diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 56436af..8d2b635 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -246,7 +246,7 @@ class Postgres(DatabaseBase, Database): async def get_project( self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, channel_id: uuid.UUID | None = None ) -> domain.Project | None: - query = domain.Project.filter(workspace_id=workspace_id).prefetch_related('channel') + query = domain.Project.filter(workspace_id=workspace_id, deleted_at__isnull=True).prefetch_related('channel') if project_id: query = query.filter(id=project_id) if channel_id: @@ -274,17 +274,39 @@ class Postgres(DatabaseBase, Database): await project.save() async def get_workspace_projects( - self, workspace_id: uuid.UUID, allowed_project_ids: set[uuid.UUID] | None = None + self, + workspace_id: uuid.UUID, + allowed_project_ids: set[uuid.UUID] | None = None, + include_archived: bool = False, ) -> list[domain.Project]: - query = domain.Project.filter(workspace_id=workspace_id).prefetch_related('channel') + query = domain.Project.filter(workspace_id=workspace_id, deleted_at__isnull=True).prefetch_related('channel') if allowed_project_ids is not None: if not allowed_project_ids: return [] query = query.filter(id__in=list(allowed_project_ids)) + if not include_archived: + query = query.filter(status=domain.ProjectStatus.ACTIVE) + return await query.order_by('created_at').all() + async def archive_project(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: + project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id) + if not project: + raise domain.ProjectNotFound() + project.status = domain.ProjectStatus.ARCHIVED + await project.save() + + async def delete_project(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: + project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id) + if not project: + raise domain.ProjectNotFound() + import datetime + + project.deleted_at = datetime.datetime.now() + await project.save() + async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None: return await domain.Purchase.get_or_none( project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE diff --git a/src/controller/http_v1/projects.py b/src/controller/http_v1/projects.py index f81b82a..beab770 100644 --- a/src/controller/http_v1/projects.py +++ b/src/controller/http_v1/projects.py @@ -1,7 +1,7 @@ import uuid from typing import Annotated -from fastapi import Depends +from fastapi import Depends, Query from fastapi.routing import APIRouter from fastapi_pagination import Page, paginate @@ -13,11 +13,14 @@ projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=[ @projects_router.get('') async def get_projects( - workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)] + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + include_archived: bool = Query(default=False), ) -> Page[dto.ProjectOutput]: input = dto.GetWorkspaceProjectsInput( user_id=current_user.user_id, workspace_id=workspace_id, + include_archived=include_archived, ) result = await deps.get_usecase().get_workspace_projects(input=input) @@ -37,3 +40,30 @@ async def update_project_invite_link_type( purchase_invite_type_default=input.purchase_invite_type_default, user_id=current_user.user_id, ) + + +@projects_router.post('/{project_id}/archive') +async def archive_project( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.ProjectOutput: + input = dto.ArchiveProjectInput( + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) + return await deps.get_usecase().archive_project(input=input) + + +@projects_router.delete('/{project_id}') +async def delete_project( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> None: + await deps.get_usecase().delete_project( + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) diff --git a/src/dto/__init__.py b/src/dto/__init__.py index d1243d4..045bd85 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -4,6 +4,7 @@ __all__ = ( 'GetWorkspaceProjectsInput', 'GetWorkspaceProjectsOutput', 'GetProjectInput', + 'ArchiveProjectInput', 'DisconnectProjectByTgIdInput', 'ConnectProjectInput', 'ProjectOutput', @@ -121,6 +122,7 @@ from .creative import ( ) from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput from .project import ( + ArchiveProjectInput, ChannelBotPermissions, ConnectProjectInput, DisconnectProjectByTgIdInput, diff --git a/src/dto/project.py b/src/dto/project.py index 02d3cea..06678cd 100644 --- a/src/dto/project.py +++ b/src/dto/project.py @@ -42,6 +42,7 @@ class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel): class GetWorkspaceProjectsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID + include_archived: bool = False class GetWorkspaceProjectsOutput(pydantic.BaseModel): @@ -63,3 +64,9 @@ class UpdateProjectPermissionsInput(pydantic.BaseModel): class GetProjectInput(pydantic.BaseModel): workspace_id: uuid.UUID project_id: uuid.UUID + + +class ArchiveProjectInput(pydantic.BaseModel): + workspace_id: uuid.UUID + project_id: uuid.UUID + user_id: uuid.UUID diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index b368109..cb2f136 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -28,6 +28,8 @@ from .creative.update_creative import update_creative from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle from .placement.get_placement import get_placement from .placement.get_placements import get_placements +from .project.archive_project import archive_project +from .project.delete_project import delete_project from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id from .project.get_project import get_project from .project.get_workspace_projects import get_workspace_projects @@ -158,9 +160,13 @@ class Database(typing.Protocol): async def update_project(self, project: domain.Project) -> None: ... async def get_workspace_projects( - self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None + self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None, include_archived: bool = False ) -> list[domain.Project]: ... + async def archive_project(self, workspace_id: UUID, project_id: UUID) -> None: ... + + async def delete_project(self, workspace_id: UUID, project_id: UUID) -> None: ... + async def get_active_purchase(self, project_id: UUID, creative_id: UUID) -> domain.Purchase | None: ... async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ... @@ -415,6 +421,8 @@ class Usecase: tg_add_project = tg_add_project get_workspace_projects = get_workspace_projects get_project = get_project + archive_project = archive_project + delete_project = delete_project disconnect_project_by_tg_id = disconnect_project_by_tg_id update_project_permissions = update_project_permissions update_project_invite_link_type = update_project_invite_link_type diff --git a/src/usecase/project/archive_project.py b/src/usecase/project/archive_project.py new file mode 100644 index 0000000..dffc0a7 --- /dev/null +++ b/src/usecase/project/archive_project.py @@ -0,0 +1,32 @@ +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def archive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dto.ProjectOutput: + await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=input.project_id + ) + + async with self.database.transaction(): + await self.database.archive_project(input.workspace_id, input.project_id) + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + + if not project: + raise domain.ProjectNotFound() + + await project.fetch_related('channel') + + return dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + ) + diff --git a/src/usecase/project/delete_project.py b/src/usecase/project/delete_project.py new file mode 100644 index 0000000..91c2480 --- /dev/null +++ b/src/usecase/project/delete_project.py @@ -0,0 +1,17 @@ +import uuid +from typing import TYPE_CHECKING + +from src import domain + +if TYPE_CHECKING: + from .. import Usecase + + +async def delete_project(self: 'Usecase', workspace_id: uuid.UUID, project_id: uuid.UUID, user_id: uuid.UUID) -> None: + await self.ensure_workspace_permission( + workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id + ) + + async with self.database.transaction(): + await self.database.delete_project(workspace_id, project_id) + diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py index 43aebd7..ef38b15 100644 --- a/src/usecase/project/get_workspace_projects.py +++ b/src/usecase/project/get_workspace_projects.py @@ -13,7 +13,9 @@ async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProject allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ) - projects = await self.database.get_workspace_projects(input.workspace_id, allowed_project_ids=allowed_project_ids) + projects = await self.database.get_workspace_projects( + input.workspace_id, allowed_project_ids=allowed_project_ids, include_archived=input.include_archived + ) return [ dto.ProjectOutput( diff --git a/telegram_bot/screens/main_menu.go b/telegram_bot/screens/main_menu.go index f74b5e1..91023ad 100644 --- a/telegram_bot/screens/main_menu.go +++ b/telegram_bot/screens/main_menu.go @@ -51,7 +51,7 @@ func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) { } const tgexAbout = ` -TGEX — Telegram-бот для покупки и продажи рекламы без ЛС и ручных таблиц. +Smartpost — платформа для покупки и продажи рекламы без ЛС и ручных таблиц. • Внутренний счёт и безопасные сделки • Фиксация цен, дат и форматов