From f3f281ae51fbff66476ddc616c54f4927e29d8e0 Mon Sep 17 00:00:00 2001 From: ITQ Date: Tue, 18 Feb 2025 23:54:26 +0300 Subject: [PATCH] feat: added campaigns endpoints --- .../backend/api/v1/campaigns/__init__.py | 0 .../backend/api/v1/campaigns/schemas.py | 110 +++++++++ .../backend/api/v1/campaigns/utils.py | 7 + .../backend/api/v1/campaigns/views.py | 212 ++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 solution/services/backend/api/v1/campaigns/__init__.py create mode 100644 solution/services/backend/api/v1/campaigns/schemas.py create mode 100644 solution/services/backend/api/v1/campaigns/utils.py create mode 100644 solution/services/backend/api/v1/campaigns/views.py diff --git a/solution/services/backend/api/v1/campaigns/__init__.py b/solution/services/backend/api/v1/campaigns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/services/backend/api/v1/campaigns/schemas.py b/solution/services/backend/api/v1/campaigns/schemas.py new file mode 100644 index 0000000..4325bf7 --- /dev/null +++ b/solution/services/backend/api/v1/campaigns/schemas.py @@ -0,0 +1,110 @@ +import typing +from typing import Any +from uuid import UUID + +from ninja import ModelSchema, Schema +from pydantic import field_validator +from pydantic.types import NonNegativeInt, PositiveInt + +from apps.campaign.models import Campaign + + +class CampaignTargeting(ModelSchema): + class Meta: + model = Campaign + fields: typing.ClassVar[tuple[str]] = ( + Campaign.gender.field.name, + Campaign.age_from.field.name, + Campaign.age_to.field.name, + Campaign.location.field.name, + ) + fields_optional = "__all__" + + +class CampaignOut(ModelSchema): + campaign_id: UUID + advertiser_id: UUID + targeting: CampaignTargeting = None + + class Meta: + model = Campaign + fields: typing.ClassVar[tuple[str]] = ( + Campaign.ad_title.field.name, + Campaign.ad_text.field.name, + Campaign.ad_image.field.name, + Campaign.impressions_limit.field.name, + Campaign.clicks_limit.field.name, + Campaign.cost_per_impression.field.name, + Campaign.cost_per_click.field.name, + Campaign.start_date.field.name, + Campaign.end_date.field.name, + ) + + +class CampaignCreateIn(ModelSchema): + targeting: CampaignTargeting = None + + class Meta: + model = Campaign + fields: typing.ClassVar[tuple[str]] = ( + Campaign.ad_title.field.name, + Campaign.ad_text.field.name, + Campaign.impressions_limit.field.name, + Campaign.clicks_limit.field.name, + Campaign.cost_per_impression.field.name, + Campaign.cost_per_click.field.name, + Campaign.start_date.field.name, + Campaign.end_date.field.name, + ) + + @field_validator("targeting", mode="before") + @classmethod + def validate_target(cls, value: Any) -> Any: + if ( + not isinstance(value, dict) + and not isinstance( + value, + CampaignTargeting, + ) + and value + ): + err = "The 'targeting' field must be a valid object or null." + raise ValueError(err) + return value + + +class CampaignUpdateIn(ModelSchema): + targeting: CampaignTargeting = None + + class Meta: + model = Campaign + fields: typing.ClassVar[tuple[str]] = ( + Campaign.impressions_limit.field.name, + Campaign.clicks_limit.field.name, + Campaign.ad_title.field.name, + Campaign.ad_text.field.name, + Campaign.cost_per_impression.field.name, + Campaign.cost_per_click.field.name, + Campaign.start_date.field.name, + Campaign.end_date.field.name, + ) + + @field_validator("targeting", mode="before") + @classmethod + def validate_target(cls, value: Any) -> Any: + if ( + not isinstance(value, dict) + and not isinstance( + value, + CampaignTargeting, + ) + and value + ): + err = "The 'targeting' field must be a valid object or null." + raise ValueError(err) + return value + + +class CampaignListFilters(Schema): + page: PositiveInt = 1 + size: NonNegativeInt = 100 diff --git a/solution/services/backend/api/v1/campaigns/utils.py b/solution/services/backend/api/v1/campaigns/utils.py new file mode 100644 index 0000000..724cdc5 --- /dev/null +++ b/solution/services/backend/api/v1/campaigns/utils.py @@ -0,0 +1,7 @@ +from api.v1.campaigns import schemas +from apps.campaign.models import Campaign + + +def normalize_campaign(campaign: Campaign) -> schemas.CampaignOut: + campaign.targeting = schemas.CampaignTargeting.from_orm(campaign) + return schemas.CampaignOut.from_orm(campaign) diff --git a/solution/services/backend/api/v1/campaigns/views.py b/solution/services/backend/api/v1/campaigns/views.py new file mode 100644 index 0000000..1317d82 --- /dev/null +++ b/solution/services/backend/api/v1/campaigns/views.py @@ -0,0 +1,212 @@ +from http import HTTPStatus as status +from uuid import UUID + +from django.http import HttpRequest +from django.shortcuts import get_object_or_404 +from ninja import File, Query, Router +from ninja.errors import HttpError +from ninja.files import UploadedFile +from PIL import Image + +from api.v1 import schemas as global_schemas +from api.v1.campaigns import schemas, utils +from apps.advertiser.models import Advertiser +from apps.campaign.models import Campaign +from config.errors import ForbiddenError + +router = Router(tags=["campaigns"]) + + +@router.post( + "/{advertiser_id}/campaigns", + response={ + status.CREATED: schemas.CampaignOut, + status.BAD_REQUEST: global_schemas.BadRequestError, + status.NOT_FOUND: global_schemas.NotFoundError, + }, +) +def create_campaign( + request: HttpRequest, advertiser_id: UUID, data: schemas.CampaignCreateIn +) -> tuple[status, schemas.CampaignOut]: + advertiser = get_object_or_404(Advertiser, id=advertiser_id) + + campaign = Campaign.objects.create( + advertiser_id=advertiser.id, + **data.dict(exclude={"targeting"}), + **data.targeting.dict() if data.targeting else {}, + ) + + return status.CREATED, utils.normalize_campaign(campaign) + + +@router.get( + "/{advertiser_id}/campaigns", + response={ + status.OK: list[schemas.CampaignOut], + status.BAD_REQUEST: global_schemas.BadRequestError, + status.NOT_FOUND: global_schemas.NotFoundError, + }, +) +def list_campaigns( + request: HttpRequest, + advertiser_id: UUID, + filters: Query[schemas.CampaignListFilters], +) -> tuple[status, list[schemas.CampaignOut]]: + advertaiser = get_object_or_404(Advertiser, id=advertiser_id) + campaigns = Campaign.objects.filter(advertiser=advertaiser).order_by( + "-end_date" + ) + paginated_campaigns = campaigns[ + (filters.page - 1) * filters.size : filters.page * filters.size + ] + + return status.OK, [ + utils.normalize_campaign(campaign) for campaign in paginated_campaigns + ] + + +@router.get( + "/{advertiser_id}/campaigns/{campaign_id}", + response={ + status.OK: schemas.CampaignOut, + status.BAD_REQUEST: global_schemas.BadRequestError, + status.NOT_FOUND: global_schemas.NotFoundError, + }, +) +def get_campaign( + request: HttpRequest, advertiser_id: UUID, campaign_id: UUID +) -> tuple[status, schemas.CampaignOut]: + return status.OK, utils.normalize_campaign( + get_object_or_404( + Campaign, + id=campaign_id, + advertiser_id=advertiser_id, + ) + ) + + +@router.put( + "/{advertiser_id}/campaigns/{campaign_id}", + response={ + status.OK: schemas.CampaignOut, + status.BAD_REQUEST: global_schemas.BadRequestError, + status.FORBIDDEN: global_schemas.ForbiddenError, + status.NOT_FOUND: global_schemas.NotFoundError, + }, +) +def update_campaign( + request: HttpRequest, + advertiser_id: UUID, + campaign_id: UUID, + data: schemas.CampaignUpdateIn, +) -> tuple[status, schemas.CampaignOut]: + campaign = get_object_or_404( + Campaign, + id=campaign_id, + advertiser_id=advertiser_id, + ) + + for attr, value in data.dict().items(): + if attr == "targeting": + for t_attr, t_value in value.items(): + setattr(campaign, t_attr, t_value) + elif not ( + attr in Campaign.READONLY_AFTER_START_FIELDS + and campaign.started + and getattr(campaign, attr) != value + ): + setattr(campaign, attr, value) + else: + raise ForbiddenError + + campaign.save() + + return status.OK, utils.normalize_campaign(campaign) + + +@router.delete( + "/{advertiser_id}/campaigns/{campaign_id}", + response={ + status.NO_CONTENT: None, + status.BAD_REQUEST: global_schemas.BadRequestError, + status.NOT_FOUND: global_schemas.NotFoundError, + }, +) +def delete_campaign( + request: HttpRequest, advertiser_id: UUID, campaign_id: UUID +) -> tuple[status, None]: + campaign = get_object_or_404( + Campaign, + id=campaign_id, + advertiser_id=advertiser_id, + ) + + if campaign.ad_image: + campaign.ad_image.delete() + + campaign.delete() + + return status.NO_CONTENT, None + + +@router.post( + "/{advertiser_id}/campaigns/{campaign_id}/ad_image/upload", + response={ + status.OK: schemas.CampaignOut, + status.BAD_REQUEST: global_schemas.BadRequestError, + status.NOT_FOUND: global_schemas.NotFoundError, + }, + description=( + "Uploads image to ad_image field. " + "If image already exists then image will be overridden." + ), +) +def upload_ad_image( + request: HttpRequest, + advertiser_id: UUID, + campaign_id: UUID, + ad_image: UploadedFile = File(...), # noqa: B008 +) -> tuple[status, Campaign]: + campaign = get_object_or_404( + Campaign, + id=campaign_id, + advertiser_id=advertiser_id, + ) + try: + Image.open(ad_image).verify() + except (OSError, SyntaxError): + raise HttpError( + status.BAD_REQUEST, "File must be a valid image." + ) from None + if campaign.ad_image: + campaign.ad_image.delete(save=True) + campaign.ad_image = ad_image + campaign.save() + + return status.OK, campaign + + +@router.delete( + "/{advertiser_id}/campaigns/{campaign_id}/ad_image/delete", + response={ + status.NO_CONTENT: None, + status.BAD_REQUEST: global_schemas.BadRequestError, + status.NOT_FOUND: global_schemas.NotFoundError, + }, + description=( + "Deletes image from ad_image field. " + "If no image exists still returns 204." + ), +) +def delete_ad_image( + request: HttpRequest, advertiser_id: UUID, campaign_id: UUID +) -> tuple[status, None]: + campaign = get_object_or_404( + Campaign, + id=campaign_id, + advertiser_id=advertiser_id, + ) + if campaign.ad_image: + campaign.ad_image.delete(save=True) + + return status.NO_CONTENT, None