refactor: global codebase refactoring

This commit is contained in:
ITQ
2025-02-21 18:00:42 +03:00
parent ea8f5cfd49
commit cebb87fa73
18 changed files with 89 additions and 240 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
# Custom evnironment files # Custom environment files
.env .env
# Password files # Password files
@@ -8,15 +8,16 @@ DJANGO_LANGUAGE_CODE=en-us
DJANGO_STATIC_URL=http://localhost:13241/ DJANGO_STATIC_URL=http://localhost:13241/
REDIS_URI=redis://redis:6379 REDIS_URI=redis://redis:6379
DJANGO_DB_URI=postgresql://postgres:postgres@postgres/postgres DJANGO_DB_URI=postgresql://postgres:postgres@postgres/postgres
DJANGO_CREATE_SUPERUSER=True DJANGO_CREATE_SUPERUSER=True
DJANGO_SUPERUSER_USERNAME=admin DJANGO_SUPERUSER_USERNAME=admin
DJANGO_SUPERUSER_EMAIL=admin@mail.com DJANGO_SUPERUSER_EMAIL=admin@mail.com
DJANGO_SUPERUSER_PASSWORD=admin DJANGO_SUPERUSER_PASSWORD=admin
YANDEX_CLOUD_FOLDER_ID=b1grd7nb04jbfvgm9121 YANDEX_CLOUD_FOLDER_ID=b1grd7nb04jbfvgm9121
YANDEX_CLOUD_API_KEY=AQVN19lztDgUcBrHeW0HtJ0bbDGda6i6e3InXsDl YANDEX_CLOUD_API_KEY=AQVN19lztDgUcBrHeW0HtJ0bbDGda6i6e3InXsDl
MINIO_ENDPOINT=minio:9000 MINIO_ENDPOINT=minio:9000
MINIO_CUSTOM_ENDPOINT_URL=http://127.0.0.1:13244 MINIO_CUSTOM_ENDPOINT_URL=http://127.0.0.1:13244
MINIO_ACCESS_KEY=admin MINIO_ACCESS_KEY=admin
MINIO_SECRET_KEY=password MINIO_SECRET_KEY=password
DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN=6196898691:AAFucgj7ieEuYMvWG_MZAn0Ao3UBuHpvaVY
DJANGO_NOTIFIER_TELEGRAM_CHAT_ID=-1002304409222
-9
View File
@@ -31,12 +31,3 @@ DJANGO_CREATE_SUPERUSER=False
DJANGO_SUPERUSER_USERNAME= DJANGO_SUPERUSER_USERNAME=
DJANGO_SUPERUSER_EMAIL= DJANGO_SUPERUSER_EMAIL=
DJANGO_SUPERUSER_PASSWORD= DJANGO_SUPERUSER_PASSWORD=
# Notifiers settings (only with DJANGO_DEBUG=False)
# Telegram
DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN=
DJANGO_NOTIFIER_TELEGRAM_CHAT_ID=
DJANGO_NOTIFIER_TELEGRAM_THREAD_ID=
@@ -19,7 +19,7 @@ COPY . .
RUN uv run python manage.py collectstatic --noinput RUN uv run python manage.py collectstatic --noinput
# Stage 2: Start nginx to serve staticfiles # Stage 2: Start nginx and serve staticfiles
FROM docker.io/nginx:latest FROM docker.io/nginx:latest
COPY --from=builder /app/static /usr/share/nginx/html COPY --from=builder /app/static /usr/share/nginx/html
@@ -1,6 +1,5 @@
from http import HTTPStatus as status from http import HTTPStatus as status
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
import json import json
from uuid import uuid4 from uuid import uuid4
from apps.client.models import Client from apps.client.models import Client
@@ -22,6 +22,7 @@ class AdvertiserModelTest(TestCase):
new_id = uuid4() new_id = uuid4()
self.advertiser.advertiser_id = new_id self.advertiser.advertiser_id = new_id
self.assertEqual(self.advertiser.id, new_id) self.assertEqual(self.advertiser.id, new_id)
@override_settings( @override_settings(
@@ -6,7 +6,6 @@ from uuid import UUID
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.validators import ( from django.core.validators import (
MaxValueValidator, MaxValueValidator,
MinLengthValidator, MinLengthValidator,
@@ -20,6 +19,7 @@ from apps.campaign.validators import (
CampaignDurationValidator, CampaignDurationValidator,
CampaignLimitsValidator, CampaignLimitsValidator,
CampaignReportMessageValidator, CampaignReportMessageValidator,
CampaignStartDateValidator,
CampaignTargetingLocationValidator, CampaignTargetingLocationValidator,
) )
from apps.client.models import Client from apps.client.models import Client
@@ -100,21 +100,7 @@ class Campaign(BaseModel):
CampaignAgeValidator()(self) CampaignAgeValidator()(self)
CampaignDurationValidator()(self) CampaignDurationValidator()(self)
CampaignLimitsValidator()(self) CampaignLimitsValidator()(self)
CampaignStartDateValidator()(self)
current_date = cache.get("current_date", default=0)
err = "start_date must be greater than the current date."
try:
original = Campaign.objects.get(id=self.id or "")
if (
original.start_date != self.start_date
and self.start_date < current_date
):
raise ValidationError(err)
except Campaign.DoesNotExist:
if self.start_date < current_date:
raise ValidationError(err) from None
def save(self, *args: Any, **kwargs: Any) -> None: def save(self, *args: Any, **kwargs: Any) -> None:
created = self.pk is None created = self.pk is None
@@ -134,6 +120,20 @@ class Campaign(BaseModel):
) )
cache.set(f"campaign_{self.id}_clicks_count", self.clicks.count()) cache.set(f"campaign_{self.id}_clicks_count", self.clicks.count())
def inc_views(self) -> None:
try:
cache.incr(f"campaign_{self.id}_impressions_count", 1)
except ValueError:
self.setup_cache()
logger.warning("Seems that %s missing caches", self.campaign_id)
def inc_clicks(self) -> None:
try:
cache.incr(f"campaign_{self.id}_clicks_count", 1)
except ValueError:
self.setup_cache()
logger.warning("Seems that %s missing caches", self.campaign_id)
@property @property
def ad_id(self) -> UUID: def ad_id(self) -> UUID:
return self.id return self.id
@@ -175,13 +175,7 @@ class Campaign(BaseModel):
price=self.cost_per_impression, price=self.cost_per_impression,
date=cache.get("current_date", default=0), date=cache.get("current_date", default=0),
) )
try: self.inc_views()
cache.incr(f"campaign_{self.id}_impressions_count", 1)
except ValueError:
self.setup_cache()
logger.warning(
"Seems that %s missing caches", self.campaign_id
)
except ConflictError: except ConflictError:
pass pass
@@ -198,13 +192,7 @@ class Campaign(BaseModel):
price=self.cost_per_click, price=self.cost_per_click,
date=cache.get("current_date", default=0), date=cache.get("current_date", default=0),
) )
try: self.inc_clicks()
cache.incr(f"campaign_{self.id}_clicks_count", 1)
except ValueError:
self.setup_cache()
logger.warning(
"Seems that %s missing caches", self.campaign_id
)
except ConflictError: except ConflictError:
pass pass
@@ -235,7 +223,6 @@ class Campaign(BaseModel):
total=models.Count("id"), total=models.Count("id"),
spent=models.Sum("price", default=0.0), spent=models.Sum("price", default=0.0),
) )
clicks = self.clicks.values("date").annotate( clicks = self.clicks.values("date").annotate(
total=models.Count("id"), total=models.Count("id"),
spent=models.Sum("price", default=0.0), spent=models.Sum("price", default=0.0),
@@ -1,48 +1,83 @@
from typing import TYPE_CHECKING
from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models.fields import Field
if TYPE_CHECKING:
from apps.campaign.models import Campaign, CampaignReport
class CampaignTargetingLocationValidator:
def __call__(self, instance: "Campaign") -> None:
if instance.location == "":
err = {
"targeting": {
type(
instance
).location.field.name: Field.default_error_messages[
"blank"
]
}
}
raise ValidationError(err)
class CampaignAgeValidator: class CampaignAgeValidator:
def __call__(self, instance) -> None: # noqa: ANN001 def __call__(self, instance: "Campaign") -> None:
if ( if (
isinstance(instance.age_from, int) isinstance(instance.age_from, int)
and isinstance(instance.age_to, int) and isinstance(instance.age_to, int)
and instance.age_from > instance.age_to and instance.age_from > instance.age_to
): ):
err = "age_from can't be greater than age_to" err = "targeting.age_from can't be greater than targeting.age_to."
raise ValidationError(err) raise ValidationError(err)
class CampaignDurationValidator: class CampaignDurationValidator:
def __call__(self, instance) -> None: # noqa: ANN001 def __call__(self, instance: "Campaign") -> None:
if ( if (
isinstance(instance.start_date, int) isinstance(instance.start_date, int)
and isinstance(instance.end_date, int) and isinstance(instance.end_date, int)
and instance.start_date > instance.end_date and instance.start_date > instance.end_date
): ):
err = "start_date can't be greater than end_date" err = "start_date can't be greater than end_date."
raise ValidationError(err) raise ValidationError(err)
class CampaignLimitsValidator: class CampaignLimitsValidator:
def __call__(self, instance) -> None: # noqa: ANN001 def __call__(self, instance: "Campaign") -> None:
if ( if (
isinstance(instance.impressions_limit, int) isinstance(instance.impressions_limit, int)
and isinstance(instance.clicks_limit, int) and isinstance(instance.clicks_limit, int)
and instance.impressions_limit < instance.clicks_limit and instance.impressions_limit < instance.clicks_limit
): ):
err = "clicks_limit can't be greater than impressions_limit" err = "clicks_limit can't be greater than impressions_limit."
raise ValidationError(err) raise ValidationError(err)
class CampaignTargetingLocationValidator: class CampaignStartDateValidator:
def __call__(self, instance) -> None: # noqa: ANN001 def __call__(self, instance: "Campaign") -> None:
if instance.location == "": current_date = cache.get("current_date", default=0)
err = "targeting.location cannot be blank" err = "start_date must be greater or equal than the current_date."
raise ValidationError(err) try:
original = type(instance).objects.get(id=instance.id or "")
if (
original.start_date != instance.start_date
and instance.start_date < current_date
):
raise ValidationError(err)
except type(instance).DoesNotExist:
if instance.start_date < current_date:
raise ValidationError(err) from None
class CampaignReportMessageValidator: class CampaignReportMessageValidator:
def __call__(self, instance) -> None: # noqa: ANN001 def __call__(self, instance: "CampaignReport") -> None:
if instance.message == "": if instance.message == "":
err = "message cannot be blank" err = {
instance.message.field.name: Field.default_error_messages[
"blank"
]
}
raise ValidationError(err) raise ValidationError(err)
@@ -1,133 +0,0 @@
import datetime
import logging
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
import httpx
from django.utils.timezone import get_current_timezone
TELEGRAM_LOG_HANDLER = logging.getLogger("telegram_log_handler")
LEVEL_EMOJIS = {
"DEBUG": "🐞",
"INFO": "",
"WARNING": "⚠️",
"ERROR": "",
"CRITICAL": "🚨",
}
class LoggingHandler(logging.Handler):
_executor = ThreadPoolExecutor(max_workers=5)
def __init__(
self,
token: str,
chat_id: int,
thread_id: int | None = None,
retries: int | None = 3,
delay: int | None = 2,
timeout: int | None = 5,
) -> None:
super().__init__()
self.token = token
self.chat_id = chat_id
self.thread_id = thread_id
self.retries = retries
self.delay = delay
self.timeout = timeout
self.api_url = f"https://api.telegram.org/bot{self.token}/sendMessage"
self.template = (
"<b>{levelname}</b>\n"
"\t<b>Guid:</b> <code>{correlation_id}</code>\n"
"\t<b>Timestamp:</b> <code>{asctime}</code>\n"
"\t<b>Logger:</b> <code>{name}</code>\n"
"\t<b>File:</b> <code>{pathname}</code> "
"(Line: <code>{lineno}</code>)\n\n"
'<pre><code class="language-message">{message}</code></pre>\n'
)
def emit(self, record: logging.LogRecord) -> None:
try:
formatted_record = self.format(record)
self._executor.submit(self._send_message, formatted_record)
except Exception as e: # noqa: BLE001
self.handleError(record)
TELEGRAM_LOG_HANDLER.exception(e)
def _send_message(self, formatted_record: str) -> None:
payload = {
"chat_id": self.chat_id,
"text": formatted_record,
"parse_mode": "HTML",
}
if self.thread_id:
payload["reply_to_message_id"] = self.thread_id
for attempt in range(1, self.retries + 1):
response = httpx.post(
self.api_url,
data=payload,
timeout=self.timeout,
)
if response.status_code != httpx.codes.OK:
if attempt == self.retries:
TELEGRAM_LOG_HANDLER.exception(
"Failed to send to Telegram after %d attempts: %s",
self.retries,
response.text,
)
else:
time.sleep(self.delay)
else:
return
def format(self, record: logging.LogRecord) -> str:
try:
asctime = datetime.datetime.fromtimestamp(
record.created,
tz=get_current_timezone(),
).strftime("%Y-%m-%d %H:%M:%S %Z")
level_emoji = LEVEL_EMOJIS.get(record.levelname, "")
formatted_message = self.template.format(
levelname=f"{level_emoji} {record.levelname}",
correlation_id=getattr(record, "correlation_id", "N/A"),
asctime=asctime,
name=record.name,
pathname=record.pathname,
lineno=record.lineno,
message=record.getMessage(),
)
if record.exc_info:
formatted_message += self._format_exception(record.exc_info)
formatted_message += (
f"\n#{record.levelname.lower()} "
f"#{record.name.replace('.', '_')}"
)
if hasattr(record, "correlation_id"):
formatted_message += f" #{record.correlation_id}"
except Exception as format_error: # noqa: BLE001
TELEGRAM_LOG_HANDLER.exception(
"Error formatting log record: %s",
format_error,
)
return f"Error formatting log record: {format_error}"
else:
return formatted_message
@staticmethod
def _format_exception(exc_info: Exception) -> str:
exc_text = "".join(traceback.format_exception(*exc_info))
return (
f"\n<pre><code class='language-traceback'>{exc_text}</code></pre>"
)
@classmethod
def shutdown_executor(cls) -> None:
cls._executor.shutdown(wait=True)
+2 -39
View File
@@ -41,7 +41,8 @@ YANDEX_CLOUD_INTEGRATION_ENABLED = (
YANDEX_CLOUD_FOLDER_ID and YANDEX_CLOUD_API_KEY YANDEX_CLOUD_FOLDER_ID and YANDEX_CLOUD_API_KEY
) )
# Register healthcheck
# Register healthchecks
plugin_dir.register(YandexAIHealthCheck) plugin_dir.register(YandexAIHealthCheck)
@@ -300,26 +301,6 @@ USE_X_FORWARDED_PORT = False
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
# Notifiers
# Telegram
NOTIFIER_TELEGRAM_BOT_TOKEN = env(
"DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN",
default=None,
)
NOTIFIER_TELEGRAM_CHAT_ID = env(
"DJANGO_NOTIFIER_TELEGRAM_CHAT_ID",
default=None,
)
NOTIFIER_TELEGRAM_THREAD_ID = env(
"DJANGO_NOTIFIER_TELEGRAM_THREAD_ID",
default=None,
)
# Logging # Logging
LOGGER_NAME = "adnova" LOGGER_NAME = "adnova"
@@ -435,24 +416,6 @@ LOGGING = {
LOGGING_CONFIG = "logging.config.dictConfig" LOGGING_CONFIG = "logging.config.dictConfig"
if NOTIFIER_TELEGRAM_BOT_TOKEN and NOTIFIER_TELEGRAM_CHAT_ID:
LOGGING_HANDLERS["telegram"] = {
"class": "config.notifiers.telegram.LoggingHandler",
"level": "INFO",
"filters": ["require_debug_false"],
"token": NOTIFIER_TELEGRAM_BOT_TOKEN,
"chat_id": NOTIFIER_TELEGRAM_CHAT_ID,
"thread_id": NOTIFIER_TELEGRAM_THREAD_ID,
"retries": 5,
"delay": 2,
"timeout": 5,
}
LOGGING_LOGGERS["django"]["handlers"].append("telegram")
LOGGING_LOGGERS["django.request"]["handlers"].append("telegram")
LOGGING_LOGGERS["health-check"]["handlers"].append("telegram")
LOGGING_LOGGERS[LOGGER_NAME]["handlers"].append("telegram")
# Models # Models
ABSOLUTE_URL_OVERRIDES: dict[str, Callable] = {} ABSOLUTE_URL_OVERRIDES: dict[str, Callable] = {}
@@ -16,8 +16,10 @@ class YandexAIHealthCheck(BaseHealthCheckBackend):
result = sdk.models.completions( result = sdk.models.completions(
"yandexgpt-lite", model_version="latest" "yandexgpt-lite", model_version="latest"
).tokenize("ping") ).tokenize("ping")
if not result: if not result:
self.add_error("YandexAI API is unaccessible") self.add_error("YandexAI API is unaccessible")
except YCloudMLError: except YCloudMLError:
self.add_error("YandexAI API is unaccessible") self.add_error("YandexAI API is unaccessible")
+2 -2
View File
@@ -1,6 +1,6 @@
# AdNova Tests # AdNova Tests
There is `unit` tests and `e2e` tests available, unit tests are placed all around `backend` serivce folder and `e2e` tests placed [here](./e2e/) There is `unit` and `e2e` tests available, unit tests are placed all around `backend` serivce folder and `e2e` tests placed [here](./e2e/)
## Running unit tests ## Running unit tests
@@ -12,7 +12,7 @@ See [services/backend/README.md](../services/backend/README.md#testing)
### Backend service ### Backend service
-image- ![backend coverage](../assets/images/backend-coverage.png)
## Running e2e tests ## Running e2e tests
+1 -1
View File
@@ -31,7 +31,7 @@ cd devitq/solution/tests/e2e
uv sync --no-dev uv sync --no-dev
``` ```
## Customize environment ## Customize environment (optional)
```bash ```bash
cp .env.template .env cp .env.template .env
-1
View File
@@ -107,4 +107,3 @@ unfixable = []
[tool.ruff.lint.pylint] [tool.ruff.lint.pylint]
max-args = 6 max-args = 6
@@ -8,8 +8,15 @@ logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Tests integration between: backend, redis, yandexgpt and celery
def test_generate_ad_text(client: Client) -> None: def test_generate_ad_text(client: Client) -> None:
"""
Tests integration between:
- backend
- redis
- yandexgpt
- celery
"""
payload = { payload = {
"advertiser_name": "Центральный Университет", "advertiser_name": "Центральный Университет",
"ad_title": "Всероссийский кейс-чемпионат DEADLINE", "ad_title": "Всероссийский кейс-чемпионат DEADLINE",
@@ -25,10 +32,7 @@ def test_generate_ad_text(client: Client) -> None:
while True: while True:
result_response = client.get(f"/generate/ad_text/{task_id}/result") result_response = client.get(f"/generate/ad_text/{task_id}/result")
assert ( assert result_response.status_code in (status.OK, status.NOT_FOUND)
result_response.status_code == status.OK
or result_response.status_code == status.NOT_FOUND
)
result_data = result_response.json() result_data = result_response.json()
if ( if (
@@ -9,7 +9,7 @@ logger = logging.getLogger(__name__)
def test_healthcheck(client: Client) -> None: def test_healthcheck(client: Client) -> None:
""" """
Checks that backend can use theese services: Tests integration between:
- redis - redis
- celery - celery
- postgres - postgres