feat: пагинация

This commit is contained in:
Artem Tsyrulnikov
2025-12-15 23:18:59 +03:00
parent 83a81f46f7
commit 2339471956
25 changed files with 151 additions and 121 deletions

View File

@@ -11,6 +11,7 @@ dependencies = [
"asyncpg>=0.30.0",
"beautifulsoup4>=4.14.2",
"fastapi>=0.121.0",
"fastapi-pagination>=0.15.3",
"httpx>=0.28.1",
"lxml>=6.0.2",
"pydantic-settings>=2.11.0",

View File

@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
@@ -16,13 +17,14 @@ async def get_placements_analytics(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID | None = None,
) -> dto.GetPlacementsAnalyticsOutput:
) -> Page[dto.PlacementAnalyticsOutput]:
input = dto.GetPlacementsAnalyticsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
)
return await deps.get_usecase().get_placements_analytics(input)
result = await deps.get_usecase().get_placements_analytics(input)
return paginate(result) # type: ignore[no-any-return]
@analytics_router.get('/creatives')
@@ -30,13 +32,14 @@ async def get_creatives_analytics(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID | None = None,
) -> dto.GetCreativesAnalyticsOutput:
) -> Page[dto.CreativeAnalyticsOutput]:
input = dto.GetCreativesAnalyticsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
)
return await deps.get_usecase().get_creatives_analytics(input)
result = await deps.get_usecase().get_creatives_analytics(input)
return paginate(result) # type: ignore[no-any-return]
@analytics_router.get('/channels')
@@ -44,13 +47,14 @@ async def get_channel_analytics(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID | None = None,
) -> dto.GetChannelAnalyticsOutput:
) -> Page[dto.ChannelAnalyticsOutput]:
input = dto.GetChannelAnalyticsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
)
return await deps.get_usecase().get_channel_analytics(input)
result = await deps.get_usecase().get_channel_analytics(input)
return paginate(result) # type: ignore[no-any-return]
@analytics_router.get('/spending')

View File

@@ -2,6 +2,7 @@ from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
@@ -13,6 +14,7 @@ channels_router = APIRouter(prefix='/channels', tags=['channels'])
async def get_channels(
_: Annotated[JWTPayload, Depends(deps.get_current_user)],
username: str | None = None,
) -> dto.GetChannelsOutput:
) -> Page[dto.ChannelOutput]:
input = dto.GetChannelsInput(username=username)
return await deps.get_usecase().get_channels(input=input)
result = await deps.get_usecase().get_channels(input=input)
return paginate(result) # type: ignore[no-any-return]

View File

@@ -3,6 +3,7 @@ from typing import Annotated
from fastapi import Depends, Query
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
@@ -16,7 +17,7 @@ async def list_creatives(
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID | None = None,
include_archived: bool = False,
) -> dto.GetCreativesOutput:
) -> Page[dto.CreativeOutput]:
input = dto.GetCreativesInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
@@ -24,7 +25,8 @@ async def list_creatives(
include_archived=include_archived,
)
return await deps.get_usecase().get_creatives(input=input)
result = await deps.get_usecase().get_creatives(input=input)
return paginate(result) # type: ignore[no-any-return]
@creatives_router.get('/{creative_id}')

View File

@@ -3,6 +3,7 @@ from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
@@ -18,7 +19,7 @@ async def list_placements(
placement_channel_id: uuid.UUID | None = None,
creative_id: uuid.UUID | None = None,
include_archived: bool = False,
) -> dto.GetPlacementsOutput:
) -> Page[dto.PlacementOutput]:
input = dto.GetPlacementsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
@@ -28,7 +29,8 @@ async def list_placements(
include_archived=include_archived,
)
return await deps.get_usecase().get_placements(input=input)
result = await deps.get_usecase().get_placements(input=input)
return paginate(result) # type: ignore[no-any-return]
@placements_router.get('/{placement_id}')

View File

@@ -3,6 +3,7 @@ from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
@@ -14,10 +15,11 @@ projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=[
async def get_workspace_projects(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.GetWorkspaceProjectsOutput:
) -> Page[dto.ProjectOutput]:
input = dto.GetWorkspaceProjectsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
)
return await deps.get_usecase().get_workspace_projects(input=input)
result = await deps.get_usecase().get_workspace_projects(input=input)
return paginate(result) # type: ignore[no-any-return]

