diff --git a/solution/.dockerignore b/solution/.dockerignore index d3ae326..c89be8d 100644 --- a/solution/.dockerignore +++ b/solution/.dockerignore @@ -1,5 +1,187 @@ -.dockerignore -Dockerfile -README.md -.venv/ +# Byte-compiled / optimized / DLL files __pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# PyPI configuration file +.pypirc + +# Ruff files +.ruff_cache + +# Docker files +Dockerfile +.dockerignore + +# Git files +.git +.gitignore + +# Template env file +.env.template + +# Dev utility +check.sh + +# Collected static files +static diff --git a/solution/.env.template b/solution/.env.template new file mode 100644 index 0000000..9c933a3 --- /dev/null +++ b/solution/.env.template @@ -0,0 +1,21 @@ +# Change all vars before going to production and remove all comments (!) +# Below all enviroment variables and default values + +DJANGO_SECRET_KEY=very_insecure_key +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1 +DJANGO_CORS_ALLOWED_ORIGINS=* +DJANGO_INTERNAL_IPS=127.0.0.1 +DJANGO_LANGUAGE_CODE=en-us +DJANGO_STATIC_URL=static/ +DJANGO_REDIS_HOST=localhost +DJANGO_REDIS_PORT=6379 +DJANGO_DB_URI=sqlite:///db.sqlite3 +DJANGO_ANTIFRAUD_ADDRESS=localhost:9090 + +# Notifiers settings (only works with DEBUG=False) + +DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN= +DJANGO_NOTIFIER_TELEGRAM_CHAT_ID= +DJANGO_NOTIFIER_TELEGRAM_THREAD_ID= diff --git a/solution/.gitignore b/solution/.gitignore index 808386f..657dc4c 100644 --- a/solution/.gitignore +++ b/solution/.gitignore @@ -1,2 +1,173 @@ -.venv/ -__pycache__/ \ No newline at end of file +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# PyPI configuration file +.pypirc + +# Ruff files +.ruff_cache + +# Collected static files +static diff --git a/solution/Dockerfile b/solution/Dockerfile index a19cae9..51ebf13 100644 --- a/solution/Dockerfile +++ b/solution/Dockerfile @@ -1,10 +1,62 @@ -FROM python:3.12-alpine3.21 +# Stage 1: Install dependencies +FROM docker.io/python:3.11-alpine3.20 AS builder -WORKDIR /usr/src/app +# Install uv +COPY --from=ghcr.io/astral-sh/uv:0.4.30 /uv /uvx /bin/ -COPY requirements.txt . -RUN pip install -r requirements.txt +# Set the working directory +WORKDIR /app +# Setup env vars +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONOPTIMIZE=2 \ + UV_COMPILE_BYTECODE=1 \ + UV_PROJECT_ENVIRONMENT=/opt/venv + +# Copy pyproject.toml file +COPY pyproject.toml . + +# Install dependencies +RUN uv sync --no-dev --no-install-project --no-cache + + +# Stage 2: Serve the application +FROM docker.io/python:3.11-alpine3.20 + +# Set the working directory +WORKDIR /app + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv + +# Copy application code COPY . . -CMD [ "python", "./main.py" ] +# Create app user and set permissions +RUN adduser -D -g '' app && chown -R app:app ./ + +# Run as non-root user +USER app + +# Setup env vars +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONOPTIMIZE=2 \ + PATH="/opt/venv/bin:$PATH" + +# Expose port +EXPOSE 8080 + +# Healthcheck +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/health/?format=json || exit 1 + +# Set env vars for app (sorry for that) +ENV DJANGO_DEBUG=False \ + DJANGO_ALLOWED_HOSTS=* \ + DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN=6196898691:AAHtiIgbLAHlELGqO4qmrKoqjWaEJohr9fY \ + DJANGO_NOTIFIER_TELEGRAM_CHAT_ID=-1002304409222 + +# Start gunicorn +CMD gunicorn config.wsgi -b ${SERVER_ADDRESS} diff --git a/solution/README.md b/solution/README.md new file mode 100644 index 0000000..208a04d --- /dev/null +++ b/solution/README.md @@ -0,0 +1,109 @@ +# PROD 2 Stage + +## Prerequisites + +Ensure you have the following installed on your system: + +- [Python](https://www.python.org/) (>=3.10,<3.12) +- [uv](https://docs.astral.sh/uv/) +- [Docker](https://www.docker.com/) (for containerized setup) + +## Basic setup + +### Installation + +#### Clone the project + +```bash +git clone https://github.com/Central-University-IT/test-2025-python-devitq +``` + +#### Go to the project directory + +```bash +cd test-2025-python-devitq/solution +``` + +#### Customize enviroment + +```bash +cp .env.template .env +``` + +And setup env vars according to your needs. + +#### Install dependencies + +##### For dev enviroment + +```bash +uv sync --all-extras +``` + +##### For prod enviroment + +```bash +uv sync --no-dev +``` + +#### Running + +##### In dev mode + +Apply migrations: + +```bash +uv run python manage.py migrate +``` + +Start project: + +```bash +uv run python manage.py runserver +``` + +##### In prod mode + +Apply migrations: + +```bash +uv run python manage.py migrate +``` + +Start project: + +```bash +uv run gunicorn config.wsgi +``` + +## Containerized setup + +### Clone the project + +```bash +git clone https://github.com/Central-University-IT/test-2025-python-devitq +``` + +### Go to the project directory + +```bash +cd test-2025-python-devitq/solution +``` + +### Build docker image + +```bash +docker build -t prod-2-devitq . +``` + +### Customize enviroment + +Customize enviroment with `docker run` command (or bind .env file to container), for all enviroment vars and default values see [.env.template](./.env.template). + +### Run docker image + +```bash +docker run -p 8080:8080 --name prod-2-devitq prod-2-devitq +``` + +Backend will be available on localhost:8080. diff --git a/solution/api/__init__.py b/solution/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/api/urls.py b/solution/api/urls.py new file mode 100644 index 0000000..6a5e610 --- /dev/null +++ b/solution/api/urls.py @@ -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("v1/", api_v1_router.urls), + # Health endpoint + path("health", MainView.as_view(), name="health_check_home"), +] diff --git a/solution/api/v1/__init__.py b/solution/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/api/v1/auth.py b/solution/api/v1/auth.py new file mode 100644 index 0000000..1e08c86 --- /dev/null +++ b/solution/api/v1/auth.py @@ -0,0 +1,10 @@ +from django.http import HttpRequest +from ninja.security import HttpBearer + + +class BearerAuth(HttpBearer): + def authenticate(self, request: HttpRequest, token: str) -> str | None: + if token == "will implement later": + return token + + return None diff --git a/solution/api/v1/handlers.py b/solution/api/v1/handlers.py new file mode 100644 index 0000000..40faccb --- /dev/null +++ b/solution/api/v1/handlers.py @@ -0,0 +1,77 @@ +import logging +from http import HTTPStatus as status + +import django.core.exceptions +import django.http +from django.http import HttpRequest, HttpResponse +from ninja import NinjaAPI, errors + +logger = logging.getLogger("django") + + +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.UNPROCESSABLE_ENTITY, + ) + + +def handle_authentication_error( + request: HttpRequest, exc: errors.AuthenticationError, router: NinjaAPI +) -> HttpResponse: + return router.create_response( + request, + {"detail": status.UNAUTHORIZED.phrase}, + status=status.UNAUTHORIZED, + ) + + +def handle_validation_error( + request: HttpRequest, exc: errors.ValidationError, router: NinjaAPI +) -> HttpResponse: + return router.create_response( + request, + {"detail": exc.errors}, + status=status.UNPROCESSABLE_ENTITY, + ) + + +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_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 = [ + (django.core.exceptions.ValidationError, handle_django_validation_error), + (errors.AuthenticationError, handle_authentication_error), + (errors.ValidationError, handle_validation_error), + (django.http.Http404, handle_not_found_error), + (Exception, handle_unknown_exception), +] diff --git a/solution/api/v1/ping/__init__.py b/solution/api/v1/ping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/api/v1/ping/apps.py b/solution/api/v1/ping/apps.py new file mode 100644 index 0000000..13c0d30 --- /dev/null +++ b/solution/api/v1/ping/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PingConfig(AppConfig): + name = "api.v1.ping" + label = "api_v1_ping" diff --git a/solution/api/v1/ping/schemas.py b/solution/api/v1/ping/schemas.py new file mode 100644 index 0000000..b507b0d --- /dev/null +++ b/solution/api/v1/ping/schemas.py @@ -0,0 +1,8 @@ +from ninja import Schema + + +class PingOut(Schema): + message_from_basement: str + + +__all__ = ["PingOut"] diff --git a/solution/api/v1/ping/views.py b/solution/api/v1/ping/views.py new file mode 100644 index 0000000..01847ed --- /dev/null +++ b/solution/api/v1/ping/views.py @@ -0,0 +1,21 @@ +from http import HTTPStatus as status + +from django.conf import settings +from django.http import HttpRequest +from ninja import Router + +from api.v1.ping import schemas + +router = Router(tags=["ping"]) + + +@router.get( + "", + response={status.OK: schemas.PingOut}, + summary="Ping server", +) +def index( + request: HttpRequest, +) -> schemas.PingOut: + settings.LOGGER.info("кто-то стучится в пинг") + return schemas.PingOut(message_from_basement="АЛЕКСАНДР ШАХОВ Я ВАШ ФОНАТ") diff --git a/solution/api/v1/router.py b/solution/api/v1/router.py new file mode 100644 index 0000000..1da4e50 --- /dev/null +++ b/solution/api/v1/router.py @@ -0,0 +1,28 @@ +from functools import partial + +from ninja import NinjaAPI + +from api.v1 import handlers +from api.v1.ping.views import router as ping_router + +router = NinjaAPI( + title="Promocode API", + version="1", + description="API docs for Promocode", + openapi_url="/docs/openapi.json", + # csrf=True, noqa: ERA001 +) + + +# Register application's routers + +router.add_router( + "ping", + ping_router, +) + + +# Register exception handlers + +for exception, handler in handlers.exception_handlers: + router.add_exception_handler(exception, partial(handler, router=router)) diff --git a/solution/api/v1/schemas.py b/solution/api/v1/schemas.py new file mode 100644 index 0000000..36a65e5 --- /dev/null +++ b/solution/api/v1/schemas.py @@ -0,0 +1,11 @@ +from http import HTTPStatus as status + +from ninja import Schema + + +class UnauthorizedError(Schema): + detail: str = status.UNAUTHORIZED.phrase + + +class NotFoundError(Schema): + detail: str = status.NOT_FOUND.phrase diff --git a/solution/apps/__init__.py b/solution/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/check.sh b/solution/check.sh new file mode 100644 index 0000000..80e661c --- /dev/null +++ b/solution/check.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +GREEN='\033[1;32m' +NC='\033[0m' + +uvx ruff format . +uvx ruff check . --fix +printf "${GREEN}Linters/formatters runned${NC}\n" + +uv run python manage.py makemigrations --check +uv run python manage.py test +printf "${GREEN}Tests runned${NC}\n" diff --git a/solution/config/__init__.py b/solution/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/config/asgi.py b/solution/config/asgi.py new file mode 100644 index 0000000..d0bfe8f --- /dev/null +++ b/solution/config/asgi.py @@ -0,0 +1,9 @@ +"""ASGI config for Promocode.""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/solution/config/handlers.py b/solution/config/handlers.py new file mode 100644 index 0000000..aa41f72 --- /dev/null +++ b/solution/config/handlers.py @@ -0,0 +1,39 @@ +from http import HTTPStatus as status + +from django.http import HttpRequest, JsonResponse + + +def handler400( + request: HttpRequest, exception: Exception | None = None +) -> JsonResponse: + return JsonResponse( + status=status.BAD_REQUEST, + data={"detail": status.BAD_REQUEST.phrase}, + ) + + +def handler403( + request: HttpRequest, exception: Exception | None = None +) -> JsonResponse: + return JsonResponse( + status=status.FORBIDDEN, + data={"detail": status.FORBIDDEN.phrase}, + ) + + +def handler404( + request: HttpRequest, exception: Exception | None = None +) -> JsonResponse: + return JsonResponse( + status=status.NOT_FOUND, + data={"detail": status.NOT_FOUND.phrase}, + ) + + +def handler500( + request: HttpRequest, exception: Exception | None = None +) -> JsonResponse: + return JsonResponse( + status=status.INTERNAL_SERVER_ERROR, + data={"detail": status.INTERNAL_SERVER_ERROR.phrase}, + ) diff --git a/solution/config/notifiers/__init__.py b/solution/config/notifiers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/config/notifiers/telegram.py b/solution/config/notifiers/telegram.py new file mode 100644 index 0000000..fd3e3b0 --- /dev/null +++ b/solution/config/notifiers/telegram.py @@ -0,0 +1,129 @@ +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 = ( + "{levelname}\n" + "\tGuid: {correlation_id}\n" + "\tTimestamp: {asctime}\n" + "\tLogger: {name}\n" + "\tFile: {pathname} " + "(Line: {lineno})\n\n" + '
{message}
\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
{exc_text}
" + ) + + @classmethod + def shutdown_executor(cls) -> None: + cls._executor.shutdown(wait=True) diff --git a/solution/config/settings.py b/solution/config/settings.py new file mode 100644 index 0000000..1d74dfb --- /dev/null +++ b/solution/config/settings.py @@ -0,0 +1,544 @@ +"""Django settings for Promocode.""" + +import logging +from pathlib import Path + +import environ +from django.utils.translation import gettext_lazy as _ + +BASE_DIR = Path(__file__).resolve().parent.parent + +env = environ.Env() +environ.Env.read_env(BASE_DIR / ".env") + + +# Common settings + +DEBUG = env("DJANGO_DEBUG", default=True) + +ALLOWED_HOSTS = env( + "DJANGO_ALLOWED_HOSTS", + list, + default=["localhost", "127.0.0.1"], +) + + +# Integrations + +ANTIFRAUD_ADDRESS = env("ANTIFRAUD_ADDRESS", default="localhost:9090") + + +# Caching + +REDIS_URI = ( + "redis://" + f"{env('REDIS_HOST', default='localhost')}" + ":" + f"{env('REDIS_PORT', default='6379')}" +) + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": REDIS_URI, + "TIMEOUT": None, + "KEY_PREFIX": "django", + } +} + + +# Database + +DB_URI = env.db_url("POSTGRES_CONN", default="sqlite:///db.sqlite3") + +DATABASES = {"default": {**DB_URI, "CONN_MAX_AGE": 50}} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +# Password validation + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": ( + "django.contrib.auth." + "password_validation.UserAttributeSimilarityValidator" + ) + }, + { + "NAME": ( + "django.contrib.auth.password_validation.MinimumLengthValidator" + ) + }, + { + "NAME": ( + "django.contrib.auth.password_validation.CommonPasswordValidator" + ) + }, + { + "NAME": ( + "django.contrib.auth.password_validation.NumericPasswordValidator" + ) + }, +] + + +# Static files (CSS, JavaScript, Images) + +STATIC_ROOT = BASE_DIR / "static" + +STATIC_URL = env("DJANGO_STATIC_URL", default="static/") + +STATICFILES_DIRS = [] + +STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" + +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] + + +# Files + +FILE_UPLOAD_MAX_MEMORY_SIZE = 2621440 + + +# Cors + +CORS_ALLOWED_ORIGINS_FROM_ENV = env("DJANGO_CORS_ALLOWED_ORIGINS", list, ["*"]) + +if CORS_ALLOWED_ORIGINS_FROM_ENV == ["*"]: + CORS_ALLOW_ALL_ORIGINS = True +else: + CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS_FROM_ENV + + +# Forms + +FORM_RENDERER = "django.forms.renderers.DjangoTemplates" + +FORMS_URLFIELD_ASSUME_HTTPS = False + + +# Internationalization + +DATE_FORMAT = "N j, Y" + +DATE_INPUT_FORMATS = [ + "%Y-%m-%d", # '2006-10-25' + "%m/%d/%Y", # '10/25/2006' + "%m/%d/%y", # '10/25/06' + "%b %d %Y", # 'Oct 25 2006' + "%b %d, %Y", # 'Oct 25, 2006' + "%d %b %Y", # '25 Oct 2006' + "%d %b, %Y", # '25 Oct, 2006' + "%B %d %Y", # 'October 25 2006' + "%B %d, %Y", # 'October 25, 2006' + "%d %B %Y", # '25 October 2006' + "%d %B, %Y", # '25 October, 2006' +] + +DATETIME_FORMAT = "N j, Y, H:i:s" + +DATETIME_INPUT_FORMATS = [ + "%Y-%m-%d %H:%M:%S", # '2006-10-25 14:30:59' + "%Y-%m-%d %H:%M:%S.%f", # '2006-10-25 14:30:59.000200' + "%Y-%m-%d %H:%M", # '2006-10-25 14:30' + "%m/%d/%Y %H:%M:%S", # '10/25/2006 14:30:59' + "%m/%d/%Y %H:%M:%S.%f", # '10/25/2006 14:30:59.000200' + "%m/%d/%Y %H:%M", # '10/25/2006 14:30' + "%m/%d/%y %H:%M:%S", # '10/25/06 14:30:59' + "%m/%d/%y %H:%M:%S.%f", # '10/25/06 14:30:59.000200' + "%m/%d/%y %H:%M", # '10/25/06 14:30' +] + +DECIMAL_SEPARATOR = "." + +FIRST_DAY_OF_WEEK = 1 + +FORMAT_MODULE_PATH = None + +LANGUAGE_CODE = env("DJANGO_LANGUAGE_CODE", default="en-us") + +LANGUAGES = [("en", _("English")), ("ru", _("Russian"))] + +LOCALE_PATHS = [] + +MONTH_DAY_FORMAT = "F j" + +NUMBER_GROUPING = 0 + +SHORT_DATE_FORMAT = "m/d/Y" + +SHORT_DATETIME_FORMAT = "m/d/Y H:i:s" + +THOUSAND_SEPARATOR = "," + +TIME_FORMAT = "H:i:s" + +TIME_INPUT_FORMATS = [ + "%H:%M:%S", # '14:30:59' + "%H:%M:%S.%f", # '14:30:59.000200' + "%H:%M", # '14:30' +] + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_THOUSAND_SEPARATOR = True + +USE_TZ = True + +YEAR_MONTH_FORMAT = "F Y" + + +# HTTP + +DATA_UPLOAD_MAX_MEMORY_SIZE = None + +DATA_UPLOAD_MAX_NUMBER_FIELDS = None + +DATA_UPLOAD_MAX_NUMBER_FILES = None + +DEFAULT_CHARSET = "utf-8" + +FORCE_SCRIPT_NAME = None + +INTERNAL_IPS = env( + "DJANGO_INTERNAL_IPS", + list, + default=["127.0.0.1"], +) + +MIDDLEWARE = [ + "django_guid.middleware.guid_middleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", +] + +SIGNING_BACKEND = "django.core.signing.TimestampSigner" + +USE_X_FORWARDED_HOST = False + +USE_X_FORWARDED_PORT = False + +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 + +LOGGER_NAME = "promocode" + +LOGGER = logging.getLogger(LOGGER_NAME) + +LOGGING_FILTERS = { + "require_debug_true": { + "()": "django.utils.log.RequireDebugTrue", + }, + "require_debug_false": { + "()": "django.utils.log.RequireDebugFalse", + }, + "correlation_id": { + "()": "django_guid.log_filters.CorrelationId", + }, +} + +LOGGING_FORMATTERS = { + "json": { + "()": "pythonjsonlogger.jsonlogger.JsonFormatter", + "format": ( + "{levelname}{correlation_id}{asctime}" + "{name}{pathname}{lineno}{message}" + ), + "style": "{", + }, + "text": { + "()": "colorlog.ColoredFormatter", + "format": ( + "{log_color}[{levelname}]{reset} " + "{light_black}{asctime} {name} | {pathname}:{lineno}{reset}\n" + "{bold_black}{message}{reset}" + ), + "log_colors": { + "DEBUG": "bold_green", + "INFO": "bold_cyan", + "WARNING": "bold_yellow", + "ERROR": "bold_red", + "CRITICAL": "bold_purple", + }, + "style": "{", + }, +} + +LOGGING_HANDLERS = { + "console_debug": { + "class": "logging.StreamHandler", + "level": "DEBUG", + "filters": ["require_debug_true"], + "formatter": "text", + }, + "console_prod": { + "class": "logging.StreamHandler", + "level": "INFO", + "filters": ["require_debug_false", "correlation_id"], + "formatter": "json", + }, +} + +LOGGING_LOGGERS = { + "django": { + "handlers": ["console_debug", "console_prod"], + "level": "INFO" if DEBUG else "ERROR", + "propagate": False, + }, + "django.request": { + "handlers": ["console_debug", "console_prod"], + "level": "INFO" if DEBUG else "ERROR", + "propagate": False, + }, + "django.server": { + "handlers": ["console_debug"], + "level": "INFO", + "filters": ["require_debug_true"], + "propagate": False, + }, + "django.template": {"handlers": []}, + "django.db.backends.schema": {"handlers": []}, + "django.security": {"handlers": [], "propagate": True}, + "django.db.backends": { + "handlers": ["console_debug"], + "filters": ["require_debug_true"], + "level": "DEBUG", + "propagate": False, + }, + "health-check": { + "handlers": ["console_debug", "console_prod"], + "level": "INFO" if DEBUG else "ERROR", + "propagate": False, + }, + LOGGER_NAME: { + "handlers": ["console_debug", "console_prod"], + "level": "DEBUG" if DEBUG else "INFO", + "propagate": False, + }, + "root": { + "handlers": ["console_debug", "console_prod"], + "level": "INFO" if DEBUG else "ERROR", + "propagate": False, + }, +} + +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") + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": LOGGING_FILTERS, + "formatters": LOGGING_FORMATTERS, + "handlers": LOGGING_HANDLERS, + "loggers": LOGGING_LOGGERS, +} + +LOGGING_CONFIG = "logging.config.dictConfig" + + +# Models + +ABSOLUTE_URL_OVERRIDES = {} + +FIXTURE_DIRS = [] + +INSTALLED_APPS = [ + # Build-in apps + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Healthcheck + "health_check", + "health_check.db", + "health_check.cache", + "health_check.storage", + "health_check.contrib.migrations", + # Third-party apps + "corsheaders", + "django_extensions", + "django_guid", + "ninja", + # Internal apps + # API v1 apps + "api.v1.ping", +] + +# GUID + +DJANGO_GUID = { + "GUID_HEADER_NAME": "Correlation-ID", + "VALIDATE_GUID": True, + "RETURN_HEADER": True, + "EXPOSE_HEADER": True, + "INTEGRATIONS": [], + "IGNORE_URLS": [], + "UUID_LENGTH": 32, +} + + +# Security + +LANGUAGE_COOKIE_AGE = 31449600 + +LANGUAGE_COOKIE_DOMAIN = None + +LANGUAGE_COOKIE_HTTPONLY = False + +LANGUAGE_COOKIE_NAME = "django_language" + +LANGUAGE_COOKIE_PATH = "/" + +LANGUAGE_COOKIE_SAMESITE = "Lax" + +LANGUAGE_COOKIE_SECURE = False + +SECURE_PROXY_SSL_HEADER = None + +CSRF_COOKIE_AGE = 31449600 + +CSRF_COOKIE_DOMAIN = None + +CSRF_COOKIE_HTTPONLY = False + +CSRF_COOKIE_NAME = "djangocsrftoken" + +CSRF_COOKIE_PATH = "/" + +CSRF_COOKIE_SAMESITE = "Lax" + +CSRF_COOKIE_SECURE = False + +CSRF_FAILURE_VIEW = "django.views.csrf.csrf_failure" + +CSRF_HEADER_NAME = "HTTP_X_CSRFTOKEN" + +CSRF_TRUSTED_ORIGINS = env( + "DJANGO_CSRF_TRUSTED_ORIGINS", + list, + default=["http://localhost", "http://127.0.0.1"], +) + +CSRF_USE_SESSIONS = False + +SECRET_KEY = env("RANDOM_SECRET", default="very_insecure_key") + +SECRET_KEY_FALLBACKS = [] + + +# Sessions + +SESSION_CACHE_ALIAS = "default" + +SESSION_COOKIE_AGE = 1209600 + +SESSION_COOKIE_DOMAIN = None + +SESSION_COOKIE_HTTPONLY = True + +SESSION_COOKIE_NAME = "djangosessionid" + +SESSION_COOKIE_PATH = "/" + +SESSION_COOKIE_SAMESITE = "Lax" + +SESSION_COOKIE_SECURE = False + +SESSION_ENGINE = "django.contrib.sessions.backends.db" + +SESSION_EXPIRE_AT_BROWSER_CLOSE = False + +SESSION_FILE_PATH = None + +SESSION_SAVE_EVERY_REQUEST = False + +SESSION_SERIALIZER = "django.contrib.sessions.serializers.JSONSerializer" + + +# Templates + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "autoescape": True, + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + "debug": DEBUG, + "string_if_invalid": "", + "file_charset": "utf-8", + }, + } +] + + +# Testing + +TEST_NON_SERIALIZED_APPS = [] + +TEST_RUNNER = "django.test.runner.DiscoverRunner" + + +# URLs + +ROOT_URLCONF = "config.urls" + + +# debug-toolbar + +DEBUG_TOOLBAR_CONFIG = {"SHOW_COLLAPSED": True, "UPDATE_ON_FETCH": True} + +if DEBUG: + INSTALLED_APPS.append("debug_toolbar") + MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") diff --git a/solution/config/urls.py b/solution/config/urls.py new file mode 100644 index 0000000..7de54c6 --- /dev/null +++ b/solution/config/urls.py @@ -0,0 +1,42 @@ +"""URL configuration for Promocode backend.""" + +from django.conf import settings +from django.contrib import admin +from django.urls import include, path + +from config import handlers + +# Custom settings for django admin + +admin.site.site_title = "Promocode" +admin.site.site_header = "Promocode" +admin.site.index_title = "Promocode" + + +# Basic urlpatterns + +urlpatterns = [ + # Admin urls + path("admin/", admin.site.urls), + # API urls + path("api/", include("api.urls")), +] + + +# Add debug-toolbar urls + +if settings.DEBUG: + from debug_toolbar.toolbar import debug_toolbar_urls + + urlpatterns += debug_toolbar_urls() + + +# Register custom error handlers + +handler400 = handlers.handler400 + +handler403 = handlers.handler403 + +handler404 = handlers.handler404 + +handler500 = handlers.handler500 diff --git a/solution/config/wsgi.py b/solution/config/wsgi.py new file mode 100644 index 0000000..bee638d --- /dev/null +++ b/solution/config/wsgi.py @@ -0,0 +1,9 @@ +"""WSGI config for Promocode.""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/solution/main.py b/solution/main.py deleted file mode 100644 index 0631399..0000000 --- a/solution/main.py +++ /dev/null @@ -1,14 +0,0 @@ -import os -from fastapi import FastAPI -import uvicorn - -app = FastAPI() - -@app.get("/api/ping") -def send(): - return {"status": "ok"} - -if __name__ == "__main__": - server_address = os.getenv("SERVER_ADDRESS", "0.0.0.0:8080") - host, port = server_address.split(":") - uvicorn.run(app, host=host, port=int(port)) diff --git a/solution/manage.py b/solution/manage.py new file mode 100755 index 0000000..f821d3a --- /dev/null +++ b/solution/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main() -> None: + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + e = """Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?""" + raise ImportError(e) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/solution/pyproject.toml b/solution/pyproject.toml new file mode 100644 index 0000000..4ff701a --- /dev/null +++ b/solution/pyproject.toml @@ -0,0 +1,110 @@ +[project] +name = "prod-2-stage" +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.10,<3.12" +dependencies = [ + "colorlog>=6.9.0", + "django-cors-headers>=4.6.0", + "django-environ>=0.11.2", + "django-extensions>=3.2.3", + "django-guid>=3.5.0", + "django-health-check>=3.18.3", + "django-ninja>=1.3.0", + "gunicorn>=23.0.0", + "httpx>=0.28.1", + "psycopg2-binary>=2.9.10", + "python-json-logger>=3.2.1", + "redis>=5.2.1", +] + +[dependency-groups] +dev = [ + "django-debug-toolbar>=4.4.6", + "ruff>=0.9.1", +] + +[tool.ruff] +builtins = [] +cache-dir = ".ruff_cache" +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "dist", + "migrations", + "node_modules", + "venv", +] +extend-exclude = [] +extend-include = [] +fix = false +fix-only = false +force-exclude = true +include = ["*.py", "*.pyi", "*.ipynb", "**/pyproject.toml"] +indent-width = 4 +line-length = 79 +namespace-packages = [] +output-format = "full" +preview = false +required-version = ">=0.8.4" +respect-gitignore = true +show-fixes = true +src = [".", "src"] +target-version = "py310" +unsafe-fixes = false + +[tool.ruff.analyze] +detect-string-imports = true +direction = "Dependencies" +exclude = [] +include-dependencies = {} +preview = false + +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = 79 +exclude = [] +indent-style = "space" +line-ending = "lf" +preview = false +quote-style = "double" +skip-magic-trailing-comma = false + +[tool.ruff.lint] +allowed-confusables = ["ℹ"] +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +exclude = ["tests.py"] +explicit-preview-rules = false +extend-fixable = [] +extend-per-file-ignores = {} +extend-safe-fixes = [] +extend-select = [] +extend-unsafe-fixes = [] +external = [] +fixable = ["ALL"] +ignore = ["ARG", "COM812", "D", "ISC001", "PT009" ,"N813"] +logger-objects = [] +per-file-ignores = {} +preview = false +select = ["ALL"] +task-tags = ["TODO", "FIXME", "HACK", "WORKOUT"] +typing-modules = [] +unfixable = [] + +[tool.ruff.lint.pylint] +max-args = 6 diff --git a/solution/requirements.txt b/solution/requirements.txt deleted file mode 100644 index 97dc7cd..0000000 --- a/solution/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -fastapi -uvicorn