chore: restructured project

This commit is contained in:
ITQ
2025-03-07 19:32:09 +03:00
parent bfb7ad901a
commit 0a35951c62
178 changed files with 304 additions and 376 deletions
View File
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from health_check.views import MainView
from api.v1.router import router as api_v1_router
urlpatterns = [
path("", api_v1_router.urls),
# Health endpoint
path("health", MainView.as_view(), name="health_check_home"),
]
View File
+23
View File
@@ -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
+58
View File
@@ -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)
+214
View File
@@ -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,)
+155
View File
@@ -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)
+63
View File
@@ -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
+56
View File
@@ -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,
)
+121
View File
@@ -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),
]
+18
View File
@@ -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"
+48
View File
@@ -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()
+58
View File
@@ -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))
+24
View File
@@ -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
+20
View File
@@ -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
+133
View File
@@ -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)
+77
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# noqa: A005
+21
View File
@@ -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
+120
View File
@@ -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)
+25
View File
@@ -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