View File

@@ -3,6 +3,7 @@ from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
@@ -18,13 +19,14 @@ async def get_purchase_plan_channels(
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.GetPurchasePlanChannelsOutput:
) -> Page[dto.PurchasePlanChannelOutput]:
input = dto.GetPurchasePlanChannelsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
)
return await deps.get_usecase().get_purchase_plan_channels(input=input)
result = await deps.get_usecase().get_purchase_plan_channels(input=input)
return paginate(result) # type: ignore[no-any-return]
@purchase_plan_channels_router.post('')

View File

@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
@@ -18,7 +19,7 @@ async def get_views_history(
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
from_date: datetime.datetime | None = None,
to_date: datetime.datetime | None = None,
) -> dto.GetViewsHistoryOutput:
) -> Page[dto.PostViewsHistoryOutput]:
input_data = dto.GetViewsHistoryInput(
placement_id=placement_id,
user_id=current_user.user_id,
@@ -27,4 +28,5 @@ async def get_views_history(
to_date=to_date,
)
return await deps.get_usecase().get_views_history(input=input_data)
result = await deps.get_usecase().get_views_history(input=input_data)
return paginate(result) # type: ignore[no-any-return]

View File

@@ -3,6 +3,7 @@ from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
@@ -14,11 +15,12 @@ workspace_invites_router = APIRouter(prefix='/workspaces/{workspace_id}/invites'
async def list_workspace_invites(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.GetWorkspaceInvitesOutput:
return await deps.get_usecase().get_workspace_invites(
) -> Page[dto.WorkspaceInviteOutput]:
result = await deps.get_usecase().get_workspace_invites(
workspace_id=workspace_id,
user_id=current_user.user_id,
)
return paginate(result) # type: ignore[no-any-return]
@workspace_invites_router.post('')

View File

@@ -3,6 +3,7 @@ from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
@@ -14,11 +15,12 @@ workspace_members_router = APIRouter(prefix='/workspaces/{workspace_id}/members'
async def list_workspace_members(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.GetWorkspaceMembersOutput:
return await deps.get_usecase().get_workspace_members(
) -> Page[dto.WorkspaceMemberOutput]:
result = await deps.get_usecase().get_workspace_members(
workspace_id=workspace_id,
user_id=current_user.user_id,
)
return paginate(result) # type: ignore[no-any-return]
@workspace_members_router.put('/{workspace_user_id}/permissions')

View File

@@ -3,6 +3,7 @@ from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
@@ -13,8 +14,9 @@ workspaces_router = APIRouter(prefix='/workspaces', tags=['workspaces'])
@workspaces_router.get('')
async def list_workspaces(
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.GetWorkspacesOutput:
return await deps.get_usecase().get_workspaces(current_user.user_id)
) -> Page[dto.WorkspaceMembershipOutput]:
result = await deps.get_usecase().get_workspaces(current_user.user_id)
return paginate(result) # type: ignore[no-any-return]
@workspaces_router.post('')

View File

@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi_pagination import add_pagination
from shared import logger
from shared.worker_base import WorkerConfig
@@ -68,4 +69,6 @@ app.add_middleware(
allow_headers=['*'],
)
add_pagination(app)
app.include_router(api_router)

View File

@@ -11,7 +11,9 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsInput) -> dto.GetChannelAnalyticsOutput:
async def get_channel_analytics(
self: 'Usecase', input: dto.GetChannelAnalyticsInput
) -> list[dto.ChannelAnalyticsOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
)
@@ -105,4 +107,4 @@ async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsI
)
)
return dto.GetChannelAnalyticsOutput(channels=result)
return result

View File

@@ -13,7 +13,7 @@ log = logging.getLogger(__name__)
async def get_creatives_analytics(
self: 'Usecase', input: dto.GetCreativesAnalyticsInput
) -> dto.GetCreativesAnalyticsOutput:
) -> list[dto.CreativeAnalyticsOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
)
@@ -94,4 +94,4 @@ async def get_creatives_analytics(
)
)
return dto.GetCreativesAnalyticsOutput(creatives=result)
return result

View File

