feat: delete & archive projects

This commit is contained in:
ivannoskov
2026-01-07 17:32:20 +03:00
parent 89f4d6bf69
commit 30791d7f76
9 changed files with 128 additions and 8 deletions

View File

@@ -246,7 +246,7 @@ class Postgres(DatabaseBase, Database):
async def get_project( async def get_project(
self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, channel_id: uuid.UUID | None = None self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, channel_id: uuid.UUID | None = None
) -> domain.Project | 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: if project_id:
query = query.filter(id=project_id) query = query.filter(id=project_id)
if channel_id: if channel_id:
@@ -274,17 +274,39 @@ class Postgres(DatabaseBase, Database):
await project.save() await project.save()
async def get_workspace_projects( 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]: ) -> 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 allowed_project_ids is not None:
if not allowed_project_ids: if not allowed_project_ids:
return [] return []
query = query.filter(id__in=list(allowed_project_ids)) 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() 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: async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None:
return await domain.Purchase.get_or_none( return await domain.Purchase.get_or_none(
project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE

View File

@@ -1,7 +1,7 @@
import uuid import uuid
from typing import Annotated from typing import Annotated
from fastapi import Depends from fastapi import Depends, Query
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate from fastapi_pagination import Page, paginate
@@ -13,11 +13,14 @@ projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=[
@projects_router.get('') @projects_router.get('')
async def get_projects( 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]: ) -> Page[dto.ProjectOutput]:
input = dto.GetWorkspaceProjectsInput( input = dto.GetWorkspaceProjectsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
include_archived=include_archived,
) )
result = await deps.get_usecase().get_workspace_projects(input=input) 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, purchase_invite_type_default=input.purchase_invite_type_default,
user_id=current_user.user_id, 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,
)

View File

@@ -4,6 +4,7 @@ __all__ = (
'GetWorkspaceProjectsInput', 'GetWorkspaceProjectsInput',
'GetWorkspaceProjectsOutput', 'GetWorkspaceProjectsOutput',
'GetProjectInput', 'GetProjectInput',
'ArchiveProjectInput',
'DisconnectProjectByTgIdInput', 'DisconnectProjectByTgIdInput',
'ConnectProjectInput', 'ConnectProjectInput',
'ProjectOutput', 'ProjectOutput',
@@ -121,6 +122,7 @@ from .creative import (
) )
from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput
from .project import ( from .project import (
ArchiveProjectInput,
ChannelBotPermissions, ChannelBotPermissions,
ConnectProjectInput, ConnectProjectInput,
DisconnectProjectByTgIdInput, DisconnectProjectByTgIdInput,

View File

@@ -42,6 +42,7 @@ class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
class GetWorkspaceProjectsInput(pydantic.BaseModel): class GetWorkspaceProjectsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
include_archived: bool = False
class GetWorkspaceProjectsOutput(pydantic.BaseModel): class GetWorkspaceProjectsOutput(pydantic.BaseModel):
@@ -63,3 +64,9 @@ class UpdateProjectPermissionsInput(pydantic.BaseModel):
class GetProjectInput(pydantic.BaseModel): class GetProjectInput(pydantic.BaseModel):
workspace_id: uuid.UUID workspace_id: uuid.UUID
project_id: uuid.UUID project_id: uuid.UUID
class ArchiveProjectInput(pydantic.BaseModel):
workspace_id: uuid.UUID
project_id: uuid.UUID
user_id: uuid.UUID

View File

@@ -28,6 +28,8 @@ from .creative.update_creative import update_creative
from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle
from .placement.get_placement import get_placement from .placement.get_placement import get_placement
from .placement.get_placements import get_placements 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.disconnect_project_by_tg_id import disconnect_project_by_tg_id
from .project.get_project import get_project from .project.get_project import get_project
from .project.get_workspace_projects import get_workspace_projects 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 update_project(self, project: domain.Project) -> None: ...
async def get_workspace_projects( 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]: ... ) -> 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_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: ... 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 tg_add_project = tg_add_project
get_workspace_projects = get_workspace_projects get_workspace_projects = get_workspace_projects
get_project = get_project get_project = get_project
archive_project = archive_project
delete_project = delete_project
disconnect_project_by_tg_id = disconnect_project_by_tg_id disconnect_project_by_tg_id = disconnect_project_by_tg_id
update_project_permissions = update_project_permissions update_project_permissions = update_project_permissions
update_project_invite_link_type = update_project_invite_link_type update_project_invite_link_type = update_project_invite_link_type

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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) 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 [ return [
dto.ProjectOutput( dto.ProjectOutput(

View File

@@ -51,7 +51,7 @@ func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) {
} }
const tgexAbout = ` const tgexAbout = `
TGEX — Telegram-бот для покупки и продажи рекламы без ЛС и ручных таблиц. Smartpost — платформа для покупки и продажи рекламы без ЛС и ручных таблиц.
• Внутренний счёт и безопасные сделки • Внутренний счёт и безопасные сделки
• Фиксация цен, дат и форматов • Фиксация цен, дат и форматов