размещения ручки
This commit is contained in:
4
Makefile
4
Makefile
@@ -1,6 +1,10 @@
|
|||||||
test:
|
test:
|
||||||
pytest --no-header
|
pytest --no-header
|
||||||
|
|
||||||
|
build-parser:
|
||||||
|
docker build --platform linux/amd64 -f ./tg_parser/Dockerfile -t tg-parser:latest .
|
||||||
|
docker save tg-parser:latest -o tg-parser-x86.tar
|
||||||
|
|
||||||
env:
|
env:
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
|
|||||||
106
migrations/models/4_20260120193804_update.py
Normal file
106
migrations/models/4_20260120193804_update.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
DROP INDEX IF EXISTS "idx_placement_p_creativ_a8f431";
|
||||||
|
DROP INDEX IF EXISTS "idx_placement_p_project_888982";
|
||||||
|
ALTER TABLE "placement_post" DROP CONSTRAINT IF EXISTS "fk_placemen_project_f98a29a5";
|
||||||
|
ALTER TABLE "placement_post" DROP CONSTRAINT IF EXISTS "fk_placemen_creative_db63ad95";
|
||||||
|
ALTER TABLE "placement" ALTER COLUMN "creative_id" DROP NOT NULL;
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN "comment";
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN "project_id";
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN "cost";
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN "wanted_placement_date";
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN "creative_id";"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "placement" ALTER COLUMN "creative_id" SET NOT NULL;
|
||||||
|
ALTER TABLE "placement_post" ADD "comment" TEXT;
|
||||||
|
ALTER TABLE "placement_post" ADD "project_id" UUID NOT NULL;
|
||||||
|
ALTER TABLE "placement_post" ADD "cost" DOUBLE PRECISION;
|
||||||
|
ALTER TABLE "placement_post" ADD "wanted_placement_date" TIMESTAMPTZ NOT NULL;
|
||||||
|
ALTER TABLE "placement_post" ADD "creative_id" UUID NOT NULL;
|
||||||
|
ALTER TABLE "placement_post" ADD CONSTRAINT "fk_placemen_creative_db63ad95" FOREIGN KEY ("creative_id") REFERENCES "creative" ("id") ON DELETE CASCADE;
|
||||||
|
ALTER TABLE "placement_post" ADD CONSTRAINT "fk_placemen_project_f98a29a5" FOREIGN KEY ("project_id") REFERENCES "project" ("id") ON DELETE CASCADE;
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_placement_p_project_888982" ON "placement_post" ("project_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_placement_p_creativ_a8f431" ON "placement_post" ("creative_id");"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"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"
|
||||||
|
"f/C4NtvdBk9VSAWNZJcSZj+9vP8f6Gvh+w=="
|
||||||
|
)
|
||||||
140
migrations/models/5_20260120195442_update.py
Normal file
140
migrations/models/5_20260120195442_update.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "placement" ALTER COLUMN "status" SET DEFAULT 'Без статуса';
|
||||||
|
UPDATE "placement"
|
||||||
|
SET "status" = 'Без статуса'
|
||||||
|
WHERE "status" IN ('planned', 'approved', 'in_progress', 'completed', 'rejected');
|
||||||
|
ALTER TABLE "placement" ALTER COLUMN "status" TYPE VARCHAR(64) USING "status"::VARCHAR(64);
|
||||||
|
COMMENT ON COLUMN "placement"."status" IS 'NO_STATUS: Без статуса
|
||||||
|
WRITE: Написать
|
||||||
|
WAITING_RESPONSE: Ждём ответа
|
||||||
|
TERMS_APPROVAL: Согласование условий
|
||||||
|
TO_PAY: Оплатить
|
||||||
|
PAID: Оплачено
|
||||||
|
CANCELED: Отмена
|
||||||
|
PRICE_NOT_OK: Не подходит цена
|
||||||
|
NOT_RELEVANT: Неактуально
|
||||||
|
NO_RESPONSE: Не отвечает';
|
||||||
|
ALTER TABLE "placement" ALTER COLUMN "invite_link" DROP NOT NULL;
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "wanted_placement_date";
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "cost";
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "comment";
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "creative_id";
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "project_id";
|
||||||
|
ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "status";"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "placement" ALTER COLUMN "status" SET DEFAULT 'planned';
|
||||||
|
UPDATE "placement"
|
||||||
|
SET "status" = 'planned'
|
||||||
|
WHERE "status" IN (
|
||||||
|
'Без статуса',
|
||||||
|
'Написать',
|
||||||
|
'Ждём ответа',
|
||||||
|
'Согласование условий',
|
||||||
|
'Оплатить',
|
||||||
|
'Оплачено',
|
||||||
|
'Отмена',
|
||||||
|
'Не подходит цена',
|
||||||
|
'Неактуально',
|
||||||
|
'Не отвечает'
|
||||||
|
);
|
||||||
|
COMMENT ON COLUMN "placement"."status" IS 'PLANNED: planned
|
||||||
|
APPROVED: approved
|
||||||
|
REJECTED: rejected
|
||||||
|
IN_PROGRESS: in_progress
|
||||||
|
COMPLETED: completed';
|
||||||
|
ALTER TABLE "placement" ALTER COLUMN "status" TYPE VARCHAR(11) USING "status"::VARCHAR(11);
|
||||||
|
ALTER TABLE "placement" ALTER COLUMN "invite_link" SET NOT NULL;
|
||||||
|
ALTER TABLE "placement_post" ADD "wanted_placement_date" TIMESTAMPTZ;
|
||||||
|
ALTER TABLE "placement_post" ADD "cost" DOUBLE PRECISION;
|
||||||
|
ALTER TABLE "placement_post" ADD "comment" TEXT;
|
||||||
|
ALTER TABLE "placement_post" ADD "creative_id" UUID;
|
||||||
|
ALTER TABLE "placement_post" ADD "project_id" UUID;
|
||||||
|
ALTER TABLE "placement_post" ADD "status" VARCHAR(8) NOT NULL DEFAULT 'active';
|
||||||
|
COMMENT ON COLUMN "placement_post"."status" IS 'ACTIVE: active\nARCHIVED: archived';"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
'eJztXWtv2soW/SuITz1SbpUACTnR1ZUc4rRuCSAeac9pKsuYgfjG2BzbNI2q/vcz4wee8Q'
|
||||||
|
'NsY8CG/YWmM7PH47XntdY8/Ks61ydINd+3niVNQ2r1pvKrqklzhP8IRp1VqtJi4UeQAEsa'
|
||||||
|
'q3ZamUo0Ni1Dki0cPJVUE+GgCTJlQ1lYiq7hUG2pqiRQl3FCRZv5QUtN+WeJREufIesZGT'
|
||||||
|
'ji23ccrGgT9BOZ3n8XL+JUQeqEKawyIc+2w0XrbWGHjUbC3b2dkjxuLMq6upxrfurFm/Ws'
|
||||||
|
'a6vky6UyeU9sSNwMaciQLDShXoOU0n1hL8gpMQ6wjCVaFXXiB0zQVFqqBIzqf6dLTSYYVO'
|
||||||
|
'wnkZ/G/6op4JF1jUCraBbB4tdv5638d7ZDq+RRrY9c/1396g/7LXXTmhl2pI1I9bdtKFmS'
|
||||||
|
'Y2rj6gMpG4i8tihZYUDvcIylzFE0qKxlANyJa/re+yMLyF6Aj7JfwzyYPfiyYVrF7zDpau'
|
||||||
|
'qb68E1GA+FB34w5B565E3mpvmPakPEDXkSU7ND3wKh7xyX6Lh9OO1mlUnlizD8WCH/rfzd'
|
||||||
|
'7fBBx63SDf+ukjJJS0sXNf1VlCZUZfNCPWBwSt+xy8Uko2NZS3DsQR3rFt73K+6LUTa/sp'
|
||||||
|
'Y5+NUt7R7dWhI3eq+9toFa2B0zQ5qLUWPZrTITNCvajwHDgCMxVLsZ0LZskDPylP/8WavV'
|
||||||
|
'683aef3q+rLRbF5en1/jtHaRwlHNNe6+FT4InSHrPRLwm8VYsVQURhfPcYwYbD2DAKr4VQ'
|
||||||
|
'ra0c2ln6KKtJn1jP9bu7xcg9kj17dnCjhVoN533KiaE8eCuDSRYf+dAkfaJh8od15Bdw+k'
|
||||||
|
'JMvINMVnyXxO1+IDhpla/P4768M0+YVlhsGNRdZNXS5EaxeNZuO6ftVYAbkKWYefgxWhVN'
|
||||||
|
'MXiguQgLEkv7xKxkRkYihQVUlGc6RFYXvr2t5/7iNVsl8pjKhLLntePsXsTH971cMLjZqA'
|
||||||
|
'kXnAtjDgLMqMgKH/H8lbg+DkUmYckDFXTBNnLZqyTpraVoB80Y0Xc4EbyAgPnr1V3gOSdS'
|
||||||
|
'G7o1iQSJei1/S4TiYcNa/NgyGSJs3sUpNnkyd5+hSRHZQfqBqlXXlxZ2vFKzoVqFegXoHI'
|
||||||
|
'AeoVOBbUK1CvkqtXaQWBfMWAo9BVLPQzohUMcWic7vczqu4XFsJ11Z3/OmRqugfUuwfu6x'
|
||||||
|
'9MbW93Ox+85BSwrXb3NoDnHE0UyYElRcVkrTJhewAhgKmd9VqCylmvxdZNEhUF5VRRUaQ6'
|
||||||
|
'vQlNyrCUgF5eJEEUp4qF1I6LwtSsiy/oLT2kvh0g6iE6XloWfmgYzE+DbicaTMokgONIwy'
|
||||||
|
'/4baLI1llFVUzr+646VYpajZeKaima+Z48cEfsimCxvqsN9qqB2QLJINjVmpZkLSOAJ7WY'
|
||||||
|
'15ZzG3wBF1HSZBRygm+9v6Gsikm+y/lZjKtcayg88jcVJ8GTRtDBAXc4xJCfcZBTi1JW+O'
|
||||||
|
'sE1f06trJfB6u6K7lF9sbxygFrlaeCEIY85+WY7HpBSOAOoRiG8F43kDLTPqO3UM3dg3i5'
|
||||||
|
'G+hCshwONqTXlRwVqB34HR2KZUPMDVrcHV/9XdTFgQKJnaAIH1gRbuszRRvqL0irRmjCVO'
|
||||||
|
'zZOlVYJelEa5UQhGEQhkE/BGEYHAvCMAjDKbY1eiNo4i13ngHsE3MhRD8XCvZIhrbAWpaz'
|
||||||
|
'jzumxrA0sw1VJvRnRXHhHJkm5h2RykvstkLW6IR2Fwb3DacUrCgTUKsIGDlIVSM3m6KCtl'
|
||||||
|
'GnoipFWpFql7KDL1dFqA6MlhUvOiyYZLuUHL7R4qcrwH8HHQJ0CKCroEOcrmNBhzjWeXsJ'
|
||||||
|
'l6iflueNiwvyW7+0f5sV8k/DDmrU7KBz/+9G3Y+tn0esbHe6IkZxOBrcVLbK+kn70heGvJ'
|
||||||
|
'vLxE9cn9q/10xiPxMZG3LCUOh8EPv8oNftDLw8ruy0DfJ76djJdkQdUeWp+cVdlVAb8v2H'
|
||||||
|
'gcj1ev3uI9d2sqtd+LZ1u+T1MVUYOpZ60/rEL3z9shJ67XHIzEn6Jy5EV+xxf7nvgigk6M'
|
||||||
|
'dSNj4ePU6422DXpLw0ccugtbhOi2/zjK37BDmUHsPU6wstXux0h2L3M+M350XdxyLfDY3L'
|
||||||
|
'YIhXdAeZq6inkPz7uFiPXGfIPoWCWQpWKzd87GLCvCeusoGqwhQ7rno0qXzdCpNl00btPI'
|
||||||
|
'mYdx6v5Z2H9m14E/wMA0rQFoaUAw8pC+ktqysZS3DkgR0p4/zWbBTePD1gMjjshszqvfCV'
|
||||||
|
'jAtTBZvhgaL3cFORF/Ms3V/Oe9ZskH5I6jIC5ntVl2K0U9YsAO6U2JWtfdx1R7dtvtLr8y'
|
||||||
|
'1hILjbMVcNwo60VZl/VMVRtvo8144Cc4ymuoHEsWTM8HPToxq2B3iD4/Q23UI4lwP3DQO+'
|
||||||
|
'fS/iqfJD96ZiInUqLgx9rj9puAPt3HF93G2QN5lIRqY9rhdXCTqMi2BH7/cYJIp1A66e86'
|
||||||
|
'jRNf5QjG9Rkn3x+z4VI+tzT2BOiillAqBGgqpoP3BfIqqK9hLdV8QI5axZScDdw0kOCpit'
|
||||||
|
'OuCofA58XK7awwOQ0LqpLJZjVZGfNF+7kBa4O8YznSLM1ZwLPVMu17JWJ7Jiy8DmXiWRFj'
|
||||||
|
'fWbAvgNvcGhcQNjrPAcZZDHGeJars5wEffO1PQBrsRvkCflAA//w7obeHzcypt7WOHwhwO'
|
||||||
|
'U4l53DPmZVb2C8fgWNX+j1WxlWfdHievdiXY52TXajstnK+CfU2w/WWvayOwr+lkHAv7mo'
|
||||||
|
'517dIfSlOS54DdidDn4M29aVHzTU5DpVknN9Cb1bcVHPK9CPpQkkOgTW0mzd70d1v4kvG5'
|
||||||
|
'wooNVLNiMBvww0pn1G4n48rmcrwq5ZaUcEBlVcypyWE4YBz128z4dsLzvgV0Fuqc3XfggD'
|
||||||
|
'sZPoEDHjlVAA54pI4FDnisHPBQZ9IP0RpzPpQOly7nuxPK6xmmhj4XvclZ9g4mIhvobQ69'
|
||||||
|
'Wx72BlW3V09gufzolssLtBBMt9cfCno1xWfFtHRD2XatnEDxSDL8aOf3VuhRb//iCANNjF'
|
||||||
|
'AShG+9aCLS7nvbhYRCy+DIkp+deT3oJ6CfAM0G/QQcC/rJsTMaZ4iV9WXUWmasgBKwOlUF'
|
||||||
|
'hZozpGwFrOV+erf9UcPStYKDbYcolNu22A+x17X84nL5uMX8Q9/P6J5piWJl/nGXNWSMSp'
|
||||||
|
'QzA3v1NqC7sFFqCNAwoGEwWwcaBo4FGnbsE9ASXtGY+CuCQscLUbRif1lwicsimXgq4tyd'
|
||||||
|
'QDIUvTfO6JpNee7TYfQdC/u4mOEiyR2CF/F3CF6E7hCE9ddMu/2Dk+ykwAXtTgQ6WLr2Kk'
|
||||||
|
'IOS9eR9TAH/L7QeZUWwWALy778711asOUid4r7G4q0uB15LGyPH2EtLBZwXcD+d0MwJ2gi'
|
||||||
|
'NLfgCZt44c0Mpsx7/wOzZ8jtgyzcAc0MaS56X5sBIQ6EONBrQIgDx4IQd+xC3AGvM90/S9'
|
||||||
|
'nDdabHLGyOOoPR7aDVF26JkLnU3NnaOJuYmQj9NeCHsadLlGXECZtD93TojSpRE/bEW1ai'
|
||||||
|
'jE9E02MPvgUITgoMo2xPBMIk96GIee0Eyv+6xoNfjLJxc1BsHc0B0KGbX+k/gRvVAIu014'
|
||||||
|
'oBOkL8CToiXvwJVQG4KBJEnXwmYcD9QdQBx4KocyKsaTWSRo1lt8os9pxLwDCfcy6bB7Rc'
|
||||||
|
'Trn8WavV683aef3q+rLRbF5en6+Ou4Sj1p17uRU+kKMvjPfCZ2HIJMX+OwRwvGpG25TyC0'
|
||||||
|
'C1y8sEog1OFf+dVBIXOFWkGJgopMWStQI0PTRVKQOYjNFpYxli/Uk2o8B1lKn3GLA9aTxm'
|
||||||
|
'XQ0NdfyzGbmEVL9gmzJSUO04ir2ZWgOjXj8BAUYNxAsYNTgWGHX+w17R3ZiOURdwCW3X/D'
|
||||||
|
'rHFbQkc2lVnykYGf0FbTuVbpOchiSjYvZ7iXY0m/Y3BuztNnltZhbs3EqMCXWKAICJBob0'
|
||||||
|
'NXlufi8ZKGEqmnip2SOce1po3lPvvat15q3Wjv0zTRGsljnwFE9tmTNWwG+B3+YzXQYaBP'
|
||||||
|
'wWHAv89kT4bdoFo63Wig7RBAu0WOTeLrbtaV3/jrLiwZ2IrAB3A+6276PKweqyjnf4NSoB'
|
||||||
|
'+3CViPxJSPjKQDimDPwEprHAT8CxwE9OhZ+U8FjtAmkT4p6QA6s9vnMndD7cVNwkTxrXav'
|
||||||
|
'G9oX09oCyjBS7Jk9bnH7ufSZCBfugvhbgw0JnjTMTxW8ol0JDhiRwhDG4YTgnb6Z25hFv8'
|
||||||
|
'MkO35rgq3EN3lukeuui+LwcQS38qNdShbwYwp4O9pYeuoOd4WcVonS6yebsxK2WBJgKaCG'
|
||||||
|
'giheJcoImcjGNBEwFNpDCaSIpvKDwKth7iTjSftNt2t2XLIWNVlzPKIetO/XpySDNWDmmG'
|
||||||
|
'7hsDRg+MHhh9MWhVekYPhHRrQhp18fpurlwv5iwx0caOYt1JXySU9qZnUPVok7LBVrmkGo'
|
||||||
|
'e4YM12JnfQEwj/kSB7gOwB7BhkD3AsyB7HLnuwI20W6YPN4cBb2Kvc3YPQEe9HbfL1wclc'
|
||||||
|
'0cQpTvek9frdT3xrOBD7PHd3U/F2jYukhVKxX/rCkKeiXw3FQk9aC1sRScUzX30uy7X349'
|
||||||
|
'0M/ARuDr021+If+I5fgtV3prwy+Cm8UvhJ3Fy4Dtf+ayi0vEzwdE19sxTZy8OPJzWnOxqK'
|
||||||
|
'rbbQ+jygU74q2ENLS5RVRX4xcdk/cp0O3169mvNVttWbebHei3nRdomyyEa1RpLTAo34ww'
|
||||||
|
'KNoHIUOZ9Lr4acnpqURBLJ6wrpHeyhP7g2UobF5yCnTc7YVhw4A21zuHn+5A2Y2Q66AWBm'
|
||||||
|
'Rz+BB2Z2pI4FZgbMDJgZMLNiMjP2E99Jp2Os1RbTss0dUGGoGIOaW0XTwsaanR5u/seSsn'
|
||||||
|
'7Z7GSRc3rVtLgxVqeHGuhOoDsVU3eKaNs5QJn8kpRDNejN39NjuqzN4Hljag7otaisygpf'
|
||||||
|
'YIqRoPJ5Q2se1Y/Oq6wIBucaCaqgMxvOowb6OZUVPpYaFEl055ChyM/VCHndjTlbJ6RLfp'
|
||||||
|
'rC6OWx37VK+jkr14Hb6eRb6nzO16xqF41m47p+1Vh9xGoVsm4Xu/edqnh5/Acy4hWaaPQo'
|
||||||
|
'kxO/743u5UjTSAGim7ycAF6cnycAEKeK/1o7iQuIBrpmRY6znwbdToxg4JsEgBxp+AW/TR'
|
||||||
|
'TZOquoiml9Lyasa1Akb80ovR547x64r0FcW+3ubZCqkAxu011KmP/w8vtfpk0r+g=='
|
||||||
|
)
|
||||||
@@ -382,6 +382,14 @@ class Postgres(DatabaseBase):
|
|||||||
'project', 'project__channel', 'channel', 'creative'
|
'project', 'project__channel', 'channel', 'creative'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_placement(placement: domain.Placement) -> None:
|
||||||
|
await placement.save()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_placement(placement_id: uuid.UUID) -> None:
|
||||||
|
await domain.Placement.filter(id=placement_id).delete()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_project_placements(
|
async def get_project_placements(
|
||||||
workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False
|
workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False
|
||||||
@@ -391,10 +399,12 @@ class Postgres(DatabaseBase):
|
|||||||
if not include_archived:
|
if not include_archived:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
status__in=[
|
status__in=[
|
||||||
domain.PlacementStatus.PLANNED,
|
domain.PlacementStatus.NO_STATUS,
|
||||||
domain.PlacementStatus.APPROVED,
|
domain.PlacementStatus.WRITE,
|
||||||
domain.PlacementStatus.IN_PROGRESS,
|
domain.PlacementStatus.WAITING_RESPONSE,
|
||||||
domain.PlacementStatus.COMPLETED,
|
domain.PlacementStatus.TERMS_APPROVAL,
|
||||||
|
domain.PlacementStatus.TO_PAY,
|
||||||
|
domain.PlacementStatus.PAID,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -418,6 +428,10 @@ class Postgres(DatabaseBase):
|
|||||||
'post__channel',
|
'post__channel',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def count_placement_posts_by_placement(placement_id: uuid.UUID) -> int:
|
||||||
|
return await domain.PlacementPost.filter(placement_id=placement_id).count()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_workspace_placement_posts(
|
async def get_workspace_placement_posts(
|
||||||
workspace_id: uuid.UUID,
|
workspace_id: uuid.UUID,
|
||||||
@@ -450,9 +464,6 @@ class Postgres(DatabaseBase):
|
|||||||
if date_to:
|
if date_to:
|
||||||
query = query.filter(created_at__lte=date_to)
|
query = query.filter(created_at__lte=date_to)
|
||||||
|
|
||||||
if not include_archived:
|
|
||||||
query = query.filter(status=domain.PlacementPostStatus.ACTIVE)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
await query.prefetch_related(
|
await query.prefetch_related(
|
||||||
'placement',
|
'placement',
|
||||||
@@ -481,9 +492,6 @@ class Postgres(DatabaseBase):
|
|||||||
placement_id__in=placement_ids,
|
placement_id__in=placement_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not include_archived:
|
|
||||||
query = query.filter(status=domain.PlacementPostStatus.ACTIVE)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
await query.prefetch_related(
|
await query.prefetch_related(
|
||||||
'placement',
|
'placement',
|
||||||
|
|||||||
@@ -61,3 +61,51 @@ async def get_placement(
|
|||||||
placement_id=placement_id,
|
placement_id=placement_id,
|
||||||
)
|
)
|
||||||
return await deps.get_usecase().get_placement_user(input=input)
|
return await deps.get_usecase().get_placement_user(input=input)
|
||||||
|
|
||||||
|
|
||||||
|
@placements_user_router.patch('/{placement_id}')
|
||||||
|
async def update_placement(
|
||||||
|
placement_id: uuid.UUID,
|
||||||
|
request: dto.UpdatePlacementInput,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||||
|
) -> dto.PlacementWithPostsOutput:
|
||||||
|
return await deps.get_usecase().update_placement(
|
||||||
|
placement_id=placement_id,
|
||||||
|
input=request,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
project_id=project_id,
|
||||||
|
user_id=current_user.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@placements_user_router.post('/{placement_id}/creative')
|
||||||
|
async def build_placement_creative(
|
||||||
|
placement_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||||
|
) -> dto.CreativePreviewOutput:
|
||||||
|
return await deps.get_usecase().build_placement_creative(
|
||||||
|
placement_id=placement_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
project_id=project_id,
|
||||||
|
user_id=current_user.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@placements_user_router.delete('/{placement_id}')
|
||||||
|
async def delete_placement(
|
||||||
|
placement_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||||
|
) -> None:
|
||||||
|
input = dto.DeletePlacementInput(
|
||||||
|
user_id=current_user.user_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
project_id=project_id,
|
||||||
|
placement_id=placement_id,
|
||||||
|
)
|
||||||
|
await deps.get_usecase().delete_placement(input=input)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ __all__ = (
|
|||||||
'PlacementStatus',
|
'PlacementStatus',
|
||||||
'PlacementType',
|
'PlacementType',
|
||||||
'PlacementPost',
|
'PlacementPost',
|
||||||
'PlacementPostStatus',
|
|
||||||
'Creative',
|
'Creative',
|
||||||
'replace_invite_link_with_tag',
|
'replace_invite_link_with_tag',
|
||||||
'validate_media_size',
|
'validate_media_size',
|
||||||
@@ -34,7 +33,6 @@ __all__ = (
|
|||||||
'SubscriptionStatus',
|
'SubscriptionStatus',
|
||||||
'InviteLinkType',
|
'InviteLinkType',
|
||||||
'CostType',
|
'CostType',
|
||||||
'PostViewsAvailability',
|
|
||||||
'LoginToken',
|
'LoginToken',
|
||||||
'UserNotFound',
|
'UserNotFound',
|
||||||
'WorkspaceNotFound',
|
'WorkspaceNotFound',
|
||||||
@@ -104,7 +102,7 @@ from .placement import (
|
|||||||
PlacementStatus,
|
PlacementStatus,
|
||||||
PlacementType,
|
PlacementType,
|
||||||
)
|
)
|
||||||
from .placement_post import PlacementPost, PlacementPostStatus, PostViewsAvailability
|
from .placement_post import PlacementPost
|
||||||
from .post import Post
|
from .post import Post
|
||||||
from .post_views_history import PostViewsHistory
|
from .post_views_history import PostViewsHistory
|
||||||
from .project import Project, ProjectStatus
|
from .project import Project, ProjectStatus
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ def PlacementPostNotFound(placement_post_id: uuid.UUID | None = None) -> HTTPExc
|
|||||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'PlacementPost {placement_post_id} not found')
|
return HTTPException(status.HTTP_404_NOT_FOUND, f'PlacementPost {placement_post_id} not found')
|
||||||
|
|
||||||
|
|
||||||
|
def PlacementHasPosts(placement_id: uuid.UUID) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
f'Placement {placement_id} has placement_posts and cannot remove creative',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
|
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
|
||||||
return HTTPException(status.HTTP_409_CONFLICT, f'Channel {telegram_id} already exists in the system')
|
return HTTPException(status.HTTP_409_CONFLICT, f'Channel {telegram_id} already exists in the system')
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ class CostType(enum.StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class PlacementStatus(enum.StrEnum):
|
class PlacementStatus(enum.StrEnum):
|
||||||
PLANNED = 'planned'
|
NO_STATUS = 'Без статуса'
|
||||||
APPROVED = 'approved'
|
WRITE = 'Написать'
|
||||||
REJECTED = 'rejected'
|
WAITING_RESPONSE = 'Ждём ответа'
|
||||||
IN_PROGRESS = 'in_progress'
|
TERMS_APPROVAL = 'Согласование условий'
|
||||||
COMPLETED = 'completed'
|
TO_PAY = 'Оплатить'
|
||||||
|
PAID = 'Оплачено'
|
||||||
|
CANCELED = 'Отмена'
|
||||||
|
PRICE_NOT_OK = 'Не подходит цена'
|
||||||
|
NOT_RELEVANT = 'Неактуально'
|
||||||
|
NO_RESPONSE = 'Не отвечает'
|
||||||
|
|
||||||
|
|
||||||
class PlacementType(enum.StrEnum):
|
class PlacementType(enum.StrEnum):
|
||||||
@@ -38,7 +43,7 @@ class InviteLinkType(enum.StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class Placement(TimestampedModel):
|
class Placement(TimestampedModel):
|
||||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.PLANNED)
|
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.NO_STATUS)
|
||||||
placement_at = fields.DatetimeField(null=True)
|
placement_at = fields.DatetimeField(null=True)
|
||||||
payment_at = fields.DatetimeField(null=True)
|
payment_at = fields.DatetimeField(null=True)
|
||||||
cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
|
cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
|
||||||
@@ -48,14 +53,14 @@ class Placement(TimestampedModel):
|
|||||||
format = fields.TextField(null=True)
|
format = fields.TextField(null=True)
|
||||||
comment = fields.TextField(null=True)
|
comment = fields.TextField(null=True)
|
||||||
|
|
||||||
invite_link = fields.CharField(max_length=512)
|
invite_link = fields.CharField(max_length=512, null=True)
|
||||||
invite_link_type = fields.CharEnumField(InviteLinkType)
|
invite_link_type = fields.CharEnumField(InviteLinkType)
|
||||||
|
|
||||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||||
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
|
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
)
|
)
|
||||||
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
|
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
|
||||||
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True
|
'models.Creative', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
|
||||||
)
|
)
|
||||||
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
|
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
|
||||||
'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
|
'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
@@ -63,7 +68,7 @@ class Placement(TimestampedModel):
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
project_id: uuid.UUID
|
project_id: uuid.UUID
|
||||||
creative_id: uuid.UUID
|
creative_id: uuid.UUID | None
|
||||||
channel_id: uuid.UUID
|
channel_id: uuid.UUID
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import enum
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -7,50 +6,21 @@ from tortoise import fields
|
|||||||
from .base import TimestampedModel
|
from .base import TimestampedModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .creative import Creative
|
|
||||||
from .placement import Placement
|
from .placement import Placement
|
||||||
from .post import Post
|
from .post import Post
|
||||||
from .project import Project
|
|
||||||
|
|
||||||
__all__ = ['PlacementPost', 'PlacementPostStatus', 'PostViewsAvailability']
|
__all__ = ['PlacementPost']
|
||||||
|
|
||||||
|
|
||||||
class PlacementPostStatus(str, enum.Enum):
|
|
||||||
ACTIVE = 'active' # Пост найден и отслеживается
|
|
||||||
ARCHIVED = 'archived' # Вручную архивировано
|
|
||||||
|
|
||||||
|
|
||||||
class PostViewsAvailability(str, enum.Enum):
|
|
||||||
UNKNOWN = 'unknown' # Ещё не проверялось
|
|
||||||
AVAILABLE = 'available' # Просмотры доступны
|
|
||||||
UNAVAILABLE = 'unavailable' # Нет доступа / битая ссылка / приватный канал
|
|
||||||
MANUAL = 'manual' # Просмотры вводятся вручную
|
|
||||||
|
|
||||||
|
|
||||||
class PlacementPost(TimestampedModel):
|
class PlacementPost(TimestampedModel):
|
||||||
wanted_placement_date = fields.DatetimeField(db_column='placement_date')
|
|
||||||
cost = fields.FloatField(null=True)
|
|
||||||
comment = fields.TextField(null=True)
|
|
||||||
|
|
||||||
status = fields.CharEnumField(PlacementPostStatus, default=PlacementPostStatus.ACTIVE)
|
|
||||||
|
|
||||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
|
||||||
'models.Project', related_name='placement_posts', on_delete=fields.CASCADE, index=True
|
|
||||||
)
|
|
||||||
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
|
|
||||||
'models.Creative', related_name='placement_posts', on_delete=fields.CASCADE, index=True
|
|
||||||
)
|
|
||||||
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
|
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
|
||||||
'models.Placement', related_name='placement_posts', on_delete=fields.CASCADE, index=True
|
'models.Placement', related_name='placement_posts', on_delete=fields.CASCADE, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
|
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
|
||||||
'models.Post', related_name='placement_posts', on_delete=fields.SET_NULL, null=True, index=True
|
'models.Post', related_name='placement_posts', on_delete=fields.SET_NULL, null=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
project_id: UUID
|
|
||||||
creative_id: UUID
|
|
||||||
post_id: UUID | None
|
post_id: UUID | None
|
||||||
placement_id: UUID
|
placement_id: UUID
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ __all__ = (
|
|||||||
'GetPlacementsInput',
|
'GetPlacementsInput',
|
||||||
'GetPlacementsOutput',
|
'GetPlacementsOutput',
|
||||||
'GetPlacementInput',
|
'GetPlacementInput',
|
||||||
|
'UpdatePlacementInput',
|
||||||
|
'DeletePlacementInput',
|
||||||
'PlacementPostOutput',
|
'PlacementPostOutput',
|
||||||
|
'PostOutput',
|
||||||
'PlacementWithPostsOutput',
|
'PlacementWithPostsOutput',
|
||||||
'CreativeButton',
|
'CreativeButton',
|
||||||
'CreativeOutput',
|
'CreativeOutput',
|
||||||
|
'CreativePreviewOutput',
|
||||||
'GetCreativesInput',
|
'GetCreativesInput',
|
||||||
'GetCreativesOutput',
|
'GetCreativesOutput',
|
||||||
'GetCreativeInput',
|
'GetCreativeInput',
|
||||||
@@ -123,6 +127,7 @@ from .creative import (
|
|||||||
CreateCreativeInput,
|
CreateCreativeInput,
|
||||||
CreativeButton,
|
CreativeButton,
|
||||||
CreativeOutput,
|
CreativeOutput,
|
||||||
|
CreativePreviewOutput,
|
||||||
DeleteCreativeInput,
|
DeleteCreativeInput,
|
||||||
GetCreativeInput,
|
GetCreativeInput,
|
||||||
GetCreativesInput,
|
GetCreativesInput,
|
||||||
@@ -146,13 +151,16 @@ from .purchase import (
|
|||||||
CostInfo,
|
CostInfo,
|
||||||
CreatePlacementChannelInput,
|
CreatePlacementChannelInput,
|
||||||
CreatePlacementsInput,
|
CreatePlacementsInput,
|
||||||
|
DeletePlacementInput,
|
||||||
GetPlacementInput,
|
GetPlacementInput,
|
||||||
GetPlacementsInput,
|
GetPlacementsInput,
|
||||||
GetPlacementsOutput,
|
GetPlacementsOutput,
|
||||||
PlacementDetails,
|
PlacementDetails,
|
||||||
PlacementOutput,
|
PlacementOutput,
|
||||||
PlacementPostOutput,
|
PlacementPostOutput,
|
||||||
|
PostOutput,
|
||||||
PlacementWithPostsOutput,
|
PlacementWithPostsOutput,
|
||||||
|
UpdatePlacementInput,
|
||||||
)
|
)
|
||||||
from .user import UserOutput
|
from .user import UserOutput
|
||||||
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
|
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ class CreativeOutput(pydantic.BaseModel):
|
|||||||
placements_count: int
|
placements_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class CreativePreviewOutput(pydantic.BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
text: str
|
||||||
|
media_type: str | None
|
||||||
|
media_file_id: str | None
|
||||||
|
buttons: list[CreativeButton]
|
||||||
|
|
||||||
|
|
||||||
class GetCreativesInput(pydantic.BaseModel):
|
class GetCreativesInput(pydantic.BaseModel):
|
||||||
user_id: uuid.UUID
|
user_id: uuid.UUID
|
||||||
workspace_id: uuid.UUID
|
workspace_id: uuid.UUID
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ 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
|
||||||
|
from .creative import CreativePreviewOutput
|
||||||
|
|
||||||
|
|
||||||
class CostInfo(pydantic.BaseModel):
|
class CostInfo(pydantic.BaseModel):
|
||||||
@@ -22,41 +22,41 @@ class PlacementDetails(pydantic.BaseModel):
|
|||||||
placement_type: PlacementType | None = None
|
placement_type: PlacementType | None = None
|
||||||
format: str | None = None
|
format: str | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
creative_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class PlacementOutput(pydantic.BaseModel):
|
class PlacementOutput(pydantic.BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
status: PlacementStatus
|
status: PlacementStatus
|
||||||
|
creative_id: uuid.UUID | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
invite_link: str
|
invite_link: str | None
|
||||||
invite_link_type: InviteLinkType
|
invite_link_type: InviteLinkType
|
||||||
channel: ChannelOutput
|
channel: ChannelOutput
|
||||||
details: PlacementDetails | None = None
|
details: PlacementDetails | None = None
|
||||||
|
|
||||||
|
|
||||||
class PlacementPostOutput(pydantic.BaseModel):
|
class PostOutput(pydantic.BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
project_id: uuid.UUID
|
message_id: int
|
||||||
project_channel_title: str | None
|
text: str
|
||||||
placement_channel_id: uuid.UUID # Канал, где размещен пост
|
url: str | None
|
||||||
placement_channel_title: str | None
|
deleted_from_channel_at: datetime.datetime | 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
|
created_at: datetime.datetime
|
||||||
|
updated_at: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PlacementPostOutput(pydantic.BaseModel):
|
||||||
|
subscriptions_count: int
|
||||||
|
views_count: int | None
|
||||||
|
created_at: datetime.datetime
|
||||||
|
post: PostOutput
|
||||||
|
|
||||||
|
|
||||||
class PlacementWithPostsOutput(PlacementOutput):
|
class PlacementWithPostsOutput(PlacementOutput):
|
||||||
placement_posts: list[PlacementPostOutput] = pydantic.Field(default_factory=list)
|
placement_post: PlacementPostOutput | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CreatePlacementChannelInput(pydantic.BaseModel):
|
class CreatePlacementChannelInput(pydantic.BaseModel):
|
||||||
@@ -71,7 +71,7 @@ class CreatePlacementChannelInput(pydantic.BaseModel):
|
|||||||
class CreatePlacementsInput(pydantic.BaseModel):
|
class CreatePlacementsInput(pydantic.BaseModel):
|
||||||
"""Input для создания нескольких placements (бывший CreatePurchaseInput)"""
|
"""Input для создания нескольких placements (бывший CreatePurchaseInput)"""
|
||||||
|
|
||||||
creative_id: uuid.UUID
|
creative_id: uuid.UUID | None = None
|
||||||
channels: list[CreatePlacementChannelInput]
|
channels: list[CreatePlacementChannelInput]
|
||||||
details: PlacementDetails | None = None # Общие детали для всех placements
|
details: PlacementDetails | None = None # Общие детали для всех placements
|
||||||
|
|
||||||
@@ -91,3 +91,22 @@ class GetPlacementInput(pydantic.BaseModel):
|
|||||||
workspace_id: uuid.UUID
|
workspace_id: uuid.UUID
|
||||||
project_id: uuid.UUID
|
project_id: uuid.UUID
|
||||||
placement_id: uuid.UUID
|
placement_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePlacementInput(pydantic.BaseModel):
|
||||||
|
status: PlacementStatus | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
creative_id: uuid.UUID | None = None
|
||||||
|
placement_at: datetime.datetime | None = None
|
||||||
|
payment_at: datetime.datetime | None = None
|
||||||
|
cost: CostInfo | None = None
|
||||||
|
cost_before_bargain: float | None = None
|
||||||
|
placement_type: PlacementType | None = None
|
||||||
|
format: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeletePlacementInput(pydantic.BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
workspace_id: uuid.UUID
|
||||||
|
project_id: uuid.UUID
|
||||||
|
placement_id: uuid.UUID
|
||||||
|
|||||||
@@ -38,9 +38,12 @@ from .project.move_project_to_workspace import move_project_to_workspace
|
|||||||
from .project.tg_add_project import tg_add_project
|
from .project.tg_add_project import tg_add_project
|
||||||
from .project.update_project_invite_link_type import update_project_invite_link_type
|
from .project.update_project_invite_link_type import update_project_invite_link_type
|
||||||
from .project.update_project_permissions import update_project_permissions
|
from .project.update_project_permissions import update_project_permissions
|
||||||
|
from .purchase.build_placement_creative import build_placement_creative
|
||||||
from .purchase.create_placements import create_placements
|
from .purchase.create_placements import create_placements
|
||||||
|
from .purchase.delete_placement import delete_placement
|
||||||
from .purchase.get_placement import get_placement_user
|
from .purchase.get_placement import get_placement_user
|
||||||
from .purchase.get_placements import get_placements
|
from .purchase.get_placements import get_placements
|
||||||
|
from .purchase.update_placement import update_placement
|
||||||
from .subscription.handle_subscription import handle_subscription
|
from .subscription.handle_subscription import handle_subscription
|
||||||
from .subscription.handle_unsubscription import handle_unsubscription
|
from .subscription.handle_unsubscription import handle_unsubscription
|
||||||
from .views.get_views_history import get_views_history
|
from .views.get_views_history import get_views_history
|
||||||
@@ -167,6 +170,9 @@ class Usecase:
|
|||||||
create_placements = create_placements
|
create_placements = create_placements
|
||||||
get_placements = get_placements
|
get_placements = get_placements
|
||||||
get_placement_user = get_placement_user
|
get_placement_user = get_placement_user
|
||||||
|
build_placement_creative = build_placement_creative
|
||||||
|
update_placement = update_placement
|
||||||
|
delete_placement = delete_placement
|
||||||
# Creative use cases
|
# Creative use cases
|
||||||
get_creatives = get_creatives
|
get_creatives = get_creatives
|
||||||
get_creative = get_creative
|
get_creative = get_creative
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ if TYPE_CHECKING:
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cost(placement_post: domain.PlacementPost) -> float:
|
||||||
|
placement = placement_post.placement
|
||||||
|
return placement.cost_value if placement and placement.cost_value is not None else 0.0
|
||||||
|
|
||||||
|
|
||||||
async def get_channel_analytics(
|
async def get_channel_analytics(
|
||||||
self: 'Usecase', input: dto.GetChannelAnalyticsInput
|
self: 'Usecase', input: dto.GetChannelAnalyticsInput
|
||||||
) -> list[dto.ChannelAnalyticsOutput]:
|
) -> list[dto.ChannelAnalyticsOutput]:
|
||||||
@@ -69,7 +74,7 @@ async def get_channel_analytics(
|
|||||||
if stats is None:
|
if stats is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stats.total_cost += placement_post.cost if placement_post.cost is not None else 0
|
stats.total_cost += _get_cost(placement_post)
|
||||||
stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0)
|
stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0)
|
||||||
|
|
||||||
# Get views from batch data
|
# Get views from batch data
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ if TYPE_CHECKING:
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cost(placement_post: domain.PlacementPost) -> float:
|
||||||
|
placement = placement_post.placement
|
||||||
|
return placement.cost_value if placement and placement.cost_value is not None else 0.0
|
||||||
|
|
||||||
|
|
||||||
async def get_creatives_analytics(
|
async def get_creatives_analytics(
|
||||||
self: 'Usecase', input: dto.GetCreativesAnalyticsInput
|
self: 'Usecase', input: dto.GetCreativesAnalyticsInput
|
||||||
) -> list[dto.CreativeAnalyticsOutput]:
|
) -> list[dto.CreativeAnalyticsOutput]:
|
||||||
@@ -57,17 +62,20 @@ async def get_creatives_analytics(
|
|||||||
placement_ids = [p.id for p in placements]
|
placement_ids = [p.id for p in placements]
|
||||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
|
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
|
||||||
|
|
||||||
for p in placements:
|
for placement_post in placements:
|
||||||
stats = creative_stats.get(p.creative_id)
|
placement = placement_post.placement
|
||||||
|
if not placement or not placement.creative_id:
|
||||||
|
continue
|
||||||
|
stats = creative_stats.get(placement.creative_id)
|
||||||
if stats is None:
|
if stats is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stats.total_cost += p.cost if p.cost is not None else 0
|
stats.total_cost += _get_cost(placement_post)
|
||||||
stats.total_subscriptions += subscriptions_counts.get(p.id, 0)
|
stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0)
|
||||||
|
|
||||||
# Get views from batch data
|
# Get views from batch data
|
||||||
if p.post and p.post.id in views_map:
|
if placement_post.post and placement_post.post.id in views_map:
|
||||||
views_count = views_map[p.post.id][0]
|
views_count = views_map[placement_post.post.id][0]
|
||||||
stats.total_views += views_count
|
stats.total_views += views_count
|
||||||
|
|
||||||
stats.placements_count += 1
|
stats.placements_count += 1
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ class ChannelAggregate:
|
|||||||
total_subs: int = 0
|
total_subs: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
|
||||||
|
placement = placement_post.placement
|
||||||
|
if placement and placement.placement_at:
|
||||||
|
return placement.placement_at
|
||||||
|
if placement_post.post and placement_post.post.created_at:
|
||||||
|
return placement_post.post.created_at
|
||||||
|
return placement_post.created_at
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cost(placement_post: domain.PlacementPost) -> float:
|
||||||
|
placement = placement_post.placement
|
||||||
|
return placement.cost_value if placement and placement.cost_value is not None else 0.0
|
||||||
|
|
||||||
|
|
||||||
async def get_overview_analytics(
|
async def get_overview_analytics(
|
||||||
self: 'Usecase', input: dto.GetOverviewAnalyticsInput
|
self: 'Usecase', input: dto.GetOverviewAnalyticsInput
|
||||||
) -> dto.GetOverviewAnalyticsOutput:
|
) -> dto.GetOverviewAnalyticsOutput:
|
||||||
@@ -68,7 +82,7 @@ async def get_overview_analytics(
|
|||||||
previous_placements: list[domain.PlacementPost] = []
|
previous_placements: list[domain.PlacementPost] = []
|
||||||
|
|
||||||
for placement_post in placements:
|
for placement_post in placements:
|
||||||
placement_date = placement_post.wanted_placement_date
|
placement_date = _get_placement_date(placement_post)
|
||||||
if placement_date >= input.date_from and placement_date <= input.date_to:
|
if placement_date >= input.date_from and placement_date <= input.date_to:
|
||||||
current_placements.append(placement_post)
|
current_placements.append(placement_post)
|
||||||
elif placement_date >= previous_period_start and placement_date < input.date_from:
|
elif placement_date >= previous_period_start and placement_date < input.date_from:
|
||||||
@@ -105,8 +119,7 @@ async def get_overview_analytics(
|
|||||||
total_views = 0
|
total_views = 0
|
||||||
|
|
||||||
for placement_post in placement_list:
|
for placement_post in placement_list:
|
||||||
if placement_post.cost is not None:
|
total_cost += _get_cost(placement_post)
|
||||||
total_cost += placement_post.cost
|
|
||||||
|
|
||||||
subs = subs_per_placement.get(placement_post.id, 0)
|
subs = subs_per_placement.get(placement_post.id, 0)
|
||||||
total_subscriptions += subs
|
total_subscriptions += subs
|
||||||
@@ -131,17 +144,18 @@ async def get_overview_analytics(
|
|||||||
project_meta: dict[UUID, domain.Project] = {}
|
project_meta: dict[UUID, domain.Project] = {}
|
||||||
|
|
||||||
for placement_post in current_placements:
|
for placement_post in current_placements:
|
||||||
placement_day = placement_post.wanted_placement_date.date()
|
placement_day = _get_placement_date(placement_post).date()
|
||||||
placement = placement_post.placement
|
placement = placement_post.placement
|
||||||
if placement and placement.project:
|
if not placement:
|
||||||
project_meta[placement_post.project_id] = placement.project
|
continue
|
||||||
|
if placement.project:
|
||||||
|
project_meta[placement.project_id] = placement.project
|
||||||
|
|
||||||
cost_value = placement_post.cost or 0.0
|
cost_value = _get_cost(placement_post)
|
||||||
cost_per_day[placement_day] += cost_value
|
cost_per_day[placement_day] += cost_value
|
||||||
project_spending_map[placement_post.project_id] += cost_value
|
project_spending_map[placement.project_id] += cost_value
|
||||||
subs = subs_per_placement_current.get(placement_post.id, 0)
|
subs = subs_per_placement_current.get(placement_post.id, 0)
|
||||||
|
|
||||||
if placement:
|
|
||||||
channel_id = placement.channel_id
|
channel_id = placement.channel_id
|
||||||
if channel_id not in channel_stats:
|
if channel_id not in channel_stats:
|
||||||
channel_stats[channel_id] = ChannelAggregate(channel=placement.channel)
|
channel_stats[channel_id] = ChannelAggregate(channel=placement.channel)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -9,6 +10,20 @@ if TYPE_CHECKING:
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
|
||||||
|
placement = placement_post.placement
|
||||||
|
if placement and placement.placement_at:
|
||||||
|
return placement.placement_at
|
||||||
|
if placement_post.post and placement_post.post.created_at:
|
||||||
|
return placement_post.post.created_at
|
||||||
|
return placement_post.created_at
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cost(placement_post: domain.PlacementPost) -> float | None:
|
||||||
|
placement = placement_post.placement
|
||||||
|
return placement.cost_value if placement else None
|
||||||
|
|
||||||
|
|
||||||
def _calculate_cpf(cost: float | None, subscriptions: int) -> float | None:
|
def _calculate_cpf(cost: float | None, subscriptions: int) -> float | None:
|
||||||
if cost is None or subscriptions == 0:
|
if cost is None or subscriptions == 0:
|
||||||
return None
|
return None
|
||||||
@@ -52,21 +67,36 @@ async def get_placements_analytics(
|
|||||||
placement_ids = [p.id for p in placements]
|
placement_ids = [p.id for p in placements]
|
||||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
|
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
|
||||||
|
|
||||||
return [
|
results: list[dto.PlacementAnalyticsOutput] = []
|
||||||
|
for placement_post in placements:
|
||||||
|
placement = placement_post.placement
|
||||||
|
if not placement or not placement.project or not placement.channel or not placement.creative:
|
||||||
|
log.warning('PlacementPost %s missing placement data', placement_post.id)
|
||||||
|
continue
|
||||||
|
if not placement.project.channel:
|
||||||
|
log.warning('Placement %s missing project channel', placement.id)
|
||||||
|
continue
|
||||||
|
cost = _get_cost(placement_post)
|
||||||
|
subs_count = subscriptions_counts.get(placement_post.id, 0)
|
||||||
|
if placement_post.post and placement_post.post.id in views_map:
|
||||||
|
views_count = views_map[placement_post.post.id][0]
|
||||||
|
else:
|
||||||
|
views_count = None
|
||||||
|
results.append(
|
||||||
dto.PlacementAnalyticsOutput(
|
dto.PlacementAnalyticsOutput(
|
||||||
id=p.id,
|
id=placement_post.id,
|
||||||
project_id=p.project_id,
|
project_id=placement.project_id,
|
||||||
project_channel_title=p.project.channel.title,
|
project_channel_title=placement.project.channel.title,
|
||||||
placement_channel_id=p.placement.channel_id,
|
placement_channel_id=placement.channel_id,
|
||||||
placement_channel_title=p.placement.channel.title,
|
placement_channel_title=placement.channel.title,
|
||||||
creative_id=p.creative_id,
|
creative_id=placement.creative_id,
|
||||||
creative_name=p.creative.name,
|
creative_name=placement.creative.name,
|
||||||
placement_date=p.wanted_placement_date,
|
placement_date=_get_placement_date(placement_post),
|
||||||
cost=p.cost,
|
cost=cost,
|
||||||
subscriptions_count=subscriptions_counts.get(p.id, 0),
|
subscriptions_count=subs_count,
|
||||||
views_count=views_map[p.post.id][0] if p.post and p.post.id in views_map else None,
|
views_count=views_count,
|
||||||
cpf=_calculate_cpf(p.cost, subscriptions_counts.get(p.id, 0)),
|
cpf=_calculate_cpf(cost, subs_count),
|
||||||
cpm=_calculate_cpm(p.cost, views_map[p.post.id][0] if p.post and p.post.id in views_map else None),
|
cpm=_calculate_cpm(cost, views_count),
|
||||||
)
|
)
|
||||||
for p in placements
|
)
|
||||||
]
|
return results
|
||||||
|
|||||||
@@ -60,20 +60,29 @@ def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> s
|
|||||||
raise ValueError('Invalid date grouping')
|
raise ValueError('Invalid date grouping')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
|
||||||
|
placement = placement_post.placement
|
||||||
|
if placement and placement.placement_at:
|
||||||
|
return placement.placement_at
|
||||||
|
if placement_post.post and placement_post.post.created_at:
|
||||||
|
return placement_post.post.created_at
|
||||||
|
return placement_post.created_at
|
||||||
|
|
||||||
|
|
||||||
def _get_grouping_date(placement_post: domain.PlacementPost, date_grouping: dto.DateGroupingType) -> datetime.datetime:
|
def _get_grouping_date(placement_post: domain.PlacementPost, date_grouping: dto.DateGroupingType) -> datetime.datetime:
|
||||||
match date_grouping:
|
match date_grouping:
|
||||||
case dto.DateGroupingType.PLACEMENT_DATE:
|
case dto.DateGroupingType.PLACEMENT_DATE:
|
||||||
return placement_post.wanted_placement_date
|
return _get_placement_date(placement_post)
|
||||||
case dto.DateGroupingType.PURCHASE_DATE:
|
case dto.DateGroupingType.PURCHASE_DATE:
|
||||||
if placement_post.placement:
|
if placement_post.placement:
|
||||||
return placement_post.placement.created_at
|
return placement_post.placement.created_at
|
||||||
return placement_post.wanted_placement_date # Fallback
|
return _get_placement_date(placement_post) # Fallback
|
||||||
case dto.DateGroupingType.LINK_DATE:
|
case dto.DateGroupingType.LINK_DATE:
|
||||||
if placement_post.placement:
|
if placement_post.placement:
|
||||||
return placement_post.placement.created_at
|
return placement_post.placement.created_at
|
||||||
return placement_post.wanted_placement_date # Fallback
|
return _get_placement_date(placement_post) # Fallback
|
||||||
case _:
|
case _:
|
||||||
return placement_post.wanted_placement_date
|
return _get_placement_date(placement_post)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -223,7 +232,8 @@ async def get_projects_analytics(
|
|||||||
pd.purchases_count += 1
|
pd.purchases_count += 1
|
||||||
total_metrics.purchases_count += 1
|
total_metrics.purchases_count += 1
|
||||||
|
|
||||||
cost = placement_post.cost or 0.0
|
placement = placement_post.placement
|
||||||
|
cost = placement.cost_value if placement and placement.cost_value is not None else 0.0
|
||||||
pd.total_cost += cost
|
pd.total_cost += cost
|
||||||
total_metrics.total_cost += cost
|
total_metrics.total_cost += cost
|
||||||
|
|
||||||
@@ -241,7 +251,6 @@ async def get_projects_analytics(
|
|||||||
total_metrics.reach_volume += views_count
|
total_metrics.reach_volume += views_count
|
||||||
|
|
||||||
# Расчет скидок
|
# Расчет скидок
|
||||||
placement = placement_post.placement
|
|
||||||
if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost:
|
if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost:
|
||||||
discount = placement.cost_before_bargain - cost
|
discount = placement.cost_before_bargain - cost
|
||||||
discount_percent = (
|
discount_percent = (
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str:
|
|||||||
raise ValueError('Invalid date grouping')
|
raise ValueError('Invalid date grouping')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
|
||||||
|
placement = placement_post.placement
|
||||||
|
if placement and placement.placement_at:
|
||||||
|
return placement.placement_at
|
||||||
|
if placement_post.post and placement_post.post.created_at:
|
||||||
|
return placement_post.post.created_at
|
||||||
|
return placement_post.created_at
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cost(placement_post: domain.PlacementPost) -> float | None:
|
||||||
|
placement = placement_post.placement
|
||||||
|
return placement.cost_value if placement else None
|
||||||
|
|
||||||
|
|
||||||
async def get_spending_analytics(
|
async def get_spending_analytics(
|
||||||
self: 'Usecase', input: dto.GetSpendingAnalyticsInput
|
self: 'Usecase', input: dto.GetSpendingAnalyticsInput
|
||||||
) -> dto.GetSpendingAnalyticsOutput:
|
) -> dto.GetSpendingAnalyticsOutput:
|
||||||
@@ -53,12 +67,14 @@ async def get_spending_analytics(
|
|||||||
allowed_project_ids=allowed_project_ids,
|
allowed_project_ids=allowed_project_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
filtered = [
|
filtered: list[tuple[domain.PlacementPost, datetime.datetime]] = []
|
||||||
p
|
for placement_post in placements:
|
||||||
for p in placements
|
placement_date = _get_placement_date(placement_post)
|
||||||
if (not input.date_from or p.wanted_placement_date >= input.date_from)
|
if input.date_from and placement_date < input.date_from:
|
||||||
and (not input.date_to or p.wanted_placement_date <= input.date_to)
|
continue
|
||||||
]
|
if input.date_to and placement_date > input.date_to:
|
||||||
|
continue
|
||||||
|
filtered.append((placement_post, placement_date))
|
||||||
|
|
||||||
total_cost = 0.0
|
total_cost = 0.0
|
||||||
total_subs = 0
|
total_subs = 0
|
||||||
@@ -71,23 +87,24 @@ async def get_spending_analytics(
|
|||||||
views: int = 0
|
views: int = 0
|
||||||
|
|
||||||
# Batch fetch views data for all posts
|
# Batch fetch views data for all posts
|
||||||
post_ids = [p.post.id for p in filtered if p.post]
|
post_ids = [p.post.id for p, _ in filtered if p.post]
|
||||||
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
|
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
|
||||||
|
|
||||||
# Batch fetch subscriptions counts
|
# Batch fetch subscriptions counts
|
||||||
placement_ids = [p.id for p in filtered]
|
placement_ids = [p.id for p, _ in filtered]
|
||||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
|
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
|
||||||
|
|
||||||
# Группировка по периодам
|
# Группировка по периодам
|
||||||
period_data: dict[str, PeriodData] = defaultdict(PeriodData)
|
period_data: dict[str, PeriodData] = defaultdict(PeriodData)
|
||||||
|
|
||||||
for p in filtered:
|
for p, placement_date in filtered:
|
||||||
period = _format_period(p.wanted_placement_date, input.grouping)
|
period = _format_period(placement_date, input.grouping)
|
||||||
pd = period_data[period]
|
pd = period_data[period]
|
||||||
|
|
||||||
if p.cost is not None:
|
cost = _get_cost(p)
|
||||||
total_cost += p.cost
|
if cost is not None:
|
||||||
pd.cost += p.cost
|
total_cost += cost
|
||||||
|
pd.cost += cost
|
||||||
|
|
||||||
subs_count = subscriptions_counts.get(p.id, 0)
|
subs_count = subscriptions_counts.get(p.id, 0)
|
||||||
total_subs += subs_count
|
total_subs += subs_count
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
|
|||||||
placements = (
|
placements = (
|
||||||
await domain.Placement.filter(
|
await domain.Placement.filter(
|
||||||
status__in=[
|
status__in=[
|
||||||
domain.PlacementStatus.APPROVED,
|
domain.PlacementStatus.NO_STATUS,
|
||||||
domain.PlacementStatus.PLANNED,
|
domain.PlacementStatus.WRITE,
|
||||||
domain.PlacementStatus.IN_PROGRESS,
|
domain.PlacementStatus.WAITING_RESPONSE,
|
||||||
|
domain.PlacementStatus.TERMS_APPROVAL,
|
||||||
|
domain.PlacementStatus.TO_PAY,
|
||||||
|
domain.PlacementStatus.PAID,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
.prefetch_related('channel', 'project')
|
.prefetch_related('channel', 'project')
|
||||||
@@ -36,6 +39,13 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
|
|||||||
if placement.channel is None or placement.invite_link is None:
|
if placement.channel is None or placement.invite_link is None:
|
||||||
log.warning('Placement %s missing channel or invite_link, skipping', placement.id)
|
log.warning('Placement %s missing channel or invite_link, skipping', placement.id)
|
||||||
continue
|
continue
|
||||||
|
if placement.creative_id is None:
|
||||||
|
log.warning('Placement %s missing creative_id, skipping', placement.id)
|
||||||
|
continue
|
||||||
|
existing_for_placement = await domain.PlacementPost.filter(placement_id=placement.id).first()
|
||||||
|
if existing_for_placement:
|
||||||
|
log.debug('Placement %s already has placement_post %s, skipping', placement.id, existing_for_placement.id)
|
||||||
|
continue
|
||||||
|
|
||||||
# Ищем посты в канале, содержащие invite_link из placement
|
# Ищем посты в канале, содержащие invite_link из placement
|
||||||
posts = await domain.Post.filter(
|
posts = await domain.Post.filter(
|
||||||
@@ -52,13 +62,7 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
|
|||||||
|
|
||||||
placement_post = domain.PlacementPost(
|
placement_post = domain.PlacementPost(
|
||||||
placement_id=placement.id,
|
placement_id=placement.id,
|
||||||
project_id=placement.project_id,
|
|
||||||
creative_id=placement.creative_id,
|
|
||||||
post_id=post.id,
|
post_id=post.id,
|
||||||
wanted_placement_date=placement.placement_at or post.created_at,
|
|
||||||
cost=placement.cost_value,
|
|
||||||
comment=placement.comment,
|
|
||||||
status=domain.PlacementPostStatus.ACTIVE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.database.create_placement_post(placement_post)
|
await self.database.create_placement_post(placement_post)
|
||||||
|
|||||||
92
src/usecase/purchase/build_placement_creative.py
Normal file
92
src/usecase/purchase/build_placement_creative.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from src import domain, dto
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .. import Usecase
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_INVITE_LINK_TAG = re.compile(r'<a\s+class=["\']tg-link["\']>([^<]*)</a>', re.IGNORECASE)
|
||||||
|
_INVITE_LINK_PLACEHOLDER = '{{invite_link}}'
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_invite_link(text: str, invite_link: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _replace(match: re.Match) -> str:
|
||||||
|
inner = match.group(1).strip()
|
||||||
|
if inner:
|
||||||
|
return f'<a href="{invite_link}">{inner}</a>'
|
||||||
|
return f'<a href="{invite_link}">{invite_link}</a>'
|
||||||
|
|
||||||
|
return _INVITE_LINK_TAG.sub(_replace, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_buttons(buttons: list[dict], invite_link: str) -> list[dto.CreativeButton]:
|
||||||
|
result: list[dto.CreativeButton] = []
|
||||||
|
for raw in buttons:
|
||||||
|
text = raw.get('text')
|
||||||
|
url = raw.get('url')
|
||||||
|
if not text or not url:
|
||||||
|
continue
|
||||||
|
if url == _INVITE_LINK_PLACEHOLDER:
|
||||||
|
url = invite_link
|
||||||
|
result.append(dto.CreativeButton(text=text, url=url))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def build_placement_creative(
|
||||||
|
self: 'Usecase',
|
||||||
|
placement_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> dto.CreativePreviewOutput:
|
||||||
|
context = await self.ensure_workspace_permission(
|
||||||
|
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
|
||||||
|
)
|
||||||
|
|
||||||
|
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||||
|
if not project:
|
||||||
|
raise domain.ProjectNotFound(project_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||||
|
|
||||||
|
placement = await self.database.get_placement(workspace_id, placement_id)
|
||||||
|
if not placement or placement.project_id != project.id:
|
||||||
|
raise domain.PlacementNotFound(placement_id)
|
||||||
|
|
||||||
|
if placement.creative_id is None:
|
||||||
|
raise domain.CreativeNotFound()
|
||||||
|
|
||||||
|
creative = await self.database.get_creative(workspace_id, placement.creative_id)
|
||||||
|
if not creative or creative.project_id != project.id:
|
||||||
|
raise domain.CreativeNotFound(placement.creative_id)
|
||||||
|
|
||||||
|
if not placement.invite_link:
|
||||||
|
if project.channel.telegram_id is None:
|
||||||
|
raise domain.ChannelNotFound(project.channel.id)
|
||||||
|
|
||||||
|
requires_approval = placement.invite_link_type == domain.InviteLinkType.APPROVAL
|
||||||
|
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
|
||||||
|
placement.invite_link = invite_link
|
||||||
|
await self.database.update_placement(placement)
|
||||||
|
|
||||||
|
invite_link = placement.invite_link
|
||||||
|
if not invite_link:
|
||||||
|
raise domain.PlacementNotFound(placement.id)
|
||||||
|
|
||||||
|
preview = dto.CreativePreviewOutput(
|
||||||
|
id=creative.id,
|
||||||
|
name=creative.name,
|
||||||
|
text=_inject_invite_link(creative.text, invite_link),
|
||||||
|
media_type=creative.media_type,
|
||||||
|
media_file_id=creative.media_file_id,
|
||||||
|
buttons=_build_buttons(creative.buttons, invite_link),
|
||||||
|
)
|
||||||
|
return preview
|
||||||
@@ -40,6 +40,7 @@ def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput:
|
|||||||
return dto.PlacementOutput(
|
return dto.PlacementOutput(
|
||||||
id=placement.id,
|
id=placement.id,
|
||||||
status=placement.status,
|
status=placement.status,
|
||||||
|
creative_id=placement.creative_id,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
invite_link=placement.invite_link,
|
invite_link=placement.invite_link,
|
||||||
invite_link_type=placement.invite_link_type,
|
invite_link_type=placement.invite_link_type,
|
||||||
@@ -69,18 +70,13 @@ async def create_placements(
|
|||||||
|
|
||||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||||
|
|
||||||
creative = await self.database.get_creative(workspace_id, input.creative_id)
|
|
||||||
if not creative or creative.project_id != project.id:
|
|
||||||
raise domain.CreativeNotFound(input.creative_id)
|
|
||||||
|
|
||||||
if project.channel.telegram_id is None:
|
if project.channel.telegram_id is None:
|
||||||
raise domain.ChannelNotFound(project.channel.id)
|
raise domain.ChannelNotFound(project.channel.id)
|
||||||
|
|
||||||
invite_link_type = project.purchase_invite_type_default
|
invite_link_type = project.purchase_invite_type_default
|
||||||
requires_approval = invite_link_type == domain.InviteLinkType.APPROVAL
|
|
||||||
|
|
||||||
details = input.details
|
details = input.details
|
||||||
placements: list[domain.Placement] = []
|
placements: list[domain.Placement] = []
|
||||||
|
creatives_by_id: dict[uuid.UUID, domain.Creative] = {}
|
||||||
|
|
||||||
for channel_input in input.channels:
|
for channel_input in input.channels:
|
||||||
channel = await self.database.get_channel(username=channel_input.username)
|
channel = await self.database.get_channel(username=channel_input.username)
|
||||||
@@ -98,23 +94,26 @@ async def create_placements(
|
|||||||
await self.database.create_channel(channel)
|
await self.database.create_channel(channel)
|
||||||
log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id)
|
log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id)
|
||||||
|
|
||||||
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
|
|
||||||
log.info(
|
|
||||||
'Created invite link for placement channel_telegram_id=%s requires_approval=%s invite_link=%s',
|
|
||||||
project.channel.telegram_id,
|
|
||||||
requires_approval,
|
|
||||||
invite_link,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Объединяем общие детали с деталями конкретного канала
|
# Объединяем общие детали с деталями конкретного канала
|
||||||
channel_details = channel_input.details
|
channel_details = channel_input.details
|
||||||
|
creative_id = (
|
||||||
|
channel_details.creative_id if channel_details and channel_details.creative_id else None
|
||||||
|
) or (details.creative_id if details and details.creative_id else None) or input.creative_id
|
||||||
|
if not creative_id:
|
||||||
|
raise domain.CreativeNotFound()
|
||||||
|
creative = creatives_by_id.get(creative_id)
|
||||||
|
if not creative:
|
||||||
|
creative = await self.database.get_creative(workspace_id, creative_id)
|
||||||
|
if not creative or creative.project_id != project.id:
|
||||||
|
raise domain.CreativeNotFound(creative_id)
|
||||||
|
creatives_by_id[creative_id] = creative
|
||||||
placement = domain.Placement(
|
placement = domain.Placement(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
creative_id=creative.id,
|
creative_id=creative.id,
|
||||||
channel_id=channel.id,
|
channel_id=channel.id,
|
||||||
invite_link=invite_link,
|
invite_link=None,
|
||||||
invite_link_type=invite_link_type,
|
invite_link_type=invite_link_type,
|
||||||
status=channel_input.status or domain.PlacementStatus.PLANNED,
|
status=channel_input.status or domain.PlacementStatus.NO_STATUS,
|
||||||
comment=channel_input.comment,
|
comment=channel_input.comment,
|
||||||
# Приоритет: channel details > общие details
|
# Приоритет: channel details > общие details
|
||||||
placement_at=(channel_details.placement_at if channel_details else None)
|
placement_at=(channel_details.placement_at if channel_details else None)
|
||||||
@@ -135,10 +134,10 @@ async def create_placements(
|
|||||||
placements.append(placement)
|
placements.append(placement)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
'Created %s placements for project %s (creative %s)',
|
'Created %s placements for project %s (creatives %s)',
|
||||||
len(placements),
|
len(placements),
|
||||||
project.id,
|
project.id,
|
||||||
creative.id,
|
list(creatives_by_id.keys()),
|
||||||
)
|
)
|
||||||
|
|
||||||
placement_outputs = []
|
placement_outputs = []
|
||||||
@@ -147,7 +146,7 @@ async def create_placements(
|
|||||||
placement_outputs.append(
|
placement_outputs.append(
|
||||||
dto.PlacementWithPostsOutput(
|
dto.PlacementWithPostsOutput(
|
||||||
**placement_output.model_dump(),
|
**placement_output.model_dump(),
|
||||||
placement_posts=[],
|
placement_post=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
28
src/usecase/purchase/delete_placement.py
Normal file
28
src/usecase/purchase/delete_placement.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from src import domain, dto
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .. import Usecase
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None:
|
||||||
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_WRITE
|
||||||
|
)
|
||||||
|
|
||||||
|
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
|
||||||
|
if not project:
|
||||||
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||||
|
|
||||||
|
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
|
||||||
|
if not placement or placement.project_id != project.id:
|
||||||
|
log.warning('User %s attempted to delete unavailable placement %s', input.user_id, input.placement_id)
|
||||||
|
raise domain.PlacementNotFound(input.placement_id)
|
||||||
|
|
||||||
|
await self.database.delete_placement(placement.id)
|
||||||
@@ -7,39 +7,40 @@ from src.usecase.purchase.create_placements import _build_placement_output
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_post_output(post: domain.Post) -> dto.PostOutput:
|
||||||
|
channel = post.channel
|
||||||
|
if not channel:
|
||||||
|
raise ValueError(f'Post {post.id} has no channel')
|
||||||
|
|
||||||
|
return dto.PostOutput(
|
||||||
|
id=post.id,
|
||||||
|
message_id=post.message_id,
|
||||||
|
text=post.text,
|
||||||
|
url=post.url,
|
||||||
|
deleted_from_channel_at=post.deleted_from_channel_at,
|
||||||
|
created_at=post.created_at,
|
||||||
|
updated_at=post.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_placement_post_output(
|
def _build_placement_post_output(
|
||||||
placement_post: domain.PlacementPost, subscriptions_count: int
|
placement_post: domain.PlacementPost, subscriptions_count: int, views_count: int | None
|
||||||
) -> dto.PlacementPostOutput | None:
|
) -> dto.PlacementPostOutput | None:
|
||||||
placement = placement_post.placement
|
if not placement_post.post:
|
||||||
if not placement:
|
log.warning('PlacementPost %s missing post', placement_post.id)
|
||||||
log.warning('PlacementPost %s missing placement data', placement_post.id)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not placement.channel or not placement.project or not placement.creative:
|
try:
|
||||||
log.warning('PlacementPost %s placement missing related data', placement_post.id)
|
post_output = _build_post_output(placement_post.post)
|
||||||
|
except ValueError:
|
||||||
|
log.warning('PlacementPost %s post missing channel data', placement_post.id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
ad_post_url = placement_post.post.url if placement_post.post else None
|
|
||||||
|
|
||||||
return dto.PlacementPostOutput(
|
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,
|
subscriptions_count=subscriptions_count,
|
||||||
placement_id=placement.id,
|
views_count=views_count,
|
||||||
placement=_build_placement_output(placement),
|
|
||||||
created_at=placement_post.created_at,
|
created_at=placement_post.created_at,
|
||||||
|
post=post_output,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,17 +76,27 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
|
|||||||
)
|
)
|
||||||
placement_post_ids = [post.id for post in placement_posts]
|
placement_post_ids = [post.id for post in placement_posts]
|
||||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
|
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
|
||||||
|
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 {}
|
||||||
|
|
||||||
placement_post_outputs = []
|
placement_post_output = None
|
||||||
|
if placement_posts:
|
||||||
|
if len(placement_posts) > 1:
|
||||||
|
log.warning('Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts))
|
||||||
for placement_post in placement_posts:
|
for placement_post in placement_posts:
|
||||||
|
views_count = None
|
||||||
|
if placement_post.post:
|
||||||
|
views_count = views_map.get(placement_post.post.id, (None,))[0]
|
||||||
placement_post_output = _build_placement_post_output(
|
placement_post_output = _build_placement_post_output(
|
||||||
placement_post, subscriptions_counts.get(placement_post.id, 0)
|
placement_post,
|
||||||
|
subscriptions_counts.get(placement_post.id, 0),
|
||||||
|
views_count,
|
||||||
)
|
)
|
||||||
if placement_post_output is not None:
|
if placement_post_output is not None:
|
||||||
placement_post_outputs.append(placement_post_output)
|
break
|
||||||
|
|
||||||
placement_output = _build_placement_output(placement)
|
placement_output = _build_placement_output(placement)
|
||||||
return dto.PlacementWithPostsOutput(
|
return dto.PlacementWithPostsOutput(
|
||||||
**placement_output.model_dump(),
|
**placement_output.model_dump(),
|
||||||
placement_posts=placement_post_outputs,
|
placement_post=placement_post_output,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,11 +36,18 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
|
|||||||
)
|
)
|
||||||
placement_post_ids = [post.id for post in placement_posts]
|
placement_post_ids = [post.id for post in placement_posts]
|
||||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
|
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
|
||||||
|
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 {}
|
||||||
|
|
||||||
placement_posts_by_placement_id: dict[uuid.UUID, list[dto.PlacementPostOutput]] = {}
|
placement_posts_by_placement_id: dict[uuid.UUID, list[dto.PlacementPostOutput]] = {}
|
||||||
for placement_post in placement_posts:
|
for placement_post in placement_posts:
|
||||||
|
views_count = None
|
||||||
|
if placement_post.post:
|
||||||
|
views_count = views_map.get(placement_post.post.id, (None,))[0]
|
||||||
placement_post_output = _build_placement_post_output(
|
placement_post_output = _build_placement_post_output(
|
||||||
placement_post, subscriptions_counts.get(placement_post.id, 0)
|
placement_post,
|
||||||
|
subscriptions_counts.get(placement_post.id, 0),
|
||||||
|
views_count,
|
||||||
)
|
)
|
||||||
if placement_post_output is None:
|
if placement_post_output is None:
|
||||||
continue
|
continue
|
||||||
@@ -49,10 +56,18 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
|
|||||||
placement_outputs = []
|
placement_outputs = []
|
||||||
for placement in placements:
|
for placement in placements:
|
||||||
placement_output = _build_placement_output(placement)
|
placement_output = _build_placement_output(placement)
|
||||||
|
placement_post_output = None
|
||||||
|
placement_posts_for_placement = placement_posts_by_placement_id.get(placement.id, [])
|
||||||
|
if placement_posts_for_placement:
|
||||||
|
if len(placement_posts_for_placement) > 1:
|
||||||
|
log.warning(
|
||||||
|
'Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts_for_placement)
|
||||||
|
)
|
||||||
|
placement_post_output = placement_posts_for_placement[0]
|
||||||
placement_outputs.append(
|
placement_outputs.append(
|
||||||
dto.PlacementWithPostsOutput(
|
dto.PlacementWithPostsOutput(
|
||||||
**placement_output.model_dump(),
|
**placement_output.model_dump(),
|
||||||
placement_posts=placement_posts_by_placement_id.get(placement.id, []),
|
placement_post=placement_post_output,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
80
src/usecase/purchase/update_placement.py
Normal file
80
src/usecase/purchase/update_placement.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from src import domain, dto
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .. import Usecase
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_placement(
|
||||||
|
self: 'Usecase',
|
||||||
|
placement_id: uuid.UUID,
|
||||||
|
input: dto.UpdatePlacementInput,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> dto.PlacementWithPostsOutput:
|
||||||
|
context = await self.ensure_workspace_permission(
|
||||||
|
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
|
||||||
|
)
|
||||||
|
|
||||||
|
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||||
|
if not project:
|
||||||
|
raise domain.ProjectNotFound(project_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||||
|
|
||||||
|
placement = await self.database.get_placement(workspace_id, placement_id)
|
||||||
|
if not placement or placement.project_id != project.id:
|
||||||
|
raise domain.PlacementNotFound(placement_id)
|
||||||
|
|
||||||
|
fields_set = input.model_fields_set
|
||||||
|
|
||||||
|
if 'creative_id' in fields_set:
|
||||||
|
if input.creative_id is None:
|
||||||
|
placement_posts_count = await self.database.count_placement_posts_by_placement(placement.id)
|
||||||
|
if placement_posts_count > 0:
|
||||||
|
log.warning('Placement %s has placement_posts and cannot remove creative', placement.id)
|
||||||
|
raise domain.PlacementHasPosts(placement.id)
|
||||||
|
placement.creative_id = None
|
||||||
|
else:
|
||||||
|
creative = await self.database.get_creative(workspace_id, input.creative_id)
|
||||||
|
if not creative or creative.project_id != project.id:
|
||||||
|
raise domain.CreativeNotFound(input.creative_id)
|
||||||
|
placement.creative_id = creative.id
|
||||||
|
|
||||||
|
if 'status' in fields_set and input.status is not None:
|
||||||
|
placement.status = input.status
|
||||||
|
if 'comment' in fields_set:
|
||||||
|
placement.comment = input.comment
|
||||||
|
if 'placement_at' in fields_set:
|
||||||
|
placement.placement_at = input.placement_at
|
||||||
|
if 'payment_at' in fields_set:
|
||||||
|
placement.payment_at = input.payment_at
|
||||||
|
if 'cost' in fields_set:
|
||||||
|
if input.cost is None:
|
||||||
|
placement.cost_type = None
|
||||||
|
placement.cost_value = None
|
||||||
|
else:
|
||||||
|
placement.cost_type = input.cost.type
|
||||||
|
placement.cost_value = input.cost.value
|
||||||
|
if 'cost_before_bargain' in fields_set:
|
||||||
|
placement.cost_before_bargain = input.cost_before_bargain
|
||||||
|
if 'placement_type' in fields_set:
|
||||||
|
placement.placement_type = input.placement_type
|
||||||
|
if 'format' in fields_set:
|
||||||
|
placement.format = input.format
|
||||||
|
|
||||||
|
await self.database.update_placement(placement)
|
||||||
|
|
||||||
|
placement_input = dto.GetPlacementInput(
|
||||||
|
user_id=user_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
project_id=project_id,
|
||||||
|
placement_id=placement.id,
|
||||||
|
)
|
||||||
|
return await self.get_placement_user(input=placement_input)
|
||||||
@@ -555,27 +555,36 @@ type PlacementDetails struct {
|
|||||||
PlacementType *string `json:"placement_type,omitempty"`
|
PlacementType *string `json:"placement_type,omitempty"`
|
||||||
Format *string `json:"format,omitempty"`
|
Format *string `json:"format,omitempty"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
CreativeID *string `json:"creative_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlacementOutput struct {
|
type PlacementOutput struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
CreativeID *string `json:"creative_id,omitempty"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
InviteLink string `json:"invite_link"`
|
InviteLink *string `json:"invite_link,omitempty"`
|
||||||
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"`
|
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlacementPostOutput struct {
|
type PlacementPostOutput struct {
|
||||||
ID string `json:"id"`
|
|
||||||
PlacementID string `json:"placement_id"`
|
|
||||||
Placement PlacementOutput `json:"placement"`
|
|
||||||
AdPostURL *string `json:"ad_post_url,omitempty"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
SubscriptionsCount int `json:"subscriptions_count"`
|
SubscriptionsCount int `json:"subscriptions_count"`
|
||||||
|
ViewsCount *int `json:"views_count,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
Post PostOutput `json:"post"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostOutput struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
URL *string `json:"url,omitempty"`
|
||||||
|
DeletedFromChannelAt *string `json:"deleted_from_channel_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatePlacementChannelInput struct {
|
type CreatePlacementChannelInput struct {
|
||||||
@@ -586,7 +595,7 @@ type CreatePlacementChannelInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreatePlacementsInput struct {
|
type CreatePlacementsInput struct {
|
||||||
CreativeID string `json:"creative_id"`
|
CreativeID *string `json:"creative_id,omitempty"`
|
||||||
Channels []CreatePlacementChannelInput `json:"channels"`
|
Channels []CreatePlacementChannelInput `json:"channels"`
|
||||||
Details *PlacementDetails `json:"details,omitempty"`
|
Details *PlacementDetails `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -595,6 +604,15 @@ type GetPlacementsOutput struct {
|
|||||||
Placements []PlacementOutput `json:"placements"`
|
Placements []PlacementOutput `json:"placements"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreativePreviewOutput struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
MediaFileID string `json:"media_file_id"`
|
||||||
|
Buttons []CreativeButton `json:"buttons"`
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) CreatePlacements(
|
func (c *Client) CreatePlacements(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
jwt string,
|
jwt string,
|
||||||
@@ -661,6 +679,33 @@ func (c *Client) GetPlacement(
|
|||||||
return &placement, err
|
return &placement, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) BuildPlacementCreative(
|
||||||
|
ctx context.Context,
|
||||||
|
jwt string,
|
||||||
|
workspaceID string,
|
||||||
|
projectID string,
|
||||||
|
placementID string,
|
||||||
|
) (*CreativePreviewOutput, error) {
|
||||||
|
path := fmt.Sprintf(
|
||||||
|
"api/v1/workspaces/%s/projects/%s/placements/%s/creative",
|
||||||
|
workspaceID,
|
||||||
|
projectID,
|
||||||
|
placementID,
|
||||||
|
)
|
||||||
|
|
||||||
|
var resp CreativePreviewOutput
|
||||||
|
err := c.do(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
path,
|
||||||
|
nil,
|
||||||
|
&resp,
|
||||||
|
withBearer(jwt),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &resp, err
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Workspace Members
|
// Workspace Members
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -329,13 +329,16 @@ func (b *Bot) Update(u *echotron.Update) {
|
|||||||
|
|
||||||
// 1. Команды (приоритетный маршрут)
|
// 1. Команды (приоритетный маршрут)
|
||||||
if u.Message != nil {
|
if u.Message != nil {
|
||||||
switch u.Message.Text {
|
switch {
|
||||||
case "/start":
|
case u.Message.Text == "/start":
|
||||||
b.SetState(b.commandRouter("/start"), NewMessage)
|
b.SetState(b.commandRouter("/start"), NewMessage)
|
||||||
return
|
return
|
||||||
case "/start login":
|
case u.Message.Text == "/start login":
|
||||||
b.SetState(b.commandRouter("/start login"), NewMessage)
|
b.SetState(b.commandRouter("/start login"), NewMessage)
|
||||||
return
|
return
|
||||||
|
case strings.HasPrefix(u.Message.Text, "/start project_"):
|
||||||
|
b.SetState(b.commandRouter(u.Message.Text), NewMessage)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
b.CurrentState.HandleMessage(b, u)
|
b.CurrentState.HandleMessage(b, u)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,11 +25,19 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var commandRouter = func(command string) bot.State {
|
var commandRouter = func(command string) bot.State {
|
||||||
switch command {
|
createCreativeRe := regexp.MustCompile(`^/start project_(.+)_createcreative$`)
|
||||||
case "/start":
|
|
||||||
|
switch {
|
||||||
|
case command == "/start":
|
||||||
return &screens.MainMenu{}
|
return &screens.MainMenu{}
|
||||||
case "/start login":
|
case command == "/start login":
|
||||||
return &screens.Login{}
|
return &screens.Login{}
|
||||||
|
case createCreativeRe.MatchString(command):
|
||||||
|
projectID := createCreativeRe.FindStringSubmatch(command)[1]
|
||||||
|
return &screens.AddCreativeStart{Ctx: &screens.AddCreativeCtx{
|
||||||
|
ProjectID: projectID,
|
||||||
|
BackState: &screens.MainMenu{},
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
panic("Unknown command: " + command)
|
panic("Unknown command: " + command)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type AddCreativeCtx struct {
|
|||||||
BackState bot.State
|
BackState bot.State
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddCreativeStart struct{ ctx *AddCreativeCtx }
|
type AddCreativeStart struct{ Ctx *AddCreativeCtx }
|
||||||
|
|
||||||
type AddCreativeEdit struct{ ctx *AddCreativeCtx }
|
type AddCreativeEdit struct{ ctx *AddCreativeCtx }
|
||||||
|
|
||||||
@@ -102,8 +102,8 @@ func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if u.CallbackQuery.Data == "cancel" && s.ctx.BackState != nil {
|
if u.CallbackQuery.Data == "cancel" && s.Ctx.BackState != nil {
|
||||||
b.SetState(s.ctx.BackState, bot.EditMessage)
|
b.SetState(s.Ctx.BackState, bot.EditMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,14 +112,14 @@ func (s *AddCreativeStart) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.ctx.extractCreativeFromMessage(u.Message)
|
s.Ctx.extractCreativeFromMessage(u.Message)
|
||||||
if s.ctx.Name == nil {
|
if s.Ctx.Name == nil {
|
||||||
name := s.ctx.generateCreativeName()
|
name := s.Ctx.generateCreativeName()
|
||||||
s.ctx.Name = &name
|
s.Ctx.Name = &name
|
||||||
}
|
}
|
||||||
|
|
||||||
s.ctx.DeleteUserMessage(b, u.Message.ID)
|
s.Ctx.DeleteUserMessage(b, u.Message.ID)
|
||||||
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.NewMessage)
|
b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AddCreativeStart) Handle(_ *bot.Bot, _ *echotron.Update) {}
|
func (s *AddCreativeStart) Handle(_ *bot.Bot, _ *echotron.Update) {}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(Row(Button("← Назад", "back"))))
|
b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(Row(Button("← Назад", "back"))))
|
||||||
|
|
||||||
case data == "add_creative":
|
case data == "add_creative":
|
||||||
b.SetState(&AddCreativeStart{ctx: &AddCreativeCtx{
|
b.SetState(&AddCreativeStart{Ctx: &AddCreativeCtx{
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
BackState: s,
|
BackState: s,
|
||||||
}}, bot.EditMessage)
|
}}, bot.EditMessage)
|
||||||
|
|||||||
@@ -28,10 +28,7 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var placementPost *backend.PlacementPostOutput
|
placementPost := placement.PlacementPost
|
||||||
if len(placement.PlacementPosts) > 0 {
|
|
||||||
placementPost = &placement.PlacementPosts[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
text := "<b>Детали размещения</b>\n\n"
|
text := "<b>Детали размещения</b>\n\n"
|
||||||
|
|
||||||
@@ -69,12 +66,11 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
|
|||||||
|
|
||||||
if placementPost != nil {
|
if placementPost != nil {
|
||||||
text += "\n<b>Пост</b>\n"
|
text += "\n<b>Пост</b>\n"
|
||||||
if placementPost.AdPostURL != nil && *placementPost.AdPostURL != "" {
|
if placementPost.Post.URL != nil && *placementPost.Post.URL != "" {
|
||||||
text += fmt.Sprintf("• <b>Ссылка на пост:</b> %s\n", *placementPost.AdPostURL)
|
text += fmt.Sprintf("• <b>Ссылка на пост:</b> %s\n", *placementPost.Post.URL)
|
||||||
} else {
|
} else {
|
||||||
text += "• <b>Ссылка на пост:</b> не найдена\n"
|
text += "• <b>Ссылка на пост:</b> не найдена\n"
|
||||||
}
|
}
|
||||||
text += fmt.Sprintf("• <b>Статус поста:</b> %s\n", formatPlacementPostStatus(placementPost.Status))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keyboard := Keyboard(Row(Button("← Назад", "back")))
|
keyboard := Keyboard(Row(Button("← Назад", "back")))
|
||||||
@@ -128,18 +124,6 @@ func formatPlacementType(value string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatPlacementPostStatus(status string) string {
|
|
||||||
normalized := strings.TrimSpace(strings.ToLower(status))
|
|
||||||
switch normalized {
|
|
||||||
case "active":
|
|
||||||
return "Активен"
|
|
||||||
case "archived":
|
|
||||||
return "Архивирован"
|
|
||||||
default:
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatCostType(value string) string {
|
func formatCostType(value string) string {
|
||||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||||
case "cpm":
|
case "cpm":
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package screens
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -1425,7 +1424,7 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input := backend.CreatePlacementsInput{
|
input := backend.CreatePlacementsInput{
|
||||||
CreativeID: s.CreativeID,
|
CreativeID: &s.CreativeID,
|
||||||
Channels: apiChannels,
|
Channels: apiChannels,
|
||||||
Details: placementDetails,
|
Details: placementDetails,
|
||||||
}
|
}
|
||||||
@@ -1439,20 +1438,24 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
creative, err := b.Backend.GetCreative(context.Background(), jwt, b.Session.WorkspaceID, s.CreativeID)
|
for _, placement := range placements.Placements {
|
||||||
|
creativePreview, err := b.Backend.BuildPlacementCreative(
|
||||||
|
context.Background(),
|
||||||
|
jwt,
|
||||||
|
b.Session.WorkspaceID,
|
||||||
|
s.ProjectID,
|
||||||
|
placement.ID,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to get creative")
|
log.Error().Err(err).Msg("Failed to build placement creative")
|
||||||
creative = nil
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if creative != nil {
|
|
||||||
for _, placement := range placements.Placements {
|
|
||||||
channelName := s.formatChannelName(placement)
|
channelName := s.formatChannelName(placement)
|
||||||
header := fmt.Sprintf("<b>Канал:</b> %s", channelName)
|
header := fmt.Sprintf("<b>Канал:</b> %s", channelName)
|
||||||
b.SendMessage(header, b.ChatID, &echotron.MessageOptions{ParseMode: echotron.HTML})
|
b.SendMessage(header, b.ChatID, &echotron.MessageOptions{ParseMode: echotron.HTML})
|
||||||
|
|
||||||
s.sendCreativeWithInviteLink(b, creative, placement.InviteLink)
|
s.sendCreativePreview(b, creativePreview)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
b.SetState(&Placements{
|
b.SetState(&Placements{
|
||||||
@@ -1588,15 +1591,12 @@ func (s *PurchaseOptionalDetails) formatChannelName(placement backend.PlacementO
|
|||||||
return "Без названия"
|
return "Без названия"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) sendCreativeWithInviteLink(
|
func (s *PurchaseOptionalDetails) sendCreativePreview(
|
||||||
b *bot.Bot,
|
b *bot.Bot,
|
||||||
creative *backend.Creative,
|
creative *backend.CreativePreviewOutput,
|
||||||
inviteLink string,
|
|
||||||
) {
|
) {
|
||||||
log.Info().Str("invite_link", inviteLink).Msg("Sending creative with invite link")
|
|
||||||
linkedText := s.injectInviteLink(creative.Text, inviteLink)
|
|
||||||
editor := CreativeEditorFields{
|
editor := CreativeEditorFields{
|
||||||
Text: &linkedText,
|
Text: &creative.Text,
|
||||||
MediaType: creative.MediaType,
|
MediaType: creative.MediaType,
|
||||||
MediaFileID: creative.MediaFileID,
|
MediaFileID: creative.MediaFileID,
|
||||||
}
|
}
|
||||||
@@ -1604,34 +1604,12 @@ func (s *PurchaseOptionalDetails) sendCreativeWithInviteLink(
|
|||||||
if len(creative.Buttons) > 0 {
|
if len(creative.Buttons) > 0 {
|
||||||
editor.Buttons = make([]InlineButton, 0, len(creative.Buttons))
|
editor.Buttons = make([]InlineButton, 0, len(creative.Buttons))
|
||||||
for _, btn := range creative.Buttons {
|
for _, btn := range creative.Buttons {
|
||||||
url := btn.URL
|
|
||||||
if url == inviteLinkPlaceholder {
|
|
||||||
url = inviteLink
|
|
||||||
}
|
|
||||||
editor.Buttons = append(editor.Buttons, InlineButton{
|
editor.Buttons = append(editor.Buttons, InlineButton{
|
||||||
Text: btn.Text,
|
Text: btn.Text,
|
||||||
URL: url,
|
URL: btn.URL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.SendCreativePreview(b)
|
editor.SendCreativePreview(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) injectInviteLink(text, inviteLink string) string {
|
|
||||||
if text == "" {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
re := regexp.MustCompile(`<a\s+class="tg-link">([^<]*)</a>`)
|
|
||||||
return re.ReplaceAllStringFunc(text, func(match string) string {
|
|
||||||
sub := re.FindStringSubmatch(match)
|
|
||||||
if len(sub) < 2 {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
inner := strings.TrimSpace(sub[1])
|
|
||||||
if inner == "" {
|
|
||||||
return fmt.Sprintf(`<a href="%s">%s</a>`, inviteLink, inviteLink)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(`<a href="%s">%s</a>`, inviteLink, sub[1])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input := backend.CreatePlacementsInput{
|
input := backend.CreatePlacementsInput{
|
||||||
CreativeID: s.CreativeID,
|
CreativeID: &s.CreativeID,
|
||||||
Channels: apiChannels,
|
Channels: apiChannels,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ RUN go mod download
|
|||||||
|
|
||||||
# Build layer
|
# Build layer
|
||||||
COPY tg_parser /app/tg_parser
|
COPY tg_parser /app/tg_parser
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /parser .
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /parser .
|
||||||
|
|
||||||
FROM alpine:latest AS run
|
FROM alpine:latest AS run
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user