From 484e52c07727075e806783ecdb4e08b8c0daf7ee Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Wed, 28 Jan 2026 12:34:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=B2=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BF=D0=B5=20=D0=B8=20=D1=82=D1=8D=D0=B3=D0=B8=20?= =?UTF-8?q?=D0=BA=D1=80=D0=B5=D0=B0=D1=82=D0=B8=D0=B2=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...20260128120011_add_published_at_to_post.py | 99 +++++++++++++++++++ src/adapter/postgres.py | 36 ++++++- src/controller/http_v1/analytics.py | 4 +- src/domain/__init__.py | 2 + src/domain/creative.py | 6 ++ src/domain/post.py | 1 + src/dto/analytics.py | 4 + src/dto/creative.py | 3 + src/dto/purchase.py | 1 + .../analytics/get_creatives_analytics.py | 2 + .../analytics/get_projects_analytics.py | 11 ++- src/usecase/creative/create_creative.py | 2 + src/usecase/creative/get_creative.py | 1 + src/usecase/creative/get_creatives.py | 1 + src/usecase/creative/update_creative.py | 3 + src/usecase/purchase/get_placement.py | 17 +++- src/usecase/purchase/get_placements.py | 27 +++++ tg_bot/backend/backend_client.go | 3 + tg_bot/screens/add_creative.go | 18 ++++ tg_bot/screens/creative_details.go | 17 ++++ tg_bot/screens/creative_editor.go | 4 + tg_bot/screens/creative_editor_ui.go | 36 +++++++ .../internal/adapter/database/create_post.go | 25 ++--- .../adapter/telegram/get_channel_diff.go | 6 +- .../internal/adapter/telegram/history.go | 4 +- tg_parser/internal/domain/post.go | 29 +++--- 26 files changed, 326 insertions(+), 36 deletions(-) create mode 100644 migrations/models/12_20260128120011_add_published_at_to_post.py diff --git a/migrations/models/12_20260128120011_add_published_at_to_post.py b/migrations/models/12_20260128120011_add_published_at_to_post.py new file mode 100644 index 0000000..695bb48 --- /dev/null +++ b/migrations/models/12_20260128120011_add_published_at_to_post.py @@ -0,0 +1,99 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "creative" ADD "tag" VARCHAR(10) NOT NULL DEFAULT 'testing'; + ALTER TABLE "post" ADD "published_at" TIMESTAMPTZ; + COMMENT ON COLUMN "creative"."tag" IS 'TESTING: testing\nPRODUCTION: production';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "post" DROP COLUMN "published_at"; + ALTER TABLE "creative" DROP COLUMN "tag";""" + + +MODELS_STATE = ( + "eJztXWtz2rga/isMn7ozOZ2ES8hmzpwZQtyutwQYIGl3m47HGEF8Ymxqm6SZnfz3lXzBtn" + "zBN7AN7xeaSnpl+dH1efRK/qe+UuZI0j72nnhZRlL9uvZPXeZXCP9BR53V6vx67USQAJ2f" + "SUZawZVopukqL+g4eMFLGsJBc6QJqrjWRUXGofJGkkigIuCEorx0gjay+HODOF1ZIv0JqT" + "ji+w8cLMpz9Atp9n/Xz9xCRNLcU1hxTp5thHP629oIu79nbz8ZKcnjZpygSJuV7KRev+lP" + "irxNvtmI84/EhsQtkYxUXkdz12uQUlovbAeZJcYBurpB26LOnYA5WvAbiYBR/+9iIwsEg5" + "rxJPLT+l89ATyCIhNoRVknWPzzbr6V885GaJ08qvdHd/yhefmb8ZaKpi9VI9JApP5uGPI6" + "b5oauDpACioir83xuh/QWxyjiysUDKrXkgJ3bpl+tP9IA7Id4KDstDAbZhu+dJjW8TvMh7" + "L0ZtVgBMZT9o6ZTLt3I/ImK037KRkQdacMiWkYoW9U6AezShTcP8x+s82k9pWd/lEj/639" + "PRwwdMVt003/rpMy8Rtd4WTllePnrsZmh9rA4JROxW7W85QV67WEii20Yq3CO/WKx2KUrl" + "69ljnUq1XaA1ZrRarRfu3IDqrj6liq/IoLmstuxCUr68H1SBlSFYmh2s+ElrFDLslT/vN7" + "o9FsdhrnzcurdqvTaV+dX+G0RpH8UZ2I6r5hP7ODqbf2SMC7F2NRl5AfXbzGUUOwtQ0oVP" + "GrlHSgW/G/OAnJS/0J/7fRbkdg9tAdGysFnIpq9wMrqmHGeUHcaEg1/k6Ao9smFZT02LL3" + "9rl/HHlBQJrGPfHaU7IOTxmm6vCHH6uL6fFrXfODG4qslbpaiDYuWp3WVfOytQVyGxKFnx" + "8rUX4RdcRJovycpG9TZrl070MPlBfnjVaMHk6ShXZxM/L9nZDUxbOLXZGAGS88v/LqnPPE" + "uNqpxAtoheSg5npj2X76MkYSb7yoH2eLro/sfMo5Pb3bjccOrQcsacnKKisMOIsqI6Aq/0" + "dCZhDMXCqGA+ktSkMJ6z/+qFVjRYfwMr80Sk2eTZ5ki1lEoxBfUD1I6LLjziKVLncqkLpA" + "6gJFBKQuqFiQukDqii91JVUPMikHxynC6OhXQC+Y4tAwkfBXUNsvLYRRzZ35NvW0dBuoD3" + "fdb795Wnt/OPhsJ3cB2+sPbyg8ZxtdxyXwQ/rnZDgIhtRlQqF6L+O3/T4XBf2sJoma/mNf" + "GLtWWrONKOmirH0kD9zTYotgEY08DTI1eJAMaOQ1ndc3AcCT4YCRNysDfBYXkZcF5KsEx/" + "pwLbuO1/wWBfBiXO/2puwDc10zEzzKBB0ccItDVOEJB5mtKOEAchVj+LgKHTyufEMHv0yL" + "tmV6QKh1pOkEKT/WUzwNsYPPeC42kzzKo/Hw9h7XwHBwXcMMer4RbH6cXA6KJQZFSEE06D" + "ZlmL1xRAsP3OQJZ3HB1hlY3e71S856ehYK59NFEqLntcqTC5cYNp8K6UPRD+EnRUXiUv6C" + "3nzDwAEUpv1A5xOYcLDKv26FFap14Hc0yYIBcXfS694y9aienAOK91Y2Je2zOxEMHp08SE" + "6YaW1w3+/X3+OI4Ss0F3lO1NEqowJqa3p3JMNSL3R36MEF7A4URnF3QIHUlahpOGtOExQy" + "IGZC5KuiPmtrjArphKNt3hOSdbVAOoRubvajCPF829F2K+jcaps2Vx39u/MEa8JXNNHI6A" + "dI7HtZn4HEfuRKLEjsR1qxILEfq8RuLqEN/H0VGS60e62qohV7tZtmI4Z202yEajck6j0A" + "y4UooUDhYRecLsNqItq+iAMpThWKqREXBKrW5J7RW3JMHbtKupvtBdHtOteHZri/o8skH7" + "/x6nk9UmwhkSTrIhknryq6PaMyCmJuV6yyghdPFXMaSLCwGK6G7ZPO95WlKE+VZyTXA7i8" + "K/YsishLJB2m3nZC8IYDqg6MDqg6VCxQdaDqCQ5+2jNo7EOJtkE+dPIIjtKhX2sR10iKvu" + "C1rOYYd0ydYaOlm6o0GM/KUoUrpGmYdwTy6VAlwmt0ogcwU3iG5eMOdiQSxGH9ccorPYR5" + "4RQsOzjeJwGqg8c1JVx0WHuS7VNy+O72k7PcjH+ADgE6BNBV0CFOt2JBhzjWdXsFD+I8bs" + "5bFxfkt9k2fjs18k/LCGo1jKBz5+9W04ltnvunvvpgyGEUp/eT61qmrB/lr2N2yli5zJ3E" + "zYXxe+VJ7GQiYMMuS461cGNmMhoOJnYel0baFvltm3aCEdFErvI0nOJuSyhPmfHdhOuORu" + "PhQ7dvZte4cGybRsmbM1dh3LGuN23OncI32zXfa898ZmbS33Ehhtyo+5f1LsiFhPuxLhsH" + "j1GXvd1h13HV0twqg9zrDnpMn/HYWk8QfOnPyeEhtsdwg+GUG37x1Jv5otZjkVMNrTYdYh" + "fdROYy6Ckk/zEu1kN3MPU+xQUzTzcrK3xmYeJ5T9xkqabiKXZY8+i48rUaTD3eROZV8+Ic" + "lGqEH5Rq+A5KbRf4KSYU2hamlIKnlDX/lrYqPZZQkQVXpIDzi/Am3L088GRQrM9W/RP7jc" + "wLCxGb4YlidHddE9arNMNfzidzDZBeeGkT5EQjKXyIduo1o8BdELuq9Y/b4f1Nn6mNxkyP" + "nbDWofNthzAiDVXmpySaytaY6faDwJyhhaIibsarS/zc5Kj67QFeep7OMiz4cyl4bJgw/U" + "8cXirfDa9rGpIW3FpVVsqjjAfQwW13jIcN8iZzXk11kv/iMs65cnqgd50rv6SHDNw8V0Gz" + "a/hNII5FRVxnD30ViKCsbIE5LqYuEwA1ENQTvlt0L87eLmAyDcBB+RR8SqE+whMQ27uurT" + "czSRQeZUe74Nd4OMYrnTKs1cxPniT1GvdYnciObam87eHmk2NvbnDzid0M9nHzycFPeJT7" + "2pNdBzwCpow84HNyqmzr806FSf1UgohoHveG25lV/AJxbTPbZp4RkokrqwojAvfGhIB0EI" + "8voztFeX3Z/S2G55fRz420cOIMPL3AIeigu0Xg6XUyFQueXse6m+tMpQnlBMruRAQF+ttE" + "SVFzTE5Dt4oSYNzu+1klmHw/dVWUCEP1qd0ygr38zQpfPIZbWvnF1a0SXzW7V84TRnV2M5" + "y98JrvlNLiOmkHF2IC54GlMXAeqFjgPMfOeYo6lV5Eb8z5WDp8aypfXyh7ZFioyoqzF2fp" + "B5iAbGC0KVphIa5L2lOqiYO2hcos+vADuHrVs0s/4P1wdN4PJdrFdvfXFxG9atyTqOmKKm" + "bd6CdQPJAM/zDyeyv1EubAu/s0NCGqFw1ftALGuavvbR96mFvDR7pgzbQghoEYBpoJiGFQ" + "sSCGHTujMadYQdkEbcSGqmGU1anKYa41Q8Je4LU8zOh2OGpYuV5QmC9HqaotgzPHQR0Rys" + "vlwzwRir5u0zqiFMTKnNNLEWTMlShnBvZqe89bsLnUEKBhQMNgtQ40DCoWaNixL0AreOMm" + "XgRYx3apSxu6vSn7wFzXzASPMjuwQ0TZDiNb5zjoFqdShScclOoSnZwvclhvcFl4DS9FzK" + "swSIac/cYpq2ZXnoesMPeVGYe4Z+MizpWQF+FXQl74roSE/ddURxXoRXZc4Gi7E4EOtq7t" + "hpDD1nVgO8wBv6/uvCqLIN3D0m//23dQZNzkzveDq4Vs+m8dIfLygagyFnDXweG9ITz3ZA" + "RobvQ9GuHCm0anzNv/gTpypuOxZ6nyK87+bhBocKDBgVQDGhxULGhwx67BFXgx7eEJygEu" + "pj1mTfN+MLm/mfTG7A3RMDeytVCbpdMxY6EfAb4fe3eJ0sw4fnMYnor2UYGrWtLqnz5akw" + "C+INsTgTDK4Qdub8l8e4unZeWA49TKr/IfLQ7qcmVyp/IAHaDv0BURru/4mgBcZAniTT6L" + "LeD4IN5AxYJ4cyLsaDuTBs1lN+Iy9CgLZZjPUZbdE1ouB1l+bzSazU7jvHl51W51Ou2r8+" + "2JFn9U1NGWG/YzOd3iqT3/cReySDH+9gEcro65bSr5zaZGux1DnMGpwr9sS+Kog0Oiqulc" + "Uiy9VoCmjabEpwDTY3TaWPp4fhx/E/iuRGI3Au9IGo7ZUEZTBf/sRi4m1S+Z30UCqh1GsX" + "dTa2DU0QsQYNRAvIBRQ8UCo85/2it7NSZj1CXcNNs3v85xzyy27zZu+Yf34S7RytDD6JSl" + "iF9LeUZZuUWf5DQlGZVzIogFh2ZsKxp+Rnk5cLNGbhXGxHVyAoAJBoYMvnk6/FcMFD83j7" + "33bjPwA+28H2g629fGe6bNdOccVwDN9xzyCuf6nnNlQPiB8OfDH4AXAuGHigXCfyKEP+kO" + "WqbNsyK64P53IvkXPK2pnNbkntFbEix9hpXckcztGEkqFcW6pS7rqW/nrrvyNeFYBBD4MP" + "DhQx95p5tLFJdzWlQMRmepO/kTO//Vk3DmHTgfUAPgfFCxwPlOhfNV8Iz2GslzUj2+CqyP" + "mMEtO/h8XbOSPMrdXo8ZTY1rJgUBrXFJHuUx8zD8QoJU9KI8l+LiSXONM+dmbwn32X2GJ3" + "IylfZKTwjb6R3lhdsgU0MXcQoa7jM8S3WfYfDYlwOIlT/67BvQdwOY0+nxykNX0sPiXsUo" + "ShfZ7dPulbJAEwFNBDSRUnEu0EROpmJBEwFNpDSaSIJvcTywhh5iLTQf5Zv+sGfIITNJEV" + "LKIVFHy205pBMqh3R8l9cBowdGD4y+HLQqOaMHQpqZkAZd4L+fq/vLuUqM5dhRrm8blAml" + "g+kZrna0S9nwNrm4Gge39prtTe5wLyCcR4LsAbIHsGOQPaBiQfY4dtnDO9OmkT68ORR8LK" + "Devb1jB9yn+z75iuV8JcrcAqd7lEfj4Z9Mbzrhxkz39rpme41zpIe6Yr+O2Snjin5VRR09" + "yj1sRSQV23x7ZN+yd+KtDJwEVg6jfrfH3DEDpwTb75XZZXBS2KVwkli5dAfd/l9Ttmdngp" + "dr0psuCnYeTjxpOcP7Kdfrs70vE3fKVxHX0EbnBEkUnjW3zfDrgNu+idtEeZW9txQkPnbR" + "inPqohV+6KJFq0WBa7jkCsjpKUhxZJC87ibfg9984XpIFTacaR4bn6VteW8Kqmby8fwJG7" + "CxPQwDwMaOftEObOxIKxbYGLAxYGPAxsrDxuziJORhlFmGxdjuYac0BKxkX3erKnLmmJAU" + "N4/V6aEGqgmoJuVUTQL6dg5Qxr/Wo6gOvfsLg54hazd49pyaA3q53ihbEHzUEiNG4yvoG5" + "elRTDeJy6LUT27SBWFp3qAvmnFnEUpmbyTpjSCZegXq+J+qMqqwmxCZUahxfxOVeOi1Wld" + "NS9b289TbUOiXIftL1CF65MvSA2nyMHouUzg4jLn4jLcNRKAaCWvJoAX5+cxAMSpwr+3Tu" + "Io3qvIeuBU8edkOAjhvI4JBeS9jF/w+1wU9LOaJGr6j3LCGoEieWuP1GaD9+Gu+43Gtdcf" + "3tCrbZLBTbKb4PKfXt7/BaQvMYA=" +) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 9e35202..005405e 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -335,9 +335,8 @@ class Postgres(DatabaseBase): 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() + project.deleted_at = timezone.now() await project.save() @staticmethod @@ -362,6 +361,7 @@ class Postgres(DatabaseBase): include_archived: bool = False, allowed_project_ids: set[uuid.UUID] | None = None, created_by_user_id: uuid.UUID | None = None, + tag: domain.CreativeTag | None = None, ) -> list[domain.Creative]: query = domain.Creative.filter(project__workspace_id=workspace_id) @@ -375,6 +375,9 @@ class Postgres(DatabaseBase): if created_by_user_id is not None: query = query.filter(created_by_user_id=created_by_user_id) + if tag is not None: + query = query.filter(tag=tag) + if not include_archived: query = query.filter(status=domain.CreativeStatus.ACTIVE) @@ -672,3 +675,32 @@ class Postgres(DatabaseBase): @staticmethod async def has_placement_posts_for_creative(creative_id: uuid.UUID) -> bool: return await domain.PlacementPost.filter(placement__creative_id=creative_id).exists() + + @staticmethod + async def get_next_post_after(channel_id: uuid.UUID, message_id: int) -> domain.Post | None: + """Get first post in channel after the given message_id.""" + return await domain.Post.filter( + channel_id=channel_id, + message_id__gt=message_id, + deleted_from_channel_at__isnull=True, + ).order_by('message_id').first() + + @staticmethod + async def get_next_posts_after_batch( + channel_message_pairs: list[tuple[uuid.UUID, int]] + ) -> dict[tuple[uuid.UUID, int], domain.Post]: + """Batch version: get next post for each (channel_id, message_id) pair.""" + if not channel_message_pairs: + return {} + + results: dict[tuple[uuid.UUID, int], domain.Post] = {} + for channel_id, message_id in channel_message_pairs: + next_post = await domain.Post.filter( + channel_id=channel_id, + message_id__gt=message_id, + deleted_from_channel_at__isnull=True, + ).order_by('message_id').first() + if next_post: + results[(channel_id, message_id)] = next_post + + return results diff --git a/src/controller/http_v1/analytics.py b/src/controller/http_v1/analytics.py index 99b7c02..f4be026 100644 --- a/src/controller/http_v1/analytics.py +++ b/src/controller/http_v1/analytics.py @@ -6,7 +6,7 @@ from fastapi import Depends from fastapi.routing import APIRouter from fastapi_pagination import Page, paginate -from src import deps, dto +from src import deps, domain, dto from src.adapter.jwt import JWTPayload analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags=['analytics']) @@ -40,11 +40,13 @@ async def get_creatives_analytics( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], project_id: uuid.UUID | None = None, + tag: domain.CreativeTag | None = None, ) -> Page[dto.CreativeAnalyticsOutput]: input = dto.GetCreativesAnalyticsInput( user_id=current_user.user_id, workspace_id=workspace_id, project_id=project_id, + tag=tag, ) result = await deps.get_usecase().get_creatives_analytics(input) return paginate(result) # type: ignore[no-any-return] diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 305bdc3..56f6d7b 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -35,6 +35,7 @@ __all__ = ( 'ChannelNotFound', 'ProjectNotFound', 'CreativeStatus', + 'CreativeTag', 'SubscriptionStatus', 'InviteLinkType', 'CostType', @@ -76,6 +77,7 @@ from .creative import ( Creative, CreativeMedia, CreativeStatus, + CreativeTag, replace_invite_link_with_tag, validate_media_items, validate_media_size, diff --git a/src/domain/creative.py b/src/domain/creative.py index 911ac83..fd01885 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -24,11 +24,17 @@ class CreativeStatus(str, enum.Enum): ARCHIVED = 'archived' +class CreativeTag(str, enum.Enum): + TESTING = 'testing' # Тестовый + PRODUCTION = 'production' # Рабочий + + class Creative(TimestampedModel): name = fields.CharField(max_length=255) text = fields.TextField() buttons = fields.JSONField(default=list) status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE) + tag = fields.CharEnumField(CreativeTag, default=CreativeTag.TESTING) project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( 'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True diff --git a/src/domain/post.py b/src/domain/post.py index c867d0c..54b2a73 100644 --- a/src/domain/post.py +++ b/src/domain/post.py @@ -13,6 +13,7 @@ class Post(TimestampedModel): message_id = fields.IntField() text = fields.TextField() deleted_from_channel_at = fields.DatetimeField(null=True) + published_at = fields.DatetimeField(null=True) channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( 'models.Channel', related_name='posts', on_delete=fields.CASCADE, index=True diff --git a/src/dto/analytics.py b/src/dto/analytics.py index 8ab558f..b795089 100644 --- a/src/dto/analytics.py +++ b/src/dto/analytics.py @@ -4,6 +4,8 @@ from enum import StrEnum import pydantic +from src import domain + class DateGrouping(StrEnum): DAY = 'day' @@ -54,6 +56,7 @@ class PlacementAnalyticsOutput(pydantic.BaseModel): class CreativeAnalyticsOutput(pydantic.BaseModel): id: uuid.UUID name: str + tag: domain.CreativeTag placements_count: int total_cost: float total_subscriptions: int @@ -92,6 +95,7 @@ class GetCreativesAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID project_id: uuid.UUID | None = None + tag: domain.CreativeTag | None = None class GetCreativesAnalyticsOutput(pydantic.BaseModel): diff --git a/src/dto/creative.py b/src/dto/creative.py index 66d7bb3..697106c 100644 --- a/src/dto/creative.py +++ b/src/dto/creative.py @@ -33,6 +33,7 @@ class CreativeOutput(pydantic.BaseModel): project_channel_title: str created_at: datetime.datetime status: domain.CreativeStatus + tag: domain.CreativeTag placements_count: int @@ -66,6 +67,7 @@ class CreateCreativeInput(pydantic.BaseModel): text: str media_items: list[CreativeMediaInput] = pydantic.Field(default_factory=list) buttons: list[CreativeButton] = pydantic.Field(default_factory=list) + tag: domain.CreativeTag | None = None class UpdateCreativeInput(pydantic.BaseModel): @@ -74,6 +76,7 @@ class UpdateCreativeInput(pydantic.BaseModel): media_items: list[CreativeMediaInput] | None = None buttons: list[CreativeButton] | None = None status: domain.CreativeStatus | None = None + tag: domain.CreativeTag | None = None class DeleteCreativeInput(pydantic.BaseModel): diff --git a/src/dto/purchase.py b/src/dto/purchase.py index 466340c..28de07b 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -49,6 +49,7 @@ class PlacementPostOutput(pydantic.BaseModel): subscriptions_count: int views_count: int | None created_at: datetime.datetime + time_on_top: int | None = None post: PostOutput diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py index b57ae44..d28e0f8 100644 --- a/src/usecase/analytics/get_creatives_analytics.py +++ b/src/usecase/analytics/get_creatives_analytics.py @@ -42,6 +42,7 @@ async def get_creatives_analytics( include_archived=False, allowed_project_ids=allowed_project_ids, created_by_user_id=created_by_filter, + tag=input.tag, ) placements = await self.database.get_workspace_placement_posts( input.workspace_id, @@ -101,6 +102,7 @@ async def get_creatives_analytics( dto.CreativeAnalyticsOutput( id=cr.id, name=cr.name, + tag=cr.tag, placements_count=stats.placements_count, total_cost=stats.total_cost, total_subscriptions=total_subscriptions, diff --git a/src/usecase/analytics/get_projects_analytics.py b/src/usecase/analytics/get_projects_analytics.py index a8748d5..24b359e 100644 --- a/src/usecase/analytics/get_projects_analytics.py +++ b/src/usecase/analytics/get_projects_analytics.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING from fastapi import HTTPException, status +from tortoise import timezone from src import domain, dto @@ -278,25 +279,25 @@ async def get_projects_analytics( # Определяем дату для period_label (берем первую дату периода) if input.grouping == dto.DateGrouping.DAY: - period_dt = datetime.datetime.strptime(period_key, '%Y-%m-%d') + period_dt = datetime.datetime.strptime(period_key, '%Y-%m-%d').replace(tzinfo=datetime.UTC) elif input.grouping == dto.DateGrouping.WEEK: # ISO week format: YYYY-Www year, week_str = period_key.split('-W') week = int(week_str) # Находим первый день недели (понедельник) для данной ISO недели - jan4 = datetime.datetime(int(year), 1, 4) + jan4 = datetime.datetime(int(year), 1, 4, tzinfo=datetime.UTC) jan4_weekday = jan4.weekday() # 0=Monday, 6=Sunday days_since_monday = (jan4_weekday + 1) % 7 jan4_monday = jan4 - datetime.timedelta(days=days_since_monday) period_dt = jan4_monday + datetime.timedelta(weeks=week - 1) elif input.grouping == dto.DateGrouping.MONTH: - period_dt = datetime.datetime.strptime(period_key, '%Y-%m') + period_dt = datetime.datetime.strptime(period_key, '%Y-%m').replace(tzinfo=datetime.UTC) elif input.grouping == dto.DateGrouping.QUARTER: year, quarter = period_key.split('-Q') month = (int(quarter) - 1) * 3 + 1 - period_dt = datetime.datetime(int(year), month, 1) + period_dt = datetime.datetime(int(year), month, 1, tzinfo=datetime.UTC) else: - period_dt = datetime.datetime.now() + period_dt = timezone.now() period_label = _format_period_label(period_dt, input.grouping) metrics = _calculate_metrics(pd, input.metrics, hide_subscriptions) diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py index 83f541d..f175242 100644 --- a/src/usecase/creative/create_creative.py +++ b/src/usecase/creative/create_creative.py @@ -33,6 +33,7 @@ async def create_creative( text=creative_text, buttons=[button.model_dump() for button in input.buttons], status=domain.CreativeStatus.ACTIVE, + tag=input.tag or domain.CreativeTag.TESTING, project_id=project.id, created_by_user_id=user_id, ) @@ -50,6 +51,7 @@ async def create_creative( project_channel_title=project.channel.title, created_at=creative.created_at, status=creative.status, + tag=creative.tag, placements_count=0, ) diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py index 13adbf6..5ad21fd 100644 --- a/src/usecase/creative/get_creative.py +++ b/src/usecase/creative/get_creative.py @@ -44,5 +44,6 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea project_channel_title=creative.project.channel.title, created_at=creative.created_at, status=creative.status, + tag=creative.tag, placements_count=placements_count, ) diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py index f0e7b9d..db2fd8e 100644 --- a/src/usecase/creative/get_creatives.py +++ b/src/usecase/creative/get_creatives.py @@ -52,6 +52,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d project_channel_title=creative.project.channel.title, created_at=creative.created_at, status=creative.status, + tag=creative.tag, placements_count=placements_counts.get(creative.id, 0), ) ) diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py index 472cb88..edd11d7 100644 --- a/src/usecase/creative/update_creative.py +++ b/src/usecase/creative/update_creative.py @@ -41,6 +41,8 @@ async def update_creative( creative.buttons = [button.model_dump() for button in input.buttons] if input.status: creative.status = input.status + if input.tag: + creative.tag = input.tag await self.database.update_creative(creative) @@ -60,6 +62,7 @@ async def update_creative( project_channel_title=creative.project.channel.title, created_at=creative.created_at, status=creative.status, + tag=creative.tag, placements_count=placements_count, ) diff --git a/src/usecase/purchase/get_placement.py b/src/usecase/purchase/get_placement.py index d413ce2..f22333c 100644 --- a/src/usecase/purchase/get_placement.py +++ b/src/usecase/purchase/get_placement.py @@ -1,6 +1,8 @@ import logging from typing import TYPE_CHECKING +from tortoise import timezone + from src import domain, dto from src.usecase.purchase.create_placements import _build_placement_output @@ -24,7 +26,10 @@ def _build_post_output(post: domain.Post) -> dto.PostOutput: def _build_placement_post_output( - placement_post: domain.PlacementPost, subscriptions_count: int, views_count: int | None + placement_post: domain.PlacementPost, + subscriptions_count: int, + views_count: int | None, + time_on_top: int | None = None, ) -> dto.PlacementPostOutput | None: if not placement_post.post: log.warning('PlacementPost %s missing post', placement_post.id) @@ -40,6 +45,7 @@ def _build_placement_post_output( subscriptions_count=subscriptions_count, views_count=views_count, created_at=placement_post.created_at, + time_on_top=time_on_top, post=post_output, ) @@ -85,12 +91,21 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d log.warning('Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts)) for placement_post in placement_posts: views_count = None + time_on_top = None if placement_post.post: views_count = views_map.get(placement_post.post.id, (None,))[0] + # Calculate time_on_top + post = placement_post.post + next_post = await self.database.get_next_post_after(post.channel_id, post.message_id) + if next_post and next_post.published_at and post.published_at: + time_on_top = int((next_post.published_at - post.published_at).total_seconds()) + elif post.published_at: + time_on_top = int((timezone.now() - post.published_at).total_seconds()) placement_post_output = _build_placement_post_output( placement_post, subscriptions_count, views_count, + time_on_top, ) if placement_post_output is not None: break diff --git a/src/usecase/purchase/get_placements.py b/src/usecase/purchase/get_placements.py index 3f54568..d83a98d 100644 --- a/src/usecase/purchase/get_placements.py +++ b/src/usecase/purchase/get_placements.py @@ -2,6 +2,8 @@ import logging import uuid from typing import TYPE_CHECKING +from tortoise import timezone + from src import domain, dto from .create_placements import _build_placement_output @@ -39,15 +41,40 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. post_ids = [placement_post.post.id for placement_post in placement_posts if placement_post.post] views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} + # Collect (channel_id, message_id) pairs for batch next post lookup + channel_message_pairs = [ + (placement_post.post.channel_id, placement_post.post.message_id) + for placement_post in placement_posts + if placement_post.post + ] + next_posts_map = await self.database.get_next_posts_after_batch(channel_message_pairs) + + # Calculate time_on_top for each placement_post + time_on_top_map: dict[uuid.UUID, int] = {} + now = timezone.now() + for placement_post in placement_posts: + post = placement_post.post + if not post or not post.published_at: + continue + published_at = post.published_at + key = (post.channel_id, post.message_id) + next_post = next_posts_map.get(key) + if next_post and next_post.published_at: + time_on_top_map[placement_post.id] = int((next_post.published_at - published_at).total_seconds()) + else: + time_on_top_map[placement_post.id] = int((now - published_at).total_seconds()) + placement_posts_by_placement_id: dict[uuid.UUID, list[dto.PlacementPostOutput]] = {} for placement_post in placement_posts: views_count = None + time_on_top = time_on_top_map.get(placement_post.id) if placement_post.post: views_count = views_map.get(placement_post.post.id, (None,))[0] placement_post_output = _build_placement_post_output( placement_post, subscriptions_counts.get(placement_post.placement_id, 0), views_count, + time_on_top, ) if placement_post_output is None: continue diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index 37dfaa3..e03f046 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -378,6 +378,7 @@ type Creative struct { ProjectChannelTitle string `json:"project_channel_title"` CreatedAt string `json:"created_at"` Status string `json:"status"` + Tag string `json:"tag"` PlacementsCount int `json:"placements_count"` } @@ -463,6 +464,7 @@ type CreateCreativeInput struct { Text string `json:"text"` MediaItems []CreativeMediaInput `json:"media_items,omitempty"` Buttons []CreativeButton `json:"buttons,omitempty"` + Tag *string `json:"tag,omitempty"` } func (c *Client) CreateCreative( @@ -496,6 +498,7 @@ type UpdateCreativeInput struct { MediaItems *[]CreativeMediaInput `json:"media_items,omitempty"` Buttons *[]CreativeButton `json:"buttons,omitempty"` Status *string `json:"status,omitempty"` + Tag *string `json:"tag,omitempty"` } func (c *Client) UpdateCreative( diff --git a/tg_bot/screens/add_creative.go b/tg_bot/screens/add_creative.go index 2ee18cf..83f8e80 100644 --- a/tg_bot/screens/add_creative.go +++ b/tg_bot/screens/add_creative.go @@ -188,6 +188,18 @@ func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) { s.ctx.MediaChanged = true s.ctx.UpdateCreativePreview(b) s.Enter(b, bot.EditMessage) + case data == "edit_tag": + s.ctx.ShowTagPanel(b) + case data == "tag_testing": + tag := "testing" + s.ctx.Tag = &tag + s.Enter(b, bot.EditMessage) + case data == "tag_production": + tag := "production" + s.ctx.Tag = &tag + s.Enter(b, bot.EditMessage) + case data == "cancel_edit": + s.Enter(b, bot.EditMessage) case data == "confirm_create": s.ctx.createCreative(b) case strings.HasPrefix(data, "delete_button:"): @@ -428,10 +440,16 @@ func (s *AddCreativeCtx) createCreative(b *bot.Bot) { log.Info().Str("text", *s.Text).Msg("Creative text") + tag := "testing" + if s.Tag != nil { + tag = *s.Tag + } + input := backend.CreateCreativeInput{ Name: *s.Name, Text: *s.Text, Buttons: buttons, + Tag: &tag, } if len(mediaItems) > 0 { input.MediaItems = mediaItems diff --git a/tg_bot/screens/creative_details.go b/tg_bot/screens/creative_details.go index d11d2ba..b3d150a 100644 --- a/tg_bot/screens/creative_details.go +++ b/tg_bot/screens/creative_details.go @@ -60,6 +60,9 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) { for _, btn := range creative.Buttons { s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL}) } + if creative.Tag != "" { + s.Tag = &creative.Tag + } s.MediaChanged = false s.ClearInputMode() @@ -109,6 +112,19 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { s.UpdateCreativePreview(b) s.showManagementPanelWithDelete(b) + case data == "edit_tag": + s.ShowTagPanel(b) + + case data == "tag_testing": + tag := "testing" + s.Tag = &tag + s.showManagementPanelWithDelete(b) + + case data == "tag_production": + tag := "production" + s.Tag = &tag + s.showManagementPanelWithDelete(b) + case data == "cancel_add_button": // Отменяем добавление кнопки s.CancelPendingButton(b) @@ -293,6 +309,7 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) { input := backend.UpdateCreativeInput{ Name: s.Name, Text: s.Text, + Tag: s.Tag, } if s.MediaChanged { if len(s.MediaItems) > 0 { diff --git a/tg_bot/screens/creative_editor.go b/tg_bot/screens/creative_editor.go index e367dcf..a5e1065 100644 --- a/tg_bot/screens/creative_editor.go +++ b/tg_bot/screens/creative_editor.go @@ -31,6 +31,7 @@ type CreativeEditorFields struct { Name *string Text *string Buttons []InlineButton + Tag *string // "testing" or "production" // Медиа MediaItems []MediaItem @@ -663,6 +664,9 @@ func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback, } buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback))) + // Кнопка выбора тега + buttons = append(buttons, Row(Button(e.GetTagLabel(), "edit_tag"))) + // Кнопка удаления медиа (если медиа добавлено) if len(e.MediaItems) > 0 { label := "⌫ Убрать медиа" diff --git a/tg_bot/screens/creative_editor_ui.go b/tg_bot/screens/creative_editor_ui.go index a6a0736..424275b 100644 --- a/tg_bot/screens/creative_editor_ui.go +++ b/tg_bot/screens/creative_editor_ui.go @@ -58,6 +58,42 @@ URL должен начинаться с http:// или https ` +const msgEditTag = ` +🏷 Выберите тег креатива: + +🧪 Тестовый — для A/B тестов и экспериментов +🚀 Рабочий — проверенный эффективный креатив + +` + +func (e *CreativeEditorFields) ShowTagPanel(b *bot.Bot) { + testingLabel := "🧪 Тестовый" + productionLabel := "🚀 Рабочий" + + if e.Tag != nil { + if *e.Tag == "testing" { + testingLabel = "✓ 🧪 Тестовый" + } else if *e.Tag == "production" { + productionLabel = "✓ 🚀 Рабочий" + } + } + + e.ShowControlPanel(b, msgEditTag, [][]echotron.InlineKeyboardButton{ + Row( + Button(testingLabel, "tag_testing"), + Button(productionLabel, "tag_production"), + ), + Row(Button("« Назад", "cancel_edit")), + }) +} + +func (e *CreativeEditorFields) GetTagLabel() string { + if e.Tag != nil && *e.Tag == "production" { + return "🏷 Рабочий" + } + return "🏷 Тестовый" +} + func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) { inviteLabel := "Ссылка на канал" customLabel := "Своя ссылка" diff --git a/tg_parser/internal/adapter/database/create_post.go b/tg_parser/internal/adapter/database/create_post.go index 0e1c1a2..0a2fe11 100644 --- a/tg_parser/internal/adapter/database/create_post.go +++ b/tg_parser/internal/adapter/database/create_post.go @@ -11,22 +11,24 @@ import ( ) func (d *Database) CreatePost(ctx context.Context, post domain.Post) error { - query := `INSERT INTO post (id, channel_id, message_id, text) -VALUES ($1, $2, $3, $4) + query := `INSERT INTO post (id, channel_id, message_id, text, published_at) +VALUES ($1, $2, $3, $4, $5) ON CONFLICT (channel_id, message_id) DO UPDATE SET text = EXCLUDED.text, + published_at = COALESCE(post.published_at, EXCLUDED.published_at), updated_at = CURRENT_TIMESTAMP;` txOrPool := transaction.TryExtractTX(ctx) dto := createPostDTO{ - ID: pgtype.UUID{Bytes: post.ID, Valid: true}, - ChannelID: pgtype.UUID{Bytes: post.ChannelID, Valid: true}, - MessageID: pgtype.Int4{Int32: int32(post.MessageID), Valid: true}, - Text: pgtype.Text{String: post.Text, Valid: true}, + ID: pgtype.UUID{Bytes: post.ID, Valid: true}, + ChannelID: pgtype.UUID{Bytes: post.ChannelID, Valid: true}, + MessageID: pgtype.Int4{Int32: int32(post.MessageID), Valid: true}, + Text: pgtype.Text{String: post.Text, Valid: true}, + PublishedAt: pgtype.Timestamptz{Time: post.PublishedAt, Valid: !post.PublishedAt.IsZero()}, } - _, err := txOrPool.Exec(ctx, query, dto.ID, dto.ChannelID, dto.MessageID, dto.Text) + _, err := txOrPool.Exec(ctx, query, dto.ID, dto.ChannelID, dto.MessageID, dto.Text, dto.PublishedAt) if err != nil { return fmt.Errorf("txOrPool.Exec: %w", err) } @@ -35,8 +37,9 @@ SET text = EXCLUDED.text, } type createPostDTO struct { - ID pgtype.UUID - ChannelID pgtype.UUID - MessageID pgtype.Int4 - Text pgtype.Text + ID pgtype.UUID + ChannelID pgtype.UUID + MessageID pgtype.Int4 + Text pgtype.Text + PublishedAt pgtype.Timestamptz } diff --git a/tg_parser/internal/adapter/telegram/get_channel_diff.go b/tg_parser/internal/adapter/telegram/get_channel_diff.go index f5c008b..fd62da4 100644 --- a/tg_parser/internal/adapter/telegram/get_channel_diff.go +++ b/tg_parser/internal/adapter/telegram/get_channel_diff.go @@ -3,6 +3,7 @@ package telegram import ( "context" "fmt" + "time" "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" "github.com/gotd/td/tg" @@ -60,7 +61,8 @@ func extractPosts(channel domain.Channel, msgs []tg.MessageClass) []domain.Post } text := messageToHTML(m.Message, m.Entities) - p := domain.NewPost(channel, m.ID, text, m.Views) + publishedAt := time.Unix(int64(m.Date), 0).UTC() + p := domain.NewPost(channel, m.ID, text, m.Views, publishedAt) posts = append(posts, p) } @@ -73,7 +75,7 @@ func extractDeletedPosts(channel domain.Channel, updates []tg.UpdateClass) []dom for _, upd := range updates { if u, ok := upd.(*tg.UpdateDeleteChannelMessages); ok { for _, msgID := range u.Messages { - p := domain.NewPost(channel, msgID, "", 0) + p := domain.NewPost(channel, msgID, "", 0, time.Time{}) deleted = append(deleted, p) } } diff --git a/tg_parser/internal/adapter/telegram/history.go b/tg_parser/internal/adapter/telegram/history.go index 9bc2854..79a4e99 100644 --- a/tg_parser/internal/adapter/telegram/history.go +++ b/tg_parser/internal/adapter/telegram/history.go @@ -3,6 +3,7 @@ package telegram import ( "context" "fmt" + "time" "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" "github.com/gotd/td/tg" @@ -39,7 +40,8 @@ func (t *Telegram) GetChannelHistory(ctx context.Context, channel domain.Channel } text := messageToHTML(m.Message, m.Entities) - p := domain.NewPost(channel, m.ID, text, m.Views) + publishedAt := time.Unix(int64(m.Date), 0).UTC() + p := domain.NewPost(channel, m.ID, text, m.Views, publishedAt) placements = append(placements, p) } diff --git a/tg_parser/internal/domain/post.go b/tg_parser/internal/domain/post.go index 8e6f90d..7f5d452 100644 --- a/tg_parser/internal/domain/post.go +++ b/tg_parser/internal/domain/post.go @@ -2,20 +2,22 @@ package domain import ( "fmt" + "time" "github.com/google/uuid" ) type Post struct { - ID uuid.UUID - ChannelID uuid.UUID - MessageID int - Text string - Link string - Views int + ID uuid.UUID + ChannelID uuid.UUID + MessageID int + Text string + Link string + Views int + PublishedAt time.Time } -func NewPost(channel Channel, messageID int, text string, views int) Post { +func NewPost(channel Channel, messageID int, text string, views int, publishedAt time.Time) Post { link := "" if channel.Username != "" { link = fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID) @@ -23,11 +25,12 @@ func NewPost(channel Channel, messageID int, text string, views int) Post { link = fmt.Sprintf("https://t.me/c/%d/%d", ChannelIDFromChatID(channel.TelegramID), messageID) } return Post{ - ID: uuid.New(), - ChannelID: channel.ID, - MessageID: messageID, - Text: text, - Link: link, - Views: views, + ID: uuid.New(), + ChannelID: channel.ID, + MessageID: messageID, + Text: text, + Link: link, + Views: views, + PublishedAt: publishedAt, } }