Compare commits

..

29 Commits

Author SHA1 Message Date
405d7da754 Modify: HEALTHCHECK better use in docker-compose.yaml like optional 2023-09-16 18:02:11 +09:00
c316104d94 Update: meta status as Production 2023-09-16 15:32:32 +09:00
eaeecc926a Add: readme and changelog 2023-09-16 15:28:43 +09:00
51950cb7d2 Add: logging cleanup_media cmd 2023-09-16 15:23:51 +09:00
08380b2ca3 Add: uploader.sh script 2023-09-15 20:48:51 +09:00
b2bea677ef Add: http custom error pages, more checks js possible error, check if file delete from storage by cli 2023-09-14 21:36:41 +09:00
431541b3cb Add: prototype cleanup_media command 2023-09-14 01:19:05 +09:00
a7a85b1816 Modify: archive upload js now it check token status, HEALTHCHEK call health api endpoint 2023-09-14 01:17:37 +09:00
a1679b69c3 Modify: DATA_DIR default cast type Path 2023-09-12 18:04:30 +09:00
919bf6e69c Add: psql support to entrypoint.sh 2023-09-12 18:03:17 +09:00
feb579ca31 Add: psycopg deps for psql support 2023-09-12 17:35:00 +09:00
57a758f93e Modify: settings - add DATA_DIR logs_collector storage_info use DATA_DIR as storage root 2023-09-11 13:53:03 +09:00
5893920d69 Modify: ticket and archive cards 2023-09-10 16:35:30 +09:00
b7aab16a00 Fix: footer in disable 2fa template 2023-09-10 13:00:37 +09:00
9844bccd65 Fix: width ticket page and account update form 2023-09-10 12:54:54 +09:00
2cba6321c2 Add: account views, tests, override user model 2023-09-10 12:34:54 +09:00
305001c9ab Modify: disable basic auth in API add protection to storage info endpoint 2023-09-08 19:34:25 +09:00
bfe13039a3 Add: description of endpoints to swagger 2023-09-08 19:21:37 +09:00
f28b44b7fd Fix: healthcheck cmd use WEB_PORT build arg 2023-09-08 16:47:07 +09:00
158c381ac4 Add: StorageInfo serializer, url test change output from API storage-info 2023-09-08 16:02:34 +09:00
045cc3641f Modify: footer meta info 2023-09-08 02:27:04 +09:00
ff602097be Modify: cards header 2023-09-08 01:49:10 +09:00
0f9f3afbb4 Add: footer to 2fa app 2023-09-08 01:24:59 +09:00
3672d8b321 Add: footer, meta info, move api to footer 2023-09-07 22:32:54 +09:00
016994d594 Add: storage info widget and storage api endpoint refactoring project structure add version app 2023-09-07 13:07:18 +09:00
e95de1b553 Modify: Dockerfile use bild args and add healthchek docker-compose use env 2023-09-04 22:14:58 +09:00
8971752e5b Modify: settings set casting default env vars change files path disable drf web render in prod 2023-09-04 21:58:42 +09:00
3aa887c5a4 Add: link to edit for quick access to increase attempts 2023-09-03 15:00:24 +09:00
d236f3a14a Modify: navbar items 2023-09-03 14:30:34 +09:00
88 changed files with 3018 additions and 731 deletions

13
CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# Changelog
## [0.1.0] - 2023-09-16
### Added
- Web interface
- Fully featured RestFullAPI v1;
- Monitoring free space in storage;
- Deleting an archive or ticket also deletes physical files;
- Flexible deployment configuration using environment variables;
- Dockerized app, the image size is less than 150mb;
- Support sqlite3 and PostgreSQL^15;
- Whitenoise Static management;
- healthcheck checking application availability;

View File

@@ -8,23 +8,57 @@ ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=on
# install dependences
# install app dependences
COPY requirements.txt ./
RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt
# Now multistage builds
# now multistage builds
FROM python:3.10-alpine
# set env variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# default build args
ARG VERSION=0.1.0 \
APP_DIR=/app \
DATA_DIR=/app/data \
SRC_DIR=./logs_collector \
SCRIPTS_DIR=./scripts \
WEB_PORT=8000 \
USER_NAME=collector \
USER_GROUP=collector \
APP_UID=1000 \
APP_GID=1000
# copy app dependences
COPY --from=base /usr/local/lib/python3.10/site-packages/ /usr/local/lib/python3.10/site-packages/
COPY --from=base /usr/local/bin/ /usr/local/bin/
# add curl and createa user to avoid running container as root &&
# create storage dir
RUN apk add --no-cache --upgrade curl && \
addgroup --system ${USER_GROUP} --gid ${APP_GID} && \
adduser --system --uid ${APP_UID} --ingroup ${USER_GROUP} ${USER_NAME} && \
mkdir -p ${APP_DIR}/data && \
chown -R ${USER_NAME}:${USER_GROUP} ${DATA_DIR}
# switch to user
USER ${USER_NAME}
# copy src and entrypoint.sh to app dir
COPY --chown=${USER_NAME}:${USER_GROUP} ${SRC_DIR} ${APP_DIR}
COPY --chown=${USER_NAME}:${USER_GROUP} ${SCRIPTS_DIR}/entrypoint.sh ${APP_DIR}
# set workdir
WORKDIR ${APP_DIR}
# app listens on this port by default
EXPOSE ${WEB_PORT}
# set lables about app
LABEL maintainer="s.zhukovskii@ispsystem.com"
LABEL ru.isptech.logs-collector.version=v0.1.0
COPY ./logs_collector /app
WORKDIR /app
COPY entrypoint.sh ./
LABEL me.zhukovsky.logs-collector.version=v${VERSION}
# run app
ENTRYPOINT [ "sh", "entrypoint.sh" ]

324
README-ru.md Normal file
View File

