init: added template

This commit is contained in:
ITQ
2026-02-12 11:36:43 +03:00
parent 030e49edb9
commit 514393e3f8
96 changed files with 14594 additions and 183 deletions
+188
View File
@@ -0,0 +1,188 @@
# 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
Dockerfile.staticfiles
.dockerignore
# Git files
.git
.gitignore
# Template env file
.env.template
# Collected static files
static
# Profile files
*.prof
+54
View File
@@ -0,0 +1,54 @@
# Change all vars before going to production and remove all comments (!)
# Below all environment variables and default values
DJANGO_SECRET_KEY=very_insecure_key
DJANGO_DEBUG=False
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/
REDIS_URI=redis://localhost:6379
DJANGO_DB_URI=sqlite:///db.sqlite3
DJANGO_CONN_MAX_AGE=300
DJANGO_SILKY_ENABLED=True
DJANGO_SILKY_PYTHON_PROFILER=False
# Observability (OpenTelemetry)
OTEL_ENABLED=False
OTEL_SERVICE_NAME=backend-django
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
# Storages (S3)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_STORAGE_BUCKET_NAME=
AWS_S3_ENDPOINT_URL=
AWS_S3_REGION_NAME=
AWS_S3_USE_SSL=True
AWS_S3_VERIFY=True
# Applyable if you installing using docker compose
GUNICORN_WORKERS=4
GUNICORN_BIND=0.0.0.0:8080
GUNICORN_WORKER_CLASS=uvicorn_worker.UvicornWorker
GUNICORN_ACCESS_LOG=-
GUNICORN_ERROR_LOG=-
RUN_MIGRATIONS=False
COLLECT_STATIC=False
DJANGO_CREATE_SUPERUSER=False
DJANGO_SUPERUSER_USERNAME=
DJANGO_SUPERUSER_EMAIL=
DJANGO_SUPERUSER_PASSWORD=
+176
View File
@@ -0,0 +1,176 @@
# 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
# Profile files
*.prof
+64
View File
@@ -0,0 +1,64 @@
# Stage 1: Build dependencies
FROM docker.io/python:3.13-alpine3.22 AS deps
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_PROJECT_ENVIRONMENT=/opt/venv
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev --no-editable
# Stage 2: Build backend source
FROM deps AS backend-builder
COPY . .
# Stage 3: Build staticfiles
FROM deps AS static-builder
COPY . .
RUN uv run --no-dev python manage.py collectstatic --noinput
# Stage 3: Runtime application image
FROM docker.io/python:3.13-alpine3.22 AS app
WORKDIR /app
COPY --from=deps /opt/venv /opt/venv
COPY --from=backend-builder /app /app
RUN chmod +x scripts/entrypoint.sh && \
adduser -D -g '' app && \
chown -R app:app /app
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONOPTIMIZE=2 \
DJANGO_SETTINGS_MODULE=config.settings
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --start-interval=2s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/health?format=json || exit 1
ENTRYPOINT ["scripts/entrypoint.sh"]
# Stage 4: Staticfiles image
FROM docker.io/nginx:1.29-alpine-slim AS staticfiles
COPY --from=static-builder /app/static /usr/share/nginx/html
CMD ["nginx", "-g", "daemon off;"]
+123
View File
@@ -0,0 +1,123 @@
# Lotty Backend
## Prerequisites
Ensure you have the following installed on your system:
- [Python](https://www.python.org/) (>=3.13,<3.15)
- [uv](https://docs.astral.sh/uv/) (latest version recommended)
- [Docker](https://www.docker.com/) (for containerized setup, latest version recommended)
## Basic setup
### Installation
#### Clone the project
#### Go to the project directory
#### Customize environment
```bash
cp .env.template .env
```
And setup env vars according to your needs.
#### Install dependencies
##### For dev environment
```bash
uv sync --all-extras
```
##### For prod environment
```bash
uv sync --no-dev
```
#### Running
##### Apply migrations
```bash
uv run python manage.py migrate
```
##### Start celery worker
```bash
celery -A config worker -l INFO
```
##### Start server
In dev mode:
```bash
uv run python manage.py runserver
```
In prod mode:
```bash
uv run gunicorn config.wsgi
```
## Containerized setup
### Clone the project
### Go to the project directory
### Build docker image
```bash
docker build -t lotty-backend .
```
### Customize environment
Customize environment with `docker run` command (or bind .env file to container), for all environment vars and default values see [.env.template](./.env.template).
### Run docker image
#### Backend
```bash
docker run -p 8080:8080 --name lotty-backend lotty-backend
```
#### Celery worker
```bash
docker run --name lotty-celery-worker lotty-backend celery -A config worker -l INFO
```
Backend will be available on [127.0.0.1:8080](http://127.0.0.1:8080).
## Testing
### Clone the project
### Go to the project directory
### Install dependencies
```bash
uv sync --all-extras
```
### Run tests
```bash
uv run coverage run --source="." manage.py test
```
### Check coverage
```bash
uv run coverage report
```
View File
+30
View File
@@ -0,0 +1,30 @@
from django.urls import path
from health_check.views import HealthCheckView
from api.v1.router import router as api_v1_router
urlpatterns = [
path("api/v1/", api_v1_router.urls),
# Health endpoint
path(
"health",
HealthCheckView.as_view(
checks=[
"health_check.Memory"
],
),
name="liveness",
),
# Ready endpoint
path(
"ready",
HealthCheckView.as_view(
checks=[
"health_check.Cache",
"health_check.Database",
"health_check.Storage",
],
),
name="readiness"
),
]
View File
+186
View File
@@ -0,0 +1,186 @@
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 api.v1.schemas import ApiError, ValidationError
from config.errors import ConflictError, ForbiddenError
from config.utils import build_error_payload
logger = logging.getLogger("django")
def create_error_response(
request: HttpRequest,
code: str,
message: str,
http_status: int,
router: NinjaAPI,
details: dict[str, Any] | None = None,
) -> HttpResponse:
payload = build_error_payload(
request=request,
code=code,
message=message,
details=details,
)
error_data = ApiError.model_validate(payload)
return router.create_response(request, error_data, status=http_status)
def handle_validation_error(
request: HttpRequest,
exc: ninja.errors.ValidationError,
router: NinjaAPI,
) -> HttpResponse:
field_errors_data: list[dict[str, Any]] = []
for error in exc.errors:
loc = error.get("loc", [])
field = ".".join(map(str, loc)) if loc else "non_field_error"
field_errors_data.append(
{
"field": field,
"issue": error.get("msg", "Unknown error"),
"rejectedValue": error.get("input"),
}
)
payload = build_error_payload(
request=request,
code="VALIDATION_FAILED",
message="Validation failed",
field_errors=field_errors_data,
)
error_data = ValidationError.model_validate(payload)
return router.create_response(
request, error_data, status=status.UNPROCESSABLE_ENTITY
)
def handle_django_validation_error(
request: HttpRequest,
exc: django.core.exceptions.ValidationError,
router: NinjaAPI,
code: str = "VALIDATION_FAILED",
http_status: int = status.UNPROCESSABLE_ENTITY,
) -> HttpResponse:
field_errors_data: list[dict[str, Any]] = []
if hasattr(exc, "error_dict"):
for field, errors in exc.error_dict.items():
field_errors_data.extend(
{
"field": field,
"issue": str(error.message),
"rejectedValue": None,
}
for error in errors
)
else:
field_errors_data.extend(
{
"field": "non_field_error",
"issue": str(error.message),
"rejectedValue": None,
}
for error in exc.error_list
)
payload = build_error_payload(
request=request,
code=code,
message="Validation failed"
if code == "VALIDATION_FAILED"
else "Conflict",
field_errors=field_errors_data,
)
error_data = ValidationError.model_validate(payload)
return router.create_response(request, error_data, status=http_status)
def handle_authentication_error(
request: HttpRequest,
exc: ninja.errors.AuthenticationError,
router: NinjaAPI,
) -> HttpResponse:
return create_error_response(
request,
code="UNAUTHENTICATED",
message="Authentication required",
http_status=status.UNAUTHORIZED,
router=router,
)
def handle_forbidden_error(
request: HttpRequest,
exc: ForbiddenError,
router: NinjaAPI,
) -> HttpResponse:
return create_error_response(
request,
code="FORBIDDEN",
message=exc.message,
http_status=status.FORBIDDEN,
router=router,
)
def handle_not_found_error(
request: HttpRequest,
exc: Exception,
router: NinjaAPI,
) -> HttpResponse:
return create_error_response(
request,
code="NOT_FOUND",
message="Resource not found",
http_status=status.NOT_FOUND,
router=router,
)
def handle_conflict_error(
request: HttpRequest,
exc: ConflictError,
router: NinjaAPI,
) -> HttpResponse:
return handle_django_validation_error(
request,
exc.validation_error,
router,
code="CONFLICT",
http_status=status.CONFLICT,
)
def handle_unknown_exception(
request: HttpRequest,
exc: Exception,
router: NinjaAPI,
) -> HttpResponse:
logger.error("Internal server error: %s", exc, exc_info=True) # noqa: LOG014
return create_error_response(
request,
code="INTERNAL_SERVER_ERROR",
message="An unexpected error occurred",
http_status=status.INTERNAL_SERVER_ERROR,
router=router,
)
exception_handlers: list[tuple[Any, Callable[..., Any]]] = [
(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),
]
+43
View File
@@ -0,0 +1,43 @@
from functools import partial
from typing import Any, override
import orjson
from django.http import HttpRequest
from ninja import NinjaAPI, Schema
from ninja.renderers import BaseRenderer
from api.v1 import handlers
class ORJSONRenderer(BaseRenderer):
media_type: str | None = "application/json"
@override
def render(
self, request: HttpRequest, data: Any, *, response_status: int
) -> Any:
return orjson.dumps(data, default=self.default)
def default(self, obj: Any) -> Any:
if isinstance(obj, Schema):
return obj.model_dump(by_alias=True)
raise TypeError
router = NinjaAPI(
title="Lotty API",
version="1",
description="API docs for Lotty A/B platform",
openapi_url="/docs/openapi.json",
renderer=ORJSONRenderer(),
)
# router.add_router(
# "health",
# health_router,
# )
for exception, handler in handlers.exception_handlers:
router.add_exception_handler(exception, partial(handler, router=router))
+33
View File
@@ -0,0 +1,33 @@
from datetime import datetime
from typing import Any
from ninja import Schema
from pydantic import ConfigDict, Field
class FieldError(Schema):
model_config = ConfigDict(populate_by_name=True)
field: str = Field(
...,
description="Field name with error (can be nested)",
)
issue: str = Field(..., description="Problem description")
rejected_value: Any = Field(
None, alias="rejectedValue", description="Value that failed validation"
)
class ApiError(Schema):
model_config = ConfigDict(populate_by_name=True)
code: str
message: str
trace_id: str = Field(..., alias="traceId")
timestamp: datetime
path: str
details: dict[str, Any] | None = None
class ValidationError(ApiError):
field_errors: list[FieldError] = Field(..., alias="fieldErrors")
View File
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "apps.core"
label = "core"
+50
View File
@@ -0,0 +1,50 @@
import uuid
from typing import Any, override
from django.core.exceptions import ValidationError
from django.db import models
from config.errors import ConflictError
class BaseModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class Meta:
abstract = True
@override
def save(self, *args: Any, **kwargs: Any) -> None:
self.validate()
super().save(*args, **kwargs)
def validate(
self,
*,
validate_unique: bool = True,
validate_constraints: bool = True,
include: list[models.Field[Any, Any]] | None = None,
) -> None:
self.full_clean(
validate_unique=False,
validate_constraints=False,
exclude=(
field.name
for field in set(self._meta.get_fields()) - set(include)
)
if include
else None,
)
if validate_unique:
try:
self.validate_unique()
except ValidationError as e:
raise ConflictError(e) from None
if validate_constraints:
try:
self.validate_constraints()
except ValidationError as e:
raise ConflictError(e) from None
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.users"
@@ -0,0 +1,133 @@
# Generated by Django 5.2.11 on 2026-02-10 20:37
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"swappable": "AUTH_USER_MODEL",
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]
+11
View File
@@ -0,0 +1,11 @@
from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _
from apps.core.models import BaseModel
class User(AbstractUser, BaseModel):
class Meta:
swappable = "AUTH_USER_MODEL"
verbose_name = _("user")
verbose_name_plural = _("users")
+17
View File
@@ -0,0 +1,17 @@
import uuid
from django.db.models import QuerySet
from apps.users.models import User
def user_get_by_id(user_id: str) -> User | None:
try:
uuid.UUID(user_id)
except ValueError:
return None
return User.objects.filter(id=user_id).first()
def user_list() -> QuerySet[User]:
return User.objects.all()
+20
View File
@@ -0,0 +1,20 @@
from typing import Any
from apps.users.models import User
def user_create(
*,
username: str,
email: str,
password: str | None = None,
**extra_fields: Any,
) -> User:
user = User(username=username, email=email, **extra_fields)
if password is not None:
user.set_password(password)
else:
user.set_unusable_password()
user.save()
return user
+3
View File
@@ -0,0 +1,3 @@
from config.celery import app as celery_app
__all__ = ("celery_app",)
+9
View File
@@ -0,0 +1,9 @@
"""ASGI config for Lotty."""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()
+10
View File
@@ -0,0 +1,10 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("lotty")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
+13
View File
@@ -0,0 +1,13 @@
from http import HTTPStatus as status
from django.core.exceptions import ValidationError
class ConflictError(Exception):
def __init__(self, validation_error: ValidationError) -> None:
self.validation_error = validation_error
class ForbiddenError(Exception):
def __init__(self, message: str = status.FORBIDDEN.phrase) -> None:
self.message = message
+64
View File
@@ -0,0 +1,64 @@
from http import HTTPStatus as status
from django.http import HttpRequest, JsonResponse
from config.utils import build_error_payload
def create_error_response(
request: HttpRequest,
code: str,
message: str,
http_status: int,
) -> JsonResponse:
payload = build_error_payload(request, code, message)
return JsonResponse(status=http_status, data=payload)
def handler400(
request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse:
return create_error_response(
request,
code="BAD_REQUEST",
message=status.BAD_REQUEST.phrase,
http_status=status.BAD_REQUEST,
)
def handler403(
request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse:
return create_error_response(
request,
code="FORBIDDEN",
message=status.FORBIDDEN.phrase,
http_status=status.FORBIDDEN,
)
def handler404(
request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse:
return create_error_response(
request,
code="NOT_FOUND",
message=status.NOT_FOUND.phrase,
http_status=status.NOT_FOUND,
)
def handler500(
request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse:
return create_error_response(
request,
code="INTERNAL_SERVER_ERROR",
message=status.INTERNAL_SERVER_ERROR.phrase,
http_status=status.INTERNAL_SERVER_ERROR,
)
+657
View File
@@ -0,0 +1,657 @@
"""Django settings for Lotty."""
import contextlib
import logging
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
import django_stubs_ext
import environ
from django.utils.translation import gettext_lazy as _
if TYPE_CHECKING:
from django.contrib.auth.models import User
BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env()
environ.Env.read_env(BASE_DIR / ".env")
django_stubs_ext.monkeypatch()
# Common settings
DEBUG = env("DJANGO_DEBUG", default=False)
ALLOWED_HOSTS = env.list(
"DJANGO_ALLOWED_HOSTS",
default=["localhost", "127.0.0.1"],
)
# Caching
REDIS_URI = env("REDIS_URI", default="redis://localhost:6379")
CACHES = {
"default": {
"BACKEND": "django_prometheus.cache.backends.redis.RedisCache",
"LOCATION": REDIS_URI,
"TIMEOUT": None,
"KEY_PREFIX": "backend",
"VERSION": 1,
},
}
# Celery
CELERY_BROKER_URL = REDIS_URI
CELERY_RESULT_BACKEND = REDIS_URI
CELERY_TIMEZONE = "UTC"
CELERY_WORKER_SEND_TASK_EVENTS = True
CELERY_TASK_SEND_SENT_EVENT = True
CELERY_TASK_TRACK_STARTED = True
# Database
DB_URI = env.db_url("DJANGO_DB_URI", default="sqlite:///db.sqlite3")
DB_URI["ENGINE"] = DB_URI["ENGINE"].replace(
"django.db.backends", "django_prometheus.db.backends"
)
DATABASES = {
"default": {
**DB_URI,
"CONN_MAX_AGE": env.int("DJANGO_CONN_MAX_AGE", default=300),
}
}
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: list[str] = []
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
# S3 (django-storages)
AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID", default=None)
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY", default=None)
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME", default=None)
AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", default=None)
AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", default=None)
AWS_S3_USE_SSL = env.bool("AWS_S3_USE_SSL", default=True)
AWS_S3_VERIFY = env.bool("AWS_S3_VERIFY", default=True)
AWS_S3_FILE_OVERWRITE = False
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
# Cors
CORS_ALLOWED_ORIGINS_FROM_ENV = env.list(
"DJANGO_CORS_ALLOWED_ORIGINS", default=["*"]
)
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: str | None = None
LANGUAGE_CODE = env("DJANGO_LANGUAGE_CODE", default="en-us")
LANGUAGES = [("en", _("English")), ("ru", _("Russian"))]
LOCALE_PATHS: list[str] = []
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: int | None = None
DATA_UPLOAD_MAX_NUMBER_FIELDS: int | None = None
DATA_UPLOAD_MAX_NUMBER_FILES: int | None = None
DEFAULT_CHARSET = "utf-8"
FORCE_SCRIPT_NAME: str | None = None
INTERNAL_IPS = env.list(
"DJANGO_INTERNAL_IPS",
default=["127.0.0.1"],
)
MIDDLEWARE = [
"django_prometheus.middleware.PrometheusBeforeMiddleware",
"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",
"django_prometheus.middleware.PrometheusAfterMiddleware",
]
SIGNING_BACKEND = "django.core.signing.TimestampSigner"
USE_X_FORWARDED_HOST = False
USE_X_FORWARDED_PORT = False
WSGI_APPLICATION = "config.wsgi.application"
AUTH_USER_MODEL = "users.User"
# Logging
LOGGER_NAME = "lotty"
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,
},
}
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: dict[str, Callable[..., Any]] = {}
FIXTURE_DIRS: list[str] = []
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",
"health_check.contrib.celery",
"health_check.contrib.celery_ping",
# Third-party apps
"corsheaders",
"django_extensions",
"django_guid",
"django_prometheus",
"ninja",
"storages",
# Internal apps
"apps.core",
"apps.users",
# API apps
]
# 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: str | None = 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: tuple[str, str] | None = None
CSRF_COOKIE_AGE = 31449600
CSRF_COOKIE_DOMAIN: str | None = 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.list(
"DJANGO_CSRF_TRUSTED_ORIGINS",
default=["http://localhost", "http://127.0.0.1"],
)
CSRF_USE_SESSIONS = False
SECRET_KEY = env("DJANGO_SECRET_KEY", default="very_insecure_key")
SECRET_KEY_FALLBACKS: list[str] = []
# Auth
LOGIN_REDIRECT_URL = "/admin/"
LOGIN_URL = "/admin/"
# Sessions
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = 1209600
SESSION_COOKIE_DOMAIN: str | None = 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: str | None = 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",
},
},
]
if not DEBUG:
TEMPLATES[0]["OPTIONS"]["loaders"] = [ # type: ignore[index]
(
"django.template.loaders.cached.Loader",
[
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
),
]
TEMPLATES[0]["APP_DIRS"] = False
# Testing
TEST_NON_SERIALIZED_APPS: list[str] = []
TEST_RUNNER = "django.test.runner.DiscoverRunner"
# URLs
ROOT_URLCONF = "config.urls"
# debug-toolbar
DEBUG_TOOLBAR_ENABLED = False
with contextlib.suppress(Exception):
import debug_toolbar # noqa: F401
DEBUG_TOOLBAR_ENABLED = True
DEBUG_TOOLBAR_CONFIG = {"SHOW_COLLAPSED": True, "UPDATE_ON_FETCH": True}
if DEBUG and DEBUG_TOOLBAR_ENABLED:
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
# Prometheus
PROMETHEUS_LATENCY_BUCKETS = (
0.005,
0.01,
0.025,
0.05,
0.075,
0.1,
0.25,
0.5,
0.75,
1.0,
2.5,
5.0,
7.5,
10.0,
25.0,
50.0,
75.0,
float("inf"),
)
# django-silk
SILKY_ENABLED = env.bool("DJANGO_SILKY_ENABLED", default=False)
SILKY_PYTHON_PROFILER = env.bool("DJANGO_SILKY_PYTHON_PROFILER", default=False)
SILKY_PYTHON_PROFILER_BINARY = True
SILKY_PYTHON_PROFILER_RESULT_PATH = "./profiles"
SILKY_PYTHON_PROFILER_EXTENDED_FILE_NAME = True
SILKY_AUTHENTICATION = True
SILKY_AUTHORISATION = True
def is_allowed_to_use_profiling(user: "User") -> bool:
return user.is_staff
SILKY_PERMISSIONS = is_allowed_to_use_profiling
SILKY_MAX_RECORDED_REQUESTS = 10**3
SILKY_MAX_RECORDED_REQUESTS_CHECK_PERCENT = 10
SILKY_MAX_REQUEST_BODY_SIZE = 128
SILKY_INTERCEPT_PERCENT = 25
SILKY_META = True
SILKY_DYNAMIC_PROFILING: list[Any] = []
if DEBUG and SILKY_ENABLED:
INSTALLED_APPS.append("silk")
MIDDLEWARE = ["silk.middleware.SilkyMiddleware", *MIDDLEWARE]
+38
View File
@@ -0,0 +1,38 @@
"""URL configuration for Lotty."""
from django.conf import settings
from django.contrib import admin
from django.urls import include, path
from config import handlers
admin.site.site_title = "Lotty"
admin.site.site_header = "Lotty"
admin.site.index_title = "Lotty"
urlpatterns = [
# Admin urls
path("admin/", admin.site.urls),
# API urls
path("", include("api.urls")),
# Prometheus urls
path("", include("django_prometheus.urls")),
]
if settings.DEBUG and settings.DEBUG_TOOLBAR_ENABLED:
from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns += debug_toolbar_urls()
if settings.DEBUG and settings.SILKY_ENABLED:
urlpatterns.append(path("silk/", include("silk.urls", namespace="silk")))
handler400 = handlers.handler400
handler403 = handlers.handler403
handler404 = handlers.handler404
handler500 = handlers.handler500
+28
View File
@@ -0,0 +1,28 @@
import uuid
from typing import Any
from django.http import HttpRequest
from django.utils.timezone import now
from django_guid import get_guid
def build_error_payload(
request: HttpRequest,
code: str,
message: str,
details: dict[str, Any] | None = None,
field_errors: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
trace_id = get_guid() or str(uuid.uuid4())
payload = {
"code": code,
"message": message,
"traceId": str(trace_id),
"timestamp": now(),
"path": request.path,
"details": details,
}
if field_errors is not None:
payload["fieldErrors"] = field_errors
return payload
+9
View File
@@ -0,0 +1,9 @@
"""WSGI config for Lotty."""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env just --justfile
[group('help')]
[private]
default:
@ just --list --list-heading $'justfile manual page:\n'
# show help
[group('help')]
help: default
# runs service
[group('run')]
run:
@ uv run python manage.py runserver
style:
just format
just lint
just mypy
check:
just style
just test
just test-coverage
# lints codebase using golangci-lint
[group('lint')]
lint:
@ uv run ruff check .
# lints and fixes codebase using ruff
[group('lint')]
fix:
@ uv run ruff check . --fix
# formats codebase using ruff
[group('lint')]
format:
@ uv run ruff format .
alias fmt := format
# lints codebase using mypy
[group('lint')]
mypy:
@ uv run mypy .
# run tests
[group('test')]
test:
@ uv run python manage.py test
# run tests with coverage report
[group('test')]
test-coverage:
@ uv run coverage run --source="." manage.py test
# generates migrations
[group('generate')]
generate-migrations:
@ uv run python manage.py makemigrations
# applies migrations
[group('generate')]
apply-migrations:
@ uv run python manage.py migrate
alias m := apply-migrations
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python
# ruff: noqa: PLC0415
"""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()
View File
+229
View File
@@ -0,0 +1,229 @@
[project]
dependencies = [
"celery>=5.5.0,<6.0.0",
"colorlog>=6.9.0,<7.0.0",
"django-cors-headers>=4.7.0,<5.0.0",
"django-environ>=0.12.0,<1.0.0",
"django-extensions>=4.1.0,<5.0.0",
"django-guid>=3.5.1,<4.0.0",
"django-health-check>=3.18.3,<4.0.0",
"django-storages[s3]>=1.14,<2.0",
"django-ninja>=1.3.0,<2.0.0",
"django-prometheus>=2.4.1,<3.0.0",
"django-redis>=6.0.0,<7.0.0",
"django-silk[formatting]>=5.4.0,<6.0.0",
"django-stubs-ext>=5.1.3,<6.0.0",
"gunicorn>=23.0.0,<24.0.0",
"httpx>=0.28.1,<0.29.0",
"opentelemetry-api>=1.35.0",
"opentelemetry-distro>=0.56b0",
"opentelemetry-exporter-otlp>=1.35.0",
"opentelemetry-exporter-zipkin-proto-http>=1.11.1",
"opentelemetry-instrumentation-asyncio>=0.56b0",
"opentelemetry-instrumentation-celery>=0.56b0",
"opentelemetry-instrumentation-dbapi>=0.56b0",
"opentelemetry-instrumentation-django>=0.56b0",
"opentelemetry-instrumentation-httpx>=0.56b0",
"opentelemetry-instrumentation-psycopg2>=0.56b0",
"opentelemetry-instrumentation-requests>=0.56b0",
"opentelemetry-instrumentation-sqlite3>=0.56b0",
"opentelemetry-instrumentation-threading>=0.56b0",
"opentelemetry-instrumentation-urllib>=0.56b0",
"opentelemetry-instrumentation-urllib3>=0.56b0",
"opentelemetry-instrumentation-wsgi>=0.56b0",
"opentelemetry-sdk>=1.35.0",
"orjson>=3.10.15,<4.0.0",
"pillow>=11.1.0,<12.0.0",
"psycopg2-binary>=2.9.10,<3.0.0",
"pydantic>=2.10.5,<3.0.0",
"pyjwt>=2.10.1,<3.0.0",
"python-json-logger>=3.2.1,<4.0.0",
"pytz>=2024.2,<2025.0",
"redis>=6.2.0,<7.0.0",
"uvicorn[standard]>=0.34.0,<1.0.0",
"uvicorn-worker>=0.2.0,<1.0.0",
]
name = "lotty-backend"
readme = "README.md"
requires-python = ">=3.13,<3.15"
version = "0.1.0"
[dependency-groups]
dev = [
"coverage",
"django-debug-toolbar>=5.2,<5.3",
"django-stubs[compatible-mypy]",
"mypy",
"ruff",
]
[tool.ruff]
builtins = []
cache-dir = ".ruff_cache"
target-version = "py313"
exclude = [
"**/migrations/*.py",
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".mypy_cache",
".nox",
".pants.d",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"dist",
"node_modules",
"venv",
]
extend-exclude = []
extend-include = []
fix = false
fix-only = false
force-exclude = true
include = ["**/pyproject.toml", "*.ipynb", "*.py", "*.pyi"]
indent-width = 4
line-length = 79
namespace-packages = []
output-format = "full"
preview = true
required-version = ">=0.8.4"
respect-gitignore = true
show-fixes = true
src = [".", "src"]
unsafe-fixes = false
[tool.ruff.analyze]
detect-string-imports = true
direction = "Dependencies"
exclude = []
include-dependencies = {}
preview = false
[tool.ruff.lint]
select = [
"A", # flake8-builtins
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"C90", # maccabe
"COM", # flake8-commas
"D", # pydocstyle
"DTZ", # flake8-datetimez
"E", # pycodestyle
"ERA", # flake8-eradicate
"EXE", # flake8-executable
"F", # pyflakes
"FA", # flake8-future-annotations
"FBT", # flake8-boolean-trap
"FLY", # pyflint
"FLY", # pyflint
"FURB", # refurb
"G", # flake8-logging-format
"I", # isort
"ICN", # flake8-import-conventions
"ISC", # flake8-implicit-str-concat
"LOG", # flake8-logging
"N", # pep8-naming
"PERF", # perflint
"PIE", # flake8-pie
"PL", # pylint
"PT", # flake8-pytest-style
"PTH", # flake8-use-pathlib
"Q", # flake8-quotes
"RET", # flake8-return
"RSE", # flake8-raise
"RUF", # ruff
"S", # flake8-bandit
"SIM", # flake8-simpify
"SLF", # flake8-self
"SLOT", # flake8-slots
"T100", # flake8-debugger
"TRY", # tryceratops
"UP", # pyupgrade
"W", # pycodestyle
"YTT", # flake8-2020
]
ignore = [
"PLR1702",
"A005", # allow to shadow stdlib and builtin module names
"COM812", # trailing comma, conflicts with `ruff format`
# Different doc rules that we don't really care about:
"D",
"ISC001", # implicit string concat conflicts with `ruff format`
"ISC003", # prefer explicit string concat over implicit concat
"PLR09", # we have our own complexity rules
"PLR2004", # do not report magic numbers
"PLR6301", # do not require classmethod / staticmethod when self not used
"TRY003", # long exception messages from `tryceratops`
"N813",
"S106",
"ERA",
"PT022",
"RUF001",
"RUF012",
]
[tool.ruff.lint.isort]
case-sensitive = true
combine-as-imports = true
[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.mypy]
strict = true
strict_bytes = true
local_partial_types = true
warn_unreachable = true
ignore_missing_imports = true
plugins = ["mypy_django_plugin.main"]
enable_error_code = [
"truthy-bool",
"truthy-iterable",
"redundant-expr",
"unused-awaitable",
"ignore-without-code",
"possibly-undefined",
"redundant-self",
"explicit-override",
"mutable-override",
"unimported-reveal",
"deprecated",
]
[[tool.mypy.overrides]]
module = ["*.migrations.*"]
ignore_errors = true
[tool.django-stubs]
django_settings_module = "config.settings"
strict_settings = false
[tool.coverage.run]
omit = [
"config/asgi.py",
"config/errors.py",
"config/handlers.py",
"config/settings.py",
"config/urls.py",
"config/wsgi.py",
"manage.py",
]
[tool.coverage.report]
skip_covered = true
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
set -e
if [ "$RUN_MIGRATIONS" = "true" ]; then
echo "Running migrations..."
python manage.py migrate --noinput
fi
if [ "$COLLECT_STATIC" = "true" ]; then
echo "Collecting static files..."
python manage.py collectstatic --noinput
fi
if [ "$OTEL_ENABLED" = "true" ]; then
echo "Starting with OpenTelemetry instrumentation..."
export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED="true"
export OTEL_TRACES_EXPORTER="${OTEL_TRACES_EXPORTER:-otlp}"
export OTEL_METRICS_EXPORTER="${OTEL_METRICS_EXPORTER:-otlp}"
export OTEL_LOGS_EXPORTER="${OTEL_LOGS_EXPORTER:-otlp}"
export OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}"
exec opentelemetry-instrument \
--service_name "${OTEL_SERVICE_NAME:-backend-django}" \
gunicorn config.asgi:application \
--workers "${GUNICORN_WORKERS:-4}" \
--worker-class "${GUNICORN_WORKER_CLASS:-uvicorn_worker.UvicornWorker}" \
--bind "${GUNICORN_BIND:-0.0.0.0:8080}" \
--access-logfile "${GUNICORN_ACCESS_LOG:--}" \
--error-logfile "${GUNICORN_ERROR_LOG:--}" \
--access-logformat '{"remote_ip": "%(h)s", "request_id": "%({X-Request-Id}i)s", "response_code": "%(s)s", "request_method": "%(m)s", "request_path": "%(U)s", "request_timetaken": "%(D)s"}'
else
echo "Starting without OpenTelemetry instrumentation..."
exec gunicorn config.asgi:application \
--workers "${GUNICORN_WORKERS:-4}" \
--worker-class "${GUNICORN_WORKER_CLASS:-uvicorn_worker.UvicornWorker}" \
--bind "${GUNICORN_BIND:-0.0.0.0:8080}" \
--access-logfile "${GUNICORN_ACCESS_LOG:--}" \
--error-logfile "${GUNICORN_ERROR_LOG:--}" \
--access-logformat '{"remote_ip": "%(h)s", "request_id": "%({X-Request-Id}i)s", "response_code": "%(s)s", "request_method": "%(m)s", "request_path": "%(U)s", "request_timetaken": "%(D)s"}'
fi
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
python manage.py migrate
if [ $? -ne 0 ]; then
echo "Migration failed"
exit 1
fi
if [ "$DJANGO_CREATE_SUPERUSER" = "True" ]; then
python manage.py createsuperuser --noinput --username "$DJANGO_SUPERUSER_USERNAME" --email "$DJANGO_SUPERUSER_EMAIL" || true
fi
+1943
View File
File diff suppressed because it is too large Load Diff
-100
View File
@@ -1,100 +0,0 @@
program HealthServer;
{$mode objfpc}{$H+}
uses
cthreads,
fphttpserver, httpdefs, sysutils;
const
LOTTY =
'╔═════════════════════════════════════════════════════════════════════╗' + LineEnding +
'║ ║' + LineEnding +
'║ ⠀⠀⠀⠀⢀⡀⣄⢀⡄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣤⣴⡶⠶⠶⠖⠒⠒⠒⠲⠶⠶⢶⣤⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠰⣮⡿⠛⠻⠷⣯⣶⣀⡀⠀⠀⠀⠀⣀⣴⠾⠛⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠛⠶⣤⡀⠀⠀⠀⠀⠀⣀⣤⣾⣼⣿⣷⣿⣠⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⢛⣿⡄⠀⠀⠀⠀⠙⢿⣶⣂⣀⣤⡾⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠷⣄⠀⣀⣶⣿⠟⠋⠁⠀⠀⢹⣿⠆⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠾⣻⣦⡀⠀⠀⠀⠀⠙⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢻⣿⠋⠁⠀⠀⠀⢀⣴⢿⡍⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠈⠸⢻⡷⣦⣄⡀⣰⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⣦⣠⣤⣴⣾⢿⠝⠃⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⣀⢀⠀⡀⠀⠈⠋⠿⢹⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⣿⠃⠋⠉⠀⢀⠀⡀⣀⠀⠀⠀ ║' + LineEnding +
'║ ⣴⣮⣷⣿⣾⣾⣧⣿⣼⣶⣆⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢿⣴⣄⣷⣿⡾⠷⠿⠾⣾⣧⡄ ║' + LineEnding +
'║ ⠶⢿⡋⠀⠀⠀⠀⠀⠀⠉⠉⢻⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡟⠋⠁⠀⠀⠀⠀⠀⢀⣿⠶ ║' + LineEnding +
'║ ⠉⠽⣷⣦⣄⣀⣀⠀⠀⠀⣀⣸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⢀⣀⣤⣴⣿⡏⠁ ║' + LineEnding +
'║ ⠀⠀⠁⠋⠻⠹⠿⠟⡿⠻⠻⠟⣿⠀⠀⠀⠀⠀⣴⣾⣿⣶⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣷⣦⠀⠀⠀⠀⣾⢻⠟⡿⠻⠛⠏⠛⠈⠁⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⣄⣤⣰⣿⣆⠀⠀⠀⠀⣿⣿⣿⣿⡇⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⣀⠀⠀⠀⠀⢸⣿⣿⣿⣿⠃⠀⠀⣰⣿⣖⣴⢀⡀⡀⠀⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⣀⢰⣼⣷⠿⠛⠋⠉⠹⣦⡀⠀⠀⠈⠛⠛⠋⠀⠀⠀⠀⠀⠉⠛⠛⠛⠛⠛⠉⠀⠀⠀⠀⠀⠉⠛⠛⠁⠀⠀⣴⠏⠉⠙⠛⠿⢾⣧⣶⣠⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⣰⣾⠟⠉⠀⠀⠀⠀⠀⣠⣾⡷⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⢞⣷⣤⡀⠀⠀⠀⠀⠈⠙⢿⣄⡀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠈⣽⣯⡀⠀⠀⢀⣠⣴⣿⡝⠊⠀⠈⠙⠶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⠶⠋⠁⠀⠑⠋⣿⢶⣤⣤⣀⣀⣠⣼⢯⡅⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠐⠋⠿⢻⠿⡏⠷⠙⠁⠀⠀⠀⠀⠀⠀⠀⠉⠛⠳⠶⣤⣤⣄⣀⣀⣀⣀⣀⣀⣀⣠⣤⣤⠶⠚⠋⠉⠀⠀⠀⠀⠀⠀⠀⠈⠐⠃⠘⠋⠗⠙⠁⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡿⠁⠈⠉⠉⠉⠉⠉⠉⠉⠁⠀⢹⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⠃⠉⠉⠙⣆⠀⠀⠀⢰⠋⠉⠉⠀⢿⡀⠀⠀⠀╭─────────────────────╮ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡏⠀⠐⠓⠒⠃⠀⠀⠀⠈⠓⠚⠂⠀⠸⣇⠀⠀⠀│ LOTTY says: │ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⣾⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠀⠀⠀│ "Теперь вы PROD!" │ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠀⠀⠀╰─────────────────────╯ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡄⠀⣠⠚⠙⡆⠀⠀⠀⢰⠋⠑⣆⠀⢰⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣄⠀⠀⠰⠁⠀⠀⠀⠈⠃⠀⢀⣰⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⢶⣤⣤⣤⣤⣤⣤⣤⡶⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⢻⣇⠀⠀⠀⢸⡏⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⣿⠀⠀⢀⡿⢰⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡄⣿⠀⠀⣼⢇⡾⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢷⣿⠀⢰⣿⡞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣤⠿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ║' + LineEnding +
'║ ║' + LineEnding +
'║ ██████╗ ██████╗ ██████╗ ██████╗ ║' + LineEnding +
'║ ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗ ║' + LineEnding +
'║ ██████╔╝██████╔╝██║ ██║██║ ██║ ║' + LineEnding +
'║ ██╔═══╝ ██╔══██╗██║ ██║██║ ██║ ║' + LineEnding +
'║ ██║ ██║ ██║╚██████╔╝██████╔╝ ║' + LineEnding +
'║ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ║' + LineEnding +
'║ ║' + LineEnding +
'║ ║' + LineEnding +
'║ Status: HEALTHY ║' + LineEnding +
'║ ║' + LineEnding +
'║ ║' + LineEnding +
'║ ║' + LineEnding +
'║ ║' + LineEnding +
'║ © Powered by Watlon ║' + LineEnding +
'╚═════════════════════════════════════════════════════════════════════╝' + LineEnding;
type
THandler = class
procedure HandleRequest(Sender: TObject; var Req: TFPHTTPConnectionRequest;
var Res: TFPHTTPConnectionResponse);
end;
procedure THandler.HandleRequest(Sender: TObject; var Req: TFPHTTPConnectionRequest;
var Res: TFPHTTPConnectionResponse);
begin
Res.ContentType := 'text/plain; charset=utf-8';
if (Req.URI = '/ping') or (Req.URI = '/ready') or (Req.URI = '/health') then
begin
Res.Code := 200;
Res.Content := LOTTY;
end
else
begin
Res.Code := 404;
Res.Content := 'not found' + LineEnding;
end;
end;
var
Server: TFPHTTPServer;
Handler: THandler;
begin
Handler := THandler.Create;
Server := TFPHTTPServer.Create(nil);
try
Server.Port := 80;
Server.Threaded := True;
Server.OnRequest := @Handler.HandleRequest;
Server.Active := True;
WriteLn('Listening on :80');
while True do Sleep(3600 * 1000);
finally
Server.Free;
Handler.Free;
end;
end.