init: added template
This commit is contained in:
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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;"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "apps.core"
|
||||
label = "core"
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from config.celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Executable
+23
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+11
@@ -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
|
||||
Generated
+1943
File diff suppressed because it is too large
Load Diff
-100
@@ -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.
|
||||
Reference in New Issue
Block a user