refactor
This commit is contained in:
@@ -461,6 +461,37 @@ class Postgres(DatabaseBase):
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_placement_posts_by_placement_ids(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
placement_ids: list[uuid.UUID],
|
||||||
|
include_archived: bool = False,
|
||||||
|
) -> list[domain.PlacementPost]:
|
||||||
|
if not placement_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
query = domain.PlacementPost.filter(
|
||||||
|
placement__project__workspace_id=workspace_id,
|
||||||
|
placement_id__in=placement_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not include_archived:
|
||||||
|
query = query.filter(status=domain.PlacementPostStatus.ACTIVE)
|
||||||
|
|
||||||
|
return (
|
||||||
|
await query.prefetch_related(
|
||||||
|
'placement',
|
||||||
|
'placement__project',
|
||||||
|
'placement__project__channel',
|
||||||
|
'placement__channel',
|
||||||
|
'placement__creative',
|
||||||
|
'post',
|
||||||
|
'post__channel',
|
||||||
|
)
|
||||||
|
.order_by('-created_at')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def create_placement_post(placement_post: domain.PlacementPost) -> None:
|
async def create_placement_post(placement_post: domain.PlacementPost) -> None:
|
||||||
await placement_post.save()
|
await placement_post.save()
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from src.controller.http_v1.auth import auth_router
|
|||||||
from src.controller.http_v1.channels import channels_router
|
from src.controller.http_v1.channels import channels_router
|
||||||
from src.controller.http_v1.creatives import creatives_router
|
from src.controller.http_v1.creatives import creatives_router
|
||||||
from src.controller.http_v1.internal import internal_router
|
from src.controller.http_v1.internal import internal_router
|
||||||
from src.controller.http_v1.placements import placement_posts_router
|
|
||||||
from src.controller.http_v1.projects import projects_router
|
from src.controller.http_v1.projects import projects_router
|
||||||
from src.controller.http_v1.purchases import placements_user_router
|
from src.controller.http_v1.purchases import placements_user_router
|
||||||
from src.controller.http_v1.views import views_router
|
from src.controller.http_v1.views import views_router
|
||||||
@@ -23,7 +22,6 @@ api_v1_router.include_router(channels_router)
|
|||||||
api_v1_router.include_router(projects_router)
|
api_v1_router.include_router(projects_router)
|
||||||
api_v1_router.include_router(placements_user_router) # User-managed placements
|
api_v1_router.include_router(placements_user_router) # User-managed placements
|
||||||
api_v1_router.include_router(creatives_router)
|
api_v1_router.include_router(creatives_router)
|
||||||
api_v1_router.include_router(placement_posts_router) # System-managed placement_posts
|
|
||||||
api_v1_router.include_router(views_router)
|
api_v1_router.include_router(views_router)
|
||||||
api_v1_router.include_router(analytics_router)
|
api_v1_router.include_router(analytics_router)
|
||||||
api_v1_router.include_router(workspaces_router)
|
api_v1_router.include_router(workspaces_router)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
@@ -12,8 +13,7 @@ channels_router = APIRouter(prefix='/channels', tags=['channels'])
|
|||||||
|
|
||||||
@channels_router.get('')
|
@channels_router.get('')
|
||||||
async def get_channels(
|
async def get_channels(
|
||||||
_: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
_: Annotated[JWTPayload, Depends(deps.get_current_user)], username: str | None = None
|
||||||
username: str | None = None,
|
|
||||||
) -> Page[dto.ChannelOutput]:
|
) -> Page[dto.ChannelOutput]:
|
||||||
input = dto.GetChannelsInput(username=username)
|
input = dto.GetChannelsInput(username=username)
|
||||||
result = await deps.get_usecase().get_channels(input=input)
|
result = await deps.get_usecase().get_channels(input=input)
|
||||||
@@ -22,10 +22,7 @@ async def get_channels(
|
|||||||
|
|
||||||
@channels_router.get('/{channel_id}')
|
@channels_router.get('/{channel_id}')
|
||||||
async def get_channel(
|
async def get_channel(
|
||||||
channel_id: str,
|
channel_id: uuid.UUID, _: Annotated[JWTPayload, Depends(deps.get_current_user)]
|
||||||
_: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
|
||||||
) -> dto.ChannelOutput:
|
) -> dto.ChannelOutput:
|
||||||
import uuid
|
input_data = dto.GetChannelInput(channel_id=channel_id)
|
||||||
|
|
||||||
input_data = dto.GetChannelInput(channel_id=uuid.UUID(channel_id))
|
|
||||||
return await deps.get_usecase().get_channel(input=input_data)
|
return await deps.get_usecase().get_channel(input=input_data)
|
||||||
|
|||||||
@@ -38,17 +38,6 @@ async def get_jwt_by_telegram_id(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@internal_router.get('/projects/{workspace_id}/{project_id}')
|
|
||||||
async def get_project(workspace_id: str, project_id: str) -> dto.ProjectOutput:
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
input_data = dto.GetProjectInput(
|
|
||||||
workspace_id=uuid.UUID(workspace_id),
|
|
||||||
project_id=uuid.UUID(project_id),
|
|
||||||
)
|
|
||||||
return await deps.get_usecase().get_project(input_data)
|
|
||||||
|
|
||||||
|
|
||||||
class AttachChannelToWorkspaceRequest(BaseModel):
|
class AttachChannelToWorkspaceRequest(BaseModel):
|
||||||
channel_id: str
|
channel_id: str
|
||||||
workspace_id: str
|
workspace_id: str
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends
|
|
||||||
from fastapi.routing import APIRouter
|
|
||||||
|
|
||||||
from src import deps, dto
|
|
||||||
from src.adapter.jwt import JWTPayload
|
|
||||||
|
|
||||||
# PlacementPosts router - System-managed actual published posts
|
|
||||||
placement_posts_router = APIRouter(prefix='/workspaces/{workspace_id}/placement_posts', tags=['placement_posts'])
|
|
||||||
|
|
||||||
|
|
||||||
@placement_posts_router.get('')
|
|
||||||
async def list_placement_posts(
|
|
||||||
workspace_id: uuid.UUID,
|
|
||||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
|
||||||
project_id: uuid.UUID | None = None,
|
|
||||||
placement_channel_id: uuid.UUID | None = None,
|
|
||||||
creative_id: uuid.UUID | None = None,
|
|
||||||
placement_id: uuid.UUID | None = None,
|
|
||||||
include_archived: bool = False,
|
|
||||||
) -> dto.GetPlacementPostsOutput:
|
|
||||||
"""List all placement_posts (system-managed actual posts) in workspace"""
|
|
||||||
input = dto.GetPlacementPostsInput(
|
|
||||||
user_id=current_user.user_id,
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
project_id=project_id,
|
|
||||||
placement_channel_id=placement_channel_id,
|
|
||||||
creative_id=creative_id,
|
|
||||||
placement_id=placement_id,
|
|
||||||
include_archived=include_archived,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await deps.get_usecase().get_placement_posts(input=input)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@placement_posts_router.get('/{placement_post_id}')
|
|
||||||
async def get_placement_post(
|
|
||||||
placement_post_id: uuid.UUID,
|
|
||||||
workspace_id: uuid.UUID,
|
|
||||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
|
||||||
) -> dto.PlacementPostOutput:
|
|
||||||
"""Get single placement_post by ID"""
|
|
||||||
input = dto.GetPlacementPostInput(
|
|
||||||
placement_post_id=placement_post_id,
|
|
||||||
user_id=current_user.user_id,
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await deps.get_usecase().get_placement_post(input=input)
|
|
||||||
|
|
||||||
|
|
||||||
# PlacementPost creation/updates are handled by worker; API exposes only read endpoints.
|
|
||||||
@@ -7,6 +7,7 @@ from fastapi_pagination import Page, paginate
|
|||||||
|
|
||||||
from src import deps, dto
|
from src import deps, dto
|
||||||
from src.adapter.jwt import JWTPayload
|
from src.adapter.jwt import JWTPayload
|
||||||
|
from src.domain import PermissionKey
|
||||||
|
|
||||||
projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=['projects'])
|
projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=['projects'])
|
||||||
|
|
||||||
@@ -27,6 +28,24 @@ async def get_projects(
|
|||||||
return paginate(result) # type: ignore[no-any-return]
|
return paginate(result) # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
|
||||||
|
@projects_router.get('/{project_id}')
|
||||||
|
async def get_project(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||||
|
) -> dto.ProjectOutput:
|
||||||
|
context = await deps.get_usecase().ensure_workspace_permission(
|
||||||
|
workspace_id, current_user.user_id, PermissionKey.PROJECTS_READ
|
||||||
|
)
|
||||||
|
context.ensure_project_permission(PermissionKey.PROJECTS_READ, project_id)
|
||||||
|
|
||||||
|
input = dto.GetProjectInput(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
return await deps.get_usecase().get_project(input=input)
|
||||||
|
|
||||||
|
|
||||||
@projects_router.patch('/{project_id}/invite-link-type')
|
@projects_router.patch('/{project_id}/invite-link-type')
|
||||||
async def update_project_invite_link_type(
|
async def update_project_invite_link_type(
|
||||||
workspace_id: uuid.UUID,
|
workspace_id: uuid.UUID,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ async def get_placement(
|
|||||||
workspace_id: uuid.UUID,
|
workspace_id: uuid.UUID,
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||||
) -> dto.PlacementOutput:
|
) -> dto.PlacementWithPostsOutput:
|
||||||
"""Get single placement by ID"""
|
"""Get single placement by ID"""
|
||||||
input = dto.GetPlacementInput(
|
input = dto.GetPlacementInput(
|
||||||
user_id=current_user.user_id,
|
user_id=current_user.user_id,
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ __all__ = (
|
|||||||
'GetPlacementsOutput',
|
'GetPlacementsOutput',
|
||||||
'GetPlacementInput',
|
'GetPlacementInput',
|
||||||
'PlacementPostOutput',
|
'PlacementPostOutput',
|
||||||
'GetPlacementPostsInput',
|
'PlacementWithPostsOutput',
|
||||||
'GetPlacementPostsOutput',
|
|
||||||
'GetPlacementPostInput',
|
|
||||||
'CreativeButton',
|
'CreativeButton',
|
||||||
'CreativeOutput',
|
'CreativeOutput',
|
||||||
'GetCreativesInput',
|
'GetCreativesInput',
|
||||||
@@ -131,12 +129,6 @@ from .creative import (
|
|||||||
GetCreativesOutput,
|
GetCreativesOutput,
|
||||||
UpdateCreativeInput,
|
UpdateCreativeInput,
|
||||||
)
|
)
|
||||||
from .placement import (
|
|
||||||
GetPlacementPostInput,
|
|
||||||
GetPlacementPostsInput,
|
|
||||||
GetPlacementPostsOutput,
|
|
||||||
PlacementPostOutput,
|
|
||||||
)
|
|
||||||
from .project import (
|
from .project import (
|
||||||
ArchiveProjectInput,
|
ArchiveProjectInput,
|
||||||
ChannelBotPermissions,
|
ChannelBotPermissions,
|
||||||
@@ -159,6 +151,8 @@ from .purchase import (
|
|||||||
GetPlacementsOutput,
|
GetPlacementsOutput,
|
||||||
PlacementDetails,
|
PlacementDetails,
|
||||||
PlacementOutput,
|
PlacementOutput,
|
||||||
|
PlacementPostOutput,
|
||||||
|
PlacementWithPostsOutput,
|
||||||
)
|
)
|
||||||
from .user import UserOutput
|
from .user import UserOutput
|
||||||
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
|
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import datetime
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pydantic
|
|
||||||
|
|
||||||
from src import domain
|
|
||||||
from src.dto.purchase import PlacementOutput
|
|
||||||
|
|
||||||
|
|
||||||
class PlacementPostOutput(pydantic.BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
project_id: uuid.UUID
|
|
||||||
project_channel_title: str | None
|
|
||||||
placement_channel_id: uuid.UUID # Канал, где размещен пост
|
|
||||||
placement_channel_title: str | None
|
|
||||||
creative_id: uuid.UUID
|
|
||||||
creative_name: str
|
|
||||||
placement_date: datetime.datetime # wanted_placement_date
|
|
||||||
cost: float | None
|
|
||||||
comment: str | None
|
|
||||||
ad_post_url: str | None
|
|
||||||
invite_link_type: domain.InviteLinkType
|
|
||||||
invite_link: str
|
|
||||||
status: domain.PlacementPostStatus
|
|
||||||
subscriptions_count: int
|
|
||||||
placement_id: uuid.UUID # Ссылка на Placement
|
|
||||||
placement: PlacementOutput
|
|
||||||
created_at: datetime.datetime
|
|
||||||
|
|
||||||
|
|
||||||
class GetPlacementPostsInput(pydantic.BaseModel):
|
|
||||||
user_id: uuid.UUID
|
|
||||||
workspace_id: uuid.UUID
|
|
||||||
project_id: uuid.UUID | None = None
|
|
||||||
placement_channel_id: uuid.UUID | None = None
|
|
||||||
creative_id: uuid.UUID | None = None
|
|
||||||
placement_id: uuid.UUID | None = None
|
|
||||||
include_archived: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class GetPlacementPostsOutput(pydantic.BaseModel):
|
|
||||||
placement_posts: list[PlacementPostOutput]
|
|
||||||
|
|
||||||
|
|
||||||
class GetPlacementPostInput(pydantic.BaseModel):
|
|
||||||
placement_post_id: uuid.UUID
|
|
||||||
user_id: uuid.UUID
|
|
||||||
workspace_id: uuid.UUID
|
|
||||||
@@ -4,6 +4,7 @@ import uuid
|
|||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
from src.domain.placement import CostType, InviteLinkType, PlacementStatus, PlacementType
|
from src.domain.placement import CostType, InviteLinkType, PlacementStatus, PlacementType
|
||||||
|
from src.domain.placement_post import PlacementPostStatus
|
||||||
|
|
||||||
from .channel import ChannelOutput
|
from .channel import ChannelOutput
|
||||||
|
|
||||||
@@ -33,6 +34,31 @@ class PlacementOutput(pydantic.BaseModel):
|
|||||||
details: PlacementDetails | None = None
|
details: PlacementDetails | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlacementPostOutput(pydantic.BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
project_id: uuid.UUID
|
||||||
|
project_channel_title: str | None
|
||||||
|
placement_channel_id: uuid.UUID # Канал, где размещен пост
|
||||||
|
placement_channel_title: str | None
|
||||||
|
creative_id: uuid.UUID
|
||||||
|
creative_name: str
|
||||||
|
placement_date: datetime.datetime # wanted_placement_date
|
||||||
|
cost: float | None
|
||||||
|
comment: str | None
|
||||||
|
ad_post_url: str | None
|
||||||
|
invite_link_type: InviteLinkType
|
||||||
|
invite_link: str
|
||||||
|
status: PlacementPostStatus
|
||||||
|
subscriptions_count: int
|
||||||
|
placement_id: uuid.UUID # Ссылка на Placement
|
||||||
|
placement: PlacementOutput
|
||||||
|
created_at: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PlacementWithPostsOutput(PlacementOutput):
|
||||||
|
placement_posts: list[PlacementPostOutput] = pydantic.Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CreatePlacementChannelInput(pydantic.BaseModel):
|
class CreatePlacementChannelInput(pydantic.BaseModel):
|
||||||
"""Input для создания одного placement (channel + детали)"""
|
"""Input для создания одного placement (channel + детали)"""
|
||||||
|
|
||||||
@@ -57,7 +83,7 @@ class GetPlacementsInput(pydantic.BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class GetPlacementsOutput(pydantic.BaseModel):
|
class GetPlacementsOutput(pydantic.BaseModel):
|
||||||
placements: list[PlacementOutput]
|
placements: list[PlacementWithPostsOutput]
|
||||||
|
|
||||||
|
|
||||||
class GetPlacementInput(pydantic.BaseModel):
|
class GetPlacementInput(pydantic.BaseModel):
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ from .creative.get_creative import get_creative
|
|||||||
from .creative.get_creatives import get_creatives
|
from .creative.get_creatives import get_creatives
|
||||||
from .creative.update_creative import update_creative
|
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_post import get_placement_post
|
|
||||||
from .placement.get_placement_posts import get_placement_posts
|
|
||||||
from .project.archive_project import archive_project
|
from .project.archive_project import archive_project
|
||||||
from .project.delete_project import delete_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
|
||||||
@@ -172,8 +170,6 @@ class Usecase:
|
|||||||
update_creative = update_creative
|
update_creative = update_creative
|
||||||
delete_creative = delete_creative
|
delete_creative = delete_creative
|
||||||
# PlacementPost (system-managed) use cases
|
# PlacementPost (system-managed) use cases
|
||||||
get_placement_posts = get_placement_posts
|
|
||||||
get_placement_post = get_placement_post
|
|
||||||
fetch_placement_post_cycle = fetch_placement_post_cycle
|
fetch_placement_post_cycle = fetch_placement_post_cycle
|
||||||
handle_subscription = handle_subscription
|
handle_subscription = handle_subscription
|
||||||
handle_unsubscription = handle_unsubscription
|
handle_unsubscription = handle_unsubscription
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from src import domain, dto
|
|
||||||
from src.usecase.purchase.create_placements import _build_placement_output
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .. import Usecase
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_placement_post(self: 'Usecase', input: dto.GetPlacementPostInput) -> dto.PlacementPostOutput:
|
|
||||||
"""Получить одну публикацию по ID (system-managed post)"""
|
|
||||||
context = await self.ensure_workspace_permission(
|
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
|
||||||
)
|
|
||||||
|
|
||||||
placement_post = await self.database.get_placement_post(input.workspace_id, input.placement_post_id)
|
|
||||||
if not placement_post:
|
|
||||||
log.warning('PlacementPost %s not found for user %s', input.placement_post_id, input.user_id)
|
|
||||||
raise domain.PlacementPostNotFound(input.placement_post_id)
|
|
||||||
|
|
||||||
placement = placement_post.placement
|
|
||||||
if not placement:
|
|
||||||
log.error('PlacementPost %s missing placement data', placement_post.id)
|
|
||||||
raise domain.PlacementNotFound(placement_post.placement_id)
|
|
||||||
|
|
||||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
|
|
||||||
|
|
||||||
if not placement.channel or not placement.project or not placement.creative:
|
|
||||||
log.error('PlacementPost %s placement missing related data', placement_post.id)
|
|
||||||
raise domain.PlacementNotFound(placement.id)
|
|
||||||
|
|
||||||
ad_post_url = placement_post.post.url if placement_post.post else None
|
|
||||||
subscriptions_count = await self.database.count_subscriptions_by_placement_post(placement_post.id)
|
|
||||||
|
|
||||||
return dto.PlacementPostOutput(
|
|
||||||
id=placement_post.id,
|
|
||||||
project_id=placement.project_id,
|
|
||||||
project_channel_title=placement.project.channel.title if placement.project.channel else None,
|
|
||||||
placement_channel_id=placement.channel_id,
|
|
||||||
placement_channel_title=placement.channel.title,
|
|
||||||
creative_id=placement.creative_id,
|
|
||||||
creative_name=placement.creative.name,
|
|
||||||
placement_date=placement.placement_at or placement_post.created_at,
|
|
||||||
cost=placement.cost_value,
|
|
||||||
comment=placement.comment,
|
|
||||||
ad_post_url=ad_post_url,
|
|
||||||
invite_link_type=placement.invite_link_type,
|
|
||||||
invite_link=placement.invite_link,
|
|
||||||
status=placement_post.status,
|
|
||||||
subscriptions_count=subscriptions_count,
|
|
||||||
placement_id=placement.id,
|
|
||||||
placement=_build_placement_output(placement),
|
|
||||||
created_at=placement_post.created_at,
|
|
||||||
)
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from src import domain, dto
|
|
||||||
from src.usecase.purchase.create_placements import _build_placement_output
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .. import Usecase
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_placement_posts(self: 'Usecase', input: dto.GetPlacementPostsInput) -> dto.GetPlacementPostsOutput:
|
|
||||||
"""Получить список публикаций (system-managed posts)"""
|
|
||||||
context = await self.ensure_workspace_permission(
|
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PLACEMENTS_READ)
|
|
||||||
|
|
||||||
if input.project_id is not None:
|
|
||||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id)
|
|
||||||
allowed_project_ids = None
|
|
||||||
|
|
||||||
placement_posts = await self.database.get_workspace_placement_posts(
|
|
||||||
input.workspace_id,
|
|
||||||
project_id=input.project_id,
|
|
||||||
placement_channel_id=input.placement_channel_id,
|
|
||||||
creative_id=input.creative_id,
|
|
||||||
placement_id=input.placement_id,
|
|
||||||
include_archived=input.include_archived,
|
|
||||||
allowed_project_ids=allowed_project_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
placement_post_ids = [p.id for p in placement_posts]
|
|
||||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
|
|
||||||
|
|
||||||
placement_post_outputs = []
|
|
||||||
for placement_post in placement_posts:
|
|
||||||
placement = placement_post.placement
|
|
||||||
if not placement:
|
|
||||||
log.warning('PlacementPost %s missing placement data', placement_post.id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not placement.channel or not placement.project or not placement.creative:
|
|
||||||
log.warning('PlacementPost %s placement missing related data', placement_post.id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
ad_post_url = placement_post.post.url if placement_post.post else None
|
|
||||||
|
|
||||||
placement_post_outputs.append(
|
|
||||||
dto.PlacementPostOutput(
|
|
||||||
id=placement_post.id,
|
|
||||||
project_id=placement.project_id,
|
|
||||||
project_channel_title=placement.project.channel.title if placement.project.channel else None,
|
|
||||||
placement_channel_id=placement.channel_id,
|
|
||||||
placement_channel_title=placement.channel.title,
|
|
||||||
creative_id=placement.creative_id,
|
|
||||||
creative_name=placement.creative.name,
|
|
||||||
placement_date=placement.placement_at or placement_post.created_at,
|
|
||||||
cost=placement.cost_value,
|
|
||||||
comment=placement.comment,
|
|
||||||
ad_post_url=ad_post_url,
|
|
||||||
invite_link_type=placement.invite_link_type,
|
|
||||||
invite_link=placement.invite_link,
|
|
||||||
status=placement_post.status,
|
|
||||||
subscriptions_count=subscriptions_counts.get(placement_post.id, 0),
|
|
||||||
placement_id=placement.id,
|
|
||||||
placement=_build_placement_output(placement),
|
|
||||||
created_at=placement_post.created_at,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return dto.GetPlacementPostsOutput(placement_posts=placement_post_outputs)
|
|
||||||
@@ -141,4 +141,14 @@ async def create_placements(
|
|||||||
creative.id,
|
creative.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return dto.GetPlacementsOutput(placements=[_build_placement_output(p) for p in placements])
|
placement_outputs = []
|
||||||
|
for placement in placements:
|
||||||
|
placement_output = _build_placement_output(placement)
|
||||||
|
placement_outputs.append(
|
||||||
|
dto.PlacementWithPostsOutput(
|
||||||
|
**placement_output.model_dump(),
|
||||||
|
placement_posts=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return dto.GetPlacementsOutput(placements=placement_outputs)
|
||||||
|
|||||||
@@ -2,16 +2,52 @@ import logging
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from src import domain, dto
|
from src import domain, dto
|
||||||
|
from src.usecase.purchase.create_placements import _build_placement_output
|
||||||
from .create_placements import _build_placement_output
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .. import Usecase
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput:
|
def _build_placement_post_output(
|
||||||
|
placement_post: domain.PlacementPost, subscriptions_count: int
|
||||||
|
) -> dto.PlacementPostOutput | None:
|
||||||
|
placement = placement_post.placement
|
||||||
|
if not placement:
|
||||||
|
log.warning('PlacementPost %s missing placement data', placement_post.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not placement.channel or not placement.project or not placement.creative:
|
||||||
|
log.warning('PlacementPost %s placement missing related data', placement_post.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
ad_post_url = placement_post.post.url if placement_post.post else None
|
||||||
|
|
||||||
|
return dto.PlacementPostOutput(
|
||||||
|
id=placement_post.id,
|
||||||
|
project_id=placement.project_id,
|
||||||
|
project_channel_title=placement.project.channel.title if placement.project.channel else None,
|
||||||
|
placement_channel_id=placement.channel_id,
|
||||||
|
placement_channel_title=placement.channel.title,
|
||||||
|
creative_id=placement.creative_id,
|
||||||
|
creative_name=placement.creative.name,
|
||||||
|
placement_date=placement.placement_at or placement_post.created_at,
|
||||||
|
cost=placement.cost_value,
|
||||||
|
comment=placement.comment,
|
||||||
|
ad_post_url=ad_post_url,
|
||||||
|
invite_link_type=placement.invite_link_type,
|
||||||
|
invite_link=placement.invite_link,
|
||||||
|
status=placement_post.status,
|
||||||
|
subscriptions_count=subscriptions_count,
|
||||||
|
placement_id=placement.id,
|
||||||
|
placement=_build_placement_output(placement),
|
||||||
|
created_at=placement_post.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .. import Usecase
|
||||||
|
|
||||||
|
|
||||||
|
async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementWithPostsOutput:
|
||||||
"""Get single placement by ID (user-managed)"""
|
"""Get single placement by ID (user-managed)"""
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_workspace_permission(
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||||
@@ -32,4 +68,24 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
|
|||||||
channel = await self.database.get_channel(channel_id=placement.channel_id)
|
channel = await self.database.get_channel(channel_id=placement.channel_id)
|
||||||
placement.channel = channel
|
placement.channel = channel
|
||||||
|
|
||||||
return _build_placement_output(placement)
|
placement_posts = await self.database.get_workspace_placement_posts(
|
||||||
|
input.workspace_id,
|
||||||
|
placement_id=placement.id,
|
||||||
|
include_archived=True,
|
||||||
|
)
|
||||||
|
placement_post_ids = [post.id for post in placement_posts]
|
||||||
|
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
|
||||||
|
|
||||||
|
placement_post_outputs = []
|
||||||
|
for placement_post in placement_posts:
|
||||||
|
placement_post_output = _build_placement_post_output(
|
||||||
|
placement_post, subscriptions_counts.get(placement_post.id, 0)
|
||||||
|
)
|
||||||
|
if placement_post_output is not None:
|
||||||
|
placement_post_outputs.append(placement_post_output)
|
||||||
|
|
||||||
|
placement_output = _build_placement_output(placement)
|
||||||
|
return dto.PlacementWithPostsOutput(
|
||||||
|
**placement_output.model_dump(),
|
||||||
|
placement_posts=placement_post_outputs,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from src import domain, dto
|
from src import domain, dto
|
||||||
|
|
||||||
from .create_placements import _build_placement_output
|
from .create_placements import _build_placement_output
|
||||||
|
from .get_placement import _build_placement_post_output
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .. import Usecase
|
from .. import Usecase
|
||||||
@@ -26,4 +28,32 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
|
|||||||
placements = await self.database.get_project_placements(input.workspace_id, project.id)
|
placements = await self.database.get_project_placements(input.workspace_id, project.id)
|
||||||
log.debug('Fetched %s placements for project %s', len(placements), project.id)
|
log.debug('Fetched %s placements for project %s', len(placements), project.id)
|
||||||
|
|
||||||
return dto.GetPlacementsOutput(placements=[_build_placement_output(p) for p in placements])
|
placement_ids = [placement.id for placement in placements]
|
||||||
|
placement_posts = await self.database.get_placement_posts_by_placement_ids(
|
||||||
|
input.workspace_id,
|
||||||
|
placement_ids,
|
||||||
|
include_archived=True,
|
||||||
|
)
|
||||||
|
placement_post_ids = [post.id for post in placement_posts]
|
||||||
|
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
|
||||||
|
|
||||||
|
placement_posts_by_placement_id: dict[uuid.UUID, list[dto.PlacementPostOutput]] = {}
|
||||||
|
for placement_post in placement_posts:
|
||||||
|
placement_post_output = _build_placement_post_output(
|
||||||
|
placement_post, subscriptions_counts.get(placement_post.id, 0)
|
||||||
|
)
|
||||||
|
if placement_post_output is None:
|
||||||
|
continue
|
||||||
|
placement_posts_by_placement_id.setdefault(placement_post.placement_id, []).append(placement_post_output)
|
||||||
|
|
||||||
|
placement_outputs = []
|
||||||
|
for placement in placements:
|
||||||
|
placement_output = _build_placement_output(placement)
|
||||||
|
placement_outputs.append(
|
||||||
|
dto.PlacementWithPostsOutput(
|
||||||
|
**placement_output.model_dump(),
|
||||||
|
placement_posts=placement_posts_by_placement_id.get(placement.id, []),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return dto.GetPlacementsOutput(placements=placement_outputs)
|
||||||
|
|||||||
@@ -245,10 +245,11 @@ func (c *Client) GetProjects(
|
|||||||
|
|
||||||
func (c *Client) GetProject(
|
func (c *Client) GetProject(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
jwt string,
|
||||||
workspaceID string,
|
workspaceID string,
|
||||||
projectID string,
|
projectID string,
|
||||||
) (*Project, error) {
|
) (*Project, error) {
|
||||||
path := fmt.Sprintf("api/v1/internal/projects/%s/%s", workspaceID, projectID)
|
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s", workspaceID, projectID)
|
||||||
|
|
||||||
var project Project
|
var project Project
|
||||||
err := c.do(
|
err := c.do(
|
||||||
@@ -257,6 +258,7 @@ func (c *Client) GetProject(
|
|||||||
path,
|
path,
|
||||||
nil,
|
nil,
|
||||||
&project,
|
&project,
|
||||||
|
withBearer(jwt),
|
||||||
)
|
)
|
||||||
|
|
||||||
return &project, err
|
return &project, err
|
||||||
@@ -565,6 +567,7 @@ type PlacementOutput struct {
|
|||||||
InviteLinkType string `json:"invite_link_type"`
|
InviteLinkType string `json:"invite_link_type"`
|
||||||
Channel Channel `json:"channel"`
|
Channel Channel `json:"channel"`
|
||||||
Details *PlacementDetails `json:"details,omitempty"`
|
Details *PlacementDetails `json:"details,omitempty"`
|
||||||
|
PlacementPosts []PlacementPostOutput `json:"placement_posts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlacementPostOutput struct {
|
type PlacementPostOutput struct {
|
||||||
@@ -594,10 +597,6 @@ type GetPlacementsOutput struct {
|
|||||||
Placements []PlacementOutput `json:"placements"`
|
Placements []PlacementOutput `json:"placements"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetPlacementPostsOutput struct {
|
|
||||||
PlacementPosts []PlacementPostOutput `json:"placement_posts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) CreatePlacements(
|
func (c *Client) CreatePlacements(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
jwt string,
|
jwt string,
|
||||||
@@ -664,39 +663,6 @@ func (c *Client) GetPlacement(
|
|||||||
return &placement, err
|
return &placement, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetPlacementPosts(
|
|
||||||
ctx context.Context,
|
|
||||||
jwt string,
|
|
||||||
workspaceID string,
|
|
||||||
projectID *string,
|
|
||||||
placementID *string,
|
|
||||||
) (*GetPlacementPostsOutput, error) {
|
|
||||||
q := url.Values{}
|
|
||||||
if projectID != nil && *projectID != "" {
|
|
||||||
q.Set("project_id", *projectID)
|
|
||||||
}
|
|
||||||
if placementID != nil && *placementID != "" {
|
|
||||||
q.Set("placement_id", *placementID)
|
|
||||||
}
|
|
||||||
|
|
||||||
path := fmt.Sprintf("api/v1/workspaces/%s/placement_posts", workspaceID)
|
|
||||||
if len(q) > 0 {
|
|
||||||
path += "?" + q.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp GetPlacementPostsOutput
|
|
||||||
err := c.do(
|
|
||||||
ctx,
|
|
||||||
http.MethodGet,
|
|
||||||
path,
|
|
||||||
nil,
|
|
||||||
&resp,
|
|
||||||
withBearer(jwt),
|
|
||||||
)
|
|
||||||
|
|
||||||
return &resp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Workspace Members
|
// Workspace Members
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) {
|
|||||||
ParseMode: echotron.HTML,
|
ParseMode: echotron.HTML,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err)
|
log.Err(err).Msg("SendMessage error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
// Получаем проект напрямую по ID
|
// Получаем проект напрямую по ID
|
||||||
project, err := b.Backend.GetProject(
|
project, err := b.Backend.GetProject(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
b.Session.JWT,
|
||||||
s.WorkspaceID,
|
s.WorkspaceID,
|
||||||
projectID,
|
projectID,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,26 +44,9 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt st
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
placementPosts, err := b.Backend.GetPlacementPosts(
|
|
||||||
context.Background(),
|
|
||||||
jwt,
|
|
||||||
s.WorkspaceID,
|
|
||||||
&s.ProjectID,
|
|
||||||
&s.PlacementID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get placement posts")
|
|
||||||
}
|
|
||||||
|
|
||||||
var placementPost *backend.PlacementPostOutput
|
var placementPost *backend.PlacementPostOutput
|
||||||
if placementPosts != nil {
|
if len(placement.PlacementPosts) > 0 {
|
||||||
for i := range placementPosts.PlacementPosts {
|
placementPost = &placement.PlacementPosts[0]
|
||||||
post := &placementPosts.PlacementPosts[i]
|
|
||||||
if post.PlacementID == s.PlacementID {
|
|
||||||
placementPost = post
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
text := "<b>Детали размещения</b>\n\n"
|
text := "<b>Детали размещения</b>\n\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user