chore: restructured project
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
from typing import ClassVar
|
||||
from uuid import UUID
|
||||
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
from apps.campaign.models import Campaign
|
||||
|
||||
|
||||
class Advertisment(ModelSchema):
|
||||
advertiser_id: UUID
|
||||
ad_id: UUID
|
||||
|
||||
class Meta:
|
||||
model = Campaign
|
||||
fields: ClassVar[tuple[str]] = (
|
||||
Campaign.ad_title.field.name,
|
||||
Campaign.ad_text.field.name,
|
||||
Campaign.ad_image.field.name,
|
||||
)
|
||||
|
||||
|
||||
class ClickIn(Schema):
|
||||
client_id: UUID
|
||||
@@ -0,0 +1,58 @@
|
||||
from http import HTTPStatus as status
|
||||
from uuid import UUID
|
||||
|
||||
from django.http import Http404, HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Router
|
||||
|
||||
from api.v1 import schemas as global_schemas
|
||||
from api.v1.ads import schemas
|
||||
from apps.campaign.models import Campaign
|
||||
from apps.client.models import Client
|
||||
|
||||
router = Router(tags=["ads"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response={
|
||||
status.OK: schemas.Advertisment,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||
},
|
||||
)
|
||||
def get_advertisment(
|
||||
request: HttpRequest, client_id: UUID
|
||||
) -> tuple[status, Campaign]:
|
||||
client = get_object_or_404(Client, id=client_id)
|
||||
|
||||
campaign = Campaign.suggest(client)
|
||||
|
||||
if not campaign:
|
||||
raise Http404
|
||||
|
||||
campaign.view(client)
|
||||
|
||||
return status.OK, campaign
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{advertisment_id}/click",
|
||||
response={
|
||||
status.NO_CONTENT: None,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
status.FORBIDDEN: global_schemas.ForbiddenError,
|
||||
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||
},
|
||||
)
|
||||
def click_on_advertisment(
|
||||
request: HttpRequest, advertisment_id: UUID, client: schemas.ClickIn
|
||||
) -> tuple[status, None]:
|
||||
campaign_instance: Campaign = get_object_or_404(
|
||||
Campaign, id=advertisment_id
|
||||
)
|
||||
client_instance: Client = get_object_or_404(Client, id=client.client_id)
|
||||
|
||||
campaign_instance.click(client_instance)
|
||||
|
||||
return status.NO_CONTENT, None
|
||||
@@ -0,0 +1,21 @@
|
||||
from typing import ClassVar
|
||||
from uuid import UUID
|
||||
|
||||
from ninja import ModelSchema
|
||||
|
||||
from apps.advertiser.models import Advertiser as AdvertiserModel
|
||||
from apps.mlscore.models import Mlscore as MlscoreModel
|
||||
|
||||
|
||||
class Advertiser(ModelSchema):
|
||||
advertiser_id: UUID
|
||||
|
||||
class Meta:
|
||||
model = AdvertiserModel
|
||||
exclude: ClassVar[tuple[str]] = (AdvertiserModel.id.field.name,)
|
||||
|
||||
|
||||
class Mlscore(ModelSchema):
|
||||
class Meta:
|
||||
model = MlscoreModel
|
||||
exclude: ClassVar[tuple[str]] = (MlscoreModel.id.field.name,)
|
||||
@@ -0,0 +1,215 @@
|
||||
import json
|
||||
import uuid
|
||||
from http import HTTPStatus as status
|
||||
|
||||
from django.test import TestCase, Client, override_settings
|
||||
from apps.advertiser.models import Advertiser
|
||||
from apps.client.models import Client as ClientModel
|
||||
from apps.mlscore.models import Mlscore
|
||||
|
||||
|
||||
class TestMlscoreEndpoint(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.advertiser = Advertiser.objects.create(name="Test Advertiser")
|
||||
self.client_obj = ClientModel.objects.create(
|
||||
login="test_client",
|
||||
age=14,
|
||||
location="test_location",
|
||||
gender=ClientModel.GenderChoices.FEMALE,
|
||||
)
|
||||
|
||||
self.url = "/ml-scores"
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_create_mlscore_success(self):
|
||||
data = {
|
||||
"advertiser_id": str(self.advertiser.id),
|
||||
"client_id": str(self.client_obj.id),
|
||||
"score": 90,
|
||||
}
|
||||
response = self.client.post(
|
||||
self.url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
mlscore = Mlscore.objects.first()
|
||||
|
||||
self.assertEqual(response.status_code, status.OK)
|
||||
self.assertEqual(mlscore.score, 90)
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_update_mlscore_success(self):
|
||||
mlscore = Mlscore.objects.create(
|
||||
advertiser=self.advertiser,
|
||||
client=self.client_obj,
|
||||
score=80,
|
||||
)
|
||||
data = {
|
||||
"advertiser_id": str(self.advertiser.id),
|
||||
"client_id": str(self.client_obj.id),
|
||||
"score": 85,
|
||||
}
|
||||
response = self.client.post(
|
||||
self.url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
mlscore.refresh_from_db()
|
||||
|
||||
self.assertEqual(response.status_code, status.OK)
|
||||
self.assertEqual(mlscore.score, 85)
|
||||
|
||||
def test_missing_required_field(self):
|
||||
data = {"score": 90}
|
||||
response = self.client.post(
|
||||
self.url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
def test_invalid_uuid_format(self):
|
||||
data = {
|
||||
"advertiser_id": "invalid-uuid",
|
||||
"client_id": str(self.client_obj.id),
|
||||
"score": 90,
|
||||
}
|
||||
response = self.client.post(
|
||||
self.url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_non_existing_client(self):
|
||||
data = {
|
||||
"advertiser_id": str(self.advertiser.id),
|
||||
"client_id": str(uuid.uuid4()),
|
||||
"score": 90,
|
||||
}
|
||||
response = self.client.post(
|
||||
self.url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
def test_non_existing_advertiser(self):
|
||||
data = {
|
||||
"advertiser_id": str(uuid.uuid4()),
|
||||
"client_id": str(self.client_obj.id),
|
||||
"score": 90,
|
||||
}
|
||||
response = self.client.post(
|
||||
self.url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
|
||||
class TestBulkAdvertisersEndpoint(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.url = "/advertisers/bulk"
|
||||
self.advertiser = Advertiser.objects.create(name="Advertiser 1")
|
||||
|
||||
def test_bulk_create_success(self):
|
||||
uuid1 = self.advertiser.id
|
||||
uuid2 = uuid.uuid4()
|
||||
data = [
|
||||
{"advertiser_id": str(uuid1), "name": "Advertiser 4"},
|
||||
{"advertiser_id": str(uuid2), "name": "Advertiser 1"},
|
||||
{"advertiser_id": str(uuid2), "name": "Advertiser 5"},
|
||||
{"advertiser_id": str(uuid2), "name": "Advertiser 2"},
|
||||
{"advertiser_id": str(uuid1), "name": "Advertiser 2"},
|
||||
]
|
||||
response = self.client.post(
|
||||
self.url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
self.advertiser.refresh_from_db()
|
||||
|
||||
self.assertEqual(response.status_code, status.CREATED)
|
||||
self.assertEqual(self.advertiser.name, "Advertiser 2")
|
||||
self.assertEqual(Advertiser.objects.count(), 2)
|
||||
|
||||
def test_bulk_update_success(self):
|
||||
advertiser = Advertiser.objects.create(name="Old Name")
|
||||
data = [{"advertiser_id": str(advertiser.id), "name": "New Name"}]
|
||||
response = self.client.post(
|
||||
self.url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
advertiser.refresh_from_db()
|
||||
|
||||
self.assertEqual(response.status_code, status.CREATED)
|
||||
self.assertEqual(advertiser.name, "New Name")
|
||||
|
||||
def test_duplicate_advertiser_ids(self):
|
||||
adv_id = uuid.uuid4()
|
||||
data = [
|
||||
{"advertiser_id": str(adv_id), "name": "First"},
|
||||
{"advertiser_id": str(adv_id), "name": "Last"},
|
||||
]
|
||||
response = self.client.post(
|
||||
self.url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
advertiser = Advertiser.objects.get(id=adv_id)
|
||||
|
||||
self.assertEqual(response.status_code, status.CREATED)
|
||||
self.assertEqual(advertiser.name, "Last")
|
||||
|
||||
def test_invalid_advertiser_id_format(self):
|
||||
data = [{"advertiser_id": "invalid", "name": "Invalid"}]
|
||||
response = self.client.post(
|
||||
self.url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
def test_empty_bulk_request(self):
|
||||
response = self.client.post(
|
||||
self.url, json.dumps([]), content_type="application/json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.CREATED)
|
||||
self.assertEqual(len(response.json()), 0)
|
||||
|
||||
|
||||
class TestGetAdvertiserEndpoint(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.advertiser = Advertiser.objects.create(name="Test Advertiser")
|
||||
self.url = "/advertisers"
|
||||
self.valid_url = f"{self.url}/{self.advertiser.id}"
|
||||
|
||||
def test_get_advertiser_success(self):
|
||||
response = self.client.get(self.valid_url)
|
||||
|
||||
self.assertEqual(response.status_code, status.OK)
|
||||
self.assertEqual(
|
||||
response.json()["advertiser_id"], str(self.advertiser.id)
|
||||
)
|
||||
self.assertEqual(response.json()["name"], self.advertiser.name)
|
||||
|
||||
def test_non_existent_advertiser(self):
|
||||
non_existent_url = f"{self.url}/{uuid.uuid4()}"
|
||||
response = self.client.get(non_existent_url)
|
||||
|
||||
self.assertEqual(response.status_code, status.NOT_FOUND)
|
||||
|
||||
def test_invalid_uuid_format(self):
|
||||
response = self.client.get(f"{self.url}/invalid-uuid")
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
@@ -0,0 +1,87 @@
|
||||
from collections import defaultdict
|
||||
from http import HTTPStatus as status
|
||||
from uuid import UUID
|
||||
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Router
|
||||
|
||||
from api.v1 import schemas as global_schemas
|
||||
from api.v1.advertisers import schemas
|
||||
from apps.advertiser.models import Advertiser
|
||||
from apps.mlscore.models import Mlscore
|
||||
|
||||
router = Router(tags=["advertisers"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ml-scores",
|
||||
response={
|
||||
status.OK: schemas.Mlscore,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
},
|
||||
)
|
||||
def create_or_update_mlscore(
|
||||
request: HttpRequest, mlscore: schemas.Mlscore
|
||||
) -> tuple[status, schemas.Mlscore]:
|
||||
mlscore_instance, _ = Mlscore.objects.update_or_create(
|
||||
advertiser_id=mlscore.advertiser,
|
||||
client_id=mlscore.client,
|
||||
defaults={"score": mlscore.score},
|
||||
)
|
||||
|
||||
return status.OK, mlscore_instance
|
||||
|
||||
|
||||
@router.post(
|
||||
"/advertisers/bulk",
|
||||
response={
|
||||
status.CREATED: list[schemas.Advertiser],
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
},
|
||||
)
|
||||
def bulk_create_or_update(
|
||||
request: HttpRequest, data: list[schemas.Advertiser]
|
||||
) -> tuple[status, list[Advertiser]]:
|
||||
latest_advertisers: dict[UUID, schemas.Advertiser] = defaultdict(
|
||||
lambda: None
|
||||
)
|
||||
|
||||
for item in reversed(data):
|
||||
if latest_advertisers[item.advertiser_id] is None:
|
||||
Advertiser(
|
||||
id=item.advertiser_id, **item.dict(exclude={"client_id"})
|
||||
).validate(
|
||||
validate_unique=False,
|
||||
validate_constraints=False,
|
||||
)
|
||||
latest_advertisers[item.advertiser_id] = item
|
||||
|
||||
unique_advertisers = reversed(list(latest_advertisers.values()))
|
||||
|
||||
result = []
|
||||
|
||||
with transaction.atomic():
|
||||
for advertiser in unique_advertisers:
|
||||
advertiser_instance, _ = Advertiser.objects.update_or_create(
|
||||
id=advertiser.advertiser_id,
|
||||
defaults={**dict(advertiser)},
|
||||
)
|
||||
result.append(advertiser_instance)
|
||||
|
||||
return status.CREATED, result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/advertisers/{advertiser_id}",
|
||||
response={
|
||||
status.OK: schemas.Advertiser,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||
},
|
||||
)
|
||||
def get_advertiser(
|
||||
request: HttpRequest, advertiser_id: UUID
|
||||
) -> tuple[status, Advertiser]:
|
||||
return status.OK, get_object_or_404(Advertiser, id=advertiser_id)
|
||||
@@ -0,0 +1,109 @@
|
||||
from typing import Any, ClassVar
|
||||
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: 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: 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: 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_targeting(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: 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_targeting(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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,214 @@
|
||||
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",
|
||||
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,
|
||||
)
|
||||
if ad_image.size >= 10 * 1024 * 1024:
|
||||
raise HttpError(status.BAD_REQUEST, "File can't be bigger than 10MB.")
|
||||
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",
|
||||
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
|
||||
@@ -0,0 +1,14 @@
|
||||
from typing import ClassVar
|
||||
from uuid import UUID
|
||||
|
||||
from ninja import ModelSchema
|
||||
|
||||
from apps.client.models import Client as ClientModel
|
||||
|
||||
|
||||
class Client(ModelSchema):
|
||||
client_id: UUID
|
||||
|
||||
class Meta:
|
||||
model = ClientModel
|
||||
exclude: ClassVar[tuple[str]] = (ClientModel.id.field.name,)
|
||||
@@ -0,0 +1,155 @@
|
||||
from http import HTTPStatus as status
|
||||
from django.test import TestCase
|
||||
import json
|
||||
from uuid import uuid4
|
||||
from apps.client.models import Client
|
||||
|
||||
|
||||
class ClientTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client_1 = Client.objects.create(
|
||||
login="testuser1", age=25, location="City1", gender="MALE"
|
||||
)
|
||||
self.client_2 = Client.objects.create(
|
||||
login="testuser2", age=30, location="City2", gender="FEMALE"
|
||||
)
|
||||
|
||||
self.bulk_url = "/clients/bulk"
|
||||
self.get_url = "/clients"
|
||||
|
||||
def test_bulk_create_or_update(self):
|
||||
client_3_id = str(uuid4())
|
||||
client_data = [
|
||||
{
|
||||
"client_id": client_3_id,
|
||||
"login": "newusers",
|
||||
"age": 21,
|
||||
"location": "City1",
|
||||
"gender": "FEMALE",
|
||||
},
|
||||
{
|
||||
"client_id": str(self.client_1.id),
|
||||
"login": "updateduser",
|
||||
"age": 26,
|
||||
"location": "City1",
|
||||
"gender": "MALE",
|
||||
},
|
||||
{
|
||||
"client_id": client_3_id,
|
||||
"login": "newusersa",
|
||||
"age": 25,
|
||||
"location": "City1",
|
||||
"gender": "FEMALE",
|
||||
},
|
||||
{
|
||||
"client_id": client_3_id,
|
||||
"login": "newuser",
|
||||
"age": 22,
|
||||
"location": "City3",
|
||||
"gender": "MALE",
|
||||
},
|
||||
]
|
||||
response = self.client.post(
|
||||
self.bulk_url,
|
||||
data=json.dumps(client_data),
|
||||
content_type="application/json",
|
||||
)
|
||||
client_3 = Client.objects.get(id=client_3_id)
|
||||
self.client_1.refresh_from_db()
|
||||
|
||||
self.assertEqual(response.status_code, status.CREATED)
|
||||
self.assertEqual(Client.objects.count(), 3)
|
||||
self.assertEqual(self.client_1.login, "updateduser")
|
||||
self.assertEqual(client_3.location, "City3")
|
||||
self.assertEqual(client_3.login, "newuser")
|
||||
self.assertEqual(len(response.json()), 2)
|
||||
self.assertEqual(response.json()[1]["client_id"], str(client_3.id))
|
||||
self.assertEqual(response.json()[1]["login"], client_3.login)
|
||||
|
||||
def test_bulk_create_invalid_data(self):
|
||||
client_data = [
|
||||
{
|
||||
"client_id": "invalid_uuid",
|
||||
"login": "baduser",
|
||||
"age": 150,
|
||||
"location": "City4",
|
||||
"gender": "UNKNOWN",
|
||||
}
|
||||
]
|
||||
response = self.client.post(
|
||||
self.bulk_url,
|
||||
data=json.dumps(client_data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
def test_duplicate_advertiser_ids(self):
|
||||
adv_id = uuid4()
|
||||
data = [
|
||||
{
|
||||
"client_id": str(adv_id),
|
||||
"login": "baduser",
|
||||
"age": 10,
|
||||
"location": "City4",
|
||||
"gender": "FEMALE",
|
||||
},
|
||||
{
|
||||
"client_id": str(adv_id),
|
||||
"login": "Last",
|
||||
"age": 14,
|
||||
"location": "City4",
|
||||
"gender": "MALE",
|
||||
},
|
||||
]
|
||||
response = self.client.post(
|
||||
self.bulk_url, json.dumps(data), content_type="application/json"
|
||||
)
|
||||
client = Client.objects.get(id=adv_id)
|
||||
|
||||
self.assertEqual(response.status_code, status.CREATED)
|
||||
self.assertEqual(client.login, "Last")
|
||||
self.assertEqual(client.age, 14)
|
||||
self.assertEqual(client.gender, "MALE")
|
||||
|
||||
def test_invalid_client_id_format(self):
|
||||
client_data = [
|
||||
{
|
||||
"client_id": "invalid_uuid",
|
||||
"login": "baduser",
|
||||
"age": 150,
|
||||
"location": "City4",
|
||||
"gender": "UNKNOWN",
|
||||
}
|
||||
]
|
||||
response = self.client.post(
|
||||
self.bulk_url,
|
||||
json.dumps(client_data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
def test_empty_bulk_request(self):
|
||||
response = self.client.post(
|
||||
self.bulk_url, json.dumps([]), content_type="application/json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.CREATED)
|
||||
self.assertEqual(len(response.json()), 0)
|
||||
|
||||
def test_get_client_success(self):
|
||||
response = self.client.get(f"{self.get_url}/{self.client_1.id}")
|
||||
|
||||
self.assertEqual(response.status_code, status.OK)
|
||||
self.assertEqual(response.json()["login"], self.client_1.login)
|
||||
|
||||
def test_get_client_not_found(self):
|
||||
response = self.client.get(f"{self.get_url}/{uuid4()}")
|
||||
|
||||
self.assertEqual(response.status_code, status.NOT_FOUND)
|
||||
|
||||
def test_get_client_invalid_uuid(self):
|
||||
response = self.client.get(f"{self.get_url}/invalid_uuid")
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
@@ -0,0 +1,63 @@
|
||||
from collections import defaultdict
|
||||
from http import HTTPStatus as status
|
||||
from uuid import UUID
|
||||
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Router
|
||||
|
||||
from api.v1 import schemas as global_schemas
|
||||
from api.v1.clients import schemas
|
||||
from apps.client.models import Client
|
||||
|
||||
router = Router(tags=["clients"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/bulk",
|
||||
response={
|
||||
status.CREATED: list[schemas.Client],
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
},
|
||||
)
|
||||
def bulk_create_or_update(
|
||||
request: HttpRequest, data: list[schemas.Client]
|
||||
) -> tuple[status, list[Client]]:
|
||||
latest_clients = defaultdict(lambda: None)
|
||||
|
||||
for item in reversed(data):
|
||||
Client(id=item.client_id, **item.dict(exclude={"client_id"})).validate(
|
||||
validate_unique=False,
|
||||
validate_constraints=False,
|
||||
)
|
||||
if latest_clients[item.client_id] is None:
|
||||
latest_clients[item.client_id] = item
|
||||
|
||||
unique_clients = reversed(list(latest_clients.values()))
|
||||
|
||||
result = []
|
||||
|
||||
with transaction.atomic():
|
||||
for client in unique_clients:
|
||||
client_instance, _ = Client.objects.update_or_create(
|
||||
id=client.client_id,
|
||||
defaults={**dict(client)},
|
||||
)
|
||||
result.append(client_instance)
|
||||
|
||||
return status.CREATED, result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{client_id}",
|
||||
response={
|
||||
status.OK: schemas.Client,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||
},
|
||||
)
|
||||
def get_client(
|
||||
request: HttpRequest, client_id: UUID
|
||||
) -> tuple[status, schemas.Client]:
|
||||
return status.OK, get_object_or_404(Client, id=client_id)
|
||||
@@ -0,0 +1,23 @@
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class GenerateAdTextIn(Schema):
|
||||
advertiser_name: str
|
||||
ad_title: str
|
||||
|
||||
|
||||
class Promise(Schema):
|
||||
task_id: UUID
|
||||
status: Literal[
|
||||
"PENDING",
|
||||
"RECEIVED",
|
||||
"STARTED",
|
||||
"SUCCESS",
|
||||
"FAILURE",
|
||||
"RETRY",
|
||||
"REVOKED",
|
||||
]
|
||||
result: Any
|
||||
@@ -0,0 +1,56 @@
|
||||
from http import HTTPStatus as status
|
||||
from uuid import UUID
|
||||
|
||||
import celery.states
|
||||
from celery.result import AsyncResult
|
||||
from django.http import Http404, HttpRequest
|
||||
from ninja import Router
|
||||
|
||||
from api.v1 import schemas as global_schemas
|
||||
from api.v1.generate import schemas
|
||||
from apps.campaign.tasks import generate_ad_text_task
|
||||
|
||||
router = Router(tags=["generate"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ad_text",
|
||||
response={
|
||||
status.OK: schemas.Promise,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
},
|
||||
)
|
||||
def generate_ad_text(
|
||||
request: HttpRequest, prompt: schemas.GenerateAdTextIn
|
||||
) -> tuple[status, schemas.Promise]:
|
||||
task = generate_ad_text_task.delay(prompt.advertiser_name, prompt.ad_title)
|
||||
task_result = AsyncResult(task.id)
|
||||
|
||||
return status.OK, schemas.Promise(
|
||||
task_id=task.id,
|
||||
status=task_result.status,
|
||||
result=task_result.result,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ad_text/{task_id}/result",
|
||||
response={
|
||||
status.OK: schemas.Promise,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||
},
|
||||
)
|
||||
def get_generate_ad_text_result(
|
||||
request: HttpRequest, task_id: UUID
|
||||
) -> tuple[status, schemas.Promise]:
|
||||
task_result = AsyncResult(str(task_id))
|
||||
|
||||
if task_result.status == celery.states.PENDING:
|
||||
raise Http404
|
||||
|
||||
return status.OK, schemas.Promise(
|
||||
task_id=task_result.task_id,
|
||||
status=task_result.status,
|
||||
result=task_result.result,
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus as status
|
||||
from typing import Any
|
||||
|
||||
import django.core.exceptions
|
||||
import django.http
|
||||
import ninja.errors
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from ninja import NinjaAPI
|
||||
|
||||
from config.errors import ConflictError, ForbiddenError
|
||||
|
||||
logger = logging.getLogger("django")
|
||||
|
||||
|
||||
def handle_validation_error(
|
||||
request: HttpRequest,
|
||||
exc: ninja.errors.ValidationError,
|
||||
router: NinjaAPI,
|
||||
) -> HttpResponse:
|
||||
return router.create_response(
|
||||
request,
|
||||
{"detail": exc.errors},
|
||||
status=status.BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def handle_django_validation_error(
|
||||
request: HttpRequest,
|
||||
exc: django.core.exceptions.ValidationError,
|
||||
router: NinjaAPI,
|
||||
) -> HttpResponse:
|
||||
detail = list(exc)
|
||||
|
||||
if hasattr(exc, "error_dict"):
|
||||
detail = dict(exc)
|
||||
|
||||
return router.create_response(
|
||||
request,
|
||||
{"detail": detail},
|
||||
status=status.BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def handle_authentication_error(
|
||||
request: HttpRequest,
|
||||
exc: ninja.errors.AuthenticationError,
|
||||
router: NinjaAPI,
|
||||
) -> HttpResponse:
|
||||
return router.create_response(
|
||||
request,
|
||||
{"detail": status.UNAUTHORIZED.phrase},
|
||||
status=status.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
def handle_forbidden_error(
|
||||
request: HttpRequest,
|
||||
exc: ForbiddenError,
|
||||
router: NinjaAPI,
|
||||
) -> HttpResponse:
|
||||
return router.create_response(
|
||||
request,
|
||||
{"detail": exc.message},
|
||||
status=status.FORBIDDEN,
|
||||
)
|
||||
|
||||
|
||||
def handle_not_found_error(
|
||||
request: HttpRequest,
|
||||
exc: Exception,
|
||||
router: NinjaAPI,
|
||||
) -> HttpResponse:
|
||||
return router.create_response(
|
||||
request,
|
||||
{"detail": status.NOT_FOUND.phrase},
|
||||
status=status.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
def handle_conflict_error(
|
||||
request: HttpRequest,
|
||||
exc: ConflictError,
|
||||
router: NinjaAPI,
|
||||
) -> HttpResponse:
|
||||
detail = list(exc.validation_error)
|
||||
|
||||
if hasattr(exc, "error_dict"):
|
||||
detail = dict(exc.validation_error)
|
||||
|
||||
return router.create_response(
|
||||
request,
|
||||
{"detail": detail},
|
||||
status=status.CONFLICT,
|
||||
)
|
||||
|
||||
|
||||
def handle_unknown_exception(
|
||||
request: HttpRequest,
|
||||
exc: Exception,
|
||||
router: NinjaAPI,
|
||||
) -> HttpResponse:
|
||||
logger.exception(exc)
|
||||
|
||||
return router.create_response(
|
||||
request,
|
||||
{"detail": status.INTERNAL_SERVER_ERROR.phrase},
|
||||
status=status.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
exception_handlers: list[tuple[Any, Callable]] = [
|
||||
(ninja.errors.ValidationError, handle_validation_error),
|
||||
(django.core.exceptions.ValidationError, handle_django_validation_error),
|
||||
(ninja.errors.AuthenticationError, handle_authentication_error),
|
||||
(ForbiddenError, handle_forbidden_error),
|
||||
(django.http.Http404, handle_not_found_error),
|
||||
(ConflictError, handle_conflict_error),
|
||||
(Exception, handle_unknown_exception),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
from typing import ClassVar
|
||||
from uuid import UUID
|
||||
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
from apps.campaign.models import CampaignReport
|
||||
|
||||
|
||||
class SubmitReportIn(ModelSchema):
|
||||
client_id: UUID
|
||||
|
||||
class Meta:
|
||||
model = CampaignReport
|
||||
fields: ClassVar[tuple[str]] = (CampaignReport.message.field.name,)
|
||||
|
||||
|
||||
class SubmitReportOut(Schema):
|
||||
status: str = "ok"
|
||||
@@ -0,0 +1,48 @@
|
||||
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 Router
|
||||
|
||||
from api.v1 import schemas as global_schemas
|
||||
from api.v1.report import schemas
|
||||
from apps.campaign.models import Campaign, CampaignImpression, CampaignReport
|
||||
from apps.campaign.tasks import moderate_campaign_task
|
||||
from apps.client.models import Client
|
||||
from config.errors import ForbiddenError
|
||||
|
||||
router = Router(tags=["report"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}",
|
||||
response={
|
||||
status.OK: schemas.SubmitReportOut,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
status.FORBIDDEN: global_schemas.ForbiddenError,
|
||||
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||
status.CONFLICT: global_schemas.ConflictError,
|
||||
},
|
||||
)
|
||||
def submit_report(
|
||||
request: HttpRequest, campaign_id: UUID, report: schemas.SubmitReportIn
|
||||
) -> tuple[status, schemas.SubmitReportOut]:
|
||||
campaign = get_object_or_404(Campaign, id=campaign_id)
|
||||
client = get_object_or_404(Client, id=report.client_id)
|
||||
|
||||
try:
|
||||
CampaignImpression.objects.get(campaign=campaign, client=client)
|
||||
except CampaignImpression.DoesNotExist:
|
||||
raise ForbiddenError from None
|
||||
|
||||
report_instance = CampaignReport.objects.create(
|
||||
campaign=campaign,
|
||||
client=client,
|
||||
message=report.message,
|
||||
)
|
||||
moderate_campaign_task.delay(
|
||||
report_instance.id, campaign.ad_title, campaign.ad_text
|
||||
)
|
||||
|
||||
return status.OK, schemas.SubmitReportOut()
|
||||
@@ -0,0 +1,58 @@
|
||||
from functools import partial
|
||||
|
||||
from ninja import NinjaAPI
|
||||
|
||||
from api.v1 import handlers
|
||||
from api.v1.ads.views import router as ads_router
|
||||
from api.v1.advertisers.views import router as advertisers_router
|
||||
from api.v1.campaigns.views import router as compaigns_router
|
||||
from api.v1.clients.views import router as clients_router
|
||||
from api.v1.generate.views import router as generate_router
|
||||
from api.v1.report.views import router as report_router
|
||||
from api.v1.stats.views import router as stats_router
|
||||
from api.v1.time.views import router as time_router
|
||||
|
||||
router = NinjaAPI(
|
||||
title="AdNova API",
|
||||
version="1",
|
||||
description="API docs for AdNova",
|
||||
openapi_url="/docs/openapi.json",
|
||||
)
|
||||
|
||||
|
||||
router.add_router(
|
||||
"clients",
|
||||
clients_router,
|
||||
)
|
||||
router.add_router(
|
||||
"",
|
||||
advertisers_router,
|
||||
)
|
||||
router.add_router(
|
||||
"advertisers",
|
||||
compaigns_router,
|
||||
)
|
||||
router.add_router(
|
||||
"ads",
|
||||
ads_router,
|
||||
)
|
||||
router.add_router(
|
||||
"stats",
|
||||
stats_router,
|
||||
)
|
||||
router.add_router(
|
||||
"generate",
|
||||
generate_router,
|
||||
)
|
||||
router.add_router(
|
||||
"report",
|
||||
report_router,
|
||||
)
|
||||
router.add_router(
|
||||
"time",
|
||||
time_router,
|
||||
)
|
||||
|
||||
|
||||
for exception, handler in handlers.exception_handlers:
|
||||
router.add_exception_handler(exception, partial(handler, router=router))
|
||||
@@ -0,0 +1,24 @@
|
||||
from http import HTTPStatus as status
|
||||
from typing import Any
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class BadRequestError(Schema):
|
||||
detail: Any
|
||||
|
||||
|
||||
class UnauthorizedError(Schema):
|
||||
detail: str = status.UNAUTHORIZED.phrase
|
||||
|
||||
|
||||
class ForbiddenError(Schema):
|
||||
detail: str = status.FORBIDDEN.phrase
|
||||
|
||||
|
||||
class NotFoundError(Schema):
|
||||
detail: str = status.NOT_FOUND.phrase
|
||||
|
||||
|
||||
class ConflictError(Schema):
|
||||
detail: Any
|
||||
@@ -0,0 +1,20 @@
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class Stat(Schema):
|
||||
impressions_count: int
|
||||
clicks_count: int
|
||||
conversion: float
|
||||
spent_impressions: float
|
||||
spent_clicks: float
|
||||
spent_total: float
|
||||
|
||||
|
||||
class DailyStat(Schema):
|
||||
impressions_count: int
|
||||
clicks_count: int
|
||||
conversion: float
|
||||
spent_impressions: float
|
||||
spent_clicks: float
|
||||
spent_total: float
|
||||
date: int
|
||||
@@ -0,0 +1,133 @@
|
||||
import uuid
|
||||
from django.test import TestCase, Client, override_settings
|
||||
from http import HTTPStatus as status
|
||||
from apps.campaign.models import Advertiser, Campaign
|
||||
|
||||
|
||||
class AdvertiserCampaignTestCase(TestCase):
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.advertiser = Advertiser.objects.create(name="Test Advertiser")
|
||||
self.campaign = Campaign.objects.create(
|
||||
advertiser=self.advertiser,
|
||||
impressions_limit=0,
|
||||
clicks_limit=0,
|
||||
cost_per_impression=0,
|
||||
cost_per_click=0,
|
||||
ad_title="title",
|
||||
ad_text="text",
|
||||
start_date=0,
|
||||
end_date=0,
|
||||
)
|
||||
|
||||
self.campaigns_prefix = "/stats/campaigns"
|
||||
self.advertisers_prefix = "/stats/advertisers"
|
||||
|
||||
def test_get_campaign_statistics_invalid_uuid(self):
|
||||
response = self.client.get(f"{self.campaigns_prefix}/invalid-uuid")
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
def test_get_campaign_statistics_campaign_not_found(self):
|
||||
non_existent_campaign_id = uuid.uuid4()
|
||||
response = self.client.get(
|
||||
f"{self.campaigns_prefix}/{non_existent_campaign_id}"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.NOT_FOUND)
|
||||
|
||||
def test_get_campaign_statistics_success(self):
|
||||
response = self.client.get(
|
||||
f"{self.campaigns_prefix}/{self.campaign.id}"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.OK)
|
||||
self.assertIsInstance(response.json(), dict)
|
||||
|
||||
def test_get_daily_campaign_statistics_invalid_uuid(self):
|
||||
response = self.client.get(
|
||||
f"{self.campaigns_prefix}/invalid-uuid/daily"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
def test_get_daily_campaign_statistics_campaign_not_found(self):
|
||||
non_existent_campaign_id = uuid.uuid4()
|
||||
response = self.client.get(
|
||||
f"{self.campaigns_prefix}/{non_existent_campaign_id}/daily"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.NOT_FOUND)
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_get_daily_campaign_statistics_success(self):
|
||||
response = self.client.get(
|
||||
f"{self.campaigns_prefix}/{self.campaign.id}/daily"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.OK)
|
||||
self.assertIsInstance(response.json(), list)
|
||||
|
||||
def test_get_advertiser_statistics_invalid_uuid(self):
|
||||
response = self.client.get(f"{self.advertisers_prefix}/invalid-uuid")
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
def test_get_advertiser_statistics_not_found(self):
|
||||
non_existent_advertiser_id = uuid.uuid4()
|
||||
response = self.client.get(
|
||||
f"{self.advertisers_prefix}/{non_existent_advertiser_id}"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.NOT_FOUND)
|
||||
|
||||
def test_get_advertiser_statistics_success(self):
|
||||
response = self.client.get(
|
||||
f"{self.advertisers_prefix}/{self.advertiser.id}"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.OK)
|
||||
self.assertIsInstance(response.json(), dict)
|
||||
|
||||
def test_get_daily_advertiser_statistics_invalid_uuid(self):
|
||||
response = self.client.get(
|
||||
f"{self.advertisers_prefix}/invalid-uuid/campaigns/daily"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
def test_get_daily_advertiser_statistics_advertiser_not_found(self):
|
||||
non_existent_advertiser_id = uuid.uuid4()
|
||||
response = self.client.get(
|
||||
f"{self.advertisers_prefix}/{non_existent_advertiser_id}/campaigns/daily"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.NOT_FOUND)
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_get_daily_advertiser_statistics_success(self):
|
||||
response = self.client.get(
|
||||
f"{self.advertisers_prefix}/{self.advertiser.id}/campaigns/daily"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.OK)
|
||||
self.assertIsInstance(response.json(), list)
|
||||
@@ -0,0 +1,77 @@
|
||||
from http import HTTPStatus as status
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Router
|
||||
|
||||
from api.v1 import schemas as global_schemas
|
||||
from api.v1.stats import schemas
|
||||
from apps.campaign.models import Advertiser, Campaign
|
||||
|
||||
router = Router(tags=["stats"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/campaigns/{campaign_id}",
|
||||
response={
|
||||
status.OK: schemas.Stat,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||
},
|
||||
)
|
||||
def get_campaign_statistics(
|
||||
request: HttpRequest, campaign_id: UUID
|
||||
) -> tuple[status, dict[str, Any]]:
|
||||
campaign = get_object_or_404(Campaign, id=campaign_id)
|
||||
|
||||
return status.OK, campaign.get_statistics()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/campaigns/{campaign_id}/daily",
|
||||
response={
|
||||
status.OK: list[schemas.DailyStat],
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||
},
|
||||
)
|
||||
def get_daily_campaign_statistics(
|
||||
request: HttpRequest, campaign_id: UUID
|
||||
) -> tuple[status, list[dict[str, Any]]]:
|
||||
campaign = get_object_or_404(Campaign, id=campaign_id)
|
||||
|
||||
return status.OK, campaign.get_daily_statistics()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/advertisers/{advertiser_id}",
|
||||
response={
|
||||
status.OK: schemas.Stat,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||
},
|
||||
)
|
||||
def get_advertiser_statistics(
|
||||
request: HttpRequest, advertiser_id: UUID
|
||||
) -> tuple[status, dict[str, Any]]:
|
||||
advertiser = get_object_or_404(Advertiser, id=advertiser_id)
|
||||
|
||||
return status.OK, advertiser.get_statistics()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/advertisers/{advertiser_id}/campaigns/daily",
|
||||
response={
|
||||
status.OK: list[schemas.DailyStat],
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||
},
|
||||
)
|
||||
def get_daily_advertiser_statistics(
|
||||
request: HttpRequest, advertiser_id: UUID
|
||||
) -> tuple[status, dict[str, Any]]:
|
||||
advertiser = get_object_or_404(Advertiser, id=advertiser_id)
|
||||
|
||||
return status.OK, advertiser.get_daily_statistics()
|
||||
@@ -0,0 +1 @@
|
||||
# noqa: A005
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.core.cache import cache
|
||||
from ninja import Schema
|
||||
from pydantic import field_validator
|
||||
from pydantic.types import NonNegativeInt
|
||||
|
||||
|
||||
class CurrentDate(Schema):
|
||||
current_date: NonNegativeInt
|
||||
|
||||
@field_validator("current_date", mode="after")
|
||||
@classmethod
|
||||
def check_bigger_than_setted_date(cls, value: int) -> int:
|
||||
current_date = cache.get("current_date", default=0)
|
||||
if value < current_date:
|
||||
err = (
|
||||
"current_date can't be less than setted "
|
||||
f"date ({current_date})."
|
||||
)
|
||||
raise ValueError(err)
|
||||
|
||||
return value
|
||||
@@ -0,0 +1,120 @@
|
||||
from http import HTTPStatus as status
|
||||
from django.test import TestCase, override_settings
|
||||
from django.core.cache import cache
|
||||
import json
|
||||
|
||||
|
||||
class AdvanceTimeTests(TestCase):
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
cache.set("current_date", 10)
|
||||
|
||||
self.url = "/time/advance"
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_advance_time_success(self):
|
||||
self.assertEqual(cache.get("current_date"), 10)
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps({"current_date": 15}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.OK)
|
||||
self.assertEqual(response.json()["current_date"], 15)
|
||||
self.assertEqual(cache.get("current_date"), 15)
|
||||
|
||||
# unittest & django pobeda so i can't use override_settings and parametrized at the same time, sorry
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_advance_time_failure_invalid_value1(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps({"current_date": list()}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_advance_time_failure_invalid_value2(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps({"current_date": -1241}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_advance_time_failure_invalid_value3(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps({"current_date": "lol"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_advance_time_failure_invalid_value4(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps({"current_date": dict()}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_advance_time_failure_less_than_actual(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps({"current_date": 5}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.BAD_REQUEST)
|
||||
@@ -0,0 +1,25 @@
|
||||
from http import HTTPStatus as status
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpRequest
|
||||
from ninja import Router
|
||||
|
||||
from api.v1 import schemas as global_schemas
|
||||
from api.v1.time import schemas
|
||||
|
||||
router = Router(tags=["time"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/advance",
|
||||
response={
|
||||
status.OK: schemas.CurrentDate,
|
||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||
},
|
||||
)
|
||||
def advance_time(
|
||||
request: HttpRequest, new_date: schemas.CurrentDate
|
||||
) -> tuple[status, schemas.CurrentDate]:
|
||||
cache.set("current_date", new_date.current_date)
|
||||
|
||||
return status.OK, new_date
|
||||
Reference in New Issue
Block a user