@@ -23,7 +23,7 @@ def _calculate_cpm(cost: float | None, views: int | None) -> float | None:
async def get_placements_analytics(
self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
) -> dto.GetPlacementsAnalyticsOutput:
) -> list[dto.PlacementAnalyticsOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
)
@@ -48,7 +48,7 @@ async def get_placements_analytics(
post_ids = [p.post.id for p in placements if p.post]
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
result = [
return [
dto.PlacementAnalyticsOutput(
id=p.id,
project_id=p.project_id,
@@ -66,5 +66,3 @@ async def get_placements_analytics(
)
for p in placements
]
return dto.GetPlacementsAnalyticsOutput(placements=result)

View File

@@ -9,19 +9,17 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> dto.GetChannelsOutput:
async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> list[dto.ChannelOutput]:
channels = await self.database.search_channels(username_query=input.username)
log.debug('Found %s channels for username query: %s', len(channels), input.username)
return dto.GetChannelsOutput(
channels=[
dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
)
for channel in channels
]
)
return [
dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
)
for channel in channels
]

View File

@@ -6,7 +6,7 @@ if TYPE_CHECKING:
from .. import Usecase
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput:
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[dto.CreativeOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
)
@@ -24,18 +24,16 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.Ge
allowed_project_ids=allowed_project_ids,
)
return dto.GetCreativesOutput(
creatives=[
dto.CreativeOutput(
id=creative.id,
name=creative.name,
text=creative.text,
project_id=creative.project_id,
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,
status=creative.status,
placements_count=creative.placements_count,
)
for creative in creatives
]
)
return [
dto.CreativeOutput(
id=creative.id,
name=creative.name,
text=creative.text,
project_id=creative.project_id,
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,
status=creative.status,
placements_count=creative.placements_count,
)
for creative in creatives
]

View File

@@ -8,7 +8,7 @@ if TYPE_CHECKING:
from .. import Usecase
async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput:
async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list[dto.PlacementOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
@@ -56,4 +56,4 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
)
)
return dto.GetPlacementsOutput(placements=placement_outputs)
return placement_outputs

View File

@@ -8,7 +8,7 @@ if TYPE_CHECKING:
async def get_workspace_projects(
self: 'Usecase', input: dto.GetWorkspaceProjectsInput
) -> dto.GetWorkspaceProjectsOutput:
) -> list[dto.ProjectOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
)
@@ -17,15 +17,13 @@ async def get_workspace_projects(
projects = await self.database.get_workspace_projects(input.workspace_id, allowed_project_ids=allowed_project_ids)
return dto.GetWorkspaceProjectsOutput(
projects=[
dto.ProjectOutput(
id=project.id,
telegram_id=project.channel.telegram_id,
title=project.channel.title,
username=project.channel.username,
status=project.status,
)
for project in projects
]
)
return [
dto.ProjectOutput(
id=project.id,
telegram_id=project.channel.telegram_id,
title=project.channel.title,
username=project.channel.username,
status=project.status,
)
for project in projects
]

View File

@@ -11,7 +11,7 @@ log = logging.getLogger(__name__)
async def get_purchase_plan_channels(
self: 'Usecase', input: dto.GetPurchasePlanChannelsInput
) -> dto.GetPurchasePlanChannelsOutput:
) -> list[dto.PurchasePlanChannelOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
@@ -27,20 +27,18 @@ async def get_purchase_plan_channels(
plan_channels = await self.database.get_purchase_plan_channels(plan.id)
log.debug('Fetched %s channels for purchase plan %s (project %s)', len(plan_channels), plan.id, project.id)
return dto.GetPurchasePlanChannelsOutput(
channels=[
dto.PurchasePlanChannelOutput(
id=plan_channel.id,
status=plan_channel.status,
planned_cost=plan_channel.planned_cost,
comment=plan_channel.comment,
channel=dto.ChannelOutput(
id=plan_channel.channel.id,
telegram_id=plan_channel.channel.telegram_id,
title=plan_channel.channel.title,
username=plan_channel.channel.username,
),
)
for plan_channel in plan_channels
]
)
return [
dto.PurchasePlanChannelOutput(
id=plan_channel.id,
status=plan_channel.status,
planned_cost=plan_channel.planned_cost,
comment=plan_channel.comment,
channel=dto.ChannelOutput(
id=plan_channel.channel.id,
telegram_id=plan_channel.channel.telegram_id,
title=plan_channel.channel.title,
username=plan_channel.channel.username,
),
)
for plan_channel in plan_channels
]

View File

@@ -9,7 +9,7 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput:
async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> list[dto.PostViewsHistoryOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
@@ -22,7 +22,7 @@ async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) ->
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
if not placement.post:
return dto.GetViewsHistoryOutput(histories=[])
return []
histories = await self.database.get_views_history(
placement.post.id,
@@ -30,15 +30,13 @@ async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) ->
to_date=input.to_date,
)
return dto.GetViewsHistoryOutput(
histories=[
dto.PostViewsHistoryOutput(
id=history.id,
post_id=history.post_id,
views_count=history.views_count,
fetched_at=history.fetched_at,
created_at=history.created_at,
)
for history in histories
]
)
return [
dto.PostViewsHistoryOutput(
id=history.id,
post_id=history.post_id,
views_count=history.views_count,
fetched_at=history.fetched_at,
created_at=history.created_at,
)
for history in histories
]

View File

@@ -12,9 +12,9 @@ if TYPE_CHECKING:
async def get_workspace_invites(
self: Usecase, workspace_id: uuid.UUID, user_id: uuid.UUID
) -> dto.GetWorkspaceInvitesOutput:
) -> list[dto.WorkspaceInviteOutput]:
await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
invites = await self.database.get_workspace_invites(workspace_id)
return dto.GetWorkspaceInvitesOutput(invites=[workspace_invite_to_dto(invite) for invite in invites])
return [workspace_invite_to_dto(invite) for invite in invites]