@@ -0,0 +1,324 @@
# LOGS-COLLECTOR
```sh
█░░ █▀█ █▀▀ █▀ ▄▄ █▀▀ █▀█ █░░ █░░ █▀▀ █▀▀ ▀█▀ █▀█ █▀█
█▄▄ █▄█ █▄█ ▄█ ░░ █▄▄ █▄█ █▄▄ █▄▄ ██▄ █▄▄ ░█░ █▄█ █▀▄
```
### [English lang: README.md](README.md)
### [CHANGELOG.md](CHANGELOG.md)
## Цель
Если вы являетесь разработчиком ПО которое в дальнейшем клиенты используют в своей инфраструктуре, вы должны понимать, как иногда бывает трудно изучить проблему с ПО не имея доступа к серверу на котором это ПО работает.
Для решения этой задачи вы можете настраивать ПО на автоматическую отправку обезличенных отчетов о сбоях например использовать Sentry. Это не всегда приемлемо для клиента, к тому же информация может быть не полной или клиенту требуется повышенная конфиденциальность.
В таком случае вы можете попросить клиента отправить вам нужные лог файлы и изучить их в последствии. Но тут возникает другая проблема вам нужен безопасный способ передачи этих файлов как для вас так и для клиента.
Это мог быть FTP, SFTP, облако etc. Но что если вы не хотите давать клиенту данные для аутентификации и авторизации?
Возможно у вас есть доступ к серверу клиента и вы можете прочитать лог файлы на месте. И казалось бы проблема решена. Но на сервере клиента могут отсутствовать инструменты для удобного изучения лог файлов.
Даже если сотрудник поддержки может забрать себе нужные файлы и изучить их локально, возникает проблема распространения этих файлов между другими сотрудниками.
Logs-collector позволяет решить эти задачи.
Logs-collector является удаленным хранилищем и может принимать и отдавать файлы.
## Термины
- Платформа: это ПО разработанное вашей компанией
- Тикет: это номер связанный с тикетом в вашей help desk системе
- Архив: это загруженный лог файл (поддерживается любой формат)
## Как это работает?
- Создаете платформы
- Создаете тикет связанный с платформой и номером
- Передаете клиенту уникальный токен тикета
- Клиент загружает архив лог файлов
- Скачиваете архив (находите решение проблемы)
- Удаляете архив или тикет или отмечаете тикет решенным
## Особенности
- Централизованное хранилище
- Для загрузки файла не нужно давать auth credentials
- Каждый токен на загрузку уникален и связан только с одним тикетом
- Токен имеет ограничение на количество попыток и время жизни
- Загрузить файл можно из консоли или через веб
- Полнофункциональный RestFullAPI v1
- Мониторинг свободного пространства в хранилище
- Удаление архива или тикета так же удаляет физические файлы
- Приложение соответствует архитектуре приложения 12 факторов
- Гибкая настройка развертывания переменными окружения
- Приложение докеризировано, размер образа меньше 150mb
- Может работать как с sqlite3 так и с PostgreSQL^15
- Управление статикой без настройки для этого веб сервера
- healthcheck проверка доступности приложения
## Безопасность
- Токен на загрузку не связан с авторизацией
- Токен на загрузку обладает высокой энтропией.
- Двухфакторная аутентификация для пользователей
- Для скачивания файла - 2FA должна быть принудительно включена
- Админ панель пропатчена на принудительное использование 2FA
- Пользователь в контейнере является не привилегированным
- Стандартные методы защиты Django и DRF
## Установка
### Из docker образа:
- Создайте директорию для приложения где вам удобно
- Создайте файл docker-compose.yml в директории приложения
- Создайте файл .env в директории приложения
- Наполните файл .env требуемыми переменными окружения см. ниже
>Пример файла с использованием хранилища докер и sqlite как база данных по умолчанию:
```yaml
version: "3"
# to set environment variables:
# create a .env file in the same directory as docker-compose.yaml
services:
server:
image: mois3y/logs_collector:0.1.0
container_name: logs-collector
restart: unless-stopped
env_file:
- ./.env
ports:
- "80:8000"
volumes:
- /etc/timezone:/etc/timezone:ro # optional
- /etc/localtime:/etc/localtime:ro # optional
- logs_collector_data:/data
volumes:
logs_collector_data:
```
### Из исходников:
- Клонируйте репозиторий
- docker-compose.yaml уже есть в директории с проектом
- создайте в корне проекта файл .env
- наполните .env требуемыми переменными окружения см. ниже
- соберите образ и запустите контейнер в фоне:
```sh
docker-compose up -d --build
```
- Вы можете создать свой файл и внести нужные правки:
#### docker-compose-example-psql.yaml c PostgreSQL по умолчанию:
```yaml
services:
logs_collector:
container_name: logs-collector
build:
context: .
args:
- VERSION=${VERSION}
- SRC_DIR=${SRC_DIR}
- SCRIPTS_DIR=${SCRIPTS_DIR}
- APP_DIR=${APP_DIR}
- DATA_DIR=${DATA_DIR}
- WEB_PORT=${WEB_PORT}
- USER_NAME=${USER_NAME}
- USER_GROUP=${USER_GROUP}
- APP_UID=${APP_UID}
- APP_GID=${APP_GID}
ports:
- "${WEB_HOST}:${WEB_PORT}:${WEB_PORT}"
volumes:
- type: volume
source: logs_collector_data
target: ${APP_DIR}/data
env_file:
- ./.env
depends_on:
- db
db:
image: postgres:15-alpine3.18
container_name: psql-collector
volumes:
- logs_collector_psql_data:/var/lib/postgresql/data/
env_file:
- ./.env
volumes:
logs_collector_data:
logs_collector_psql_data:
```
#### docker-compose-example-psql.yaml c sqlite и bind-mount:
```yaml
version: "3"
# to set environment variables:
# create a .env file in the same directory as docker-compose.yaml
services:
logs_collector:
container_name: logs-collector
build:
context: .
args:
- VERSION=${VERSION}
- SRC_DIR=${SRC_DIR}
- SCRIPTS_DIR=${SCRIPTS_DIR}
- APP_DIR=${APP_DIR}
- DATA_DIR=${DATA_DIR}
- WEB_PORT=${WEB_PORT}
- USER_NAME=${USER_NAME}
- USER_GROUP=${USER_GROUP}
- APP_UID=${APP_UID}
- APP_GID=${APP_GID}
ports:
- "${WEB_HOST}:${WEB_PORT}:${WEB_PORT}"
volumes:
- "/opt/collector/data:${DATA_DIR}"
- "/opt/collector/data/db.sqlite3:${DATA_DIR}/db.sqlite3"
env_file:
- /.env
```
🔴
❗ВАЖНО❗
Если вы используете bind-mount и монтируете его в хранилище приложения, помните
пользователь в контейнере не привилегирован UID 1000 если примонтированный файл
или директория будет принадлежать root приложение не сможет его прочитать и
следовательно работать.
В продакшн среде используйте приложение за вашим любимым обратным прокси.
Просто добавьте его в стек docker-compose.yaml
>Можно этого не делать, но Gunicorn рекомендуют придерживаться этого правила.
>
>Я солидарен с ними, так что вас предупредили)
🔴
## Переменные окружения:
>Приложение можно настроить, для этого передайте следующие возможные переменные
>окружения.
>Если переменная не передана, будет использоваться переменная окружения по умолчанию
```
█▀▄ ░░█ ▄▀█ █▄░█ █▀▀ █▀█ ▀
█▄▀ █▄█ █▀█ █░▀█ █▄█ █▄█ ▄
```
| ENV | DEFAULT | INFO |
| -------------------- | --------------- | ------------------------ |
| SECRET_KEY | j9QGbvM9Z4otb47 | ❗change this immediately|
| DEBUG | False | use only False in prod |
| ALLOWED_HOSTS | '*' | list separated by commas |
| CSRF_TRUSTED_ORIGINS | | list separated by commas |
| DB_URL | | url for connect db |
| TZ | 'UTC' | server timezone |
[CSRF_TRUSTED_ORIGINS](https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-trusted-origins)
Требуется в среде докер в продакшн окружении
принимает список url разделенных запятой
>http://localhost,http://*.domain.com,http://127.0.0.1,http://0.0.0.0
[DB_URL](https://django-environ.readthedocs.io/en/latest/quickstart.html)
Нужно указывать если вы хотите использовать PostgreSQL
Эти данные должны совпадать с переменными контейнера PostgreSQL
| ENV | VALUE |
| ----------------- | -------------- |
| POSTGRES_USER | admin |
| POSTGRES_PASSWORD | ddkwndkjdX7RrP |
| POSTGRES_DB | collector |
Пример:
#### psql://admin:ddkwndkjdX7RrP@psql-collector:5432/collector
- Протокол: **psql://**
- Пользователь: **admin**
- Пароль: **ddkwndkjdX7RrP**
- IP адрес: **psql-collector**
- Порт: **5432**
- Имя БД: **collector**
```
█▀▀ █░█ █▄░█ █ █▀▀ █▀█ █▀█ █▄░█ ▀
█▄█ █▄█ █░▀█ █ █▄▄ █▄█ █▀▄ █░▀█ ▄
```
| ENV | DEFAULT |
| --------------------------- | -------------- |
| GUNICORN_BIND | '0.0.0.0:8000' |
| GUNICORN_BACKLOG | 2048 |
| GUNICORN_WORKERS | 2 |
| GUNICORN_WORKER_CLASS | 'sync' |
| GUNICORN_WORKER_CONNECTIONS | 1000 |
| GUNICORN_THREADS | 1 |
| GUNICORN_TIMEOUT | 3600 |
| GUNICORN_KEEPALIVE | 2 |
| GUNICORN_LOGLEVEL | 'info' |
[GUNICORN_*](https://docs.gunicorn.org/en/stable/settings.html)
Подробная информация о каждой переменной окружения доступна в официальной документации.
GUNICORN_BIND не изменяйте это так как переменная отвечает за прослушиваемый адрес и порт внутри контейнера.
GUNICORN_TIMEOUT по умолчанию установлена в 3600. Такой большой таймаут нужен для загрузки больших файлов.
Поскольку я старался сделать приложение минималистичным и не использовать менеджер задач загрузка файла идет в один поток.
Если время загрузки будет больше часа соединение разорвется, это особенность синхронной работы воркеров gunicorn если вам не хватает времени на загрузку вы можете увеличить это значение.
❗ВАЖНО❗
Gunicorn настроен писать в лог в следующем формате:
```python
'%({X-Forwarded-For}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
```
Это значит что в логе будет видно IP адрес запроса только из заголовка
**X-Forwarded-For**
В продакшн среде приложение должно быть за обратным прокси
## Помощники
В корне репозитория проекта есть директория scripts в ней лежит скрипт uploader.sh с помощью которого можно отправить файлы из консоли используя curl.
Синтаксис простой:
```cmd
Usage: ./uploader.sh [options [parameters]]
Options:
-f | --file full path to upload file required
-t | --token access token required
-u | --url target url required
-v | --version print version
-h | --help print help
```
## Лицензия
GNU GPL 3.0

330
README.md
View File

@@ -1,3 +1,329 @@
# logs-collector
# LOGS-COLLECTOR
Серверная сторона для получения и хранения лог файлов
```sh
█░░ █▀█ █▀▀ █▀ ▄▄ █▀▀ █▀█ █░░ █░░ █▀▀ █▀▀ ▀█▀ █▀█ █▀█
█▄▄ █▄█ █▄█ ▄█ ░░ █▄▄ █▄█ █▄▄ █▄▄ ██▄ █▄▄ ░█░ █▄█ █▀▄
```
### [CHANGELOG.md](CHANGELOG.md)
### [Russian lang: README.md](README-ru.md)
## Purpose
If you are a developer of software that clients later use in their infrastructure,
you must understand how sometimes it can be difficult to research a problem
with software without access to the server on which this software runs.
To solve this problem, you can configure the software to automatically send
anonymized crash reports, for example, use Sentry.
This is not always acceptable to the client;
Moreover, the information may not be complete or the client
requires increased confidentiality.
## Terms
- Platform: this is software developed by your company
- Ticket: this is the number associated with the ticket in your help desk system
- Archive: this is an uploaded log file (any format is supported)
## How it works?
- Create platforms
- Create a ticket associated with the platform and number
- Transfer a unique ticket token to the client
- The client downloads an archive of log files
- Download the archive (find a solution to the problem)
- Delete the archive or ticket or mark the ticket as resolved
## Features
- Centralized storage;
- To download a file you do not need to provide auth credentials;
- Each download token is unique and associated with only one ticket;
- The token has a limit on the number of attempts and lifetime;
- You can download the file from the console or via the web;
- Fully featured RestFullAPI v1;
- Monitoring free space in storage;
- Deleting an archive or ticket also deletes physical files;
- The application follows the 12 factors application architecture;
- Flexible deployment configuration using environment variables;
- The application is dockerized, the image size is less than 150mb;
- Can work with both sqlite3 and PostgreSQL^15;
- Static management without configuration for this web server;
- healthcheck checking application availability;
## Security
- The download token is not associated with authorization
- The download token has high entropy.
- Two-factor authentication for users
- To download a file - 2FA must be forcibly enabled
- The admin panel has been patched to force the use of 2FA
- The user in the container is not privileged
- Standard Django and DRF protection methods
## Install
### From the docker image:
- Create a directory for the application wherever it is convenient for you
- Create a docker-compose.yml file in the application directory
- Create a .env file in the application directory
- Fill the .env file with the required environment variables, see below
>Example file using docker store and sqlite as default database:
```yaml
version: "3"
# to set environment variables:
# create a .env file in the same directory as docker-compose.yaml
services:
server:
image: mois3y/logs_collector:0.1.0
container_name: logs-collector
restart: unless-stopped
env_file:
- ./.env
ports:
- "80:8000"
volumes:
- /etc/timezone:/etc/timezone:ro # optional
- /etc/localtime:/etc/localtime:ro # optional
- logs_collector_data:/data
volumes:
logs_collector_data:
```
### From the source:
- Clone the repository
- docker-compose.yaml is already in the project directory
- create a .env file in the project root
- fill .env with the required environment variables, see below
- build the image and run the container in the background:
```sh
docker-compose up -d --build
```
- You can create your own file and make the necessary edits:
#### docker-compose.yaml PostgreSQL by default:
```yaml
services:
logs_collector:
container_name: logs-collector
build:
context: .
args:
- VERSION=${VERSION}
- SRC_DIR=${SRC_DIR}
- SCRIPTS_DIR=${SCRIPTS_DIR}
- APP_DIR=${APP_DIR}
- DATA_DIR=${DATA_DIR}
- WEB_PORT=${WEB_PORT}
- USER_NAME=${USER_NAME}
- USER_GROUP=${USER_GROUP}
- APP_UID=${APP_UID}
- APP_GID=${APP_GID}
ports:
- "${WEB_HOST}:${WEB_PORT}:${WEB_PORT}"
volumes:
- type: volume
source: logs_collector_data
target: ${APP_DIR}/data
env_file:
- ./.env
depends_on:
- db
db:
image: postgres:15-alpine3.18
container_name: psql-collector
volumes:
- logs_collector_psql_data:/var/lib/postgresql/data/
env_file:
- ./.env
volumes:
logs_collector_data:
logs_collector_psql_data:
```
#### docker-compose-example-psql.yaml c sqlite и bind-mount:
```yaml
version: "3"
# to set environment variables:
# create a .env file in the same directory as docker-compose.yaml
services:
logs_collector:
container_name: logs-collector
build:
context: .
args:
- VERSION=${VERSION}
- SRC_DIR=${SRC_DIR}
- SCRIPTS_DIR=${SCRIPTS_DIR}
- APP_DIR=${APP_DIR}
- DATA_DIR=${DATA_DIR}
- WEB_PORT=${WEB_PORT}
- USER_NAME=${USER_NAME}
- USER_GROUP=${USER_GROUP}
- APP_UID=${APP_UID}
- APP_GID=${APP_GID}
ports:
- "${WEB_HOST}:${WEB_PORT}:${WEB_PORT}"
volumes:
- "/opt/collector/data:${DATA_DIR}"
- "/opt/collector/data/db.sqlite3:${DATA_DIR}/db.sqlite3"
env_file:
- /.env
```
🔴
❗IMPORTANT❗
If you are using bind-mount and mounting it to your application's storage,
remember user in container is not privileged UID 1000 if mounted file
or the directory will belong to the root
application will not be able to read it and therefore work.
In a production environment, use the application behind your favorite reverse proxy.
Just add it to the docker-compose.yaml stack
>You don't have to do this, but Gunicorn recommends following this rule.
>
>I agree with them, so you have been warned)
🔴
## Environment:
>The application can be configured,
>to do this, pass the following possible variables surroundings.
>If no variable is passed, the default environment variable will be used
```
█▀▄ ░░█ ▄▀█ █▄░█ █▀▀ █▀█ ▀
█▄▀ █▄█ █▀█ █░▀█ █▄█ █▄█ ▄
```
| ENV | DEFAULT | INFO |
| -------------------- | --------------- | ------------------------ |
| SECRET_KEY | j9QGbvM9Z4otb47 | ❗change this immediately|
| DEBUG | False | use only False in prod |
| ALLOWED_HOSTS | '*' | list separated by commas |
| CSRF_TRUSTED_ORIGINS | | list separated by commas |
| DB_URL | | url for connect db |
| TZ | 'UTC' | server timezone |
[CSRF_TRUSTED_ORIGINS](https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-trusted-origins)
Required in a Docker environment in a production environment
accepts a list of urls separated by commas
>http://localhost,http://*.domain.com,http://127.0.0.1,http://0.0.0.0
[DB_URL](https://django-environ.readthedocs.io/en/latest/quickstart.html)
Must be specified if you want to use PostgreSQL
This data must match the PostgreSQL container variables
| ENV | VALUE |
| ----------------- | -------------- |
| POSTGRES_USER | admin |
| POSTGRES_PASSWORD | ddkwndkjdX7RrP |
| POSTGRES_DB | collector |
Example:
#### psql://admin:ddkwndkjdX7RrP@psql-collector:5432/collector
- Protocol: **psql://**
- User: **admin**
- Password: **ddkwndkjdX7RrP**
- Address: **psql-collector**
- Port: **5432**
- Database name: **collector**
```
█▀▀ █░█ █▄░█ █ █▀▀ █▀█ █▀█ █▄░█ ▀
█▄█ █▄█ █░▀█ █ █▄▄ █▄█ █▀▄ █░▀█ ▄
```
| ENV | DEFAULT |
| --------------------------- | -------------- |
| GUNICORN_BIND | '0.0.0.0:8000' |
| GUNICORN_BACKLOG | 2048 |
| GUNICORN_WORKERS | 2 |
| GUNICORN_WORKER_CLASS | 'sync' |
| GUNICORN_WORKER_CONNECTIONS | 1000 |
| GUNICORN_THREADS | 1 |
| GUNICORN_TIMEOUT | 3600 |
| GUNICORN_KEEPALIVE | 2 |
| GUNICORN_LOGLEVEL | 'info' |
[GUNICORN_*](https://docs.gunicorn.org/en/stable/settings.html)
Detailed information about each environment variable is available in
the official documentation.
**GUNICORN_BIND** do not change this since the variable
is responsible for the listening address and port inside the container.
**GUNICORN_TIMEOUT** is set to 3600 by default.
Such a large timeout is needed to download large files.
Since I tried to make the application minimalistic and not use a task manager,
the file is downloaded in one thread.
If the loading time is more than an hour, the connection will be broken,
this is a feature of the synchronous operation of gunicorn workers;
if you do not have enough time to load, you can increase this value.
❗IMPORTANT❗
Gunicorn is configured to write to the log in the following format:
```python
'%({X-Forwarded-For}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
```
This means that the log will show the IP address of the request only from the header
**X-Forwarded-For**
In a production environment, the application must be behind a reverse proxy
## Helpers
At the root of the project repository there is a scripts directory,
it contains the uploader.sh script with which you can send files
from the console using **curl**.
The syntax is simple:
```cmd
Usage: ./uploader.sh [options [parameters]]
Options:
-f | --file full path to upload file required
-t | --token access token required
-u | --url target url required
-v | --version print version
-h | --help print help
```
## License
GNU GPL 3.0

View File

@@ -1,16 +1,38 @@
version: "3.7"
version: "3"
# to set environment variables:
# create a .env file in the same directory as docker-compose.yaml
services:
logs_collector:
container_name: logs_collector
container_name: logs-collector
build:
context: .
expose:
- 8000
args:
- VERSION=${VERSION}
- SRC_DIR=${SRC_DIR}
- SCRIPTS_DIR=${SCRIPTS_DIR}
- APP_DIR=${APP_DIR}
- DATA_DIR=${DATA_DIR}
- WEB_PORT=${WEB_PORT}
- USER_NAME=${USER_NAME}
- USER_GROUP=${USER_GROUP}
- APP_UID=${APP_UID}
- APP_GID=${APP_GID}
ports:
- "8000:8000"
- "${WEB_HOST}:${WEB_PORT}:${WEB_PORT}"
volumes:
- ./logs_collector/db.sqlite3:/app/db.sqlite3
- ./logs_collector/archives:/app/archives
- type: volume
source: logs_collector_data
target: ${APP_DIR}/data
env_file:
- ./logs_collector/.env
- ./.env
healthcheck:
test: ["CMD", "curl", "-f", "http://${WEB_HOST}:${WEB_PORT}/${HEALTHCHECK_URL}"]
interval: 1m30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
logs_collector_data:

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env sh
python manage.py migrate --no-input
python manage.py collectstatic --no-input
gunicorn logs_collector.wsgi:application

View File

@@ -1,3 +1,6 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
# Register your models here.
admin.site.register(User, UserAdmin)

View File

@@ -0,0 +1,36 @@
from django import forms
from django.utils.safestring import mark_safe
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit, Div
from crispy_forms.bootstrap import PrependedText
from .models import User
class UserProfileForm(forms.ModelForm):
class Meta:
model = User
fields = [
'email',
'first_name',
'last_name',
]
def __init__(self, *args, **kwargs):
super(UserProfileForm, self).__init__(*args, **kwargs)
self.helper = FormHelper(self)
self.helper.form_show_labels = False
self.helper.layout = Layout(
Div(
PrependedText(
'email',
mark_safe('<i class="bi bi-envelope-at"></i>'),
placeholder="email"
),
PrependedText('first_name', 'First name:'),
PrependedText('last_name', 'Last name:'),
),
Submit('submit', 'Save', css_class='btn btn-primary'),
)

View File

@@ -0,0 +1,44 @@
# Generated by Django 4.2 on 2023-09-08 12:27
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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')),
('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',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@@ -1,3 +1,10 @@
from django.db import models
from django.urls import reverse
from django.contrib.auth.models import AbstractUser
# Create your models here.
# using-a-custom-user-model-when-starting-a-project
# https://docs.djangoproject.com/en/4.2/topics/auth/customizing/
class User(AbstractUser):
def get_absolute_url(self):
return reverse('account:show_profile')

View File

@@ -0,0 +1,30 @@
{% extends 'base.html' %}
{% load static %}
{% block account_head %}
<title>{% block title %}{% endblock title %}</title>
{% endblock account_head %}
{% block account_content %}
<header class="sticky-top">
<section>
{% include 'includes/navigation.html' %}
</section>
</header>
<main>
<section>
{% block main %}{% endblock main %}
</section>
</main>
<footer class="footer mt-auto">
<section>
{% include 'includes/footer.html' %}
</section>
</footer>
{% endblock account_content %}
{% block account_scripts %}
<script src="{% static 'collector/js/jquery-3.7.0.min.js' %}"></script>
{% block bs %}{% endblock bs %}
{% block jquery %}{% endblock jquery %}
{% endblock account_scripts %}

View File

@@ -0,0 +1,39 @@
<div class="container">
<h5 class="card-title">Authentication</h5>
<hr>
<div class="row">
<div class="col-lg-6">
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-person-circle"></i></span>
<input
type="text"
class="form-control"
placeholder="{{ request.user.username }}"
aria-label="Username"
disabled
readonly
>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-shield-lock"></i></span>
<input
type="password"
class="form-control"
placeholder="&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;"
aria-label="Password"
disabled
readonly
>
<a
type="button"
class="btn btn-outline-danger"
href="{% url 'account:password_change' %}"
>
<i class="bi bi-pencil-square"></i> Edit
</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,26 @@
<div class="container">
<h5 class="card-title">Profile</h5>
<hr />
<div class="row">
<div class="col-lg-6">
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-envelope-at"></i></span>
<input type="text" class="form-control" placeholder="{{ request.user.email }}" aria-label="Email" disabled readonly>
</div>
<div class="input-group mb-3">
<span class="input-group-text">First name:</span>
<input type="text" class="form-control" placeholder="{{ request.user.first_name }}" aria-label="Username" disabled readonly>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Last name:</i></span>
<input type="text" class="form-control" placeholder="{{ request.user.last_name }}" aria-label="Email" disabled readonly>
</div>
<a
href="{% url 'account:update_profile' %}"
class="btn btn-outline-warning"
>
<i class="bi bi-pencil-square"></i> Edit
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,12 @@
{% extends 'account/profile.html' %}
{% load static %}
{% load crispy_forms_tags %}
{% block password_change %}
<form method="post">
<div class="col-lg-6">
{% csrf_token %}
{{ form|crispy }}
<p><input class="btn btn-primary" type="submit" value="Change" /></p>
</div>
</form>
{% endblock password_change %}

View File

@@ -0,0 +1,11 @@
{% extends 'account/profile_info.html' %}
{% load static %}
{% block profile_alerts %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<div class=" d-flex align-items-center mt-1">
<h5><i class="bi bi-check-circle-fill"></i> Password changed</h5>
</div>
Your password has been successfully changed.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endblock profile_alerts %}

View File

@@ -0,0 +1,17 @@
{% extends 'account/base.html' %}
{% load static %}
{% block title %} {{ title }} {% endblock title %}
{% block main %}
<div class="container mt-3">
<div class="card">
<div class="card-header">
<h3 class="card-title">Account:</h3>
</div>
<div class="card-body">
{% block profile_info %}{% endblock profile_info %}
{% block profile_update %}{% endblock profile_update %}
{% block password_change %}{% endblock password_change %}
</div>
</div>
</div>
{% endblock main %}

View File

@@ -0,0 +1,7 @@
{% extends 'account/profile.html' %}
{% load static %}
{% block profile_info %}
{% block profile_alerts %}{% endblock profile_alerts %}
{% include 'account/includes/auth_credentials.html' %}
{% include 'account/includes/profile_credentials.html' %}
{% endblock profile_info %}

View File

@@ -0,0 +1,15 @@
{% extends 'account/profile.html' %}
{% load static %}
{% load crispy_forms_tags %}
{% block profile_update %}
{% include 'account/includes/auth_credentials.html' %}
<div class="container">
<h5 class="card-title">Profile</h5>
<hr />
<div class="row">
<div class="col-lg-6">
{% crispy form %}
</div>
</div>
</div>
{% endblock profile_update %}

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

View File

@@ -0,0 +1,36 @@
from django.test import TestCase
from django.urls import resolve, reverse
from django.contrib.auth.views import (
LogoutView,
PasswordChangeView,
PasswordChangeDoneView
)
from account import views
class TestUrls(TestCase):
# READ:
def test_account_logout_url_is_resolved(self):
url = reverse('account:logout')
self.assertEquals(resolve(url).func.view_class, LogoutView)
def test_account_show_url_is_resolved(self):
url = reverse('account:show_profile')
self.assertEquals(resolve(url).func.view_class, views.DetailProfile)
def test_password_change_done_url_is_resolved(self):
url = reverse('account:password_change_done')
self.assertEquals(
resolve(url).func.view_class, PasswordChangeDoneView
)
# UPDATE:
def test_password_change_url_is_resolved(self):
url = reverse('account:password_change')
self.assertEquals(resolve(url).func.view_class, PasswordChangeView)
def test_account_update_url_is_resolved(self):
url = reverse('account:update_profile')
self.assertEquals(resolve(url).func.view_class, views.UpdateProfile)

View File

@@ -1,6 +1,10 @@
from django.conf import settings
from django.urls import path
from django.contrib.auth.views import LogoutView
from django.urls import path, reverse_lazy
from django.contrib.auth.views import (
LogoutView,
PasswordChangeView,
PasswordChangeDoneView
)
from rest_framework_simplejwt.views import (
TokenObtainPairView,
@@ -8,6 +12,8 @@ from rest_framework_simplejwt.views import (
TokenVerifyView
)
from . import views
app_name = 'account'
@@ -17,7 +23,35 @@ urlpatterns = [
'account/logout/',
LogoutView.as_view(next_page=settings.LOGOUT_REDIRECT_URL),
name='logout'
)
),
# CHANGE PASSWORD:
path(
'account/password-change/',
PasswordChangeView.as_view(
template_name='account/password_change.html',
success_url=reverse_lazy('account:password_change_done'),
),
name='password_change'
),
path(
'account/password-change/done/',
PasswordChangeDoneView.as_view(
template_name='account/password_change_done.html'
),
name='password_change_done'
),
# UPDATE:
path(
'account/update/',
views.UpdateProfile.as_view(),
name='update_profile'
),
# READ:
path(
'account/show/',
views.DetailProfile.as_view(),
name='show_profile'
),
]
urlpatterns += [

View File

@@ -0,0 +1,32 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views import generic
from collector.utils.mixins import ExtraContextMixin
from .forms import UserProfileForm
from .models import User
class DetailProfile(LoginRequiredMixin, ExtraContextMixin, generic.DetailView):
model = User
template_name = 'account/profile_info.html'
context_object_name = 'profile'
def get_title(self, **kwargs):
return f'{self.title} - {self.request.user}'
def get_object(self):
return self.model.objects.get(username=self.request.user)
class UpdateProfile(LoginRequiredMixin, ExtraContextMixin, generic.UpdateView):
model = User
template_name = 'account/profile_update.html'
context_object_name = 'profile'
form_class = UserProfileForm
def get_object(self):
return self.model.objects.get(username=self.request.user)
def get_title(self, **kwargs):
return f'{self.title} - {self.kwargs.get("username", "account")}'

View File

@@ -0,0 +1,10 @@
# █▀▄▀█ █▀▀ ▀█▀ ▄▀█ ▀
# █░▀░█ ██▄ ░█░ █▀█ ▄
# -------------------
__author__ = "MOIS3Y"
__credits__ = ["Stepan Zhukovsky"]
__license__ = "GPL v3.0"
__version__ = "0.1.0"
__maintainer__ = "Stepan Zhukovsky"
__email__ = "stepan@zhukovsky.me"
__status__ = "Production"

View File

@@ -5,7 +5,7 @@ from django.utils.html import format_html
from django.utils.translation import ngettext
from .models import Platform, Archive, Ticket
from .utils import sizify
from .utils.helpers import sizify
class PlatformAdmin(admin.ModelAdmin):

View File

@@ -1,7 +1,9 @@
from django import forms
from django_filters.rest_framework import (
CharFilter,
FilterSet,
NumberFilter,
BaseInFilter,
)
from django_filters import widgets
@@ -9,21 +11,46 @@ from collector.models import Archive, Ticket
from .utils import DateTimeFilterMixin
class TextareaCSVWidget(widgets.BaseCSVWidget, forms.Textarea):
"""
The widget should create textarea.
"""
def render(self, name, value, attrs=None, renderer=None):
print("Row value: ", value)
if not self._isiterable(value):
value = [value]
if len(value) <= 1:
# delegate to main widget (Select, etc...) if not multiple values
value = value[0] if value else ''
return super(TextareaCSVWidget, self).render(name, value, attrs)
value = ','.join(value)
return super(TextareaCSVWidget, self).render(name, value, attrs)
class NumberInFilter(BaseInFilter, NumberFilter):
"""
The filter should accept coma separated integers.
"""
pass
class ArchiveFilter(DateTimeFilterMixin, FilterSet):
class Meta:
model = Archive
fields = {
'id': ['exact', 'in', 'lte', 'gte'],
'ticket': ['exact', 'in', 'lte', 'gte'],
'id': ['exact', 'lte', 'gte'],
'ticket': ['exact', 'lte', 'gte'],
'time_create': ['exact', 'lte', 'gte']
}
class TicketFilter(DateTimeFilterMixin, FilterSet):
number = NumberFilter(
number = NumberInFilter(
field_name='number',
widget=widgets.CSVWidget(),
widget=TextareaCSVWidget(),
)
user = CharFilter(
field_name='user__username'

View File

@@ -11,3 +11,15 @@ class IsGuestUpload(permissions.BasePermission):
return True
return request.user.is_authenticated
class IsGuestCheckUrls(permissions.BasePermission):
"""
Special permission class for the ability to upload attachments
to an unauthorized user using a ticket token
"""
def has_permission(self, request, view):
if request.method in ('HEAD', 'OPTIONS', 'GET',):
return True
return request.user.is_authenticated

View File

@@ -6,13 +6,13 @@ from drf_spectacular.openapi import OpenApiTypes
from collector.models import Archive, Platform, Ticket
@extend_schema_field(OpenApiTypes.NUMBER)
@extend_schema_field(OpenApiTypes.INT)
class TimestampField(serializers.Field):
def to_representation(self, value) -> int:
return value.timestamp()
@extend_schema_field(OpenApiTypes.NUMBER)
@extend_schema_field(OpenApiTypes.INT)
class JsTimestampField(serializers.Field):
def to_representation(self, value) -> int:
return round(value.timestamp()*1000)
@@ -26,7 +26,7 @@ class PublicArchiveUploadSerializer(serializers.ModelSerializer):
class ArchiveSerializer(serializers.ModelSerializer):
time_create = JsTimestampField(read_only=True)
time_create = serializers.DateTimeField(read_only=True)
class Meta:
model = Archive
@@ -41,8 +41,8 @@ class PlatformSerializer(serializers.ModelSerializer):
class TicketSerializer(serializers.ModelSerializer):
time_create = JsTimestampField(read_only=True)
time_update = JsTimestampField(read_only=True)
time_create = serializers.DateTimeField(read_only=True)
time_update = serializers.DateTimeField(read_only=True)
token = serializers.UUIDField(read_only=True)
user = serializers.ReadOnlyField(source='user.username')
@@ -59,3 +59,33 @@ class TicketSerializer(serializers.ModelSerializer):
'time_update',
'user'
]
class StorageInfoSerializer(serializers.Serializer):
total = serializers.IntegerField(read_only=True)
used = serializers.IntegerField(read_only=True)
free = serializers.IntegerField(read_only=True)
used_percent = serializers.IntegerField(read_only=True)
status = serializers.CharField(read_only=True)
class TokenStateRootSerializer(serializers.Serializer):
info = serializers.CharField(read_only=True, default="manual message")
class TokenStateSerializer(serializers.ModelSerializer):
token = serializers.UUIDField(read_only=True)
attempts = serializers.IntegerField(read_only=True)
resolved = serializers.BooleanField(read_only=True)
class Meta:
model = Ticket
fields = [
'token',
'attempts',
'resolved'
]
class AppHealthInfoSerializer(serializers.Serializer):
status = serializers.CharField(read_only=True, default="ok")

View File

@@ -29,3 +29,7 @@ class TestUrls(TestCase):
def test_detail_platform_url_is_resolved(self):
url = reverse('collector_api:platform-detail', args=['vm6'])
self.assertEquals(resolve(url).func.cls, views.PlatformViewSet)
def test_storage_info_url_is_resolved(self):
url = reverse('collector_api:storage-info')
self.assertEquals(resolve(url).func.cls, views.StorageInfo)

View File

@@ -1,12 +1,13 @@
from pathlib import Path
from django.core.files.base import ContentFile
from django.conf import settings
from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from account.models import User
from collector.models import Archive, Platform, Ticket

View File

@@ -15,7 +15,31 @@ router.register(r'archives', views.ArchiveViewSet)
router.register(r'platforms', views.PlatformViewSet)
router.register(r'tickets', views.TicketViewSet)
check_urlpatterns = [
path(
'health/',
views.AppHealthInfo.as_view(),
name='app-info'
),
path(
'storage/',
views.StorageInfo.as_view(),
name='storage-info'
),
path(
'token/',
views.TokenStateRoot.as_view(),
name='token-root'
),
path(
'token/<str:token>',
views.TokenStateInfo.as_view(),
name='token-info'
),
]
urlpatterns = [
# CRUD:
path('v1/', include(router.urls)),
path('v1/check/', include(check_urlpatterns)),
]

View File

@@ -1,4 +1,5 @@
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.conf import settings
from rest_framework import status
# from rest_framework.decorators import action
@@ -9,26 +10,41 @@ from rest_framework.parsers import (
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import viewsets
from rest_framework import filters
from rest_framework import filters, generics, views, viewsets
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema, extend_schema_view
from drf_spectacular.openapi import OpenApiParameter
from collector.models import Archive, Ticket, Platform
from collector.utils.helpers import get_mount_fs_info
from .filters import ArchiveFilter, TicketFilter
from .permissions import IsGuestUpload
from .permissions import IsGuestUpload, IsGuestCheckUrls
from .serializers import (
PublicArchiveUploadSerializer,
ArchiveSerializer,
PlatformSerializer,
TicketSerializer
TicketSerializer,
StorageInfoSerializer,
TokenStateSerializer,
AppHealthInfoSerializer,
TokenStateRootSerializer,
)
@extend_schema_view(
list=extend_schema(
description='Archives that contains log files for checking',
summary='Show all archives'
),
create=extend_schema(summary='Create (upload) a new archive'),
retrieve=extend_schema(summary='Show archive by id'),
update=extend_schema(summary='Update archive'),
partial_update=extend_schema(summary='Update archive field'),
destroy=extend_schema(summary='Delete archive'),
)
class ArchiveViewSet(viewsets.ModelViewSet):
queryset = Archive.objects.order_by('-time_create')
serializer_class = ArchiveSerializer
@@ -67,29 +83,35 @@ class ArchiveViewSet(viewsets.ModelViewSet):
bound_ticket = Ticket.objects.get(token=upload_token)
if bound_ticket.resolved:
return Response(
{'error': f'ticket {bound_ticket} already resolved'},
{'detail': f'ticket {bound_ticket} already resolved'},
status=status.HTTP_423_LOCKED
)
if bound_ticket.attempts <= 0:
return Response(
{'error': f'token {upload_token} expired'},
{'detail': f'token {upload_token} expired'},
status=status.HTTP_423_LOCKED
)
bound_ticket.attempts -= 1
bound_ticket.save()
# ? mixin bound ticket number to request.data from user
try:
request.data['ticket'] = bound_ticket.number
except AttributeError:
return Response(
{'detail': 'Bad Request'},
status=status.HTTP_400_BAD_REQUEST
)
# ? change serializer for guest user
if not request.user.is_authenticated:
self.serializer_class = PublicArchiveUploadSerializer
except (ValidationError, ObjectDoesNotExist,):
return Response(
{'error': f'token {upload_token} is not valid'},
{'detail': f'token {upload_token} is not valid'},
status=status.HTTP_403_FORBIDDEN
)
else:
return Response(
{'error': 'Header Upload-Token is required'},
{'detail': 'Header Upload-Token is required'},
status=status.HTTP_401_UNAUTHORIZED
)
# ! default create method:
@@ -104,6 +126,17 @@ class ArchiveViewSet(viewsets.ModelViewSet):
)
@extend_schema_view(
list=extend_schema(
description='Platforms are needed to relative the ticket and software',
summary='Show all platforms'
),
create=extend_schema(summary='Create a new platform'),
retrieve=extend_schema(summary='Show platform by internal name'),
update=extend_schema(summary='Update platform'),
partial_update=extend_schema(summary='Update platform field'),
destroy=extend_schema(summary='Delete platform'),
)
class PlatformViewSet(viewsets.ModelViewSet):
queryset = Platform.objects.all()
lookup_field = 'name'
@@ -111,6 +144,17 @@ class PlatformViewSet(viewsets.ModelViewSet):
permission_classes = (IsAuthenticated, )
@extend_schema_view(
list=extend_schema(
description='Tickets that will be related with the uploaded archive',
summary='Show all tickets'
),
create=extend_schema(summary='Create a new ticket'),
retrieve=extend_schema(summary='Show ticket by number'),
update=extend_schema(summary='Update ticket'),
partial_update=extend_schema(summary='Update ticket field'),
destroy=extend_schema(summary='Delete ticket'),
)
class TicketViewSet(viewsets.ModelViewSet):
queryset = Ticket.objects.order_by('-time_create')
lookup_field = 'number'
@@ -122,3 +166,53 @@ class TicketViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class StorageInfo(views.APIView):
"""Info about storage total/used/free space"""
permission_classes = (IsAuthenticated, )
@extend_schema(
responses=StorageInfoSerializer,
summary='Show storage space in bytes'
)
def get(self, request):
return Response(get_mount_fs_info(settings.DATA_DIR))
class TokenStateRoot(views.APIView):
""" Show the message of a specific upload token URL"""
permission_classes = (IsGuestCheckUrls,)
@extend_schema(
responses=TokenStateRootSerializer,
summary='Show info message how get token status'
)
def get(self, request):
message = "to find out the status of the token, place it in the URL"
return Response({"detail": message}, status=status.HTTP_303_SEE_OTHER)
@extend_schema_view(
get=extend_schema(
summary='Show the status of a specific upload token'
)
)
class TokenStateInfo(generics.RetrieveAPIView):
""" Show the status of a specific upload token"""
queryset = Ticket.objects.order_by('-time_create')
lookup_field = 'token'
serializer_class = TokenStateSerializer
permission_classes = (IsGuestCheckUrls,)
class AppHealthInfo(views.APIView):
permission_classes = (IsGuestCheckUrls,)
@extend_schema(
responses=AppHealthInfoSerializer,
summary='Show app status'
)
def get(self, request):
return Response({'status': 'ok'}, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,16 @@
from django.conf import settings
from . import __author__
from .utils.helpers import get_mount_fs_info
def metadata(request):
return {
"version": settings.VERSION,
"environment": settings.ENVIRONMENT,
"author": __author__,
}
def storage_info(request):
return {'storage': get_mount_fs_info(settings.DATA_DIR)}

View File

@@ -0,0 +1,96 @@
import os
import logging
from django.core.management.base import BaseCommand
from django.apps import apps
from django.db.models import Q
from django.conf import settings
from django.db.models import FileField
logger = logging.getLogger(__name__)
logging.config.dictConfig({
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'console': {
'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s'
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'console'
},
},
'loggers': {
'': {
'level': 'INFO',
'handlers': ['console']
}
}
})
class Command(BaseCommand):
# HELP MESSAGE:
help_part1 = 'This command deletes all media files from'
help_part2 = 'the MEDIA_ROOT directory which are no longer referenced'
help_part3 = 'by any of the models from installed_apps'
help = f'{help_part1} {help_part2} {help_part3}'
def handle(self, *args, **options):
logger.info('Start cleanup storage....')
all_models = apps.get_models()
physical_files = set()
db_files = set()
# Get all files from the database
logger.info('Get all files from the database....')
for model in all_models:
file_fields = []
filters = Q()
for f_ in model._meta.fields:
if isinstance(f_, FileField):
file_fields.append(f_.name)
is_null = {'{}__isnull'.format(f_.name): True}
is_empty = {'{}__exact'.format(f_.name): ''}
filters &= Q(**is_null) | Q(**is_empty)
# only retrieve the models which have non-empty,
# non-null file fields
if file_fields:
files = model.objects.exclude(filters).values_list(
*file_fields,
flat=True
).distinct()
db_files.update(files)
logger.info(f'Find: {len(db_files)} files from the database')
# Get all files from the MEDIA_ROOT, recursively
logger.info('Get all files from the MEDIA_ROOT, recursively....')
media_root = getattr(settings, 'MEDIA_ROOT', None)
if media_root is not None:
for relative_root, dirs, files in os.walk(media_root):
for file_ in files:
# Compute the relative file path to the media directory,
# so it can be compared to the values from the db
relative_file = os.path.join(
os.path.relpath(relative_root, media_root), file_
)
physical_files.add(relative_file)
logger.info(f'Find: {len(physical_files)} files from the MEDIA_ROOT')
# Compute the difference and delete those files
logger.info('Compute the difference and delete those files....')
deletables = physical_files - db_files
logger.info(f'Find: {len(deletables)} orphan files')
if deletables:
for file_ in deletables:
logger.info(f"Delete orphan file: {file_}")
os.remove(os.path.join(media_root, file_))
# Bottom-up - delete all empty folders
logger.info('Bottom-up - delete all empty folders....')
for relative_root, dirs, files in os.walk(
media_root, topdown=False):
for dir_ in dirs:
if not os.listdir(os.path.join(relative_root, dir_)):
os.rmdir(os.path.join(relative_root, dir_))
logger.info('Done! Storage has been cleaned up')
logger.info('Done! Nothing to delete')

View File

@@ -0,0 +1,24 @@
from django.http import HttpResponse
from django.template import loader
class HttpResponseNotAllowedMiddleware:
def __init__(self, get_response):
self.get_response = get_response
# One-time configuration and initialization.
def __call__(self, request):
# Code to be executed for each request before
# the view (and later middleware) are called.
response = self.get_response(request)
# Code to be executed for each request/response after
# the view is called.
if response.status_code == 405:
context = {}
template = loader.get_template('405.html')
return HttpResponse(template.render(context, request))
return response

View File

@@ -47,7 +47,7 @@ class Migration(migrations.Migration):
name='Archive',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to=collector.utils.logs_dir_path)),
('file', models.FileField(upload_to=collector.utils.helpers.logs_dir_path)),
('size', models.BigIntegerField(editable=False)),
('md5', models.CharField(editable=False, max_length=1024)),
('time_create', models.DateTimeField(auto_now_add=True)),

View File

@@ -2,11 +2,12 @@ import uuid
import hashlib
from django.core.validators import MaxValueValidator, MinValueValidator
from django.contrib.auth.models import User
from django.db import models
from django.urls import reverse
from .utils import logs_dir_path
from account.models import User
from .utils.helpers import logs_dir_path
class Archive(models.Model):

View File

@@ -1,6 +0,0 @@
const tooltipTriggerList = document.querySelectorAll(
'[data-bs-toggle="tooltip"]'
)
const tooltipList = [...tooltipTriggerList].map(
(tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl)
)

View File

@@ -0,0 +1,83 @@
// formatted byte size to human readable:
const sizify = (value) => {
let ext = ''
if (value < 512000) {
value = value / 1024.0
ext = 'KB'
} else if (value < 4194304000) {
value = value / 1048576.0
ext = 'MB'
} else {
value = value / 1073741824.0
ext = 'GB'
};
return `${Math.round(value * 10) / 10} ${ext}`
};
// fix update bootstrap tooltip func:
const updateBsTooltip = (instance) => {
let tt = bootstrap.Tooltip.getInstance(instance);
tt.dispose();
bootstrap.Tooltip.getOrCreateInstance(instance);
};
// update storage info widget:
const updateStorageInfo = () => {
// set storage items vars:
let storageIcon = $("#storage_icon")
let storageProgressContainer = $("#storage_progress_container")
let storage_progress = $("#storage_progress")
// set API url:
const storageUrl = storage_progress.attr("storage-url")
$.ajax({
type: "GET",
url: storageUrl,
headers: {
"Content-Type":"application/json"
},
dataType: "json",
success: function (data, textStatus, jqXHR) {
// JSON answer:
let storage = data;
// set updated fields:
let storageInfoNewFields = [
`Total: ${sizify(storage.total)}`,
'<br>',
`Used: ${sizify(storage.used)}`,
'<br>',
`Free: ${sizify(storage.free)}`
].join('')
// progress bar update:
storage_progress.attr("style", `width:${storage.used_percent}%`)
// progress bar color update:
if (storage.used_percent > 90) {
storage_progress.attr("class", "progress-bar bg-danger");
} else if (storage.used_percent > 80) {
storage_progress.attr("class", "progress-bar bg-warning");
} else {
storage_progress.attr("class", "progress-bar bg-success");
};
// tooltips update:
storageIcon.attr("data-bs-title", `Storage used: ${storage.used_percent}%`)
storageProgressContainer.attr("data-bs-title", storageInfoNewFields)
updateBsTooltip(storageIcon)
updateBsTooltip(storageProgressContainer)
}
});
};
const genAlertMessage = (
alertMessage='Success message',
alertType='success',
extraClass=''
) => {
let alertMessageHTML = [
`<div class="alert alert-${alertType} alert-dismissible ${extraClass}" role="alert">`,
` <div>${alertMessage}</div>`,
' <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
'</div>'
].join('')
return alertMessageHTML
}
export {sizify, updateBsTooltip, updateStorageInfo, genAlertMessage};

View File

@@ -1,3 +1,6 @@
import {updateStorageInfo} from "./helpers.js";
$(function () {
console.log("JQ is ready to work");
@@ -24,8 +27,11 @@ $(function () {
success: function (data, textStatus, jqXHR) {
console.log(jqXHR.status);
$(archiveListElement).hide(1500);
setTimeout(() => {
updateStorageInfo();
}, 3000);
},
error: function (data, textStatus, jqXHR) {
error: function (jqXHR, textStatus, errorThrown) {
console.log(jqXHR.status);
}
});
@@ -57,7 +63,7 @@ $(function () {
success: function (data, textStatus, jqXHR) {
console.log(jqXHR.status)
},
error: function (data, textStatus, jqXHR) {
error: function (jqXHR, textStatus, errorThrown) {
console.log(data)
console.log(jqXHR.status)
}
@@ -82,11 +88,14 @@ $(function () {
console.log(jqXHR.status);
if (delDiv.length) {
delDiv.hide(1500);
setTimeout(() => {
updateStorageInfo();
}, 3000);
} else {
window.location.href = redirectUrl;
}
},
error: function (data, textStatus, jqXHR) {
error: function (jqXHR, textStatus, errorThrown) {
console.log(jqXHR.status);
}
});

View File

@@ -1,21 +1,33 @@
$(function () {
const uploadForm = document.getElementById('upload_form');
const input_file = document.getElementById('id_file');
const progress_bar = document.getElementById('progress');
const alert_container = document.getElementById('alert');
import {updateStorageInfo, genAlertMessage} from "./helpers.js";
$(function () {
// set global variables:
const uploadForm = document.getElementById('upload_form');
const inputFile = document.getElementById('id_file');
const progressBar = document.getElementById('progress');
const alertContainer = document.getElementById('alert');
// get upload form:
$("#upload_form").submit(function(e){
e.preventDefault();
$form = $(this)
// collect request data:
let formData = new FormData(this);
let upload_token = formData.get("token")
const media_data = input_file.files[0];
if(media_data != null){
progress_bar.classList.remove("not-visible");
let uploadToken = formData.get("token")
// generate the URL for token validation:
let tokenStatusUrl = [
progressBar.getAttribute('token-status-url'),
uploadToken
].join('')
// init upload file func:
const uploadFile = () => {
// toggle visible progress bar:
const mediaData = inputFile.files[0];
if(mediaData != null){
progressBar.classList.remove("not-visible");
}
// upload file (chunk) xrh request:
$.ajax({
type: 'POST',
url: progress_bar.getAttribute("upload-url"),
url: progressBar.getAttribute("upload-url"),
data: formData,
dataType: 'json',
xhr:function(){
@@ -25,7 +37,7 @@ $(function () {
if(e.lengthComputable){
const percentProgress = (e.loaded/e.total)*100;
console.log(percentProgress);
progress_bar.innerHTML = `
progressBar.innerHTML = `
<div
class="progress-bar progress-bar-striped progress-bar-animated"
style="width: ${percentProgress}%"
@@ -35,47 +47,92 @@ $(function () {
});
return xhr
},
// set auth method:
beforeSend: function(xhr) {
if (upload_token) {
xhr.setRequestHeader("Upload-Token", upload_token);
if (uploadToken) {
xhr.setRequestHeader("Upload-Token", uploadToken);
}
},
success: function(data, textStatus, jqXHR){
console.log(jqXHR.status);
let type = "success";
alert_container.innerHTML = [
`<div class="alert alert-${type} alert-dismissible col-lg-6" role="alert">`,
` <div>The file has been successfully uploaded to the server. Thank you!</div>`,
' <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
'</div>'
].join('')
alertContainer.innerHTML = genAlertMessage(
'The file has been successfully uploaded to the server. Thank you!',
'success',
'col-lg-6'
)
uploadForm.reset()
progress_bar.classList.add('not-visible')
progressBar.classList.add('not-visible')
try {
updateStorageInfo();
} catch (error) {
console.log(error)
};
},
error: function(jqXHR, textStatus, errorThrown){
console.log(jqXHR);
let type = "danger";
let error_message = "Unexpected error. Try again please"
if (jqXHR.status === 423) {
error_message = `Error ${jqXHR.status}: ${jqXHR.responseJSON.error}`
}
if (jqXHR.status === 403) {
error_message = `Error ${jqXHR.status}: ${jqXHR.responseJSON.error}`
let errorMessage = "Unexpected error. Try again please"
if (jqXHR.status === 423 || jqXHR.status === 403) {
errorMessage = `Error ${jqXHR.status} <br> ${jqXHR.responseJSON.detail}`
}
if (jqXHR.status === 401) {
error_message = 'The token field cannot be empty'
errorMessage = `Error ${jqXHR.status} <br> The token field cannot be empty`
}
alert_container.innerHTML = [
`<div class="alert alert-${type} alert-dismissible col-lg-6" role="alert">`,
` <div>${error_message}</div>`,
' <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
'</div>'
].join('')
progress_bar.classList.add('not-visible')
if (jqXHR.status === 400) {
errorMessage = `Error ${jqXHR.status} <br> ${jqXHR.responseJSON.detail}`
}
alertContainer.innerHTML = genAlertMessage(
errorMessage,
'danger',
'col-lg-6'
)
progressBar.classList.add('not-visible')
},
cache: false,
contentType: false,
processData: false,
});
}
// check token status and upload file if token valid:
$.ajax({
type: 'GET',
url: tokenStatusUrl,
dataType: "json",
success: function (data, textStatus, jqXHR) {
if (data.attempts === 0) {
alertContainer.innerHTML = genAlertMessage(
`Error 423 <br> Token: ${uploadToken} expired`,
'danger',
'col-lg-6'
);
}
else if (data.resolved === true) {
alertContainer.innerHTML = genAlertMessage(
`Error 423 <br> Ticket bound with token: ${uploadToken} <br> already resolved`,
'danger',
'col-lg-6'
);
} else {
alertContainer.innerHTML = genAlertMessage(
`Token: ${uploadToken} is valid. <br> Starting to upload...`,
'success',
'col-lg-6'
);
uploadFile();
};
},
error: function(jqXHR){
if (jqXHR.responseJSON.detail) {
alertContainer.innerHTML = genAlertMessage(
`Error 403 <br> Token: ${uploadToken} is not valid`,
'danger',
'col-lg-6'
)
} else {
alertContainer.innerHTML = genAlertMessage(
`Unexpected error. Try again please`,
'danger',
'col-lg-6'
)
}
},
});
});
});

View File

@@ -12,7 +12,7 @@
<div class="container mt-3">
<div class="card">
<div class="card-header">
<h3>Archive upload:</h3>
<h3 class="card-title">Archive upload:</h3>
</div>
<div class="card-body">
<div class="container">
@@ -35,7 +35,8 @@
<div
id="progress"
upload-url="{% url 'collector_api:archive-list' %}"
class="progress"
token-status-url="{% url 'collector_api:token-root' %}"
class="progress not-visible"
role="progressbar"
aria-label="Example 20px high"
aria-valuenow="25"
@@ -51,5 +52,5 @@
{% endblock main %}
{% block jquery %}
<script src="{% static 'collector/js/jq.upload.progress.js' %}"></script>
<script type="module" src="{% static 'collector/js/jq.upload.progress.js' %}"></script>
{% endblock jquery %}

View File

@@ -6,7 +6,7 @@
{% endblock collector_head %}
{% block collector_content %}
<header>
<header class="sticky-top">
<section>
{% include 'includes/navigation.html' %}
</section>
@@ -16,9 +16,9 @@
{% block main %}{% endblock main %}
</section>
</main>
<footer>
<footer class="footer mt-auto">
<section>
{% block footer %}{% endblock footer %}
{% include 'includes/footer.html' %}
</section>
</footer>
{% endblock collector_content %}

View File

@@ -9,7 +9,7 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="LabelTicket">
Delete ticket #{{ ticket.number }} ?
Delete ticket {{ ticket.number }}?
</h5>
<button
type="button"

View File

@@ -2,26 +2,22 @@
<li
id="li-archive-{{ archive.id }}"
class="list-group-item list-group-item-action">
<smal>
<b>File:</b>
<span style="word-wrap: break-word">{{ archive.file.name|clean_filename }}</span>
</small>
<small>
<br>
<b>MD5:</b>
<span style="word-wrap: break-word">{{ archive.md5 }}</span>
</small>
<small>
<br>
<b>Uploaded:</b>
<span style="word-wrap: break-word">{{ archive.time_update|date:"D d.m.y H:i" }}</span>
</small>
<br>
<small>
<b>Size:</b>
<span style="word-wrap: break-word">{{ archive.size|filesizeformat }}</span>
</small>
<div class="row">
<div class="input-group input-group-sm mb-1">
<span class="input-group-text" style="width: 60px">File</span>
<input type="text" class="form-control" value="{{ archive.file.name|clean_filename }}" disabled readonly>
</div>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text" style="width: 60px">MD5</span>
<input type="text" class="form-control" value="{{ archive.md5 }}" disabled readonly>
</div>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text" style="width: 60px">Time</span>
<input type="text" class="form-control" value="{{ archive.time_update|date:"D d.m.y H:i" }}" disabled readonly>
</div>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text" style="width: 60px">Size</span>
<input type="text" class="form-control" value="{{ archive.size|filesizeformat }}" disabled readonly>
</div>
<div class="d-flex justify-content-sm-start justify-content-between" >
<a
class="btn btn-outline-success btn-sm mt-2"
@@ -34,5 +30,4 @@
data-bs-target="#modal-archive-del-{{ archive.id }}"
><i class="bi bi-trash"></i> DEL</button>
</div>
</div>
</li>

View File

@@ -1,9 +1,9 @@
<div class="d-sm-flex w-100 justify-content-between mb-2">
<h4 class="card-title mb-1">Ticket: {{ ticket.number }}</h4>
<h4 class="card-title mb-1"><i class="bi bi-bookmarks"></i> Ticket: {{ ticket.number }}</h4>
<small><i class="bi bi-clock-history"></i> {{ ticket.time_create|date:"D d.m.y H:i" }}</small>
</div>
<div class="form-check form-switch form-check-reverse d-flex w-100 justify-content-left">
<label class="form-check-label" for="ticket-state">Resolved:</label>
<div class="form-check form-switch form-check-reverse d-flex w-100 justify-content-left mb-2">
<label class="form-check-label" for="ticket-state"><i class="bi bi-award"></i> Resolved:</label>
<input
class="form-check-input ms-2 mb-2"
type="checkbox"
@@ -14,13 +14,29 @@
{% if ticket.resolved %} checked {% endif %}>
</div>
<div class="col-xl-6 mb-2">
<h6 class="card-title mb-1">Platform: {{ ticket.platform.pretty_name }}</h6>
<h6 class="card-title mb-3">Owner: {{ ticket.user.username }}</h6>
<h6 class="card-title mb-1">
<small class="me-1"><i class="bi bi-box"></i></small>
Platform: {{ ticket.platform.pretty_name }}
</h6>
<h6 class="card-title mb-1">
<small class="me-1"><i class="bi bi-person-circle"></i></small>
User: {{ ticket.user.username }}
</h6>
<h6 class="card-title mb-3">
<small class="me-1"><i class="bi bi-clock-history"></i></small>
Last modified: {{ ticket.time_update|date:"d.m.y H:i" }}
</h6>
<!-- Token -->
<div class="input-group input-group mb-3">
<span class="input-group-text" id="inputGroup-sizing-sm"><i class="bi bi-key"></i></span>
<!--Token attempts-->
<span class="input-group-text" id="inputGroup-sizing-sm">
<a
href="{% url 'collector:update' ticket.platform.name ticket.number %}"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-title="Attempts for upload archive"
>
<span
class="badge
{% if ticket.attempts <= 0 %}
@@ -31,6 +47,7 @@
bg-primary
{% endif %} rounded-pill">{{ ticket.attempts }}
</span>
</a>
</span>
<input
name="ticket-token"

View File

@@ -0,0 +1,39 @@
<li class="nav-item col-lg-auto d-flex align-items-center">
<i
class="nav-link me-1 bi bi-sd-card"
aria-current="page"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-title="Storage used: {{ storage.used_percent }}%"
>
</i>
<div
class="progress"
role="progressbar"
aria-label="storage used"
aria-valuenow="25"
aria-valuemin="0"
aria-valuemax="100"
style="width: 125px"
data-bs-toggle="tooltip"
data-bs-html="true"
data-bs-placement="bottom"
data-bs-title="
Total: {{ storage.total|filesizeformat }}
<br>
Used: {{ storage.used|filesizeformat }}
<br>
Free: {{ storage.free|filesizeformat }}
"
>
<div
class="progress-bar
{% if storage.used_percent > 90 %} bg-danger
{% elif storage.used_percent > 80 %} bg-warning
{% else %} bg-success
{% endif %}"
style="width: {{ storage.used_percent }}%"
>
</div>
</div>
</li>

View File

@@ -4,7 +4,6 @@
{% block title %} {{ title }} {% endblock title %}
{% block main %}
<div class="container mt-3">
<div class="row">
{% csrf_token %}
<div class="card">
<div class="card-body" aria-current="true">
@@ -46,7 +45,6 @@
</div>
</div>
</div>
</div>
<!-- Modal Archive -->
{% for archive in ticket.archive_set.all %}
{% include 'collector/includes/modal_archive.html' %}
@@ -56,5 +54,5 @@
</div>
{% endblock main %}
{% block jquery %}
<script src="{% static 'collector/js/jq.ticket.detail.js' %}"></script>
<script type="module" src="{% static 'collector/js/jq.ticket.detail.js' %}"></script>
{% endblock jquery %}

View File

@@ -6,7 +6,7 @@
<div class="container mt-3">
<div class="card">
<div class="card-header">
<h3>Ticket:</h3>
<h3 class="card-title">Ticket:</h3>
</div>
<div class="card-body">
{% crispy form %}

View File

@@ -1,22 +0,0 @@
{% extends 'collector/base.html' %}
{% load static %}
{% block title %} {{ title }} {% endblock title %}
{% block content %}
<div class="container mt-3">
<div class="row">
<form method="post" action="{% url 'collector:delete' ticket.number %}">
{% csrf_token %}
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>Cancel</button>
<button
type="submit"
class="btn btn-danger btn-archive-eraser"
data-bs-dismiss="modal"
>Delete</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -86,9 +86,6 @@
{% include 'collector/includes/pagination.html' %}
</div>
{% endblock main %}
{% block bs %}
<script src="{% static 'collector/js/bs.tooltip.js' %}"></script>
{% endblock bs %}
{% block jquery %}
<script src="{% static 'collector/js/jq.ticket.detail.js' %}"></script>
<script type="module" src="{% static 'collector/js/jq.ticket.detail.js' %}"></script>
{% endblock jquery %}

View File

@@ -1,9 +1,9 @@
from pathlib import Path
from django.test import TestCase
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.conf import settings
from account.models import User
from collector.models import Platform, Ticket, Archive

View File

@@ -1,6 +1,7 @@
from django.test import TestCase
from django.urls import resolve, reverse
from django.contrib.auth.models import User
from account.models import User
from collector import views
from collector.models import Ticket, Platform

View File

@@ -1,6 +1,7 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth.models import User
from account.models import User
from collector.models import Ticket, Platform

View File

@@ -1,43 +0,0 @@
def logs_dir_path(instance, filename):
"""
file will be uploaded to
MEDIA_ROOT_FOR_SENSITIVE_FILES/<ticket-token>/<filename>
"""
return f'{instance.ticket.number}/{filename}'
def sizify(value: int) -> str:
"""Simple kb/mb/gb size snippet for admin panel custom field:
Args:
value (int): size of file from Filefield
Returns:
str: format human readable size like 4.2 Gb
"""
if value < 512000:
value = value / 1024.0
ext = 'Kb'
elif value < 4194304000:
value = value / 1048576.0
ext = 'Mb'
else:
value = value / 1073741824.0
ext = 'Gb'
return f'{round(value, 2)} {ext}'
class PageTitleViewMixin:
title = 'Collector'
def get_title(self, *args, **kwargs):
"""
Return the class title attr by default,
but you can override this method to further customize
"""
return self.title
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = self.get_title()
return context

View File

@@ -0,0 +1,61 @@
import shutil
import pathlib
def logs_dir_path(instance, filename: str) -> str:
"""
file will be uploaded to
MEDIA_ROOT/view/<filename>
"""
return f'{instance.ticket.number}/{filename}'
def sizify(value: int) -> str:
"""Simple kb/mb/gb size snippet for admin panel custom field:
Args:
value (int): size of file from Filefield
Returns:
str: format human readable size like 4.2 Gb
"""
if value < 512000:
value = value / 1024.0
ext = 'KB'
elif value < 4194304000:
value = value / 1048576.0
ext = 'MB'
else:
value = value / 1073741824.0
ext = 'GB'
return f'{round(value, 1)} {ext}'
def get_mount_fs_info(path: type[pathlib.PosixPath]) -> dict:
"""
Get directory information for storing uploaded files.
Includes information total/used/free space on mount device
Args:
path (pathlib.PosixPath): path to storage dir
Returns:
dict: storage mount info
"""
mount_info: dict = {}
try:
mount_info = shutil.disk_usage(path)._asdict()
mount_info['used_percent'] = round(
mount_info['used'] / mount_info['total'] * 100,
)
mount_info['status'] = 'mount'
except Exception as error: # expected FileNotFoundError
mount_info = {
'total': 0,
'used': 0,
'free': 0,
'used_percent': 0,
'status': 'error',
'traceback': f'{error}'
}
return mount_info

View File

@@ -0,0 +1,23 @@
class ExtraContextMixin:
"""The class adds additional context
to all child view classes that inherit from it.
Overrides the get_context_data method for CBV
"""
title = 'Collector'
def get_title(self, *args, **kwargs):
"""
Return the class title attr by default,
but you can override this method to further customize
"""
return self.title
def get_context_data(self, **kwargs):
context = {}
try:
context = super().get_context_data(**kwargs)
except Exception:
pass
context['title'] = self.get_title()
return context

View File

@@ -1,27 +1,24 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import FileResponse
from django.http import FileResponse, Http404
from django.views import generic
from django.views.generic.detail import SingleObjectMixin
from django.db.models import Q
from django.shortcuts import render
from two_factor.views import OTPRequiredMixin
from .forms import TicketForm, ArchiveForm
from .models import Archive, Ticket
from .utils import PageTitleViewMixin
from .utils.mixins import ExtraContextMixin
class ArchiveUploadView(PageTitleViewMixin, generic.View):
class ArchiveUploadView(ExtraContextMixin, generic.TemplateView):
form_class = ArchiveForm()
template = 'collector/archive_upload.html',
template_name = 'collector/archive_upload.html'
def get(self, request):
return render(
request,
self.template,
context={'form': self.form_class}
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['form'] = self.form_class
return context
def get_title(self):
return f'{self.title} - upload'
@@ -38,10 +35,14 @@ class ArchiveHandlerView(
def get(self, request, path):
self.object = self.get_object()
try:
self.object.file.size
except FileNotFoundError:
raise Http404(f'File: {self.object.file} not found')
return FileResponse(self.object.file)
class CreateTicket(LoginRequiredMixin, PageTitleViewMixin, generic.CreateView):
class CreateTicket(LoginRequiredMixin, ExtraContextMixin, generic.CreateView):
model = Ticket
form_class = TicketForm
template_name = 'collector/ticket_create.html'
@@ -54,7 +55,7 @@ class CreateTicket(LoginRequiredMixin, PageTitleViewMixin, generic.CreateView):
return super().form_valid(form)
class UpdateTicket(LoginRequiredMixin, PageTitleViewMixin, generic.UpdateView):
class UpdateTicket(LoginRequiredMixin, ExtraContextMixin, generic.UpdateView):
model = Ticket
form_class = TicketForm
template_name = 'collector/ticket_create.html'
@@ -69,7 +70,7 @@ class UpdateTicket(LoginRequiredMixin, PageTitleViewMixin, generic.UpdateView):
return super().form_valid(form)
class ListAllTickets(LoginRequiredMixin, PageTitleViewMixin, generic.ListView):
class ListAllTickets(LoginRequiredMixin, ExtraContextMixin, generic.ListView):
model = Ticket
template_name = 'collector/tickets.html'
context_object_name = 'tickets'
@@ -98,7 +99,7 @@ class ListAllTickets(LoginRequiredMixin, PageTitleViewMixin, generic.ListView):
return super().get_queryset()
class ListPlatformTickets(LoginRequiredMixin, PageTitleViewMixin, generic.ListView): # noqa:E501
class ListPlatformTickets(LoginRequiredMixin, ExtraContextMixin, generic.ListView): # noqa:E501
model = Ticket
template_name = 'collector/tickets.html'
context_object_name = 'tickets'
@@ -114,7 +115,7 @@ class ListPlatformTickets(LoginRequiredMixin, PageTitleViewMixin, generic.ListVi
)
class DetailTicket(LoginRequiredMixin, PageTitleViewMixin, generic.DetailView):
class DetailTicket(LoginRequiredMixin, ExtraContextMixin, generic.DetailView):
model = Ticket
template_name = 'collector/ticket.html'
context_object_name = 'ticket'

View File

@@ -0,0 +1,29 @@
"""
An application for uploading archives with log files
for their subsequent download and check issues
that have arisen with software products.
The purpose of creating this application is
the ability to securely exchange and store log files containing sensitive data.
I have not found an application that would allow an unauthorized client
to upload data without providing him with authorization credentials.
You can use other applications for this,
such as Google cloud, Yandex cloud, DropBox etc, but in this case,
you do not have a tool that would allow you to automatically restrict uploads
later until you explicitly deny access to the shared link.
This app allows you to upload files using a unique token
associated with a support ticket.
This token has a limit on the number of file upload attempts.
Also, if the ticket is resolved, then the token is invalid.
"""
# █▀▄▀█ █▀▀ ▀█▀ ▄▀█ ▀
# █░▀░█ ██▄ ░█░ █▀█ ▄
# -------------------
__author__ = "MOIS3Y"
__credits__ = ["Stepan Zhukovsky"]
__license__ = "GPL v3.0"
__version__ = "0.1.0"
__maintainer__ = "Stepan Zhukovsky"
__email__ = "stepan@zhukovsky.me"
__status__ = "Production"

View File

@@ -2,36 +2,59 @@ import environ
from pathlib import Path
from datetime import timedelta
from . import __version__, __status__
# █▀█ █▀█ █▀█ ▀█▀ ▀
# █▀▄ █▄█ █▄█ ░█░ ▄
# -- -- -- -- -- --
env = environ.Env(
# set casting default value
DEBUG=(bool, False),
SECRET_KEY=(str, 'j9QGbvM9Z4otb47')
)
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# █▀▀ █▄░█ █░█ ▀
# ██▄ █░▀█ ▀▄▀ ▄
# -- -- -- -- --
# Set default environ variables:
env = environ.Env(
# set casting default value
VERSION=(str, __version__),
ENVIRONMENT=(str, __status__),
DEBUG=(bool, False),
SECRET_KEY=(str, 'j9QGbvM9Z4otb47'),
DATA_DIR=(Path, BASE_DIR / 'data'),
CSRF_TRUSTED_ORIGINS=(list, []),
ALLOWED_HOSTS=(list, ['*']),
TZ=(str, 'UTC'),
)
# Read .env file if exist:
environ.Env.read_env(BASE_DIR / '.env')
# █▀▀ █▀█ █▀█ █▀▀ ▀
# █▄▄ █▄█ █▀▄ ██▄ ▄
# -- -- -- -- -- -
VERSION = env('VERSION')
ENVIRONMENT = env('ENVIRONMENT')
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env('SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env('DEBUG')
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["*"])
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
# https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-trusted-origins
if not DEBUG:
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
# TODO: required for docker image
# CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=["*"])
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS")
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
@@ -66,6 +89,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'collector.middleware.HttpResponseNotAllowedMiddleware',
]
ROOT_URLCONF = 'logs_collector.urls'
@@ -77,10 +101,14 @@ TEMPLATES = [
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
# default:
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# collector:
'collector.context_processors.metadata',
'collector.context_processors.storage_info',
],
},
},
@@ -88,18 +116,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'logs_collector.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': env.db_url(
'SQLITE_URL',
default=f'sqlite:///{BASE_DIR / "db.sqlite3"}'
)
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
@@ -118,19 +134,22 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = env('TZ', default='UTC')
TIME_ZONE = env('TZ')
USE_I18N = True
USE_TZ = True
# █▀ ▀█▀ ▄▀█ ▀█▀ █ █▀▀ ▀
# ▄█ ░█░ █▀█ ░█░ █ █▄▄ ▄
# -- -- -- -- -- -- -- -
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
# Whitenoise:
@@ -138,20 +157,26 @@ USE_TZ = True
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'static'
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# █▀▄ ▄▀█ ▀█▀ ▄▀█ ▀
# █▄▀ █▀█ ░█░ █▀█ ▄
# -- -- -- -- -- --
MEDIA_ROOT = BASE_DIR / 'archives'
# Build paths inside the project for db and storage.
DATA_DIR = env('DATA_DIR')
# Create DATA_DIR ignore if exist:
Path(DATA_DIR).mkdir(parents=True, exist_ok=True)
# Custom file storage path
MEDIA_ROOT = DATA_DIR / 'archives'
STORAGES = {
# ...
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"location": MEDIA_ROOT,
"base_url": "/archives/",
"base_url": "/download/archives/",
},
},
"staticfiles": {
@@ -159,48 +184,76 @@ STORAGES = {
},
}
# █▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ ▄▀█ █▀ █▀▀ ▀
# █▄▀ █▀█ ░█░ █▀█ █▄█ █▀█ ▄█ ██▄ ▄
# -- -- -- -- -- -- -- -- -- -- --
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': env.db_url(
'DB_URL',
default=f'sqlite:///{DATA_DIR / "db.sqlite3"}'
)
}
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# █▀▀ ▀▄▀ ▀█▀ █▀▀ █▄░█ ▀█▀ █ █▀█ █▄░█ █▀ ▀
# ██▄ █░█ ░█░ ██▄ █░▀█ ░█░ █ █▄█ █░▀█ ▄█ ▄
# -- -- -- -- -- -- -- -- -- -- -- -- -- -
# django-crispy-forms and crispy-bootstrap5
# https://django-crispy-forms.readthedocs.io/en/latest/
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
# https://www.django-rest-framework.org/api-guide/settings/
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
],
'DEFAULT_PARSER_CLASSES': [
'rest_framework.parsers.JSONParser',
'rest_framework.renderers.BrowsableAPIRenderer',
'rest_framework.parsers.MultiPartParser'
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication'
# 'rest_framework.authentication.BasicAuthentication'
],
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend', ], # noqa:E501
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
# 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', # noqa:E501
# 'PAGE_SIZE': 3,
'DEFAULT_METADATA_CLASS': 'rest_framework.metadata.SimpleMetadata',
}
# https://drf-spectacular.readthedocs.io/en/latest/readme.html
# TODO: set environ vars config!
if DEBUG:
REST_FRAMEWORK.get(
'DEFAULT_RENDERER_CLASSES', []
).append('rest_framework.renderers.BrowsableAPIRenderer')
REST_FRAMEWORK.get(
'DEFAULT_PARSER_CLASSES', []
).append('rest_framework.renderers.BrowsableAPIRenderer')
# https://drf-spectacular.readthedocs.io/en/latest/readme.html
SPECTACULAR_SETTINGS = {
'TITLE': 'Logs collector API',
'DESCRIPTION': 'Collector of archives with log files for further analysis',
'VERSION': '0.1.0',
'VERSION': VERSION,
'SERVE_INCLUDE_SCHEMA': True,
'SERVE_PUBLIC': False,
"SWAGGER_UI_SETTINGS": {
"filter": True,
},
}
# https://django-rest-framework-simplejwt.readthedocs.io/en/latest/
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
@@ -241,6 +294,14 @@ SIMPLE_JWT = {
"SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", # noqa:E501
}
# ▄▀█ █░█ ▀█▀ █░█ ▀
# █▀█ █▄█ ░█░ █▀█ ▄
# -- -- -- -- -- --
LOGIN_URL = 'two_factor:login'
LOGIN_REDIRECT_URL = 'collector:index'
LOGOUT_REDIRECT_URL = 'two_factor:login'
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-user-model
AUTH_USER_MODEL = 'account.User'

View File

@@ -1,12 +1,7 @@
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView
)
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from two_factor.urls import urlpatterns as tf_urls
from account.utils import AdminSiteOTPRequiredMixinRedirectSetup
@@ -48,9 +43,4 @@ urlpatterns += [
SpectacularSwaggerView.as_view(url_name='schema'),
name='swagger-ui'
),
path(
'api/v1/schema/redoc/',
SpectacularRedocView.as_view(url_name='schema'),
name='redoc'
),
]

View File

@@ -0,0 +1,10 @@
{% extends 'errors.html' %}
{% load static %}
{% block title %} Logs Collector - CSRF error {% endblock title %}
{% block status_code %}403{% endblock status_code %}
{% block error_message %}
<p class="fs-3"> <span class="text-danger">Opps!</span> CSRF verification failed.</p>
<p class="lead">Request aborted</p>
{% endblock error_message %}

View File

@@ -0,0 +1,10 @@
{% extends 'errors.html' %}
{% load static %}
{% block title %} Logs Collector - Not Found {% endblock title %}
{% block status_code %}404{% endblock status_code %}
{% block error_message %}
<p class="fs-3"> <span class="text-danger">Opps!</span> Page not found.</p>
<p class="lead">The content you're looking for doesn't exist.</p>
{% endblock error_message %}

View File

@@ -0,0 +1,14 @@
{% extends 'errors.html' %}
{% load static %}
{% block title %} Logs Collector - Method not allowed {% endblock title %}
{% block status_code %}405{% endblock status_code %}
{% block error_message %}
<p class="fs-3"> <span class="text-danger">Opps!</span> Method not allowed</p>
<p class="lead">
Request method:
<span class="text-danger">{{ request.method }}</span>
isn't allowed for this URL
</p>
{% endblock error_message %}

View File

@@ -0,0 +1,10 @@
{% extends 'errors.html' %}
{% load static %}
{% block title %} Logs Collector - Server error {% endblock title %}
{% block status_code %}500{% endblock status_code %}
{% block error_message %}
<p class="fs-3"> <span class="text-danger">Opps!</span> Server error</p>
<p class="lead">Unexpected error, please try again or contact system admin </p>
{% endblock error_message %}

View File

@@ -34,16 +34,21 @@
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
>
{% block errors_head %}{% endblock errors_head %}
{% block collector_head %}{% endblock collector_head %}
{% block account_head %}{% endblock account_head %}
</head>
<body>
<body class="d-flex flex-column min-vh-100">
{% block http_errors %}{% endblock http_errors %}
{% block collector_content %}{% endblock collector_content %}
{% block account_content %}{% endblock account_content %}
<!-- BS dependences JS-->
<script src="{% static '/js/bootstrap.bundle.min.js' %}"></script>
<!-- Theme switcher JS-->
<script src="{% static '/js/bs.theme.mode.js' %}"></script>
<!-- BS tooltip JS-->
<script src="{% static '/js/bs.tooltip.js' %}"></script>
<!-- Apps JS-->
{% block collector_scripts %}{% endblock collector_scripts %}
{% block account_scripts %}{% endblock account_scripts %}
</body>

View File

@@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% load static %}
{% block errors_head %}
<title>{% block title %}{% endblock title %}</title>
{% endblock errors_head %}
{% block http_errors %}
<div class="d-flex align-items-center justify-content-center vh-100" >
<div class="text-center">
<h1 class="display-1 fw-bold">{% block status_code %}{% endblock status_code %}</h1>
{% block error_message %}{% endblock error_message %}
<a href="{% url 'collector:index' %}" class="btn btn-secondary">Go Home</a>
</div>
<!-- Theme switcher -->
<div class="dropdown position-fixed bottom-0 end-0 mb-3 me-3 bd-mode-toggle">
{% include 'includes/theme_switcher.html' %}
</div>
</div>
{% endblock http_errors %}

View File

@@ -0,0 +1,15 @@
<a class="navbar-brand" href="{% url 'collector:index' %}">
Logs Collector
<i class="bi bi-file-earmark-zip-fill"></i>
</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="collapse nav fields"
>
<span class="navbar-toggler-icon"></span>
</button>

View File

@@ -0,0 +1,57 @@
{% if request.user.is_authenticated %}
<li class="nav-item py-2 py-lg-1 col-12 col-lg-auto">
<div class="btn-group">
<a
type="button"
class="btn btn-outline-secondary"
href="{% url 'account:show_profile' %}"
>
<i class="bi bi-person-circle"></i> {{ request.user }}
</a>
<button
type="button"
class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if request.user.is_staff %}
<li>
<a class="dropdown-item" type="button" href="{% url 'admin:index' %}"
><i class="bi bi-shield-shaded"></i> Admin</a>
</li>
{% endif %}
<li>
<a
href="{% url 'two_factor:profile' %}"
class="dropdown-item"
type="button">
<i class="bi bi-dice-5"></i> 2FA
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<a
href="{% url 'account:logout' %}"
class="dropdown-item"
type="button"><i class="bi bi-door-closed"></i> Logout
</a>
</li>
</ul>
</div>
</li>
{% else %}
<li class="nav-item py-2 py-lg-1 col-12 col-lg-auto">
<div class="btn-group">
<a
type="button"
href="{% url 'two_factor:login' %}"
class="btn btn-outline-secondary"
>
<i class="bi bi-box-arrow-in-right"></i></i> Login
</a>
</div>
</li>
{% endif %}

View File

@@ -0,0 +1,41 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container">
<ul class="navbar-nav">
<li class="nav-item">
<button class="btn">
v{{ version }}
{% if environment != 'Production' %}
Staging: {{ environment }}
{% endif %}
</button>
</li>
<!-- Separator -->
<li class="nav-item py-2 py-lg-1 col-12 col-lg-auto">
<div class="vr d-none d-lg-flex h-100 mx-lg-2 text-white"></div>
<hr class="d-lg-none my-2 text-white-50">
</li>
<li class="nav-item">
<button class='btn'> © {{ author }} </button>
</li>
</ul>
{% if request.user.is_authenticated %}
<ul class="navbar-nav">
<li class="nav-item">
<li>
<a
class="btn"
type="button"
href="{% url 'swagger-ui' %}"
target="_blank"
data-bs-toggle="tooltip"
data-bs-html="true"
data-bs-placement="bottom"
data-bs-title="Swagger"
>
<i class="bi bi-braces-asterisk"></i> API
</a>
</li>
</ul>
{% endif %}
</div>
</nav>

View File

@@ -0,0 +1,47 @@
{% load collector_extras %}
{% get_platforms as platforms %}
<li class="nav-item dropdown">
<button
class="nav-link dropdown-toggle"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
><i class="bi bi-filter-circle"></i> Tickets
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" aria-current="page" href="{% url 'collector:create' %}">
<i class="bi bi-pencil-square"></i> Create ticket
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<a class="dropdown-item" aria-current="page" href="{% url 'collector:upload' %}">
<i class="bi bi-archive"></i>
Upload archive
</a>
<li><hr class="dropdown-divider" /></li>
{% for platform in platforms %}
<li>
<a
class="dropdown-item {% if request.resolver_match.kwargs.platform == platform.name %}active{% endif %}"
href="{{ platform.get_absolute_url }}"
>{{ platform.pretty_name}}
</a>
</li>
{% endfor %}
<li><hr class="dropdown-divider" /></li>
<li>
<a
class="dropdown-item {% if request.GET.resolved %}active{% endif %}"
href="{% url 'collector:tickets' %}?resolved=true">
<i class="bi bi-check-circle"></i> Resolved
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<a class="dropdown-item" href="{% url 'collector:tickets' %}">
<i class="bi bi-funnel"></i> Reset filter
</a>
</li>
</ul>
</li>

View File

@@ -1,158 +1,22 @@
{% load collector_extras %}
{% get_platforms as platforms %}
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<nav class="navbar navbar-expand-xl bg-body-tertiary">
<div class="container">
<a
class="navbar-brand"
href="{% url 'collector:index' %}">Logs Collector <i class="bi bi-file-earmark-zip-fill"></i>
</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Переключатель навигации"
>
<span class="navbar-toggler-icon"></span>
</button>
<!--Brand logo -->
{% include 'includes/brand.html' %}
<!-- Left fields -->
{% if request.user.is_authenticated %}
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto mb-2 mb-lg-0 me-md-auto">
<li class="nav-item dropdown">
<button
class="nav-link dropdown-toggle"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>Archives</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{% url 'collector:upload' %}">
<i class="bi bi-archive"></i> Upload archive
</a>
</li>
</ul>
</li>
<li class="nav-item dropdown">
<button
class="nav-link dropdown-toggle"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>Tickets</button>
<ul class="dropdown-menu">
{% for platform in platforms %}
<li>
<a
class="dropdown-item {% if request.resolver_match.kwargs.platform == platform.name %}active{% endif %}"
href="{{ platform.get_absolute_url }}"
>{{ platform.pretty_name}}
</a>
</li>
{% endfor %}
<li><hr class="dropdown-divider" /></li>
<li>
<a
class="dropdown-item {% if request.GET.resolved %}active{% endif %}"
href="{% url 'collector:tickets' %}?resolved=true">
<i class="bi bi-check-circle"></i> Resolved
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<a class="dropdown-item" href="{% url 'collector:tickets' %}">
<i class="bi bi-funnel"></i> Reset filter
</a>
</li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="{% url 'collector:create' %}">
<i class="bi bi-pencil-square"></i> New
</a>
</li>
<!-- Menu -->
{% include 'includes/menu.html' %}
<!-- Storage -->
{% include 'includes/storage.html' %}
</ul>
<!-- Search -->
<ul class="navbar-nav flex-row flex-wrap me-md-auto">
<li class="nav-item py-2 col-12 col-lg-auto">
<form class="d-flex" role="search" action="{% url 'collector:tickets' %}">
<input
class="form-control me-2"
type="search"
placeholder="Search"
aria-label="Search"
name="search"
/>
<button
class="btn btn-outline-success"
type="submit">
<i class="bi bi-search"></i>
</button>
</form>
</li>
</ul>
{% include 'includes/search.html' %}
<!-- Right fields -->
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
<!-- User settings -->
{% if request.user.is_authenticated %}
<li class="nav-item py-2 py-lg-1 col-12 col-lg-auto">
<div class="btn-group">
<button type="button" class="btn btn-outline-secondary">
<i class="bi bi-person-square"></i> {{ request.user }}
</button>
<button
type="button"
class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if request.user.is_staff %}
<li>
<a class="dropdown-item" type="button" href="{% url 'admin:index' %}"
><i class="bi bi-shield-shaded"></i> Admin</a>
</li>
{% endif %}
<li>
<a class="dropdown-item" type="button" href="{% url 'swagger-ui' %}"
><i class="bi bi-braces-asterisk"></i> Swagger</a>
</li>
<li>
<a
href="{% url 'two_factor:profile' %}"
class="dropdown-item"
type="button">
<i class="bi bi-gear"></i> Settings
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<a
href="{% url 'account:logout' %}"
class="dropdown-item"
type="button"><i class="bi bi-door-closed"></i> Logout
</a>
</li>
</ul>
</div>
</li>
{% else %}
<li class="nav-item py-2 py-lg-1 col-12 col-lg-auto">
<div class="btn-group">
<a
type="button"
href="{% url 'two_factor:login' %}"
class="btn btn-outline-secondary"
>
<i class="bi bi-box-arrow-in-right"></i></i> Login
</a>
</div>
</li>
{% endif %}
{% include 'includes/extra_menu.html' %}
<!-- Separator -->
<li class="nav-item py-2 py-lg-1 col-12 col-lg-auto">
<div class="vr d-none d-lg-flex h-100 mx-lg-2 text-white"></div>

View File

@@ -0,0 +1,21 @@
<ul class="navbar-nav flex-row flex-wrap me-md-auto">
<li class="nav-item py-2 col-12 col-lg-auto">
<form class="d-flex" role="search" action="{% url 'collector:tickets' %}">
<input
class="form-control me-2"
type="search"
placeholder="Search"
aria-label="Search"
name="search"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-title="Type the ticket number or comma-separated numbers"
/>
<button
class="btn btn-outline-success"
type="submit">
<i class="bi bi-search"></i>
</button>
</form>
</li>
</ul>

View File

@@ -0,0 +1,55 @@
{% if storage %}
<li class="nav-item col-lg-auto d-flex align-items-center">
<i
id="storage_icon"
class="nav-link me-1 bi bi-sd-card"
aria-current="page"
data-bs-toggle="tooltip"
data-bs-html="true"
data-bs-placement="bottom"
data-bs-title="
<span><u>STORAGE</u><span>
<br>
Used: {{ storage.used_percent }}%
<br>
Status:
<span class={% if storage.status == 'error' %}text-danger{% else %}text-success{% endif %}>
{{ storage.status }}
<span>
"
>
</i>
<div
id="storage_progress_container"
class="progress"
role="progressbar"
aria-label="storage used"
aria-valuenow="{{ storage.used_percent }}"
aria-valuemin="0"
aria-valuemax="100"
style="width: 125px"
data-bs-toggle="tooltip"
data-bs-html="true"
data-bs-placement="bottom"
data-bs-title="
Total: {{ storage.total|filesizeformat }}
<br>
Used: {{ storage.used|filesizeformat }}
<br>
Free: {{ storage.free|filesizeformat }}
"
>
<div
id="storage_progress"
class="progress-bar
{% if storage.used_percent > 90 %} bg-danger
{% elif storage.used_percent > 80 %} bg-warning
{% else %} bg-success
{% endif %}"
style="width: {{ storage.used_percent }}%"
storage-url="{% url 'collector_api:storage-info' %}"
>
</div>
</div>
</li>
{% endif %}

View File

@@ -1,15 +1,19 @@
{% extends "two_factor/_base.html" %}
{% block content_wrapper %}
<section>
<header>
{% block nav %}{% endblock nav %}
</header>
</section>
<section>
{% block nav %}{% endblock nav %}
</section>
</header>
<main>
<section>
<div class="container mt-3">
<div class="card">
<div class="card-header">
<h3 class="card-title">Two-Factor Authentication settings:</h3>
</div>
<div class="card-body">
<div class="text-center">
{% block content %}{% endblock %}
@@ -17,6 +21,12 @@
</div>
</div>
</div>
</main>
</section>
</main>
<footer class="footer mt-auto">
<section>
{% block footer_nav %}{% endblock footer_nav %}
</section>
</footer>
{% endblock %}

View File

@@ -33,3 +33,5 @@
<button class="btn btn-outline-primary" type="submit">{% trans "Generate Tokens" %}</button>
</form>
{% endblock %}
{% block footer_nav %}{% include 'includes/footer.html' %}{% endblock footer_nav %}

View File

@@ -65,3 +65,5 @@
</form>
</div>
{% endblock %}
{% block footer_nav %}{% include 'includes/footer.html' %}{% endblock footer_nav %}

View File

@@ -24,3 +24,5 @@
{% endif %}
{% endblock %}
{% block footer_nav %}{% include 'includes/footer.html' %}{% endblock footer_nav %}

View File

@@ -15,5 +15,6 @@
<button class="btn btn-danger" type="submit">{% trans "Disable" %}</button>
</form>
</div>
{% endblock %}
{% block footer_nav %}{% include 'includes/footer.html' %}{% endblock footer_nav %}

View File

@@ -57,3 +57,5 @@
</p>
{% endif %}
{% endblock %}
{% block footer_nav %}{% include 'includes/footer.html' %}{% endblock footer_nav %}

297
poetry.lock generated
View File

@@ -398,13 +398,100 @@ files = [
[[package]]
name = "phonenumberslite"
version = "8.13.19"
version = "8.13.20"
description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers."
optional = false
python-versions = "*"
files = [
{file = "phonenumberslite-8.13.19-py2.py3-none-any.whl", hash = "sha256:163a18e962e4a29ffd0bd523fa7aa60301bc7aa467bf0383bf3c4dac02097573"},
{file = "phonenumberslite-8.13.19.tar.gz", hash = "sha256:5d05d0f31f0838b1773c6093925c7c1ab3e98d98747db7c54a9385f8b6df5f71"},
{file = "phonenumberslite-8.13.20-py2.py3-none-any.whl", hash = "sha256:f0840de9bfe5b8f10b6883d971dd81f3a53bed8210326a53266f11e8be3428c3"},
{file = "phonenumberslite-8.13.20.tar.gz", hash = "sha256:09b07b32861f306dee10f17085afb3e058b4c179edf50deb94db4d4dd14235ff"},
]
[[package]]
name = "psycopg"
version = "3.1.10"
description = "PostgreSQL database adapter for Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "psycopg-3.1.10-py3-none-any.whl", hash = "sha256:8bbeddae5075c7890b2fa3e3553440376d3c5e28418335dee3c3656b06fa2b52"},
{file = "psycopg-3.1.10.tar.gz", hash = "sha256:15b25741494344c24066dc2479b0f383dd1b82fa5e75612fa4fa5bb30726e9b6"},
]
[package.dependencies]
psycopg-binary = {version = "3.1.10", optional = true, markers = "extra == \"binary\""}
typing-extensions = ">=4.1"
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
binary = ["psycopg-binary (==3.1.10)"]
c = ["psycopg-c (==3.1.10)"]
dev = ["black (>=23.1.0)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.4.1)", "types-setuptools (>=57.4)", "wheel (>=0.37)"]
docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"]
pool = ["psycopg-pool"]
test = ["anyio (>=3.6.2)", "mypy (>=1.4.1)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"]
[[package]]
name = "psycopg-binary"
version = "3.1.10"
description = "PostgreSQL database adapter for Python -- C optimisation distribution"
optional = false
python-versions = ">=3.7"
files = [
{file = "psycopg_binary-3.1.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a529c203f6e0f4c67ba27cf8f9739eb3bc880ad70d6ad6c0e56c2230a66b5a09"},
{file = "psycopg_binary-3.1.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd6e14d1aeb12754a43446c77a5ce819b68875cc25ae6538089ef90d7f6dd6f7"},
{file = "psycopg_binary-3.1.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1583ced5948cf88124212c4503dfe5b01ac3e2dd1a2833c083917f4c4aabe8b4"},
{file = "psycopg_binary-3.1.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2098721c486478987be700723b28ec7a48f134eba339de36af0e745f37dfe461"},
{file = "psycopg_binary-3.1.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e61f7b412fca7b15dd043a0b22fd528d2ed8276e76b3764c3889e29fa65082b"},
{file = "psycopg_binary-3.1.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0f33e33a072e3d5af51ee4d4a439e10dbe623fe87ef295d5d688180d529f13f"},
{file = "psycopg_binary-3.1.10-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f6f7738c59262d8d19154164d99c881ed58ed377fb6f1d685eb0dc43bbcd8022"},
{file = "psycopg_binary-3.1.10-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:511d38b1e1961d179d47d5103ba9634ecfc7ead431d19a9337ef82f3a2bca807"},
{file = "psycopg_binary-3.1.10-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:666e7acf2ffdb5e8a58e8b0c1759facdb9688c7e90ee8ca7aed675803b57404d"},
{file = "psycopg_binary-3.1.10-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:57b93c756fee5f7c7bd580c34cd5d244f7d5638f8b2cf25333f97b9b8b2ebfd1"},
{file = "psycopg_binary-3.1.10-cp310-cp310-win_amd64.whl", hash = "sha256:a1d61b7724c7215a8ea4495a5c6b704656f4b7bb6165f4cb9989b685886ebc48"},
{file = "psycopg_binary-3.1.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:36fff836a7823c9d71fa7faa333c74b2b081af216cebdbb0f481dce55ee2d974"},
{file = "psycopg_binary-3.1.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:32caf98cb00881bfcbbbae39a15f2a4e08b79ff983f1c0f13b60a888ef6e8431"},
{file = "psycopg_binary-3.1.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5565a6a86fee8d74f30de89e07f399567cdf59367aeb09624eb690d524339076"},
{file = "psycopg_binary-3.1.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9fb0d64520b29bd80a6731476ad8e1c20348dfdee00ab098899d23247b641675"},
{file = "psycopg_binary-3.1.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfc05ed4e74fa8615d7cc2bd57f00f97662f4e865a731dbd43da9a527e289c8c"},
{file = "psycopg_binary-3.1.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5b59c8cff887757ddf438ff9489d79c5e6b717112c96f5c68e16f367ff8724e"},
{file = "psycopg_binary-3.1.10-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a4cbaf12361136afefc5faab21a174a437e71c803b083f410e5140c7605bc66b"},
{file = "psycopg_binary-3.1.10-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ff72576061c774bcce5f5440b93e63d4c430032dd056d30f6cb1988e549dd92c"},
{file = "psycopg_binary-3.1.10-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:a4e91e1a8d61c60f592a1dfcebdf55e52a29fe4fdb650c5bd5414c848e77d029"},
{file = "psycopg_binary-3.1.10-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f7187269d825e84c945be7d93dd5088a4e0b6481a4bdaba3bf7069d4ac13703d"},
{file = "psycopg_binary-3.1.10-cp311-cp311-win_amd64.whl", hash = "sha256:ba7812a593c16d9d661844dc8dd4d81548fd1c2a0ee676f3e3d8638369f4c5e4"},
{file = "psycopg_binary-3.1.10-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88caa5859740507b3596c6c2e00ceaccee2c6ab5317bc535887801ad3cc7f3e1"},
{file = "psycopg_binary-3.1.10-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a3a7e99ba10c2e83a48d79431560e0d5ca7865f68f2bac3a462dc2b151e9926"},
{file = "psycopg_binary-3.1.10-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:848f4f4707dc73f4b4e844c92f3de795b2ddb728f75132602bda5e6ba55084fc"},
{file = "psycopg_binary-3.1.10-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:415961e839bb49cfd75cd961503fb8846c0768f247db1fa7171c1ac61d38711b"},
{file = "psycopg_binary-3.1.10-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0471869e658d0c6b8c3ed53153794739c18d7dad2dd5b8e6ff023a364c20f7df"},
{file = "psycopg_binary-3.1.10-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:4290060ee0d856caa979ecf675c0e6959325f508272ccf27f64c3801c7bcbde7"},
{file = "psycopg_binary-3.1.10-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:abf04bc06c8f6a1ac3dc2106d3b79c8661352e9d8a57ca2934ffa6aae8fe600a"},
{file = "psycopg_binary-3.1.10-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:51fe70708243b83bf16710d8c11b61bd46562e6a24a6300d5434380b35911059"},
{file = "psycopg_binary-3.1.10-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b658f7f8b49fb60a1c52e3f6692f690a85bdf1ad30aafe0f3f1fd74f6958cf8"},
{file = "psycopg_binary-3.1.10-cp37-cp37m-win_amd64.whl", hash = "sha256:ffc8c796194f23b9b07f6d25f927ec4df84a194bbc7a1f9e73316734eef512f9"},
{file = "psycopg_binary-3.1.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:74ce92122be34cf0e5f06d79869e1001c8421a68fa7ddf6fe38a717155cf3a64"},
{file = "psycopg_binary-3.1.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:75608a900984061c8898be68fbddc6f3da5eefdffce6e0624f5371645740d172"},
{file = "psycopg_binary-3.1.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6670d160d054466e8fdedfbc749ef8bf7dfdf69296048954d24645dd4d3d3c01"},
{file = "psycopg_binary-3.1.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d32026cfab7ba7ac687a42c33345026a2fb6fc5608a6144077f767af4386be0b"},
{file = "psycopg_binary-3.1.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:908fa388a5b75dfd17a937acb24708bd272e21edefca9a495004c6f70ec2636a"},
{file = "psycopg_binary-3.1.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e46b97073bd4de114f475249d681eaf054e950699c5d7af554d3684db39b82d"},
{file = "psycopg_binary-3.1.10-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9cf56bb4b115def3a18157f3b3b7d8322ee94a8dea30028db602c8f9ae34ad1e"},
{file = "psycopg_binary-3.1.10-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3b6c6f90241c4c5a6ca3f0d8827e37ef90fdc4deb9d8cfa5678baa0ea374b391"},
{file = "psycopg_binary-3.1.10-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:747176a6aeb058079f56c5397bd90339581ab7b3cc0d62e7445654e6a484c7e1"},
{file = "psycopg_binary-3.1.10-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:41a415e78c457b06497fa0084e4ea7245ca1a377b55756dd757034210b64da7e"},
{file = "psycopg_binary-3.1.10-cp38-cp38-win_amd64.whl", hash = "sha256:a7bbe9017edd898d7b3a8747700ed045dda96a907dff87f45e642e28d8584481"},
{file = "psycopg_binary-3.1.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0f062f20256708929a58c41d44f350efced4c00a603323d1413f6dc0b84d95a5"},
{file = "psycopg_binary-3.1.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dea30f2704337ca2d0322fccfe1fa30f61ce9185de3937eb986321063114a51f"},
{file = "psycopg_binary-3.1.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9d88ac72531034ebf7ec09114e732b066a9078f4ce213cf65cc5e42eb538d30"},
{file = "psycopg_binary-3.1.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2bea0940d69c3e24a72530730952687912893b34c53aa39e79045e7b446174d"},
{file = "psycopg_binary-3.1.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6a691dc8e2436d9c1e5cf93902d63e9501688fccc957eb22f952d37886257470"},
{file = "psycopg_binary-3.1.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa92661f99351765673835a4d936d79bd24dfbb358b29b084d83be38229a90e4"},
{file = "psycopg_binary-3.1.10-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:30eb731ed5525d8df892db6532cc8ffd8a163b73bc355127dee9c49334e16eee"},
{file = "psycopg_binary-3.1.10-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:50bf7a59d3a85a82d466fed341d352b44d09d6adc18656101d163a7cfc6509a0"},
{file = "psycopg_binary-3.1.10-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f48665947c55f8d6eb3f0be98de80411508e1ec329f354685329b57fced82c7f"},
{file = "psycopg_binary-3.1.10-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:caa771569da01fc0389ca34920c331a284425a68f92d1ba0a80cc08935f8356e"},
{file = "psycopg_binary-3.1.10-cp39-cp39-win_amd64.whl", hash = "sha256:b30887e631fd67affaed98f6cd2135b44f2d1a6d9bca353a69c3889c78bd7aa8"},
]
[[package]]
@@ -459,13 +546,13 @@ files = [
[[package]]
name = "pytz"
version = "2023.3"
version = "2023.3.post1"
description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
files = [
{file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"},
{file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"},
{file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"},
{file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"},
]
[[package]]
@@ -557,108 +644,108 @@ rpds-py = ">=0.7.0"
[[package]]
name = "rpds-py"
version = "0.10.0"
version = "0.10.2"
description = "Python bindings to Rust's persistent data structures (rpds)"
optional = false
python-versions = ">=3.8"
files = [
{file = "rpds_py-0.10.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:c1e0e9916301e3b3d970814b1439ca59487f0616d30f36a44cead66ee1748c31"},
{file = "rpds_py-0.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ce8caa29ebbdcde67e5fd652c811d34bc01f249dbc0d61e5cc4db05ae79a83b"},
{file = "rpds_py-0.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad277f74b1c164f7248afa968700e410651eb858d7c160d109fb451dc45a2f09"},
{file = "rpds_py-0.10.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8e1c68303ccf7fceb50fbab79064a2636119fd9aca121f28453709283dbca727"},
{file = "rpds_py-0.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:780fcb855be29153901c67fc9c5633d48aebef21b90aa72812fa181d731c6b00"},
{file = "rpds_py-0.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbd7b24d108509a1b9b6679fcc1166a7dd031dbef1f3c2c73788f42e3ebb3beb"},
{file = "rpds_py-0.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0700c2133ba203c4068aaecd6a59bda22e06a5e46255c9da23cbf68c6942215d"},
{file = "rpds_py-0.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:576da63eae7809f375932bfcbca2cf20620a1915bf2fedce4b9cc8491eceefe3"},
{file = "rpds_py-0.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23750a9b8a329844ba1fe267ca456bb3184984da2880ed17ae641c5af8de3fef"},
{file = "rpds_py-0.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d08395595c42bcd82c3608762ce734504c6d025eef1c06f42326a6023a584186"},
{file = "rpds_py-0.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1d7b7b71bcb82d8713c7c2e9c5f061415598af5938666beded20d81fa23e7640"},
{file = "rpds_py-0.10.0-cp310-none-win32.whl", hash = "sha256:97f5811df21703446b42303475b8b855ee07d6ab6cdf8565eff115540624f25d"},
{file = "rpds_py-0.10.0-cp310-none-win_amd64.whl", hash = "sha256:cdbed8f21204398f47de39b0a9b180d7e571f02dfb18bf5f1b618e238454b685"},
{file = "rpds_py-0.10.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:7a3a3d3e4f1e3cd2a67b93a0b6ed0f2499e33f47cc568e3a0023e405abdc0ff1"},
{file = "rpds_py-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fc72ae476732cdb7b2c1acb5af23b478b8a0d4b6fcf19b90dd150291e0d5b26b"},
{file = "rpds_py-0.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0583f69522732bdd79dca4cd3873e63a29acf4a299769c7541f2ca1e4dd4bc6"},
{file = "rpds_py-0.10.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8b9a7cd381970e64849070aca7c32d53ab7d96c66db6c2ef7aa23c6e803f514"},
{file = "rpds_py-0.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d292cabd7c8335bdd3237ded442480a249dbcdb4ddfac5218799364a01a0f5c"},
{file = "rpds_py-0.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6903cdca64f1e301af9be424798328c1fe3b4b14aede35f04510989fc72f012"},
{file = "rpds_py-0.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bed57543c99249ab3a4586ddc8786529fbc33309e5e8a1351802a06ca2baf4c2"},
{file = "rpds_py-0.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15932ec5f224b0e35764dc156514533a4fca52dcfda0dfbe462a1a22b37efd59"},
{file = "rpds_py-0.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb2d59bc196e6d3b1827c7db06c1a898bfa0787c0574af398e65ccf2e97c0fbe"},
{file = "rpds_py-0.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f99d74ddf9d3b6126b509e81865f89bd1283e3fc1b568b68cd7bd9dfa15583d7"},
{file = "rpds_py-0.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f70bec8a14a692be6dbe7ce8aab303e88df891cbd4a39af091f90b6702e28055"},
{file = "rpds_py-0.10.0-cp311-none-win32.whl", hash = "sha256:5f7487be65b9c2c510819e744e375bd41b929a97e5915c4852a82fbb085df62c"},
{file = "rpds_py-0.10.0-cp311-none-win_amd64.whl", hash = "sha256:748e472345c3a82cfb462d0dff998a7bf43e621eed73374cb19f307e97e08a83"},
{file = "rpds_py-0.10.0-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:d4639111e73997567343df6551da9dd90d66aece1b9fc26c786d328439488103"},
{file = "rpds_py-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f4760e1b02173f4155203054f77a5dc0b4078de7645c922b208d28e7eb99f3e2"},
{file = "rpds_py-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a6420a36975e0073acaeee44ead260c1f6ea56812cfc6c31ec00c1c48197173"},
{file = "rpds_py-0.10.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58fc4d66ee349a23dbf08c7e964120dc9027059566e29cf0ce6205d590ed7eca"},
{file = "rpds_py-0.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:063411228b852fb2ed7485cf91f8e7d30893e69b0acb207ec349db04cccc8225"},
{file = "rpds_py-0.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65af12f70355de29e1092f319f85a3467f4005e959ab65129cb697169ce94b86"},
{file = "rpds_py-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:298e8b5d8087e0330aac211c85428c8761230ef46a1f2c516d6a2f67fb8803c5"},
{file = "rpds_py-0.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5b9bf77008f2c55dabbd099fd3ac87009471d223a1c7ebea36873d39511b780a"},
{file = "rpds_py-0.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c7853f27195598e550fe089f78f0732c66ee1d1f0eaae8ad081589a5a2f5d4af"},
{file = "rpds_py-0.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:75dbfd41a61bc1fb0536bf7b1abf272dc115c53d4d77db770cd65d46d4520882"},
{file = "rpds_py-0.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b25136212a3d064a8f0b9ebbb6c57094c5229e0de76d15c79b76feff26aeb7b8"},
{file = "rpds_py-0.10.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:9affee8cb1ec453382c27eb9043378ab32f49cd4bc24a24275f5c39bf186c279"},
{file = "rpds_py-0.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4d55528ef13af4b4e074d067977b1f61408602f53ae4537dccf42ba665c2c7bd"},
{file = "rpds_py-0.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7865df1fb564092bcf46dac61b5def25342faf6352e4bc0e61a286e3fa26a3d"},
{file = "rpds_py-0.10.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f5cc8c7bc99d2bbcd704cef165ca7d155cd6464c86cbda8339026a42d219397"},
{file = "rpds_py-0.10.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbae50d352e4717ffc22c566afc2d0da744380e87ed44a144508e3fb9114a3f4"},
{file = "rpds_py-0.10.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fccbf0cd3411719e4c9426755df90bf3449d9fc5a89f077f4a7f1abd4f70c910"},
{file = "rpds_py-0.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d10c431073dc6ebceed35ab22948a016cc2b5120963c13a41e38bdde4a7212"},
{file = "rpds_py-0.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1b401e8b9aece651512e62c431181e6e83048a651698a727ea0eb0699e9f9b74"},
{file = "rpds_py-0.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:7618a082c55cf038eede4a918c1001cc8a4411dfe508dc762659bcd48d8f4c6e"},
{file = "rpds_py-0.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:b3226b246facae14909b465061ddcfa2dfeadb6a64f407f24300d42d69bcb1a1"},
{file = "rpds_py-0.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a8edd467551c1102dc0f5754ab55cd0703431cd3044edf8c8e7d9208d63fa453"},
{file = "rpds_py-0.10.0-cp38-none-win32.whl", hash = "sha256:71333c22f7cf5f0480b59a0aef21f652cf9bbaa9679ad261b405b65a57511d1e"},
{file = "rpds_py-0.10.0-cp38-none-win_amd64.whl", hash = "sha256:a8ab1adf04ae2d6d65835995218fd3f3eb644fe20655ca8ee233e2c7270ff53b"},
{file = "rpds_py-0.10.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:87c93b25d538c433fb053da6228c6290117ba53ff6a537c133b0f2087948a582"},
{file = "rpds_py-0.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7996aed3f65667c6dcc8302a69368435a87c2364079a066750a2eac75ea01e"},
{file = "rpds_py-0.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8856aa76839dc234d3469f1e270918ce6bec1d6a601eba928f45d68a15f04fc3"},
{file = "rpds_py-0.10.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00215f6a9058fbf84f9d47536902558eb61f180a6b2a0fa35338d06ceb9a2e5a"},
{file = "rpds_py-0.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23a059143c1393015c68936370cce11690f7294731904bdae47cc3e16d0b2474"},
{file = "rpds_py-0.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e5c26905aa651cc8c0ddc45e0e5dea2a1296f70bdc96af17aee9d0493280a17"},
{file = "rpds_py-0.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c651847545422c8131660704c58606d841e228ed576c8f1666d98b3d318f89da"},
{file = "rpds_py-0.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:80992eb20755701753e30a6952a96aa58f353d12a65ad3c9d48a8da5ec4690cf"},
{file = "rpds_py-0.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ffcf18ad3edf1c170e27e88b10282a2c449aa0358659592462448d71b2000cfc"},
{file = "rpds_py-0.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08e08ccf5b10badb7d0a5c84829b914c6e1e1f3a716fdb2bf294e2bd01562775"},
{file = "rpds_py-0.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7150b83b3e3ddaac81a8bb6a9b5f93117674a0e7a2b5a5b32ab31fdfea6df27f"},
{file = "rpds_py-0.10.0-cp39-none-win32.whl", hash = "sha256:3455ecc46ea443b5f7d9c2f946ce4017745e017b0d0f8b99c92564eff97e97f5"},
{file = "rpds_py-0.10.0-cp39-none-win_amd64.whl", hash = "sha256:afe6b5a04b2ab1aa89bad32ca47bf71358e7302a06fdfdad857389dca8fb5f04"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:b1cb078f54af0abd835ca76f93a3152565b73be0f056264da45117d0adf5e99c"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8e7e2b3577e97fa43c2c2b12a16139b2cedbd0770235d5179c0412b4794efd9b"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae46a50d235f1631d9ec4670503f7b30405103034830bc13df29fd947207f795"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f869e34d2326e417baee430ae998e91412cc8e7fdd83d979277a90a0e79a5b47"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d544a614055b131111bed6edfa1cb0fb082a7265761bcb03321f2dd7b5c6c48"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9c2f6ca9774c2c24bbf7b23086264e6b5fa178201450535ec0859739e6f78d"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2da4a8c6d465fde36cea7d54bf47b5cf089073452f0e47c8632ecb9dec23c07"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac00c41dd315d147b129976204839ca9de699d83519ff1272afbe4fb9d362d12"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:0155c33af0676fc38e1107679be882077680ad1abb6303956b97259c3177e85e"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:db6585b600b2e76e98131e0ac0e5195759082b51687ad0c94505970c90718f4a"},
{file = "rpds_py-0.10.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:7b6975d3763d0952c111700c0634968419268e6bbc0b55fe71138987fa66f309"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:6388e4e95a26717b94a05ced084e19da4d92aca883f392dffcf8e48c8e221a24"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:18f87baa20e02e9277ad8960cd89b63c79c05caf106f4c959a9595c43f2a34a5"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92f05fc7d832e970047662b3440b190d24ea04f8d3c760e33e7163b67308c878"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:291c9ce3929a75b45ce8ddde2aa7694fc8449f2bc8f5bd93adf021efaae2d10b"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:861d25ae0985a1dd5297fee35f476b60c6029e2e6e19847d5b4d0a43a390b696"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:668d2b45d62c68c7a370ac3dce108ffda482b0a0f50abd8b4c604a813a59e08f"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:344b89384c250ba6a4ce1786e04d01500e4dac0f4137ceebcaad12973c0ac0b3"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:885e023e73ce09b11b89ab91fc60f35d80878d2c19d6213a32b42ff36543c291"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:841128a22e6ac04070a0f84776d07e9c38c4dcce8e28792a95e45fc621605517"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:899b5e7e2d5a8bc92aa533c2d4e55e5ebba095c485568a5e4bedbc163421259a"},
{file = "rpds_py-0.10.0-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e7947d9a6264c727a556541b1630296bbd5d0a05068d21c38dde8e7a1c703ef0"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:4992266817169997854f81df7f6db7bdcda1609972d8ffd6919252f09ec3c0f6"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:26d9fd624649a10e4610fab2bc820e215a184d193e47d0be7fe53c1c8f67f370"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0028eb0967942d0d2891eae700ae1a27b7fd18604cfcb16a1ef486a790fee99e"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9e7e493ded7042712a374471203dd43ae3fff5b81e3de1a0513fa241af9fd41"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d68a8e8a3a816629283faf82358d8c93fe5bd974dd2704152394a3de4cec22a"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6d5f061f6a2aa55790b9e64a23dfd87b6664ab56e24cd06c78eb43986cb260b"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c7c4266c1b61eb429e8aeb7d8ed6a3bfe6c890a1788b18dbec090c35c6b93fa"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:80772e3bda6787510d9620bc0c7572be404a922f8ccdfd436bf6c3778119464c"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:b98e75b21fc2ba5285aef8efaf34131d16af1c38df36bdca2f50634bea2d3060"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:d63787f289944cc4bde518ad2b5e70a4f0d6e2ce76324635359c74c113fd188f"},
{file = "rpds_py-0.10.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:872f3dcaa8bf2245944861d7311179d2c0c9b2aaa7d3b464d99a7c2e401f01fa"},
{file = "rpds_py-0.10.0.tar.gz", hash = "sha256:e36d7369363d2707d5f68950a64c4e025991eb0177db01ccb6aa6facae48b69f"},
{file = "rpds_py-0.10.2-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:9f00d54b18dd837f1431d66b076737deb7c29ce3ebb8412ceaf44d5e1954ac0c"},
{file = "rpds_py-0.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f4d561f4728f825e3b793a53064b606ca0b6fc264f67d09e54af452aafc5b82"},
{file = "rpds_py-0.10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:013d6c784150d10236a74b4094a79d96a256b814457e388fc5a4ba9efe24c402"},
{file = "rpds_py-0.10.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd1142d22fdb183a0fff66d79134bf644401437fed874f81066d314c67ee193c"},
{file = "rpds_py-0.10.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a0536ed2b9297c75104e1a3da330828ba1b2639fa53b38d396f98bf7e3c68df"},
{file = "rpds_py-0.10.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:41bd430b7b63aa802c02964e331ac0b177148fef5f807d2c90d05ce71a52b4d4"},
{file = "rpds_py-0.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e8474f7233fe1949ce4e03bea698a600c2d5d6b51dab6d6e6336dbe69acf23e"},
{file = "rpds_py-0.10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d9d7efaad48b859053b90dedd69bc92f2095084251e732e4c57ac9726bcb1e64"},
{file = "rpds_py-0.10.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5612b0b1de8d5114520094bd5fc3d04eb8af6f3e10d48ef05b7c8e77c1fd9545"},
{file = "rpds_py-0.10.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5d5eaf988951f6ecb6854ca3300b87123599c711183c83da7ce39717a7cbdbce"},
{file = "rpds_py-0.10.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75c8766734ac0053e1d683567e65e85306c4ec62631b0591caeb287ac8f72e08"},
{file = "rpds_py-0.10.2-cp310-none-win32.whl", hash = "sha256:8de9b88f0cbac73cfed34220d13c57849e62a7099a714b929142425e926d223a"},
{file = "rpds_py-0.10.2-cp310-none-win_amd64.whl", hash = "sha256:2275f1a022e2383da5d2d101fe11ccdcbae799148c4b83260a4b9309fa3e1fc2"},
{file = "rpds_py-0.10.2-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:dd91a7d7a9ce7f4983097c91ce211f3e5569cc21caa16f2692298a07e396f82b"},
{file = "rpds_py-0.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e82b4a70cc67094f3f3fd77579702f48fcf1de7bdc67d79b8f1e24d089a6162c"},
{file = "rpds_py-0.10.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e281b71922208e00886e4b7ffbfcf27874486364f177418ab676f102130e7ec9"},
{file = "rpds_py-0.10.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3eb1a0d2b6d232d1bcdfc3fcc5f7b004ab3fbd9203011a3172f051d4527c0b6"},
{file = "rpds_py-0.10.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02945ae38fd78efc40900f509890de84cfd5ffe2cd2939eeb3a8800dc68b87cb"},
{file = "rpds_py-0.10.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccfb77f6dc8abffa6f1c7e3975ed9070a41ce5fcc11154d2bead8c1baa940f09"},
{file = "rpds_py-0.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af52078719209bef33e38131486fd784832dd8d1dc9b85f00a44f6e7437dd021"},
{file = "rpds_py-0.10.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56ba7c1100ed079527f2b995bf5486a2e557e6d5b733c52e8947476338815b69"},
{file = "rpds_py-0.10.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:899b03a3be785a7e1ff84b237da71f0efa2f021512f147dd34ffdf7aa82cb678"},
{file = "rpds_py-0.10.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22e6de18f00583f06928cc8d0993104ecc62f7c6da6478db2255de89a30e45d1"},
{file = "rpds_py-0.10.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:edd74b760a6bb950397e7a7bd2f38e6700f6525062650b1d77c6d851b82f02c2"},
{file = "rpds_py-0.10.2-cp311-none-win32.whl", hash = "sha256:18909093944727e068ebfc92e2e6ed1c4fa44135507c1c0555213ce211c53214"},
{file = "rpds_py-0.10.2-cp311-none-win_amd64.whl", hash = "sha256:9568764e72d85cf7855ca78b48e07ed1be47bf230e2cea8dabda3c95f660b0ff"},
{file = "rpds_py-0.10.2-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:0fc625059b83695fbb4fc8b7a8b66fa94ff9c7b78c84fb9986cd53ff88a28d80"},
{file = "rpds_py-0.10.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c86231c66e4f422e7c13ea6200bb4048b3016c8bfd11b4fd0dabd04d2c8e3501"},
{file = "rpds_py-0.10.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56777c57246e048908b550af9b81b0ec9cf804fd47cb7502ccd93238bd6025c2"},
{file = "rpds_py-0.10.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4cb372e22e9c879bd9a9cc9b20b7c1fbf30a605ac953da45ecec05d8a6e1c77"},
{file = "rpds_py-0.10.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa3b3a43dabc4cc57a7800f526cbe03f71c69121e21b863fdf497b59b462b163"},
{file = "rpds_py-0.10.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:59d222086daa55421d599609b32d0ebe544e57654c4a0a1490c54a7ebaa67561"},
{file = "rpds_py-0.10.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:529aab727f54a937085184e7436e1d0e19975cf10115eda12d37a683e4ee5342"},
{file = "rpds_py-0.10.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e9b1531d6a898bdf086acb75c41265c7ec4331267d7619148d407efc72bd24"},
{file = "rpds_py-0.10.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c2772bb95062e3f9774140205cd65d8997e39620715486cf5f843cf4ad8f744c"},
{file = "rpds_py-0.10.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ba1b28e44f611f3f2b436bd8290050a61db4b59a8e24be4465f44897936b3824"},
{file = "rpds_py-0.10.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5aba767e64b494483ad60c4873bec78d16205a21f8247c99749bd990d9c846c2"},
{file = "rpds_py-0.10.2-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:e1954f4b239d1a92081647eecfd51cbfd08ea16eb743b8af1cd0113258feea14"},
{file = "rpds_py-0.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:de4a2fd524993578fe093044f291b4b24aab134390030b3b9b5f87fd41ab7e75"},
{file = "rpds_py-0.10.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e69737bd56006a86fd5a78b2b85447580a6138c930a75eb9ef39fe03d90782b1"},
{file = "rpds_py-0.10.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f40abbcc0a7d9a8a80870af839d317e6932533f98682aabd977add6c53beeb23"},
{file = "rpds_py-0.10.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29ec8507664f94cc08457d98cfc41c3cdbddfa8952438e644177a29b04937876"},
{file = "rpds_py-0.10.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcde80aefe7054fad6277762fb7e9d35c72ea479a485ae1bb14629c640987b30"},
{file = "rpds_py-0.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a65de5c02884760a14a58304fb6303f9ddfc582e630f385daea871e1bdb18686"},
{file = "rpds_py-0.10.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e92e5817eb6bfed23aa5e45bfe30647b83602bdd6f9e25d63524d4e6258458b0"},
{file = "rpds_py-0.10.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2c8fc6c841ada60a86d29c9ebe2e8757c47eda6553f3596c560e59ca6e9b6fa1"},
{file = "rpds_py-0.10.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:8557c807388e6617161fe51b1a4747ea8d1133f2d2ad8e79583439abebe58fbd"},
{file = "rpds_py-0.10.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:00e97d43a36811b78fa9ad9d3329bf34f76a31e891a7031a2ac01450c9b168ab"},
{file = "rpds_py-0.10.2-cp38-none-win32.whl", hash = "sha256:1ed3d5385d14be894e12a9033be989e012214a9811e7194849c94032ad69682a"},
{file = "rpds_py-0.10.2-cp38-none-win_amd64.whl", hash = "sha256:02b4a2e28eb24dac4ef43dda4f6a6f7766e355179b143f7d0c76a1c5488a307b"},
{file = "rpds_py-0.10.2-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:2a55631b93e47956fbc97d69ba2054a8c6a4016f9a3064ec4e031f5f1030cb90"},
{file = "rpds_py-0.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2ffbf1b38c88d0466de542e91b08225d51782282512f8e2b11715126c41fda48"},
{file = "rpds_py-0.10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213f9ef5c02ec2f883c1075d25a873149daadbaea50d18d622e9db55ec9849c2"},
{file = "rpds_py-0.10.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b00150a9a3fd0a8efaa90bc2696c105b04039d50763dd1c95a34c88c5966cb57"},
{file = "rpds_py-0.10.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ab0f7aabdbce4a202e013083eeab71afdb85efa405dc4a06fea98cde81204675"},
{file = "rpds_py-0.10.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cd0c9fb5d40887500b4ed818770c68ab4fa6e0395d286f9704be6751b1b7d98"},
{file = "rpds_py-0.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8578fc6c8bdd0201327503720fa581000b4bd3934abbf07e2628d1ad3de157d"},
{file = "rpds_py-0.10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d27d08056fcd61ff47a0cd8407eff4d3e816c82cb6b9c6f0ce9a0ad49225f81"},
{file = "rpds_py-0.10.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c8f6526df47953b07c45b95c4d1da6b9a0861c0e5da0271db96bb1d807825412"},
{file = "rpds_py-0.10.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:177c033e467a66a054dd3a9534167234a3d0b2e41445807b13b626e01da25d92"},
{file = "rpds_py-0.10.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c74cbee9e532dc34371127f7686d6953e5153a1f22beab7f953d95ee4a0fe09"},
{file = "rpds_py-0.10.2-cp39-none-win32.whl", hash = "sha256:05a1382905026bdd560f806c8c7c16e0f3e3fb359ba8868203ca6e5799884968"},
{file = "rpds_py-0.10.2-cp39-none-win_amd64.whl", hash = "sha256:3fd503c27e7b7034128e30847ecdb4bff4ca5e60f29ad022a9f66ae8940d54ac"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:4a96147791e49e84207dd1530109aa0e9eeaf1c8b7a59f150047fc0fcdf9bb64"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:203eb1532d51591d32e8dfafd60b5d31347ea7278c8da02b4b550287f6abe28b"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2f416cdfe92f5fbb77177f5f3f7830059d1582db05f2c7119bf80069d1ab69b"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2660000e1a113869c86eb5cc07f3343467490f3cd9d0299f81da9ddae7137b7"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1adb04e4b4e41bf30aaa77eeb169c1b9ba9e5010e2e6ce8d6c17e1446edc9b68"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2bca97521ee786087f0c5ef318fef3eef0266a9c3deff88205523cf353af7394"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4969592e3cdeefa4cbb15a26cec102cbd4a1d6e5b695fac9fa026e19741138c8"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df61f818edf7c8626bfa392f825860fb670b5f8336e238eb0ec7e2a5689cdded"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:b589d93a60e78fe55d5bc76ee8c2bf945dbdbb7cd16044c53e0307604e448de1"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:73da69e1f612c3e682e34dcb971272d90d6f27b2c99acff444ca455a89978574"},
{file = "rpds_py-0.10.2-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:89438e8885a186c69fe31f7ef98bb2bf29688c466c3caf9060f404c0be89ae80"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:c4ecc4e9a5d73a816cae36ee6b5d8b7a0c72013cae1e101406e832887c3dc2d8"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:907b214da5d2fcff0b6ddb83de1333890ca92abaf4bbf8d9c61dc1b95c87fd6e"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb44644371eaa29a3aba7b69b1862d0d56f073bb7585baa32e4271a71a91ee82"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:80c3cf46511653f94dfe07c7c79ab105c4164d6e1dfcb35b7214fb9af53eaef4"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaba0613c759ebf95988a84f766ca6b7432d55ce399194f95dde588ad1be0878"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0527c97dcd8bb983822ee31d3760187083fd3ba18ac4dd22cf5347c89d5628f4"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9cdfd649011ce2d90cb0dd304c5aba1190fac0c266d19a9e2b96b81cfd150a09"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:75eea40355a8690459c7291ce6c8ce39c27bd223675c7da6619f510c728feb97"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4f1b804cfad04f862d6a84af9d1ad941b06f671878f0f7ecad6c92007d423de6"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:bf77f9017fcfa1232f98598a637406e6c33982ccba8a5922339575c3e2b90ea5"},
{file = "rpds_py-0.10.2-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:46c4c550bf59ce05d6bff2c98053822549aaf9fbaf81103edea325e03350bca1"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:46af4a742b90c7460e94214f923452c2c1d050a9da1d2b8d4c70cbc045e692b7"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:2a86d246a160d98d820ee7d02dc18c923c228de095be362e57b9fd8970b2c4a1"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae141c9017f8f473a6ee07a9425da021816a9f8c0683c2e5442f0ccf56b0fc62"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1147bc3d0dd1e549d991110d0a09557ec9f925dbc1ca62871fcdab2ec9d716b"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fce7a8ee8d0f682c953c0188735d823f0fcb62779bf92cd6ba473a8e730e26ad"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c7f9d70f99e1fbcbf57c75328b80e1c0a7f6cad43e75efa90a97221be5efe15"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b309908b6ff5ffbf6394818cb73b5a2a74073acee2c57fe8719046389aeff0d"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3ff1f585a0fdc1415bd733b804f33d386064a308672249b14828130dd43e7c31"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:0188b580c490bccb031e9b67e9e8c695a3c44ac5e06218b152361eca847317c3"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:abe081453166e206e3a8c6d8ace57214c17b6d9477d7601ac14a365344dbc1f4"},
{file = "rpds_py-0.10.2-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9118de88c16947eaf5b92f749e65b0501ea69e7c2be7bd6aefc12551622360e1"},
{file = "rpds_py-0.10.2.tar.gz", hash = "sha256:289073f68452b96e70990085324be7223944c7409973d13ddfe0eea1c1b5663b"},
]
[[package]]
@@ -727,4 +814,4 @@ brotli = ["Brotli"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "e67fb8ed8bd9086956f4c789fd42d8b0ebe140579bc2aa2f1c303bd1def9b2f7"
content-hash = "9c8a0b848ed1ebc1da6cafbf9b769f72f8a70e15ed4a226544c956ef69a4f750"

View File

@@ -21,6 +21,7 @@ drf-spectacular = "0.26.4"
django-two-factor-auth = {extras = ["phonenumberslite"], version = "1.15.3"}
whitenoise = "6.5.0"
gunicorn = {extras = ["gthread"], version = "21.2.0"}
psycopg = {extras = ["binary"], version = "3.1.10"}
[tool.poetry.group.dev.dependencies]
flake8 = "^6.0.0"

View File

@@ -64,18 +64,76 @@ markdown==3.4.4 ; python_version >= "3.10" and python_version < "4.0" \
packaging==23.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61 \
--hash=sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f
phonenumberslite==8.13.19 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:163a18e962e4a29ffd0bd523fa7aa60301bc7aa467bf0383bf3c4dac02097573 \
--hash=sha256:5d05d0f31f0838b1773c6093925c7c1ab3e98d98747db7c54a9385f8b6df5f71
phonenumberslite==8.13.20 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:09b07b32861f306dee10f17085afb3e058b4c179edf50deb94db4d4dd14235ff \
--hash=sha256:f0840de9bfe5b8f10b6883d971dd81f3a53bed8210326a53266f11e8be3428c3
psycopg-binary==3.1.10 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:0471869e658d0c6b8c3ed53153794739c18d7dad2dd5b8e6ff023a364c20f7df \
--hash=sha256:0f062f20256708929a58c41d44f350efced4c00a603323d1413f6dc0b84d95a5 \
--hash=sha256:1583ced5948cf88124212c4503dfe5b01ac3e2dd1a2833c083917f4c4aabe8b4 \
--hash=sha256:1e46b97073bd4de114f475249d681eaf054e950699c5d7af554d3684db39b82d \
--hash=sha256:2098721c486478987be700723b28ec7a48f134eba339de36af0e745f37dfe461 \
--hash=sha256:30eb731ed5525d8df892db6532cc8ffd8a163b73bc355127dee9c49334e16eee \
--hash=sha256:32caf98cb00881bfcbbbae39a15f2a4e08b79ff983f1c0f13b60a888ef6e8431 \
--hash=sha256:36fff836a7823c9d71fa7faa333c74b2b081af216cebdbb0f481dce55ee2d974 \
--hash=sha256:3b6c6f90241c4c5a6ca3f0d8827e37ef90fdc4deb9d8cfa5678baa0ea374b391 \
--hash=sha256:415961e839bb49cfd75cd961503fb8846c0768f247db1fa7171c1ac61d38711b \
--hash=sha256:41a415e78c457b06497fa0084e4ea7245ca1a377b55756dd757034210b64da7e \
--hash=sha256:4290060ee0d856caa979ecf675c0e6959325f508272ccf27f64c3801c7bcbde7 \
--hash=sha256:4a3a7e99ba10c2e83a48d79431560e0d5ca7865f68f2bac3a462dc2b151e9926 \
--hash=sha256:50bf7a59d3a85a82d466fed341d352b44d09d6adc18656101d163a7cfc6509a0 \
--hash=sha256:511d38b1e1961d179d47d5103ba9634ecfc7ead431d19a9337ef82f3a2bca807 \
--hash=sha256:51fe70708243b83bf16710d8c11b61bd46562e6a24a6300d5434380b35911059 \
--hash=sha256:5565a6a86fee8d74f30de89e07f399567cdf59367aeb09624eb690d524339076 \
--hash=sha256:57b93c756fee5f7c7bd580c34cd5d244f7d5638f8b2cf25333f97b9b8b2ebfd1 \
--hash=sha256:666e7acf2ffdb5e8a58e8b0c1759facdb9688c7e90ee8ca7aed675803b57404d \
--hash=sha256:6670d160d054466e8fdedfbc749ef8bf7dfdf69296048954d24645dd4d3d3c01 \
--hash=sha256:6a691dc8e2436d9c1e5cf93902d63e9501688fccc957eb22f952d37886257470 \
--hash=sha256:747176a6aeb058079f56c5397bd90339581ab7b3cc0d62e7445654e6a484c7e1 \
--hash=sha256:74ce92122be34cf0e5f06d79869e1001c8421a68fa7ddf6fe38a717155cf3a64 \
--hash=sha256:75608a900984061c8898be68fbddc6f3da5eefdffce6e0624f5371645740d172 \
--hash=sha256:7e61f7b412fca7b15dd043a0b22fd528d2ed8276e76b3764c3889e29fa65082b \
--hash=sha256:848f4f4707dc73f4b4e844c92f3de795b2ddb728f75132602bda5e6ba55084fc \
--hash=sha256:88caa5859740507b3596c6c2e00ceaccee2c6ab5317bc535887801ad3cc7f3e1 \
--hash=sha256:8b658f7f8b49fb60a1c52e3f6692f690a85bdf1ad30aafe0f3f1fd74f6958cf8 \
--hash=sha256:908fa388a5b75dfd17a937acb24708bd272e21edefca9a495004c6f70ec2636a \
--hash=sha256:9cf56bb4b115def3a18157f3b3b7d8322ee94a8dea30028db602c8f9ae34ad1e \
--hash=sha256:9fb0d64520b29bd80a6731476ad8e1c20348dfdee00ab098899d23247b641675 \
--hash=sha256:a1d61b7724c7215a8ea4495a5c6b704656f4b7bb6165f4cb9989b685886ebc48 \
--hash=sha256:a4cbaf12361136afefc5faab21a174a437e71c803b083f410e5140c7605bc66b \
--hash=sha256:a4e91e1a8d61c60f592a1dfcebdf55e52a29fe4fdb650c5bd5414c848e77d029 \
--hash=sha256:a529c203f6e0f4c67ba27cf8f9739eb3bc880ad70d6ad6c0e56c2230a66b5a09 \
--hash=sha256:a7bbe9017edd898d7b3a8747700ed045dda96a907dff87f45e642e28d8584481 \
--hash=sha256:abf04bc06c8f6a1ac3dc2106d3b79c8661352e9d8a57ca2934ffa6aae8fe600a \
--hash=sha256:b30887e631fd67affaed98f6cd2135b44f2d1a6d9bca353a69c3889c78bd7aa8 \
--hash=sha256:b9d88ac72531034ebf7ec09114e732b066a9078f4ce213cf65cc5e42eb538d30 \
--hash=sha256:ba7812a593c16d9d661844dc8dd4d81548fd1c2a0ee676f3e3d8638369f4c5e4 \
--hash=sha256:bd6e14d1aeb12754a43446c77a5ce819b68875cc25ae6538089ef90d7f6dd6f7 \
--hash=sha256:bfc05ed4e74fa8615d7cc2bd57f00f97662f4e865a731dbd43da9a527e289c8c \
--hash=sha256:c5b59c8cff887757ddf438ff9489d79c5e6b717112c96f5c68e16f367ff8724e \
--hash=sha256:caa771569da01fc0389ca34920c331a284425a68f92d1ba0a80cc08935f8356e \
--hash=sha256:d32026cfab7ba7ac687a42c33345026a2fb6fc5608a6144077f767af4386be0b \
--hash=sha256:dea30f2704337ca2d0322fccfe1fa30f61ce9185de3937eb986321063114a51f \
--hash=sha256:e0f33e33a072e3d5af51ee4d4a439e10dbe623fe87ef295d5d688180d529f13f \
--hash=sha256:f2bea0940d69c3e24a72530730952687912893b34c53aa39e79045e7b446174d \
--hash=sha256:f48665947c55f8d6eb3f0be98de80411508e1ec329f354685329b57fced82c7f \
--hash=sha256:f6f7738c59262d8d19154164d99c881ed58ed377fb6f1d685eb0dc43bbcd8022 \
--hash=sha256:f7187269d825e84c945be7d93dd5088a4e0b6481a4bdaba3bf7069d4ac13703d \
--hash=sha256:fa92661f99351765673835a4d936d79bd24dfbb358b29b084d83be38229a90e4 \
--hash=sha256:ff72576061c774bcce5f5440b93e63d4c430032dd056d30f6cb1988e549dd92c \
--hash=sha256:ffc8c796194f23b9b07f6d25f927ec4df84a194bbc7a1f9e73316734eef512f9
psycopg[binary]==3.1.10 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:15b25741494344c24066dc2479b0f383dd1b82fa5e75612fa4fa5bb30726e9b6 \
--hash=sha256:8bbeddae5075c7890b2fa3e3553440376d3c5e28418335dee3c3656b06fa2b52
pyjwt==2.8.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de \
--hash=sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320
pypng==0.20220715.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c \
--hash=sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1
pytz==2023.3 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588 \
--hash=sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb
pytz==2023.3.post1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b \
--hash=sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7
pyyaml==6.0.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc \
--hash=sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741 \
@@ -123,104 +181,104 @@ qrcode==7.4.2 ; python_version >= "3.10" and python_version < "4.0" \
referencing==0.30.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:449b6669b6121a9e96a7f9e410b245d471e8d48964c67113ce9afe50c8dd7bdf \
--hash=sha256:794ad8003c65938edcdbc027f1933215e0d0ccc0291e3ce20a4d87432b59efc0
rpds-py==0.10.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:00215f6a9058fbf84f9d47536902558eb61f180a6b2a0fa35338d06ceb9a2e5a \
--hash=sha256:0028eb0967942d0d2891eae700ae1a27b7fd18604cfcb16a1ef486a790fee99e \
--hash=sha256:0155c33af0676fc38e1107679be882077680ad1abb6303956b97259c3177e85e \
--hash=sha256:063411228b852fb2ed7485cf91f8e7d30893e69b0acb207ec349db04cccc8225 \
--hash=sha256:0700c2133ba203c4068aaecd6a59bda22e06a5e46255c9da23cbf68c6942215d \
--hash=sha256:08e08ccf5b10badb7d0a5c84829b914c6e1e1f3a716fdb2bf294e2bd01562775 \
--hash=sha256:0d292cabd7c8335bdd3237ded442480a249dbcdb4ddfac5218799364a01a0f5c \
--hash=sha256:15932ec5f224b0e35764dc156514533a4fca52dcfda0dfbe462a1a22b37efd59 \
--hash=sha256:18f87baa20e02e9277ad8960cd89b63c79c05caf106f4c959a9595c43f2a34a5 \
--hash=sha256:1a6420a36975e0073acaeee44ead260c1f6ea56812cfc6c31ec00c1c48197173 \
--hash=sha256:1b401e8b9aece651512e62c431181e6e83048a651698a727ea0eb0699e9f9b74 \
--hash=sha256:1d7b7b71bcb82d8713c7c2e9c5f061415598af5938666beded20d81fa23e7640 \
--hash=sha256:23750a9b8a329844ba1fe267ca456bb3184984da2880ed17ae641c5af8de3fef \
--hash=sha256:23a059143c1393015c68936370cce11690f7294731904bdae47cc3e16d0b2474 \
--hash=sha256:26d9fd624649a10e4610fab2bc820e215a184d193e47d0be7fe53c1c8f67f370 \
--hash=sha256:291c9ce3929a75b45ce8ddde2aa7694fc8449f2bc8f5bd93adf021efaae2d10b \
--hash=sha256:298e8b5d8087e0330aac211c85428c8761230ef46a1f2c516d6a2f67fb8803c5 \
--hash=sha256:2c7c4266c1b61eb429e8aeb7d8ed6a3bfe6c890a1788b18dbec090c35c6b93fa \
--hash=sha256:2d68a8e8a3a816629283faf82358d8c93fe5bd974dd2704152394a3de4cec22a \
--hash=sha256:344b89384c250ba6a4ce1786e04d01500e4dac0f4137ceebcaad12973c0ac0b3 \
--hash=sha256:3455ecc46ea443b5f7d9c2f946ce4017745e017b0d0f8b99c92564eff97e97f5 \
--hash=sha256:3d544a614055b131111bed6edfa1cb0fb082a7265761bcb03321f2dd7b5c6c48 \
--hash=sha256:3e5c26905aa651cc8c0ddc45e0e5dea2a1296f70bdc96af17aee9d0493280a17 \
--hash=sha256:3f5cc8c7bc99d2bbcd704cef165ca7d155cd6464c86cbda8339026a42d219397 \
--hash=sha256:4992266817169997854f81df7f6db7bdcda1609972d8ffd6919252f09ec3c0f6 \
--hash=sha256:4d55528ef13af4b4e074d067977b1f61408602f53ae4537dccf42ba665c2c7bd \
--hash=sha256:576da63eae7809f375932bfcbca2cf20620a1915bf2fedce4b9cc8491eceefe3 \
--hash=sha256:58fc4d66ee349a23dbf08c7e964120dc9027059566e29cf0ce6205d590ed7eca \
--hash=sha256:5b9bf77008f2c55dabbd099fd3ac87009471d223a1c7ebea36873d39511b780a \
--hash=sha256:5e7996aed3f65667c6dcc8302a69368435a87c2364079a066750a2eac75ea01e \
--hash=sha256:5f7487be65b9c2c510819e744e375bd41b929a97e5915c4852a82fbb085df62c \
--hash=sha256:6388e4e95a26717b94a05ced084e19da4d92aca883f392dffcf8e48c8e221a24 \
--hash=sha256:65af12f70355de29e1092f319f85a3467f4005e959ab65129cb697169ce94b86 \
--hash=sha256:668d2b45d62c68c7a370ac3dce108ffda482b0a0f50abd8b4c604a813a59e08f \
--hash=sha256:71333c22f7cf5f0480b59a0aef21f652cf9bbaa9679ad261b405b65a57511d1e \
--hash=sha256:7150b83b3e3ddaac81a8bb6a9b5f93117674a0e7a2b5a5b32ab31fdfea6df27f \
--hash=sha256:748e472345c3a82cfb462d0dff998a7bf43e621eed73374cb19f307e97e08a83 \
--hash=sha256:75dbfd41a61bc1fb0536bf7b1abf272dc115c53d4d77db770cd65d46d4520882 \
--hash=sha256:7618a082c55cf038eede4a918c1001cc8a4411dfe508dc762659bcd48d8f4c6e \
--hash=sha256:780fcb855be29153901c67fc9c5633d48aebef21b90aa72812fa181d731c6b00 \
--hash=sha256:78d10c431073dc6ebceed35ab22948a016cc2b5120963c13a41e38bdde4a7212 \
--hash=sha256:7a3a3d3e4f1e3cd2a67b93a0b6ed0f2499e33f47cc568e3a0023e405abdc0ff1 \
--hash=sha256:7b6975d3763d0952c111700c0634968419268e6bbc0b55fe71138987fa66f309 \
--hash=sha256:80772e3bda6787510d9620bc0c7572be404a922f8ccdfd436bf6c3778119464c \
--hash=sha256:80992eb20755701753e30a6952a96aa58f353d12a65ad3c9d48a8da5ec4690cf \
--hash=sha256:841128a22e6ac04070a0f84776d07e9c38c4dcce8e28792a95e45fc621605517 \
--hash=sha256:861d25ae0985a1dd5297fee35f476b60c6029e2e6e19847d5b4d0a43a390b696 \
--hash=sha256:872f3dcaa8bf2245944861d7311179d2c0c9b2aaa7d3b464d99a7c2e401f01fa \
--hash=sha256:87c93b25d538c433fb053da6228c6290117ba53ff6a537c133b0f2087948a582 \
--hash=sha256:8856aa76839dc234d3469f1e270918ce6bec1d6a601eba928f45d68a15f04fc3 \
--hash=sha256:885e023e73ce09b11b89ab91fc60f35d80878d2c19d6213a32b42ff36543c291 \
--hash=sha256:899b5e7e2d5a8bc92aa533c2d4e55e5ebba095c485568a5e4bedbc163421259a \
--hash=sha256:8ce8caa29ebbdcde67e5fd652c811d34bc01f249dbc0d61e5cc4db05ae79a83b \
--hash=sha256:8e1c68303ccf7fceb50fbab79064a2636119fd9aca121f28453709283dbca727 \
--hash=sha256:8e7e2b3577e97fa43c2c2b12a16139b2cedbd0770235d5179c0412b4794efd9b \
--hash=sha256:92f05fc7d832e970047662b3440b190d24ea04f8d3c760e33e7163b67308c878 \
--hash=sha256:97f5811df21703446b42303475b8b855ee07d6ab6cdf8565eff115540624f25d \
--hash=sha256:9affee8cb1ec453382c27eb9043378ab32f49cd4bc24a24275f5c39bf186c279 \
--hash=sha256:a2da4a8c6d465fde36cea7d54bf47b5cf089073452f0e47c8632ecb9dec23c07 \
--hash=sha256:a6903cdca64f1e301af9be424798328c1fe3b4b14aede35f04510989fc72f012 \
--hash=sha256:a8ab1adf04ae2d6d65835995218fd3f3eb644fe20655ca8ee233e2c7270ff53b \
--hash=sha256:a8edd467551c1102dc0f5754ab55cd0703431cd3044edf8c8e7d9208d63fa453 \
--hash=sha256:ac00c41dd315d147b129976204839ca9de699d83519ff1272afbe4fb9d362d12 \
--hash=sha256:ad277f74b1c164f7248afa968700e410651eb858d7c160d109fb451dc45a2f09 \
--hash=sha256:ae46a50d235f1631d9ec4670503f7b30405103034830bc13df29fd947207f795 \
--hash=sha256:afe6b5a04b2ab1aa89bad32ca47bf71358e7302a06fdfdad857389dca8fb5f04 \
--hash=sha256:b1cb078f54af0abd835ca76f93a3152565b73be0f056264da45117d0adf5e99c \
--hash=sha256:b25136212a3d064a8f0b9ebbb6c57094c5229e0de76d15c79b76feff26aeb7b8 \
--hash=sha256:b3226b246facae14909b465061ddcfa2dfeadb6a64f407f24300d42d69bcb1a1 \
--hash=sha256:b98e75b21fc2ba5285aef8efaf34131d16af1c38df36bdca2f50634bea2d3060 \
--hash=sha256:bbd7b24d108509a1b9b6679fcc1166a7dd031dbef1f3c2c73788f42e3ebb3beb \
--hash=sha256:bed57543c99249ab3a4586ddc8786529fbc33309e5e8a1351802a06ca2baf4c2 \
--hash=sha256:c0583f69522732bdd79dca4cd3873e63a29acf4a299769c7541f2ca1e4dd4bc6 \
--hash=sha256:c1e0e9916301e3b3d970814b1439ca59487f0616d30f36a44cead66ee1748c31 \
--hash=sha256:c651847545422c8131660704c58606d841e228ed576c8f1666d98b3d318f89da \
--hash=sha256:c7853f27195598e550fe089f78f0732c66ee1d1f0eaae8ad081589a5a2f5d4af \
--hash=sha256:cbae50d352e4717ffc22c566afc2d0da744380e87ed44a144508e3fb9114a3f4 \
--hash=sha256:cdbed8f21204398f47de39b0a9b180d7e571f02dfb18bf5f1b618e238454b685 \
--hash=sha256:d08395595c42bcd82c3608762ce734504c6d025eef1c06f42326a6023a584186 \
--hash=sha256:d4639111e73997567343df6551da9dd90d66aece1b9fc26c786d328439488103 \
--hash=sha256:d63787f289944cc4bde518ad2b5e70a4f0d6e2ce76324635359c74c113fd188f \
--hash=sha256:d6d5f061f6a2aa55790b9e64a23dfd87b6664ab56e24cd06c78eb43986cb260b \
--hash=sha256:d7865df1fb564092bcf46dac61b5def25342faf6352e4bc0e61a286e3fa26a3d \
--hash=sha256:db6585b600b2e76e98131e0ac0e5195759082b51687ad0c94505970c90718f4a \
--hash=sha256:e36d7369363d2707d5f68950a64c4e025991eb0177db01ccb6aa6facae48b69f \
--hash=sha256:e7947d9a6264c727a556541b1630296bbd5d0a05068d21c38dde8e7a1c703ef0 \
--hash=sha256:eb2d59bc196e6d3b1827c7db06c1a898bfa0787c0574af398e65ccf2e97c0fbe \
--hash=sha256:ee9c2f6ca9774c2c24bbf7b23086264e6b5fa178201450535ec0859739e6f78d \
--hash=sha256:f4760e1b02173f4155203054f77a5dc0b4078de7645c922b208d28e7eb99f3e2 \
--hash=sha256:f70bec8a14a692be6dbe7ce8aab303e88df891cbd4a39af091f90b6702e28055 \
--hash=sha256:f869e34d2326e417baee430ae998e91412cc8e7fdd83d979277a90a0e79a5b47 \
--hash=sha256:f8b9a7cd381970e64849070aca7c32d53ab7d96c66db6c2ef7aa23c6e803f514 \
--hash=sha256:f99d74ddf9d3b6126b509e81865f89bd1283e3fc1b568b68cd7bd9dfa15583d7 \
--hash=sha256:f9e7e493ded7042712a374471203dd43ae3fff5b81e3de1a0513fa241af9fd41 \
--hash=sha256:fc72ae476732cdb7b2c1acb5af23b478b8a0d4b6fcf19b90dd150291e0d5b26b \
--hash=sha256:fccbf0cd3411719e4c9426755df90bf3449d9fc5a89f077f4a7f1abd4f70c910 \
--hash=sha256:ffcf18ad3edf1c170e27e88b10282a2c449aa0358659592462448d71b2000cfc
rpds-py==0.10.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:00e97d43a36811b78fa9ad9d3329bf34f76a31e891a7031a2ac01450c9b168ab \
--hash=sha256:013d6c784150d10236a74b4094a79d96a256b814457e388fc5a4ba9efe24c402 \
--hash=sha256:0188b580c490bccb031e9b67e9e8c695a3c44ac5e06218b152361eca847317c3 \
--hash=sha256:02945ae38fd78efc40900f509890de84cfd5ffe2cd2939eeb3a8800dc68b87cb \
--hash=sha256:02b4a2e28eb24dac4ef43dda4f6a6f7766e355179b143f7d0c76a1c5488a307b \
--hash=sha256:0527c97dcd8bb983822ee31d3760187083fd3ba18ac4dd22cf5347c89d5628f4 \
--hash=sha256:05a1382905026bdd560f806c8c7c16e0f3e3fb359ba8868203ca6e5799884968 \
--hash=sha256:0b309908b6ff5ffbf6394818cb73b5a2a74073acee2c57fe8719046389aeff0d \
--hash=sha256:0fc625059b83695fbb4fc8b7a8b66fa94ff9c7b78c84fb9986cd53ff88a28d80 \
--hash=sha256:177c033e467a66a054dd3a9534167234a3d0b2e41445807b13b626e01da25d92 \
--hash=sha256:18909093944727e068ebfc92e2e6ed1c4fa44135507c1c0555213ce211c53214 \
--hash=sha256:1adb04e4b4e41bf30aaa77eeb169c1b9ba9e5010e2e6ce8d6c17e1446edc9b68 \
--hash=sha256:1ed3d5385d14be894e12a9033be989e012214a9811e7194849c94032ad69682a \
--hash=sha256:203eb1532d51591d32e8dfafd60b5d31347ea7278c8da02b4b550287f6abe28b \
--hash=sha256:213f9ef5c02ec2f883c1075d25a873149daadbaea50d18d622e9db55ec9849c2 \
--hash=sha256:2275f1a022e2383da5d2d101fe11ccdcbae799148c4b83260a4b9309fa3e1fc2 \
--hash=sha256:22e6de18f00583f06928cc8d0993104ecc62f7c6da6478db2255de89a30e45d1 \
--hash=sha256:289073f68452b96e70990085324be7223944c7409973d13ddfe0eea1c1b5663b \
--hash=sha256:29ec8507664f94cc08457d98cfc41c3cdbddfa8952438e644177a29b04937876 \
--hash=sha256:2a55631b93e47956fbc97d69ba2054a8c6a4016f9a3064ec4e031f5f1030cb90 \
--hash=sha256:2a86d246a160d98d820ee7d02dc18c923c228de095be362e57b9fd8970b2c4a1 \
--hash=sha256:2bca97521ee786087f0c5ef318fef3eef0266a9c3deff88205523cf353af7394 \
--hash=sha256:2c8fc6c841ada60a86d29c9ebe2e8757c47eda6553f3596c560e59ca6e9b6fa1 \
--hash=sha256:2cd0c9fb5d40887500b4ed818770c68ab4fa6e0395d286f9704be6751b1b7d98 \
--hash=sha256:2d27d08056fcd61ff47a0cd8407eff4d3e816c82cb6b9c6f0ce9a0ad49225f81 \
--hash=sha256:2ffbf1b38c88d0466de542e91b08225d51782282512f8e2b11715126c41fda48 \
--hash=sha256:3fd503c27e7b7034128e30847ecdb4bff4ca5e60f29ad022a9f66ae8940d54ac \
--hash=sha256:3ff1f585a0fdc1415bd733b804f33d386064a308672249b14828130dd43e7c31 \
--hash=sha256:41bd430b7b63aa802c02964e331ac0b177148fef5f807d2c90d05ce71a52b4d4 \
--hash=sha256:43e9b1531d6a898bdf086acb75c41265c7ec4331267d7619148d407efc72bd24 \
--hash=sha256:46af4a742b90c7460e94214f923452c2c1d050a9da1d2b8d4c70cbc045e692b7 \
--hash=sha256:46c4c550bf59ce05d6bff2c98053822549aaf9fbaf81103edea325e03350bca1 \
--hash=sha256:4969592e3cdeefa4cbb15a26cec102cbd4a1d6e5b695fac9fa026e19741138c8 \
--hash=sha256:4a0536ed2b9297c75104e1a3da330828ba1b2639fa53b38d396f98bf7e3c68df \
--hash=sha256:4a96147791e49e84207dd1530109aa0e9eeaf1c8b7a59f150047fc0fcdf9bb64 \
--hash=sha256:4c7f9d70f99e1fbcbf57c75328b80e1c0a7f6cad43e75efa90a97221be5efe15 \
--hash=sha256:4e8474f7233fe1949ce4e03bea698a600c2d5d6b51dab6d6e6336dbe69acf23e \
--hash=sha256:4f1b804cfad04f862d6a84af9d1ad941b06f671878f0f7ecad6c92007d423de6 \
--hash=sha256:529aab727f54a937085184e7436e1d0e19975cf10115eda12d37a683e4ee5342 \
--hash=sha256:5612b0b1de8d5114520094bd5fc3d04eb8af6f3e10d48ef05b7c8e77c1fd9545 \
--hash=sha256:56777c57246e048908b550af9b81b0ec9cf804fd47cb7502ccd93238bd6025c2 \
--hash=sha256:56ba7c1100ed079527f2b995bf5486a2e557e6d5b733c52e8947476338815b69 \
--hash=sha256:59d222086daa55421d599609b32d0ebe544e57654c4a0a1490c54a7ebaa67561 \
--hash=sha256:5aba767e64b494483ad60c4873bec78d16205a21f8247c99749bd990d9c846c2 \
--hash=sha256:5d5eaf988951f6ecb6854ca3300b87123599c711183c83da7ce39717a7cbdbce \
--hash=sha256:73da69e1f612c3e682e34dcb971272d90d6f27b2c99acff444ca455a89978574 \
--hash=sha256:75c8766734ac0053e1d683567e65e85306c4ec62631b0591caeb287ac8f72e08 \
--hash=sha256:75eea40355a8690459c7291ce6c8ce39c27bd223675c7da6619f510c728feb97 \
--hash=sha256:80c3cf46511653f94dfe07c7c79ab105c4164d6e1dfcb35b7214fb9af53eaef4 \
--hash=sha256:8557c807388e6617161fe51b1a4747ea8d1133f2d2ad8e79583439abebe58fbd \
--hash=sha256:89438e8885a186c69fe31f7ef98bb2bf29688c466c3caf9060f404c0be89ae80 \
--hash=sha256:899b03a3be785a7e1ff84b237da71f0efa2f021512f147dd34ffdf7aa82cb678 \
--hash=sha256:8de9b88f0cbac73cfed34220d13c57849e62a7099a714b929142425e926d223a \
--hash=sha256:8f4d561f4728f825e3b793a53064b606ca0b6fc264f67d09e54af452aafc5b82 \
--hash=sha256:907b214da5d2fcff0b6ddb83de1333890ca92abaf4bbf8d9c61dc1b95c87fd6e \
--hash=sha256:9118de88c16947eaf5b92f749e65b0501ea69e7c2be7bd6aefc12551622360e1 \
--hash=sha256:9568764e72d85cf7855ca78b48e07ed1be47bf230e2cea8dabda3c95f660b0ff \
--hash=sha256:9c74cbee9e532dc34371127f7686d6953e5153a1f22beab7f953d95ee4a0fe09 \
--hash=sha256:9cdfd649011ce2d90cb0dd304c5aba1190fac0c266d19a9e2b96b81cfd150a09 \
--hash=sha256:9f00d54b18dd837f1431d66b076737deb7c29ce3ebb8412ceaf44d5e1954ac0c \
--hash=sha256:a2f416cdfe92f5fbb77177f5f3f7830059d1582db05f2c7119bf80069d1ab69b \
--hash=sha256:a4cb372e22e9c879bd9a9cc9b20b7c1fbf30a605ac953da45ecec05d8a6e1c77 \
--hash=sha256:a65de5c02884760a14a58304fb6303f9ddfc582e630f385daea871e1bdb18686 \
--hash=sha256:aa3b3a43dabc4cc57a7800f526cbe03f71c69121e21b863fdf497b59b462b163 \
--hash=sha256:ab0f7aabdbce4a202e013083eeab71afdb85efa405dc4a06fea98cde81204675 \
--hash=sha256:abe081453166e206e3a8c6d8ace57214c17b6d9477d7601ac14a365344dbc1f4 \
--hash=sha256:ae141c9017f8f473a6ee07a9425da021816a9f8c0683c2e5442f0ccf56b0fc62 \
--hash=sha256:af52078719209bef33e38131486fd784832dd8d1dc9b85f00a44f6e7437dd021 \
--hash=sha256:b00150a9a3fd0a8efaa90bc2696c105b04039d50763dd1c95a34c88c5966cb57 \
--hash=sha256:b2660000e1a113869c86eb5cc07f3343467490f3cd9d0299f81da9ddae7137b7 \
--hash=sha256:b3eb1a0d2b6d232d1bcdfc3fcc5f7b004ab3fbd9203011a3172f051d4527c0b6 \
--hash=sha256:b589d93a60e78fe55d5bc76ee8c2bf945dbdbb7cd16044c53e0307604e448de1 \
--hash=sha256:b8578fc6c8bdd0201327503720fa581000b4bd3934abbf07e2628d1ad3de157d \
--hash=sha256:ba1b28e44f611f3f2b436bd8290050a61db4b59a8e24be4465f44897936b3824 \
--hash=sha256:bb44644371eaa29a3aba7b69b1862d0d56f073bb7585baa32e4271a71a91ee82 \
--hash=sha256:bcde80aefe7054fad6277762fb7e9d35c72ea479a485ae1bb14629c640987b30 \
--hash=sha256:bd1142d22fdb183a0fff66d79134bf644401437fed874f81066d314c67ee193c \
--hash=sha256:bf77f9017fcfa1232f98598a637406e6c33982ccba8a5922339575c3e2b90ea5 \
--hash=sha256:c2772bb95062e3f9774140205cd65d8997e39620715486cf5f843cf4ad8f744c \
--hash=sha256:c4ecc4e9a5d73a816cae36ee6b5d8b7a0c72013cae1e101406e832887c3dc2d8 \
--hash=sha256:c86231c66e4f422e7c13ea6200bb4048b3016c8bfd11b4fd0dabd04d2c8e3501 \
--hash=sha256:c8f6526df47953b07c45b95c4d1da6b9a0861c0e5da0271db96bb1d807825412 \
--hash=sha256:ccfb77f6dc8abffa6f1c7e3975ed9070a41ce5fcc11154d2bead8c1baa940f09 \
--hash=sha256:d9d7efaad48b859053b90dedd69bc92f2095084251e732e4c57ac9726bcb1e64 \
--hash=sha256:dd91a7d7a9ce7f4983097c91ce211f3e5569cc21caa16f2692298a07e396f82b \
--hash=sha256:de4a2fd524993578fe093044f291b4b24aab134390030b3b9b5f87fd41ab7e75 \
--hash=sha256:df61f818edf7c8626bfa392f825860fb670b5f8336e238eb0ec7e2a5689cdded \
--hash=sha256:e1147bc3d0dd1e549d991110d0a09557ec9f925dbc1ca62871fcdab2ec9d716b \
--hash=sha256:e1954f4b239d1a92081647eecfd51cbfd08ea16eb743b8af1cd0113258feea14 \
--hash=sha256:e281b71922208e00886e4b7ffbfcf27874486364f177418ab676f102130e7ec9 \
--hash=sha256:e69737bd56006a86fd5a78b2b85447580a6138c930a75eb9ef39fe03d90782b1 \
--hash=sha256:e82b4a70cc67094f3f3fd77579702f48fcf1de7bdc67d79b8f1e24d089a6162c \
--hash=sha256:e92e5817eb6bfed23aa5e45bfe30647b83602bdd6f9e25d63524d4e6258458b0 \
--hash=sha256:eaba0613c759ebf95988a84f766ca6b7432d55ce399194f95dde588ad1be0878 \
--hash=sha256:edd74b760a6bb950397e7a7bd2f38e6700f6525062650b1d77c6d851b82f02c2 \
--hash=sha256:f40abbcc0a7d9a8a80870af839d317e6932533f98682aabd977add6c53beeb23 \
--hash=sha256:fce7a8ee8d0f682c953c0188735d823f0fcb62779bf92cd6ba473a8e730e26ad
sqlparse==0.4.4 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3 \
--hash=sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c

104
scripts/entrypoint.sh Executable file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env sh
# set global variables:
_DB_URL="${DB_URL}"
# parses the database connection url and sets global variables:
#
# _DBMS: {psql, sqlite} default dbms used
# _DB_HOST {domain,ip_address} for connections
# _DB_PORT {5432,3306,etc} for connections
# _DB_USER {root,admin,etc} db username
# _DB_PASSWORD {super_secret} db user password
parse_db_url() {
local db_url=$1
echo "-------- Check used DBMS --------"
if [ -n "${db_url}" ]; then
# ! PARSING:
# -- -- --
# extract the protocol
local proto="$(echo "${db_url}" | grep '://' | sed -e's,^\(.*://\).*,\1,g')"
local dbms=${proto%:*}
# if dpms is "sqlite" we do not need parse url args
if [ "${dbms}" = "psql" ]; then
# remove the protocol (clean url)
local url=$(echo "${db_url}" | sed -e s,"${proto}",,g)
# extract the user and password (if any)
local userpass="$(echo "${url}" | grep @ | cut -d@ -f1)"
local password=$(echo "${userpass}" | grep : | cut -d: -f2)
# extract the host and port connection
local hostport=$(echo "${url}" | sed -e s,$userpass@,,g | cut -d/ -f1)
local port=$(echo "${hostport}" | grep : | cut -d: -f2)
# ! SET VARIABLES:
# -- -- -- -- --
# _DB_HOST or/and _DB_PORT for test db connection
if [ -n "${port}" ]; then
_DB_HOST=$(echo "${hostport}" | grep : | cut -d: -f1)
_DB_PORT="${port}"
else
_DB_HOST="${hostport}"
fi
# _DB_USER or/and _DB_PASSWORD for future features
if [ -n "${password}" ]; then
_DB_USER=$(echo "${userpass}" | grep : | cut -d: -f1)
_DB_PASSWORD="${password}"
else
_DB_USER="${userpass}"
fi
fi
# _DBMS (sqlite or psql)
_DBMS="${dbms}"
echo "${_DBMS} is set as the default DBMS"
else
# _DBMS (sqlite)
_DBMS="sqlite"
echo "${_DBMS} is set as the default DBMS"
fi
}
# waits until the database becomes available for connection
wait_db() {
local dbms=$1
local db_host=$2
local db_port=$3
if [ "${dbms}" = "psql" ];then
echo "------- Waiting for database start -------"
while ! nc -z $db_host $db_port; do
sleep 0.1
done
echo "PostgreSQL started"
fi
}
# app config performs database migration, collects static files
app_config() {
echo "-------- Apply migration --------"
python manage.py migrate --no-input
echo "----- Collect static files ------"
python manage.py collectstatic --no-input
}
# run app wsgi web server
app_run() {
echo "--------- Run gunicorn ----------"
gunicorn logs_collector.wsgi:application
}
# app entrypoint
main() {
parse_db_url $_DB_URL
wait_db $_DBMS $_DB_HOST $_DB_PORT
app_config
app_run
}
# RUN
main
exec "$@"

159
scripts/uploader.sh Executable file
View File

@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# INIT GLOBAL VARIABLES:
_VERSION="0.1.0"
_PACKGMGR="apt yum"
_SCRIPT_NAME="$0"
_CMD="curl"
_FILE=""
_TOKEN=""
_URL=""
# Colorize output
# Usage - $(colorize CYAN "Hello, friend!")
colorize() {
local RED="\033[0;31m"
local GREEN="\033[0;32m" # <-- [0 means not bold
local YELLOW="\033[1;33m" # <-- [1 means bold
local BLUE="\033[0;34m"
local MAGNETA="\033[0;35"
local CYAN="\033[1;36m"
# ... Add more colors if you like
local NC="\033[0m" # No Color
# printf "${(P)1}${2} ${NC}\n" # <-- zsh
# printf "${!1}${2} ${NC}\n" # <-- bash
echo -e "${!1}${2}${NC}" # <-- all-purpose
}
# checks whether the utility is installed
# takes the util name as input $_CMD
check_util_exists() {
local request_util=$1
if ! command -v $request_util >/dev/null; then
return 1
fi
}
# Print help message how used it script
help() {
local script=$(colorize GREEN "$_SCRIPT_NAME")
local required=$(colorize RED "required")
printf "Usage: $script [options [parameters]]\n"
printf "\n"
printf "Options:\n"
printf "\n"
printf " -f | --file full path to upload file $required\n"
printf " -t | --token access token $required\n"
printf " -u | --url target url $required\n"
printf " -v | --version print version\n"
printf " -h | --help print help\n"
}
# parse user arguments
argparser() {
# count user-passed arguments:
local count_arguments=$#
# run help if empty and exit:
if [[ count_arguments -eq 0 ]]; then
help
exit 2
fi
# parse args:
while [ ! -z "$1" ]; do
case "$1" in
--file|-f)
shift
_FILE="$1"
;;
--token|-t)
shift
_TOKEN="$1"
;;
--url|-u)
shift
_URL="$1"
;;
--help|-h)
help
exit 0
;;
--version|-v)
printf "$_VERSION\n"
exit 0
;;
*)
help
exit 2
;;
esac
shift
done
}
# check curl is exists:
curl_is_exists() {
if ! check_util_exists $_CMD; then
local error_cmd=$(colorize RED "$_CMD")
printf "$(colorize RED "ERROR"): upload util doesn't exist, "
printf "please install $error_cmd before run $_SCRIPT_NAME\n"
# Print how install curl (support only apt/yum):
for pkgmgr in $_PACKGMGR; do
if check_util_exists $pkgmgr; then
printf "$(colorize GREEN "RUN"): $pkgmgr install $error_cmd"
fi
done
exit 1
fi
}
validate_args() {
if [[ -z $_URL ]]; then
printf "$(colorize RED "ERROR"): -u | --url argument is required\n"
exit 1
fi
if [[ -z $_FILE ]]; then
printf "$(colorize RED "ERROR"): -f | --file argument is required\n"
exit 1
fi
if [[ -z $_TOKEN ]]; then
printf "$(colorize RED "ERROR"): -t | --token argument is required\n"
exit 1
fi
}
# Upload file used curl
# get $_URL $_FILE $_TOKEN
upload() {
local url=$1
local file=$2
local token=$3
# run:
curl --progress-bar -X 'POST' \
"${url}" \
-H 'accept: application/json' \
-H "Upload-Token: ${token} " \
-H 'Content-Type: multipart/form-data' \
-F "file=@${file}" | cat # cat required to show progress bar
}
main () {
argparser $@
curl_is_exists
validate_args
upload $_URL $_FILE $_TOKEN
}
# RUN IT:
main $@