View File

@@ -12,9 +12,9 @@ if TYPE_CHECKING:
async def get_workspace_members(
self: Usecase, workspace_id: uuid.UUID, user_id: uuid.UUID
) -> dto.GetWorkspaceMembersOutput:
) -> list[dto.WorkspaceMemberOutput]:
await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
members = await self.database.get_workspace_members(workspace_id)
return dto.GetWorkspaceMembersOutput(members=[workspace_member_to_dto(member) for member in members])
return [workspace_member_to_dto(member) for member in members]

View File

@@ -7,15 +7,13 @@ if TYPE_CHECKING:
from .. import Usecase
async def get_workspaces(self: 'Usecase', user_id: uuid.UUID) -> dto.GetWorkspacesOutput:
async def get_workspaces(self: 'Usecase', user_id: uuid.UUID) -> list[dto.WorkspaceMembershipOutput]:
memberships = await self.database.get_user_workspaces(user_id)
return dto.GetWorkspacesOutput(
workspaces=[
dto.WorkspaceMembershipOutput(
id=membership.workspace_id,
name=membership.workspace.name,
)
for membership in memberships
]
)
return [
dto.WorkspaceMembershipOutput(
id=membership.workspace_id,
name=membership.workspace.name,
)
for membership in memberships
]

16
uv.lock generated
View File

@@ -268,6 +268,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload-time = "2025-11-03T10:25:53.27Z" },
]
[[package]]
name = "fastapi-pagination"
version = "0.15.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/be/e5edfb47d0253b5dc019ad0430cc26b34f9b29a21456abfb8ea0cff40782/fastapi_pagination-0.15.3.tar.gz", hash = "sha256:0667c3e31eb0c47f15e2d4d0a971490beed9b65a1079158b5ad0115488a370e2", size = 571922, upload-time = "2025-12-11T21:53:43.297Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/99/11a13d3b2ff6fb716fbe8a1da8f15da332472f13bea84377e1d070cf3a6c/fastapi_pagination-0.15.3-py3-none-any.whl", hash = "sha256:6c0e8b3265270bfa46a580f7a3a24559b0825917625b5c4ed3a1cd60a173552f", size = 56231, upload-time = "2025-12-11T21:53:44.257Z" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
@@ -1012,6 +1026,7 @@ dependencies = [
{ name = "asyncpg" },
{ name = "beautifulsoup4" },
{ name = "fastapi" },
{ name = "fastapi-pagination" },
{ name = "httpx" },
{ name = "lxml" },
{ name = "pydantic-settings" },
@@ -1042,6 +1057,7 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.30.0" },
{ name = "beautifulsoup4", specifier = ">=4.14.2" },
{ name = "fastapi", specifier = ">=0.121.0" },
{ name = "fastapi-pagination", specifier = ">=0.15.3" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "lxml", specifier = ">=6.0.2" },
{ name = "pydantic-settings", specifier = ">=2.11.0" },