Compare commits

65 Commits

Author SHA1 Message Date
c197b16eb2 Update: README.md how use uploader.sh 2023-09-24 11:57:58 +09:00
baacd46248 Modify: uploader.sh url option changed to dst to upload the file, only need to specify the domain name 2023-09-23 16:04:13 +09:00
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
a3d29cd867 Modify: delete asterisk css style crispy form from 2fa login template 2023-09-02 20:24:04 +09:00
eec55b803d Modify: Archive model - add size field, now use this field for web and admin page 2023-09-02 20:22:40 +09:00
332764fca2 Modify: admin panel fields, filters and decorations 2023-09-02 17:54:53 +09:00
d314fe7e4e Add: resolved tickets filter 2023-09-01 17:58:58 +09:00
40a07bf78d Modify: default gunicorn config values 2023-09-01 16:53:54 +09:00
ebf8c83bc4 Add: FileUploadParser to ArchiveViewSet 2023-08-31 23:02:13 +09:00
7963e2fad5 Modify: replace gunicorn to gunicorn async add gunicorn config file 2023-08-31 20:53:10 +09:00
790be86f13 Fix: ajax upload error message and increase the request timeout 2023-08-31 13:34:40 +09:00
20aa084c90 Fix: docker csrf_trusted_origins 2023-08-31 01:23:25 +09:00
0834474a59 Fix: login page adaptive size class 2023-08-30 23:21:38 +09:00
fbacdfd590 Add: info alert to upload archive template 2023-08-30 23:04:12 +09:00
2b32caa585 Create: dockerized app 2023-08-30 22:26:53 +09:00
9ae11345d5 Modify: now filefield in Archive model is required 2023-08-30 20:59:24 +09:00
b65481207a Fix: custom storage path 2023-08-30 16:38:28 +09:00
68bec09209 Add: gunicorn 2023-08-30 12:51:08 +09:00
30394aa055 Add: export poetry requirements.txt 2023-08-30 11:03:18 +09:00
55374c5874 Modify: freeze dependencies versions 2023-08-30 10:59:16 +09:00
65b10e841c Add: whitenoise to manage staticfiles 2023-08-30 01:17:57 +09:00
30939755f7 Create: collector_api tests 2023-08-29 16:02:52 +09:00
5fd2bc953e Create: collector tests 2023-08-29 00:36:55 +09:00
87a6ca06e6 Create: views to upload files by ajax 2023-08-17 00:53:13 +09:00
fd19181eff Refactoring: using the apps directory is redundant 2023-08-15 13:00:24 +09:00
e45d1af857 Refactoring: new multi app structure 2023-08-15 03:13:07 +09:00
30b3efa5fc Create: MVP 2fa account app 2023-08-14 16:56:17 +09:00
2e648ac4fe Refactoring: views now use api for ajax 2023-08-13 16:31:40 +09:00
e05319ec3c Add: jwt auth endpoints 2023-08-12 18:17:40 +09:00
2ae385b941 Add: swagger 2023-08-12 02:32:31 +09:00
94640a70fa Add: simple filters to modelviewsets 2023-08-11 22:12:51 +09:00
3f37ed95ed Add: modelviewsets for all models 2023-08-11 10:38:47 +09:00
90d7e64db3 Add: public api view for upload ticket 2023-08-09 20:24:16 +09:00
02906554dd Add: token field to template, forms 2023-08-09 16:53:20 +09:00
cc7043f013 Remove: Token model use token field only in ticket model 2023-08-09 01:59:17 +09:00
116c0e4f41 Add: Token model 2023-08-08 20:27:45 +09:00
51d1c09540 Modified: Archive sha1 to md5 hashsum field 2023-08-08 19:05:11 +09:00
129 changed files with 5602 additions and 783 deletions

153
.dockerignore Normal file
View File

@@ -0,0 +1,153 @@
# Created by .ignore support plugin (hsz.mobi)
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
#*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
**/*.env
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Diagrams
diagrams/
# nixpkgs
flake.nix
flake.lock
# README
*.md
**/archives

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;

64
Dockerfile Normal file
View File

@@ -0,0 +1,64 @@
# app/Dockerfile
# pull the official docker image
FROM python:3.10-alpine as base
# set env variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=on
# install app dependences
COPY requirements.txt ./
RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt
# 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 me.zhukovsky.logs-collector.version=v${VERSION}
# run app
ENTRYPOINT [ "sh", "entrypoint.sh" ]

334
README-ru.md Normal file
View File

@@ -0,0 +1,334 @@
# 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
-d | --dst storage domain name required
-v | --version print version
-h | --help print help
```
#### Пример:
```sh
./uploader.sh \
--dst collector.domain.zone \
--token e63268f4-5946-42eb-a678-b02182f14e87 \
--file /root/logs/all-logs.tar.gz
```
## Лицензия
GNU GPL 3.0

337
README.md
View File

@@ -1,3 +1,336 @@
# 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
-d | --dst storage domain name required
-v | --version print version
-h | --help print help
```
#### Example:
```sh
./uploader.sh \
--dst collector.domain.zone \
--token e63268f4-5946-42eb-a678-b02182f14e87 \
--file /root/logs/all-logs.tar.gz
```
## License
GNU GPL 3.0

38
docker-compose.yaml Normal file
View File

@@ -0,0 +1,38 @@
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:
- type: volume
source: logs_collector_data
target: ${APP_DIR}/data
env_file:
- ./.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

View File

@@ -0,0 +1,6 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
admin.site.register(User, UserAdmin)

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'account'
verbose_name = 'Auth and account management'

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

@@ -0,0 +1,10 @@
from django.urls import reverse
from django.contrib.auth.models import AbstractUser
# 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

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

@@ -0,0 +1,74 @@
from django.conf import settings
from django.urls import path, reverse_lazy
from django.contrib.auth.views import (
LogoutView,
PasswordChangeView,
PasswordChangeDoneView
)
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
TokenVerifyView
)
from . import views
app_name = 'account'
urlpatterns = [
# WEB LOGOUT:
path(
'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 += [
# JWT AUTH:
path(
'api/v1/auth/token/',
TokenObtainPairView.as_view(),
name='token_obtain_pair'
),
path(
'api/v1/auth/token/refresh/',
TokenRefreshView.as_view(),
name='token_refresh'
),
path(
'api/v1/auth/token/verify/',
TokenVerifyView.as_view(),
name='token_verify'
),
]

View File

@@ -0,0 +1,46 @@
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect
from django.shortcuts import resolve_url
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme # renamed Dj^3.*
from two_factor.admin import AdminSiteOTPRequired, AdminSiteOTPRequiredMixin
# https://stackoverflow.com/questions/48600737/django-two-factor-auth-cant-access-admin-site
class AdminSiteOTPRequiredMixinRedirectSetup(AdminSiteOTPRequired):
"""
Fixes the current implementation of django-two-factor-auth = 1.15.3
when admin page is patched for 2fa
(circular redirect - super user created with manage.py
and cannot log in because he does not have a device configured).
The class redirects to the setup page.
After that, you can log in as usual.
"""
def login(self, request, extra_context=None):
redirect_to = request.POST.get(
REDIRECT_FIELD_NAME, request.GET.get(REDIRECT_FIELD_NAME)
)
# For users not yet verified the AdminSiteOTPRequired.has_permission
# will fail. So use the standard admin has_permission check:
# (is_active and is_staff) and then check for verification.
# Go to index if they pass, otherwise make them setup OTP device.
if request.method == "GET" and super(
AdminSiteOTPRequiredMixin, self
).has_permission(request):
# Already logged-in and verified by OTP
if request.user.is_verified():
# User has permission
index_path = reverse("admin:index", current_app=self.name)
else:
# User has permission but no OTP set:
index_path = reverse("two_factor:setup", current_app=self.name)
return HttpResponseRedirect(index_path)
if not redirect_to or not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[request.get_host()]
):
redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL)
return redirect_to_login(redirect_to)

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

@@ -1,21 +1,103 @@
from django.contrib import admin from django.contrib import admin, messages
from django.db.models import F
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import ngettext
from .models import Platform, Archive, Ticket from .models import Platform, Archive, Ticket
from .utils.helpers import sizify
# Register your models here.
class PlatformAdmin(admin.ModelAdmin): class PlatformAdmin(admin.ModelAdmin):
pass list_display = ('id', 'name', 'pretty_name')
search_fields = ('name',)
class TicketAdmin(admin.ModelAdmin): class TicketAdmin(admin.ModelAdmin):
pass list_display = (
'id',
'number',
'token',
'platform',
'user',
'time_create',
'time_update',
'resolved',
)
search_fields = ('number',)
list_filter = ('platform', 'resolved', 'time_create',)
actions = ['make_resolved', 'make_unresolved']
@admin.action(description='Mark selected ticket(s) as resolved')
def make_resolved(self, request, queryset):
updated = queryset.update(resolved=True)
self.message_user(
request,
ngettext(
f'{updated} ticket was successfully marked as resolved.',
f'{updated} tickets were successfully marked as resolved.',
number=updated,
),
messages.SUCCESS,
)
@admin.action(description='Mark selected ticket(s) as unresolved')
def make_unresolved(self, request, queryset):
updated = queryset.update(resolved=False)
self.message_user(
request,
ngettext(
f'{updated} ticket was successfully marked as unresolved.',
f'{updated} tickets were successfully marked as unresolved.',
number=updated,
),
messages.SUCCESS,
)
class ArchiveAdmin(admin.ModelAdmin): class ArchiveAdmin(admin.ModelAdmin):
pass list_display = (
'id',
'file_link',
'file_size',
'md5',
'ticket',
'time_create'
)
search_fields = ('ticket',)
list_filter = ('time_create', 'ticket')
def get_queryset(self, request):
qs = super(ArchiveAdmin, self).get_queryset(request)
qs = qs.annotate(file_size=F('size'))
return qs
def file_size(self, obj):
return sizify(obj.size)
def file_link(self, obj):
if obj.file:
file_name = obj.file.name.rpartition('/')[-1]
file_path = reverse(
'collector:download',
kwargs={'path': obj.file}
)
return format_html(
'<a href="{file_path}">{file_name}</a>',
file_path=file_path,
file_name=file_name
)
else:
return "No attachment"
file_link.allow_tags = True
file_link.short_description = 'File Download'
file_size.admin_order_field = 'file_size'
admin.site.register(Platform, PlatformAdmin) admin.site.register(Platform, PlatformAdmin)
admin.site.register(Ticket, TicketAdmin) admin.site.register(Ticket, TicketAdmin)
admin.site.register(Archive, ArchiveAdmin) admin.site.register(Archive, ArchiveAdmin)
admin.site.site_title = 'Logs Collector'
admin.site.site_header = 'LOGS COLLECTOR'

View File

View File

@@ -0,0 +1,66 @@
from django import forms
from django_filters.rest_framework import (
CharFilter,
FilterSet,
NumberFilter,
BaseInFilter,
)
from django_filters import widgets
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', 'lte', 'gte'],
'ticket': ['exact', 'lte', 'gte'],
'time_create': ['exact', 'lte', 'gte']
}
class TicketFilter(DateTimeFilterMixin, FilterSet):
number = NumberInFilter(
field_name='number',
widget=TextareaCSVWidget(),
)
user = CharFilter(
field_name='user__username'
)
class Meta:
model = Ticket
fields = {
'id': ['exact', 'in', 'lte', 'gte'],
'number': ['exact', 'contains', 'in', 'lte', 'gte'],
'resolved': ['exact'],
'user': ['exact']
}

View File

@@ -0,0 +1,25 @@
from rest_framework import permissions
class IsGuestUpload(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', 'POST',):
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

@@ -0,0 +1,91 @@
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.openapi import OpenApiTypes
from collector.models import Archive, Platform, Ticket
@extend_schema_field(OpenApiTypes.INT)
class TimestampField(serializers.Field):
def to_representation(self, value) -> int:
return value.timestamp()
@extend_schema_field(OpenApiTypes.INT)
class JsTimestampField(serializers.Field):
def to_representation(self, value) -> int:
return round(value.timestamp()*1000)
class PublicArchiveUploadSerializer(serializers.ModelSerializer):
class Meta:
model = Archive
fields = ['file', 'ticket']
class ArchiveSerializer(serializers.ModelSerializer):
time_create = serializers.DateTimeField(read_only=True)
class Meta:
model = Archive
fields = ['id', 'file', 'ticket', 'time_create']
class PlatformSerializer(serializers.ModelSerializer):
class Meta:
model = Platform
fields = ['id', 'name', 'pretty_name']
class TicketSerializer(serializers.ModelSerializer):
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')
class Meta:
model = Ticket
fields = [
'id',
'number',
'resolved',
'token',
'attempts',
'platform',
'time_create',
'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

@@ -0,0 +1,35 @@
from django.urls import resolve, reverse
from django.test import TestCase
from collector.api import views
class TestUrls(TestCase):
def test_list_all_archives_url_is_resolved(self):
url = reverse('collector_api:archive-list')
self.assertEquals(resolve(url).func.cls, views.ArchiveViewSet)
def test_detail_archive_url_is_resolved(self):
url = reverse('collector_api:archive-detail', args=[1])
self.assertEquals(resolve(url).func.cls, views.ArchiveViewSet)
def test_list_all_tickets_url_is_resolved(self):
url = reverse('collector_api:ticket-list')
self.assertEquals(resolve(url).func.cls, views.TicketViewSet)
def test_detail_ticket_url_is_resolved(self):
url = reverse('collector_api:ticket-detail', args=[1488228])
self.assertEquals(resolve(url).func.cls, views.TicketViewSet)
def test_list_all_platforms_url_is_resolved(self):
url = reverse('collector_api:platform-list')
self.assertEquals(resolve(url).func.cls, views.PlatformViewSet)
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

@@ -0,0 +1,112 @@
from pathlib import Path
from django.core.files.base import ContentFile
from django.conf import settings
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
def delete_test_files(ticket):
test_file = settings.MEDIA_ROOT / Path(str(ticket))
test_file.unlink(missing_ok=True)
class TestViews(APITestCase):
def setUp(self):
self.test_user = User.objects.create(
username='oops',
password='hG2wS231YPWmj3'
)
self.test_platform = Platform.objects.create(
name='vm6',
pretty_name='VMmanager 6'
)
self.test_ticket = Ticket.objects.create(
number=1488228,
platform=self.test_platform,
token='e04f4c3c-ef80-49ee-a2c3-89b737a67cdb',
user=self.test_user
)
self.archive_list_url = reverse('collector_api:archive-list')
self.test_file = ContentFile(b'...', name='test.tar.gz')
self.test_upload_data = {'file': self.test_file}
def test_success_upload_file_from_anon_user(self):
response = self.client.post(
self.archive_list_url,
self.test_upload_data,
format='multipart',
headers={'Upload-Token': self.test_ticket.token}
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
test_archive = Archive.objects.last()
# ? workaround delete test file:
delete_test_files(test_archive.file)
def test_deny_401_upload_file_from_anon_user(self):
response = self.client.post(
self.archive_list_url,
self.test_upload_data,
format='multipart',
# missing header: Upload-Token
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_deny_403_wrong_token_upload_file_from_anon_user(self):
response = self.client.post(
self.archive_list_url,
self.test_upload_data,
format='multipart',
headers={'Upload-Token': 'wrong-token'}
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_deny_423_expired_token_upload_file_from_anon_user(self):
# attribute overload (expired):
self.test_ticket.attempts = 0
self.test_ticket.save()
response = self.client.post(
self.archive_list_url,
self.test_upload_data,
format='multipart',
headers={'Upload-Token': self.test_ticket.token}
)
self.assertEqual(response.status_code, status.HTTP_423_LOCKED)
def test_deny_423_resolved_ticket_state_upload_file_from_anon_user(self):
# attribute overload (ticket resolved):
self.test_ticket.resolved = True
self.test_ticket.save()
response = self.client.post(
self.archive_list_url,
self.test_upload_data,
format='multipart',
headers={'Upload-Token': self.test_ticket.token}
)
self.assertEqual(response.status_code, status.HTTP_423_LOCKED)
def test_deny_archive_list_GET_method_for_anon_user(self):
response = self.client.get(self.archive_list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_deny_archive_list_PUT_method_for_anon_user(self):
response = self.client.put(
self.archive_list_url,
self.test_upload_data,
format='multipart',
headers={'Upload-Token': self.test_ticket.token}
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_deny_archive_list_DELETE_method_for_anon_user(self):
response = self.client.delete(
reverse('collector_api:archive-list'),
data={'id': 1},
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

View File

@@ -0,0 +1,45 @@
from django.urls import path, include
from rest_framework import routers
from . import views
# ▄▀█ █▀█ █
# █▀█ █▀▀ █
# -- -- --
app_name = 'collector_api'
router = routers.DefaultRouter()
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

@@ -0,0 +1,20 @@
from django_filters import NumberFilter
class DateTimeFilterMixin:
year__gte = NumberFilter(
field_name='time_create',
lookup_expr='year__gte'
)
year__lte = NumberFilter(
field_name='time_create',
lookup_expr='year__lte'
)
month__gte = NumberFilter(
field_name='time_create',
lookup_expr='month__gte'
)
month__lte = NumberFilter(
field_name='time_create',
lookup_expr='month__lte'
)

View File

@@ -0,0 +1,218 @@
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.conf import settings
from rest_framework import status
# from rest_framework.decorators import action
from rest_framework.parsers import (
FormParser,
MultiPartParser,
FileUploadParser
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import filters, generics, views, viewsets
from django_filters.rest_framework import DjangoFilterBackend
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, IsGuestCheckUrls
from .serializers import (
PublicArchiveUploadSerializer,
ArchiveSerializer,
PlatformSerializer,
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
parser_classes = (MultiPartParser, FormParser, FileUploadParser)
permission_classes = (IsGuestUpload, )
filter_backends = [DjangoFilterBackend]
filterset_class = ArchiveFilter
@extend_schema(
operation_id='upload_file',
request={
'multipart/form-data': {
'type': 'object',
'properties': {
'file': {
'type': 'string',
'format': 'binary'
}
}
}
},
parameters=[
OpenApiParameter(
name='Upload-Token',
type=str,
location=OpenApiParameter.HEADER,
description="upload permission token",
),
]
)
def create(self, request, *args, **kwargs):
# ! upload-token protection:
upload_token = request.headers.get('upload-token', '')
if upload_token:
try:
bound_ticket = Ticket.objects.get(token=upload_token)
if bound_ticket.resolved:
return Response(
{'detail': f'ticket {bound_ticket} already resolved'},
status=status.HTTP_423_LOCKED
)
if bound_ticket.attempts <= 0:
return Response(
{'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(
{'detail': f'token {upload_token} is not valid'},
status=status.HTTP_403_FORBIDDEN
)
else:
return Response(
{'detail': 'Header Upload-Token is required'},
status=status.HTTP_401_UNAUTHORIZED
)
# ! default create method:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(
serializer.data,
status=status.HTTP_201_CREATED,
headers=headers
)
@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'
serializer_class = PlatformSerializer
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'
serializer_class = TicketSerializer
permission_classes = (IsAuthenticated, )
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_class = TicketFilter
search_fields = ['number']
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

@@ -4,3 +4,4 @@ from django.apps import AppConfig
class CollectorConfig(AppConfig): class CollectorConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'collector' name = 'collector'
verbose_name = 'Collector archives for analyse'

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

@@ -3,14 +3,14 @@ from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit, Div from crispy_forms.layout import Layout, Submit, Div
from crispy_bootstrap5.bootstrap5 import FloatingField from crispy_bootstrap5.bootstrap5 import FloatingField
from .models import Ticket from .models import Ticket, Archive
class TicketForm(forms.ModelForm): class TicketForm(forms.ModelForm):
class Meta: class Meta:
model = Ticket model = Ticket
fields = ['number', 'platform', 'note'] fields = ['number', 'attempts', 'platform', 'note']
widgets = { widgets = {
'platform': forms.RadioSelect() 'platform': forms.RadioSelect()
} }
@@ -21,7 +21,33 @@ class TicketForm(forms.ModelForm):
# self.helper.attrs = {"novalidate": ''} # self.helper.attrs = {"novalidate": ''}
self.helper.layout = Layout( self.helper.layout = Layout(
Div(FloatingField('number'), 'platform', css_class='col-lg-2'), Div(
FloatingField('number', 'attempts'),
'platform',
css_class='col-lg-2'
),
Div('note', css_class='col-lg-6'), Div('note', css_class='col-lg-6'),
Submit('submit', 'Save', css_class='btn btn-primary'), Submit('submit', 'Save', css_class='btn btn-primary'),
) )
class ArchiveForm(forms.ModelForm):
token = forms.UUIDField(required=True)
class Meta:
model = Archive
fields = ['token', 'file']
def __init__(self, *args, **kwargs):
super(ArchiveForm, self).__init__(*args, **kwargs)
self.helper = FormHelper(self)
self.helper.form_id = 'upload_form'
self.helper.layout = Layout(
Div(
FloatingField('token'),
'file',
css_class='col-lg-6'
),
Submit('submit', 'Upload', css_class='btn btn-primary'),
)

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

@@ -1,11 +1,11 @@
# Generated by Django 4.2 on 2023-07-28 14:40 # Generated by Django 4.2 on 2023-09-02 09:30
import collector.utils import collector.utils
from django.conf import settings from django.conf import settings
import django.core.files.storage import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import pathlib import uuid
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
name='Platform', name='Platform',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20)), ('name', models.CharField(max_length=20, unique=True)),
('pretty_name', models.CharField(max_length=20)), ('pretty_name', models.CharField(max_length=20)),
], ],
), ),
@@ -29,26 +29,30 @@ class Migration(migrations.Migration):
name='Ticket', name='Ticket',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('number', models.IntegerField()), ('number', models.IntegerField(db_index=True, unique=True)),
('resolved', models.BooleanField(default=False)), ('resolved', models.BooleanField(default=False)),
('note', models.TextField(blank=True)), ('note', models.TextField(blank=True)),
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('attempts', models.IntegerField(default=5, validators=[django.core.validators.MaxValueValidator(10), django.core.validators.MinValueValidator(0)])),
('time_create', models.DateTimeField(auto_now_add=True)), ('time_create', models.DateTimeField(auto_now_add=True)),
('time_update', models.DateTimeField(auto_now=True)), ('time_update', models.DateTimeField(auto_now=True)),
('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='collector.platform')), ('platform', models.ForeignKey(db_column='platform_name', on_delete=django.db.models.deletion.CASCADE, to='collector.platform', to_field='name')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
], ],
options={
'ordering': ['-time_create'],
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='Archive', name='Archive',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(blank=True, null=True, storage=django.core.files.storage.FileSystemStorage(base_url='/archives/', location=pathlib.PurePosixPath('/home/stepan/Documents/Dev/ISPsystem/logs-collector/logs_collector/archives')), upload_to=collector.utils.logs_dir_path)), ('file', models.FileField(upload_to=collector.utils.helpers.logs_dir_path)),
('size', models.CharField(blank=True, max_length=50)), ('size', models.BigIntegerField(editable=False)),
('sha1', models.CharField(editable=False, max_length=1024)), ('md5', models.CharField(editable=False, max_length=1024)),
('time_create', models.DateTimeField(auto_now_add=True)), ('time_create', models.DateTimeField(auto_now_add=True)),
('time_update', models.DateTimeField(auto_now=True)), ('time_update', models.DateTimeField(auto_now=True)),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='collector.ticket')), ('ticket', models.ForeignKey(db_column='ticket_number', on_delete=django.db.models.deletion.CASCADE, to='collector.ticket', to_field='number')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
], ],
), ),
] ]

View File

@@ -1,27 +0,0 @@
# Generated by Django 4.2 on 2023-08-05 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('collector', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='ticket',
options={'ordering': ['-time_create']},
),
migrations.AlterField(
model_name='archive',
name='size',
field=models.CharField(blank=True, editable=False, max_length=50),
),
migrations.AlterField(
model_name='ticket',
name='number',
field=models.IntegerField(db_index=True, unique=True),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 4.2 on 2023-08-08 05:52
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('collector', '0002_alter_ticket_options_alter_archive_size_and_more'),
]
operations = [
migrations.AlterField(
model_name='archive',
name='ticket',
field=models.ForeignKey(db_column='ticket_number', on_delete=django.db.models.deletion.CASCADE, to='collector.ticket', to_field='number'),
),
]

View File

@@ -1,35 +1,19 @@
import uuid
import hashlib import hashlib
from functools import partial
from django.contrib.auth.models import User from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.urls import reverse from django.urls import reverse
from .utils import logs_dir_path from account.models import User
from .utils.helpers import logs_dir_path
# Create a custom storage location, using a value from your settings file
sensitive_upload_storage = FileSystemStorage(
location=settings.MEDIA_ROOT_FOR_SENSITIVE_FILES,
base_url=settings.MEDIA_URL_FOR_SENSITIVE_FILES
)
# ... and a file field that will use the custom storage
AuthenticatedFileField = partial(
models.FileField,
storage=sensitive_upload_storage
)
class Archive(models.Model): class Archive(models.Model):
file = AuthenticatedFileField( file = models.FileField(upload_to=logs_dir_path)
upload_to=logs_dir_path, size = models.BigIntegerField(editable=False)
blank=True, md5 = models.CharField(max_length=1024, editable=False)
null=True
)
size = models.CharField(max_length=50, blank=True, editable=False)
sha1 = models.CharField(max_length=1024, editable=False)
time_create = models.DateTimeField(auto_now_add=True) time_create = models.DateTimeField(auto_now_add=True)
time_update = models.DateTimeField(auto_now=True) time_update = models.DateTimeField(auto_now=True)
ticket = models.ForeignKey( ticket = models.ForeignKey(
@@ -38,15 +22,16 @@ class Archive(models.Model):
db_column='ticket_number', db_column='ticket_number',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
user = models.ForeignKey(User, on_delete=models.CASCADE)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# calculate sha 1 hash sum and write sha1 field to db # add file size in bytes
self.size = self.file.size
# calculate md5 hash sum and write md5 field to db
with self.file.open('rb') as f: with self.file.open('rb') as f:
sha1 = hashlib.sha1() md5 = hashlib.md5()
for byte_block in iter(lambda: f.read(4096), b""): for byte_block in iter(lambda: f.read(4096), b""):
sha1.update(byte_block) md5.update(byte_block)
self.sha1 = sha1.hexdigest() self.md5 = md5.hexdigest()
# Call the "real" save() method # Call the "real" save() method
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -58,7 +43,7 @@ class Archive(models.Model):
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=20) name = models.CharField(max_length=20, unique=True)
pretty_name = models.CharField(max_length=20) pretty_name = models.CharField(max_length=20)
def get_absolute_url(self): def get_absolute_url(self):
@@ -72,9 +57,19 @@ class Ticket(models.Model):
number = models.IntegerField(unique=True, db_index=True) number = models.IntegerField(unique=True, db_index=True)
resolved = models.BooleanField(default=False) resolved = models.BooleanField(default=False)
note = models.TextField(blank=True) note = models.TextField(blank=True)
token = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
attempts = models.IntegerField(default=5, validators=[
MaxValueValidator(10),
MinValueValidator(0)
])
time_create = models.DateTimeField(auto_now_add=True) time_create = models.DateTimeField(auto_now_add=True)
time_update = models.DateTimeField(auto_now=True) time_update = models.DateTimeField(auto_now=True)
platform = models.ForeignKey('Platform', on_delete=models.CASCADE) platform = models.ForeignKey(
'Platform',
to_field='name',
db_column='platform_name',
on_delete=models.CASCADE
)
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta: class Meta:

View File

@@ -1,17 +0,0 @@
from rest_framework import serializers
from .models import Archive, Ticket
class ArchiveUploadSerializer(serializers.ModelSerializer):
class Meta:
model = Archive
fields = ['file', 'ticket']
class TicketSerializer(serializers.ModelSerializer):
class Meta:
model = Ticket
fields = ['number', 'platform', 'note']

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 () { $(function () {
console.log("JQ is ready to work"); console.log("JQ is ready to work");
@@ -12,7 +15,7 @@ $(function () {
const archiveListElement = $(this).attr("data-jq-archive-target"); const archiveListElement = $(this).attr("data-jq-archive-target");
const delUrl = $(this).attr("href"); const delUrl = $(this).attr("href");
$.ajax({ $.ajax({
type: "delete", type: "DELETE",
url: delUrl, url: delUrl,
headers: { headers: {
"X-CSRFToken":CSRF, "X-CSRFToken":CSRF,
@@ -21,12 +24,15 @@ $(function () {
// beforeSend: function(xhr) { // beforeSend: function(xhr) {
// xhr.setRequestHeader("X-CSRFToken", csrf); // xhr.setRequestHeader("X-CSRFToken", csrf);
// }, // },
success: function (response) { success: function (data, textStatus, jqXHR) {
console.log(response.status); console.log(jqXHR.status);
$(archiveListElement).hide(1500); $(archiveListElement).hide(1500);
setTimeout(() => {
updateStorageInfo();
}, 3000);
}, },
error: function (response) { error: function (jqXHR, textStatus, errorThrown) {
console.log(response.status); console.log(jqXHR.status);
} }
}); });
}); });
@@ -37,16 +43,13 @@ $(function () {
let resolved = false; let resolved = false;
let ticketStateUrl = $(this).attr("ticket-state-url") let ticketStateUrl = $(this).attr("ticket-state-url")
if ($(this).attr("ticket-state-switch") === "1") { if ($(this).attr("ticket-state-switch") === "1") {
console.log('Find it!!!')
resolved = true;
$(this).attr("ticket-state-switch", "0"); // disable $(this).attr("ticket-state-switch", "0"); // disable
} else { } else {
resolved = false; resolved = true;
$(this).attr("ticket-state-switch", "1"); // enable $(this).attr("ticket-state-switch", "1"); // enable
} }
console.log(resolved)
$.ajax({ $.ajax({
type: "POST", type: "PATCH",
url: ticketStateUrl, url: ticketStateUrl,
headers: { headers: {
"X-CSRFToken":CSRF, "X-CSRFToken":CSRF,
@@ -57,11 +60,12 @@ $(function () {
data: JSON.stringify({ data: JSON.stringify({
resolved: resolved, resolved: resolved,
}), }),
success: function (response) { success: function (data, textStatus, jqXHR) {
console.log(response.resolved) console.log(jqXHR.status)
}, },
error: function (response) { error: function (jqXHR, textStatus, errorThrown) {
console.log(response.resolved) console.log(data)
console.log(jqXHR.status)
} }
}); });
}); });
@@ -80,17 +84,34 @@ $(function () {
'X-CSRFToken':CSRF, 'X-CSRFToken':CSRF,
'Content-Type':'application/json' 'Content-Type':'application/json'
}, },
success: function (response) { success: function (data, textStatus, jqXHR) {
console.log(response.status); console.log(jqXHR.status);
if (delDiv.length) { if (delDiv.length) {
delDiv.hide(1500); delDiv.hide(1500);
setTimeout(() => {
updateStorageInfo();
}, 3000);
} else { } else {
window.location.href = redirectUrl; window.location.href = redirectUrl;
} }
}, },
error: function (response) { error: function (jqXHR, textStatus, errorThrown) {
console.log(response.status); console.log(jqXHR.status);
} }
}); });
}); });
// copy token to clipboard:
// -- -- -- -- -- -- -- --
$(".token-clipboard").click(function (e) {
e.preventDefault();
const btn = $(this)
const tokenInput = btn.siblings("input[name=ticket-token]").val();
const icon = btn.children(":first").get(0)
navigator.clipboard.writeText(tokenInput);
btn.html('<i class="bi bi-check-lg"></i>')
// Revert button label after 500 milliseconds
setTimeout(function(){
btn.html(icon);
}, 500)
});
}); });

View File

@@ -0,0 +1,138 @@
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();
// collect request data:
let formData = new FormData(this);
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: progressBar.getAttribute("upload-url"),
data: formData,
dataType: 'json',
xhr:function(){
const xhr = new window.XMLHttpRequest();
xhr.timeout = 3600000; // increase request timeout to 1 hour
xhr.upload.addEventListener('progress', e=>{
if(e.lengthComputable){
const percentProgress = (e.loaded/e.total)*100;
console.log(percentProgress);
progressBar.innerHTML = `
<div
class="progress-bar progress-bar-striped progress-bar-animated"
style="width: ${percentProgress}%"
>
</div>`
}
});
return xhr
},
// set auth method:
beforeSend: function(xhr) {
if (uploadToken) {
xhr.setRequestHeader("Upload-Token", uploadToken);
}
},
success: function(data, textStatus, jqXHR){
alertContainer.innerHTML = genAlertMessage(
'The file has been successfully uploaded to the server. Thank you!',
'success',
'col-lg-6'
)
uploadForm.reset()
progressBar.classList.add('not-visible')
try {
updateStorageInfo();
} catch (error) {
console.log(error)
};
},
error: function(jqXHR, textStatus, errorThrown){
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) {
errorMessage = `Error ${jqXHR.status} <br> The token field cannot be empty`
}
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

@@ -0,0 +1,56 @@
{% extends 'collector/base.html' %}
{% load static %}
{% load crispy_forms_tags %}
{% block title %} {{ title }} {% endblock title %}
{% block main %}
<style>
.not-visible{
display: none;
}
</style>
<div class="container mt-3">
<div class="card">
<div class="card-header">
<h3 class="card-title">Archive upload:</h3>
</div>
<div class="card-body">
<div class="container">
<div class="col-lg-6 alert alert-light alert-dismissible fade show" role="alert">
<strong>Note!</strong>
<br>
Please do not close this page
until you receive an alert about a successful upload or an error.
<br>
Thank you!
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
<div id="alert" class="container"></div>
<div class="container">
{% crispy form %}
</div>
</div>
<div class="card-footer">
<div
id="progress"
upload-url="{% url 'collector_api:archive-list' %}"
token-status-url="{% url 'collector_api:token-root' %}"
class="progress not-visible"
role="progressbar"
aria-label="Example 20px high"
aria-valuenow="25"
aria-valuemin="0"
aria-valuemax="100"
style="height: 20px"
>
<div class="progress-bar"></div>
</div>
</div>
</div>
</div>
{% endblock main %}
{% block jquery %}
<script type="module" src="{% static 'collector/js/jq.upload.progress.js' %}"></script>
{% endblock jquery %}

View File

@@ -1,45 +1,14 @@
{% extends 'base.html' %}
{% load static %} {% load static %}
<!DOCTYPE html>
<html lang="en" data-bs-theme="auto"> {% block collector_head %}
<head> <title>{% block title %}{% endblock title %}</title>
<meta charset="UTF-8" /> {% endblock collector_head %}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link {% block collector_content %}
rel="stylesheet" <header class="sticky-top">
href="{% static 'collector/css/bootstrap.min.css' %}"
rel="stylesheet"
>
<link
rel="apple-touch-icon"
sizes="180x180"
href="{% static 'collector/img/apple-touch-icon.png' %}"
>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="{% static 'collector/img/favicon-32x32.png' %}"
>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="{% static 'collector/img/favicon-16x16.png' %}"
>
<link
rel="manifest"
href="{% static 'collector/img/site.webmanifest' %}"
>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
>
{% block title %}{% endblock title %}
</head>
<body>
<header>
<section> <section>
{% include 'collector/includes/navigation.html' %} {% include 'includes/navigation.html' %}
</section> </section>
</header> </header>
<main> <main>
@@ -47,15 +16,15 @@
{% block main %}{% endblock main %} {% block main %}{% endblock main %}
</section> </section>
</main> </main>
<footer> <footer class="footer mt-auto">
<section> <section>
{% block footer %}{% endblock footer %} {% include 'includes/footer.html' %}
</section> </section>
</footer> </footer>
<script src="{% static 'collector/js/bootstrap.bundle.min.js' %}"></script> {% endblock collector_content %}
<script src="{% static 'collector/js/bs.theme.mode.js' %}"></script>
{% block collector_scripts %}
<script src="{% static 'collector/js/jquery-3.7.0.min.js' %}"></script> <script src="{% static 'collector/js/jquery-3.7.0.min.js' %}"></script>
{% block bs %}{% endblock bs %} {% block bs %}{% endblock bs %}
{% block jquery %}{% endblock jquery %} {% block jquery %}{% endblock jquery %}
</body> {% endblock collector_scripts %}
</html>

View File

@@ -30,7 +30,7 @@
>Cancel >Cancel
</button> </button>
<a <a
href="{% url 'collector:download' archive.file %}" href="{% url 'collector_api:archive-detail' archive.id %}"
type="button" type="button"
class="btn btn-danger btn-archive-eraser" class="btn btn-danger btn-archive-eraser"
data-bs-dismiss="modal" data-bs-dismiss="modal"

View File

@@ -9,7 +9,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="LabelTicket"> <h5 class="modal-title" id="LabelTicket">
Delete ticket #{{ ticket.number }} ? Delete ticket {{ ticket.number }}?
</h5> </h5>
<button <button
type="button" type="button"
@@ -27,7 +27,7 @@
</button> </button>
<a <a
type="button" type="button"
href="{% url 'collector:ajax_delete_ticket' ticket.number %}" href="{% url 'collector_api:ticket-detail' ticket.number %}"
class="btn btn-danger btn-ticket-del" class="btn btn-danger btn-ticket-del"
data-bs-dismiss="modal" data-bs-dismiss="modal"
data-jq-ticket-del-target="#div-ticket-{{ ticket.number }}" data-jq-ticket-del-target="#div-ticket-{{ ticket.number }}"

View File

@@ -1,179 +0,0 @@
{% load collector_extras %}
{% get_platforms as platforms %}
<svg xmlns="http://www.w3.org/2000/svg" class="d-none">
<symbol id="check2" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
</symbol>
<symbol id="circle-half" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 0 8 1v14zm0 1A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"/>
</symbol>
<symbol id="moon-stars-fill" fill="currentColor" viewBox="0 0 16 16">
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
<path d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z"/>
</symbol>
<symbol id="sun-fill" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
</symbol>
</svg>
<nav class="navbar navbar-expand-lg 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>
<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"
>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" 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>
</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>
<!-- 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></i> Admin</a>
</li>
{% endif %}
<li><button class="dropdown-item" type="button"><i class="bi bi-gear"></i> Settings</button></li>
<li><hr class="dropdown-divider" /></li>
<li><button class="dropdown-item" type="button"><i class="bi bi-door-closed"></i> Logout</button></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 'admin:index' %}"
class="btn btn-outline-secondary"
>
<i class="bi bi-box-arrow-in-right"></i></i> Login
</a>
</div>
</li>
{% endif %}
<!-- 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>
<!-- Theme switcher-->
<li class="nav-item dropdown">
<div class="dropdown bd-mode-toggle">
<button class="btn btn-bd-primary py-2 dropdown-toggle d-flex align-items-center"
id="bd-theme"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
aria-label="Toggle theme (auto)">
<svg class="bi my-1 theme-icon-active" width="1em" height="1em"><use href="#circle-half"></use></svg>
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light" aria-pressed="false">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em"><use href="#sun-fill"></use></svg>
Light
<svg class="bi ms-auto d-none" width="1em" height="1em"><use href="#check2"></use></svg>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark" aria-pressed="false">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em"><use href="#moon-stars-fill"></use></svg>
Dark
<svg class="bi ms-auto d-none" width="1em" height="1em"><use href="#check2"></use></svg>
</button>
</li>
<li>
<button
type="button"
class="dropdown-item d-flex align-items-center active"
data-bs-theme-value="auto" aria-pressed="true"
>
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em"><use href="#circle-half"></use></svg>
Auto
<svg class="bi ms-auto d-none" width="1em" height="1em"><use href="#check2"></use></svg>
</button>
</li>
</ul>
</div>
</li>
</ul>
</div>
</div>
</nav>

View File

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

View File

@@ -1,19 +1,66 @@
<div class="d-sm-flex w-100 justify-content-between mb-2"> <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> <small><i class="bi bi-clock-history"></i> {{ ticket.time_create|date:"D d.m.y H:i" }}</small>
</div> </div>
<div class="form-check form-switch form-check-reverse d-flex w-100 justify-content-left"> <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">Resolved:</label> <label class="form-check-label" for="ticket-state"><i class="bi bi-award"></i> Resolved:</label>
<input <input
class="form-check-input ms-2 mb-2" class="form-check-input ms-2 mb-2"
type="checkbox" type="checkbox"
role="switch" role="switch"
name="ticket-state" name="ticket-state"
ticket-state-url="{% url 'collector:ajax_update_state_ticket' ticket.platform.name ticket.number %}" ticket-state-url="{% url 'collector_api:ticket-detail' ticket.number %}"
{% if ticket.resolved %} ticket-state-switch="1" {% endif %} {% if ticket.resolved %} ticket-state-switch="1" {% endif %}
{% if ticket.resolved %} checked {% endif %}> {% if ticket.resolved %} checked {% endif %}>
</div> </div>
<div class="col-xl-6 mb-2"> <div class="col-xl-6 mb-2">
<h6 class="card-title mb-1">Platform: {{ ticket.platform.pretty_name }}</h6> <h6 class="card-title mb-1">
<h6 class="card-title mb-3">Owner: {{ ticket.user.username }}</h6> <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 %}
bg-danger
{% elif ticket.attempts < 5 %}
text-dark bg-warning
{% else %}
bg-primary
{% endif %} rounded-pill">{{ ticket.attempts }}
</span>
</a>
</span>
<input
name="ticket-token"
class="form-control"
type="text"
value="{{ ticket.token }}"
aria-label="Disabled input example"
aria-describedby="inputGroup-sizing-sm"
disabled
readonly>
<button
class="input-group-text token-clipboard"
id="inputGroup-sizing-sm"><i class="bi bi-clipboard"></i>
</button>
</div>
</div> </div>

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

@@ -1,10 +1,9 @@
{% extends 'collector/base.html' %} {% extends 'collector/base.html' %}
{% load static %} {% load static %}
{% load collector_extras %} {% load collector_extras %}
{% block title %}<title>{{ title }}</title>{% endblock title %} {% block title %} {{ title }} {% endblock title %}
{% block main %} {% block main %}
<div class="container mt-3"> <div class="container mt-3">
<div class="row">
{% csrf_token %} {% csrf_token %}
<div class="card"> <div class="card">
<div class="card-body" aria-current="true"> <div class="card-body" aria-current="true">
@@ -46,7 +45,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Modal Archive --> <!-- Modal Archive -->
{% for archive in ticket.archive_set.all %} {% for archive in ticket.archive_set.all %}
{% include 'collector/includes/modal_archive.html' %} {% include 'collector/includes/modal_archive.html' %}
@@ -56,5 +54,5 @@
</div> </div>
{% endblock main %} {% endblock main %}
{% block jquery %} {% 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 %} {% endblock jquery %}

View File

@@ -1,12 +1,12 @@
{% extends 'collector/base.html' %} {% extends 'collector/base.html' %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block title %}<title>{{ title }}</title>{% endblock title %} {% block title %} {{ title }} {% endblock title %}
{% block main %} {% block main %}
<div class="container mt-3"> <div class="container mt-3">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Ticket:</h3> <h3 class="card-title">Ticket:</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
{% crispy form %} {% 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

@@ -1,14 +1,14 @@
{% extends 'collector/base.html' %} {% extends 'collector/base.html' %}
{% load static %} {% load static %}
{% load collector_extras %} {% load collector_extras %}
{% block title %}<title>{{ title }}</title>{% endblock title %} {% block title %} {{ title }} {% endblock title %}
{% block main %} {% block main %}
<div class="container mt-3"> <div class="container mt-3">
{% csrf_token %} {% csrf_token %}
<!-- Ticket --> <!-- Ticket -->
{% for ticket in tickets %} {% for ticket in tickets %}
<div id="div-ticket-{{ ticket.number }}" class="list-group mb-2"> <ul id="div-ticket-{{ ticket.number }}" class="list-group mb-2">
<div class="list-group-item list-group-item-action disable" aria-current="true"> <li class="list-group-item list-group-item-action disable" aria-current="true">
{% include 'collector/includes/ticket_info.html' %} {% include 'collector/includes/ticket_info.html' %}
<div class="col-xl-6 mt-1 mb-2"> <div class="col-xl-6 mt-1 mb-2">
<div class="accordion" id="#archive_{{ ticket.number }}"> <div class="accordion" id="#archive_{{ ticket.number }}">
@@ -66,7 +66,7 @@
<a <a
href="{{ ticket.get_absolute_url }}" href="{{ ticket.get_absolute_url }}"
class="btn btn-outline-primary mb-1 mt-1" class="btn btn-outline-primary mb-1 mt-1"
>Open</a> ><i class="bi bi-arrow-return-right"></i> Open</a>
<button <button
class="btn btn-outline-danger mb-1 mt-1" class="btn btn-outline-danger mb-1 mt-1"
data-bs-toggle="modal" data-bs-toggle="modal"
@@ -74,8 +74,8 @@
><i class="bi bi-trash"></i> Delete ><i class="bi bi-trash"></i> Delete
</button> </button>
</div> </div>
</div> </li>
</div> </ul>
<!-- Modal ticket --> <!-- Modal ticket -->
{% include 'collector/includes/modal_ticket.html' %} {% include 'collector/includes/modal_ticket.html' %}
<!-- Modal archive --> <!-- Modal archive -->
@@ -86,9 +86,6 @@
{% include 'collector/includes/pagination.html' %} {% include 'collector/includes/pagination.html' %}
</div> </div>
{% endblock main %} {% endblock main %}
{% block bs %}
<script src="{% static 'collector/js/bs.tooltip.js' %}"></script>
{% endblock bs %}
{% block jquery %} {% 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 %} {% endblock jquery %}

View File

@@ -13,31 +13,6 @@ def get_platforms():
return Platform.objects.all() return Platform.objects.all()
@register.filter(name='sizify')
def sizify(value: int) -> str:
"""Simple kb/mb/gb size snippet for templates:
{{ Archive.file.size|sizify }}
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}'
@register.filter(name='clean_filename') @register.filter(name='clean_filename')
def clean_filename(filename: str) -> str: def clean_filename(filename: str) -> str:
"""delete prefix ticket number folder for template """delete prefix ticket number folder for template

View File

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

View File

@@ -0,0 +1,58 @@
from pathlib import Path
from django.test import TestCase
from django.core.files.base import ContentFile
from django.conf import settings
from account.models import User
from collector.models import Platform, Ticket, Archive
def delete_test_files(ticket):
test_file = settings.MEDIA_ROOT / Path(str(ticket))
test_file.unlink(missing_ok=True)
class TestModels(TestCase):
def setUp(self):
# create test user:
self.test_user = User.objects.create_user(
username='oops',
password='hG2wS231YPWmj3'
)
# create test platform and ticket:
self.test_platform = Platform.objects.create(
name='vm6',
pretty_name='VMmanager 6'
)
self.test_ticket = Ticket.objects.create(
number=1488228,
platform=self.test_platform,
user=self.test_user
)
def test_correct_platform_fields(self):
self.assertEqual(self.test_platform.name, 'vm6')
self.assertEqual(self.test_platform.pretty_name, 'VMmanager 6')
def test_correct_ticket_fields(self):
self.assertEqual(self.test_ticket.number, 1488228)
self.assertEqual(self.test_ticket.attempts, 5)
self.assertEqual(
self.test_ticket.platform.name,
self.test_platform.name
)
def test_correct_archive_fields(self):
test_archive = Archive.objects.create(
file=ContentFile(b'...', name='test.tar.gz'),
ticket=self.test_ticket
)
triple_dots_md5 = '2f43b42fd833d1e77420a8dae7419000'
self.assertEquals(test_archive.md5, triple_dots_md5)
self.assertEquals(
test_archive.file.name,
f'{self.test_ticket.number}/test.tar.gz'
)
# ? workaround delete test file:
delete_test_files(test_archive.file)

View File

@@ -0,0 +1,68 @@
from django.test import TestCase
from django.urls import resolve, reverse
from account.models import User
from collector import views
from collector.models import Ticket, Platform
class TestUrls(TestCase):
def setUp(self):
# create test user:
self.test_user = User.objects.create_user(
username='oops',
password='hG2wS231YPWmj3'
)
# create test platform and ticket:
self.test_platform = Platform.objects.create(
name='dci6',
pretty_name='DCImanager 6'
)
self.test_ticket = Ticket.objects.create(
number=1488228,
platform=self.test_platform,
user=self.test_user
)
# CREATE:
def test_ticket_create_url_is_resolved(self):
url = reverse('collector:create')
self.assertEquals(resolve(url).func.view_class, views.CreateTicket)
# READ:
def test_index_page_url_is_resolved(self):
url = reverse('collector:index')
self.assertEquals(resolve(url).func.view_class, views.ListAllTickets)
def test_list_all_tickets_url_is_resolved(self):
url = reverse('collector:tickets')
self.assertEquals(resolve(url).func.view_class, views.ListAllTickets)
def test_list_platform_tickets_url_is_resolved(self):
url = reverse('collector:platform', args=[self.test_platform.name])
self.assertEquals(
resolve(url).func.view_class, views.ListPlatformTickets
)
def test_detail_ticket_url_is_resolved(self):
url = reverse(
'collector:ticket',
args=[self.test_platform.name, self.test_ticket.number]
)
self.assertEquals(resolve(url).func.view_class, views.DetailTicket)
def test_archives_download_url_is_resolved(self):
url = reverse('collector:download', args=['1488228/test.tar.gz'])
self.assertEquals(
resolve(url).func.view_class, views.ArchiveHandlerView
)
# UPDATE:
def test_update_ticket_url_is_resolved(self):
url = reverse(
'collector:update',
args=[self.test_platform.name, self.test_ticket.number]
)
self.assertEquals(resolve(url).func.view_class, views.UpdateTicket)

View File

@@ -0,0 +1,132 @@
from django.test import TestCase, Client
from django.urls import reverse
from account.models import User
from collector.models import Ticket, Platform
class TestViews(TestCase):
def setUp(self):
# create test user:
self.test_user = User.objects.create_user(
username='oops',
password='hG2wS231YPWmj3'
)
# create test client:
self.client = Client()
self.client.login(username='oops', password='hG2wS231YPWmj3')
# create test platform and ticket:
self.test_platform = Platform.objects.create(
name='vm6',
pretty_name='VMmanager 6'
)
self.test_ticket = Ticket.objects.create(
number=1488228,
platform=self.test_platform,
user=self.test_user
)
# test urls:
# -- -- -- --
# CREATE:
self.ticket_create_url = reverse('collector:create')
# READ:
self.all_tickets_list = reverse('collector:tickets')
self.platform_tickets_list = reverse(
'collector:platform',
args=[self.test_platform.name]
)
self.ticket_detail_url = reverse(
'collector:ticket',
args=[self.test_platform.name, self.test_ticket.number]
)
# UPDATE:
self.ticket_update_url = reverse(
'collector:update',
args=[self.test_platform.name, self.test_ticket.number]
)
# CREATE:
def test_create_ticket_POST(self):
response = self.client.post(
self.ticket_create_url,
data={
'number': 1111,
'platform': self.test_platform.name,
'attempts': 5
}
)
self.assertEquals(response.status_code, 302)
self.assertEquals(Ticket.objects.get(number=1111).number, 1111)
self.assertEquals(Ticket.objects.get(number=1111).platform.name, 'vm6')
self.assertEquals(
Ticket.objects.get(
number=1111).platform.pretty_name, 'VMmanager 6'
)
self.assertEquals(
Ticket.objects.get(number=1111).user.username, 'oops'
)
# READ:
def test_all_ticket_list_GET(self):
response = self.client.get(self.all_tickets_list)
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'collector/tickets.html')
self.assertTemplateUsed(response, 'collector/base.html')
self.assertTemplateUsed(response, 'base.html')
self.assertTemplateUsed(response, 'includes/theme_switcher.html')
self.assertTemplateUsed(response, 'includes/navigation.html')
self.assertTemplateUsed(response, 'collector/includes/pagination.html')
self.assertTemplateUsed(
response,
'collector/includes/ticket_info.html'
)
self.assertTemplateUsed(
response,
'collector/includes/modal_ticket.html'
)
def test_platform_tickets_list_GET(self):
response = self.client.get(self.platform_tickets_list)
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'collector/tickets.html')
def test_ticket_detail_GET(self):
response = self.client.get(self.ticket_detail_url)
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'collector/ticket.html')
self.assertTemplateUsed(
response, 'collector/includes/ticket_info.html'
)
self.assertTemplateUsed(
response, 'collector/includes/modal_ticket.html'
)
def test_ticket_create_GET(self):
response = self.client.get(self.ticket_create_url)
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'collector/ticket_create.html')
def test_ticket_update_GET(self):
response = self.client.get(self.ticket_update_url)
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'collector/ticket_create.html')
# UPDATE:
def test_ticket_update_UPDATE(self):
response = self.client.post(
self.ticket_update_url,
data={
'number': 1488229,
'platform': self.test_platform.name,
'attempts': 3
}
)
self.assertEquals(response.status_code, 302)
self.assertEquals(Ticket.objects.get(number=1488229).number, 1488229)
self.assertEquals(Ticket.objects.get(number=1488229).attempts, 3)

View File

@@ -1,28 +1,25 @@
from django.urls import path, include from django.urls import path
from rest_framework import routers
from . import views from . import views
app_name = 'collector' app_name = 'collector'
router = routers.DefaultRouter()
router.register(r'archives', views.ArchiveUploadViewSet)
router.register(r'tickets/create', views.TicketCreateViewSet)
urlpatterns = [
# █░█░█ █▀▀ █▄▄ # █░█░█ █▀▀ █▄▄
# ▀▄▀▄▀ ██▄ █▄█ # ▀▄▀▄▀ ██▄ █▄█
# -- -- -- -- -- # -- -- -- -- --
urlpatterns = [
# CREATE: # CREATE:
path( path(
'tickets/create/', 'tickets/create/',
views.CreateTicket.as_view(), views.CreateTicket.as_view(),
name='create' name='create'
), ),
path(
'archives/upload/',
views.ArchiveUploadView.as_view(),
name='upload'
),
# READ: # READ:
path( path(
'', '',
@@ -45,7 +42,7 @@ urlpatterns = [
name='ticket' name='ticket'
), ),
path( path(
'archives/<path:path>', 'archives/download/<path:path>',
views.ArchiveHandlerView.as_view(), views.ArchiveHandlerView.as_view(),
name="download" name="download"
), ),
@@ -55,28 +52,4 @@ urlpatterns = [
views.UpdateTicket.as_view(), views.UpdateTicket.as_view(),
name='update' name='update'
), ),
# ▄▀█ ░░█ ▄▀█ ▀▄▀
# █▀█ █▄█ █▀█ █░█
# -- -- -- -- --
# UPDATE:
path(
'ajax/tickets/update/<slug:platform>/<int:ticket>/',
views.UpdateTicketStateHandler.as_view(),
name='ajax_update_state_ticket'
),
# DELETE:
path(
'ajax/tickets/delete/<int:ticket>/',
views.DeleteTicketHandler.as_view(),
name='ajax_delete_ticket'
),
# ▄▀█ █▀█ █
# █▀█ █▀▀ █
# -- -- --
# CREATE:
path('api/v1/', include(router.urls))
] ]

View File

@@ -1,39 +0,0 @@
import os
def logs_dir_path(instance, filename):
# file will be uploaded to
# MEDIA_ROOT_FOR_SENSITIVE_FILES/<ticket>/<filename>
return f'{instance.ticket}/{filename}'
def get_file_size(file_path, unit='bytes'):
file_size = os.path.getsize(file_path)
exponents_map = {'bytes': 0, 'kb': 1, 'mb': 2, 'gb': 3}
if unit not in exponents_map:
raise ValueError("Must select from \
['bytes', 'kb', 'mb', 'gb']")
else:
size = file_size / 1024 ** exponents_map[unit]
return round(size, 3)
def is_ajax(request):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return True
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,41 +1,48 @@
import json
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import FileResponse, JsonResponse from django.http import FileResponse, Http404
from django.views import generic from django.views import generic
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.urls import reverse_lazy
from django.db.models import Q from django.db.models import Q
from rest_framework import status from two_factor.views import OTPRequiredMixin
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from .forms import TicketForm, ArchiveForm
from .models import Archive, Ticket from .models import Archive, Ticket
from .forms import TicketForm from .utils.mixins import ExtraContextMixin
from .utils import PageTitleViewMixin, is_ajax
from .serializers import ArchiveUploadSerializer, TicketSerializer
class ArchiveHandlerView(LoginRequiredMixin, SingleObjectMixin, generic.View): class ArchiveUploadView(ExtraContextMixin, generic.TemplateView):
form_class = ArchiveForm()
template_name = 'collector/archive_upload.html'
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'
class ArchiveHandlerView(
OTPRequiredMixin,
LoginRequiredMixin,
SingleObjectMixin,
generic.View):
model = Archive model = Archive
slug_field = 'file' slug_field = 'file'
slug_url_kwarg = 'path' slug_url_kwarg = 'path'
def get(self, request, path): def get(self, request, path):
self.object = self.get_object() 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) return FileResponse(self.object.file)
def delete(self, request, path):
if is_ajax(request):
self.object = self.get_object()
self.object.delete()
return JsonResponse({'file': path}, status=status.HTTP_200_OK)
class CreateTicket(LoginRequiredMixin, ExtraContextMixin, generic.CreateView):
class CreateTicket(LoginRequiredMixin, PageTitleViewMixin, generic.CreateView):
model = Ticket model = Ticket
form_class = TicketForm form_class = TicketForm
template_name = 'collector/ticket_create.html' template_name = 'collector/ticket_create.html'
@@ -48,7 +55,7 @@ class CreateTicket(LoginRequiredMixin, PageTitleViewMixin, generic.CreateView):
return super().form_valid(form) return super().form_valid(form)
class UpdateTicket(LoginRequiredMixin, PageTitleViewMixin, generic.UpdateView): class UpdateTicket(LoginRequiredMixin, ExtraContextMixin, generic.UpdateView):
model = Ticket model = Ticket
form_class = TicketForm form_class = TicketForm
template_name = 'collector/ticket_create.html' template_name = 'collector/ticket_create.html'
@@ -59,12 +66,11 @@ class UpdateTicket(LoginRequiredMixin, PageTitleViewMixin, generic.UpdateView):
return f'{self.title} - {self.kwargs.get("ticket", "update")}' return f'{self.title} - {self.kwargs.get("ticket", "update")}'
def form_valid(self, form): def form_valid(self, form):
print(self.request.user)
form.instance.user = self.request.user form.instance.user = self.request.user
return super().form_valid(form) return super().form_valid(form)
class ListAllTickets(PageTitleViewMixin, generic.ListView): class ListAllTickets(LoginRequiredMixin, ExtraContextMixin, generic.ListView):
model = Ticket model = Ticket
template_name = 'collector/tickets.html' template_name = 'collector/tickets.html'
context_object_name = 'tickets' context_object_name = 'tickets'
@@ -73,6 +79,9 @@ class ListAllTickets(PageTitleViewMixin, generic.ListView):
def get_queryset(self): def get_queryset(self):
search_query = self.request.GET.get('search', '') search_query = self.request.GET.get('search', '')
resolved_status_query = self.request.GET.get('resolved', '')
if search_query or resolved_status_query:
self.paginate_by = 100 # ? fake disable pagination)
if search_query: if search_query:
query_list = [] query_list = []
try: try:
@@ -81,15 +90,16 @@ class ListAllTickets(PageTitleViewMixin, generic.ListView):
except ValueError: except ValueError:
return super().get_queryset() return super().get_queryset()
queryset = self.model.objects.filter( queryset = self.model.objects.filter(
Q(number__in=query_list) | Q(number__icontains=query_list[0]) Q(number__in=query_list) | Q(number__icontains=query_list[0]) # noqa:E501
) )
self.paginate_by = 100 # fake disable pagination) if resolved_status_query:
queryset = self.model.objects.filter(Q(resolved=True))
return queryset return queryset
return super().get_queryset() return super().get_queryset()
class ListPlatformTickets(PageTitleViewMixin, generic.ListView): class ListPlatformTickets(LoginRequiredMixin, ExtraContextMixin, generic.ListView): # noqa:E501
model = Ticket model = Ticket
template_name = 'collector/tickets.html' template_name = 'collector/tickets.html'
context_object_name = 'tickets' context_object_name = 'tickets'
@@ -105,7 +115,7 @@ class ListPlatformTickets(PageTitleViewMixin, generic.ListView):
) )
class DetailTicket(PageTitleViewMixin, generic.DetailView): class DetailTicket(LoginRequiredMixin, ExtraContextMixin, generic.DetailView):
model = Ticket model = Ticket
template_name = 'collector/ticket.html' template_name = 'collector/ticket.html'
context_object_name = 'ticket' context_object_name = 'ticket'
@@ -114,78 +124,3 @@ class DetailTicket(PageTitleViewMixin, generic.DetailView):
def get_title(self, **kwargs): def get_title(self, **kwargs):
return f'{self.title} - {self.kwargs.get("ticket", "show")}' return f'{self.title} - {self.kwargs.get("ticket", "show")}'
class DeleteTicket(PageTitleViewMixin, generic.DeleteView):
model = Ticket
template_name = 'collector/ticket_delete.html'
context_object_name = 'ticket'
slug_field = 'number'
slug_url_kwarg = 'ticket'
success_url = reverse_lazy('tickets')
class UpdateTicketStateHandler(SingleObjectMixin, generic.View):
model = Ticket
slug_field = 'number'
slug_url_kwarg = 'ticket'
def post(self, request, **kwargs):
if is_ajax(request):
self.object = self.get_object()
if request.body:
data = json.loads(request.body)
resolved_field = data.get('resolved')
if isinstance(resolved_field, bool):
self.object.resolved = not resolved_field
self.object.save()
return JsonResponse(
{'resolved': not resolved_field},
status=status.HTTP_201_CREATED
)
return JsonResponse(
{'resolved': 'must be a boolean'},
status=status.HTTP_400_BAD_REQUEST
)
return JsonResponse(
{'error': 'header XMLHttpRequest is required'},
status=status.HTTP_406_NOT_ACCEPTABLE
)
class DeleteTicketHandler(SingleObjectMixin, generic.View):
model = Ticket
slug_field = 'number'
slug_url_kwarg = 'ticket'
def delete(self, request, ticket):
if is_ajax(request):
self.object = self.get_object()
self.object.delete()
return JsonResponse(
{'status': status.HTTP_200_OK},
status=status.HTTP_200_OK
)
return JsonResponse(
{'error': 'header XMLHttpRequest is required'},
status=status.HTTP_406_NOT_ACCEPTABLE
)
class ArchiveUploadViewSet(mixins.CreateModelMixin, GenericViewSet):
queryset = Archive.objects.order_by('-time_create')
serializer_class = ArchiveUploadSerializer
parser_classes = (MultiPartParser, FormParser)
# permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class TicketCreateViewSet(mixins.CreateModelMixin, GenericViewSet):
queryset = Ticket.objects.order_by('-time_create')
serializer_class = TicketSerializer
# permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def perform_create(self, serializer):
serializer.save(user=self.request.user)

View File

@@ -0,0 +1,49 @@
import environ
import gunicorn
from logs_collector.settings import BASE_DIR
# █▀▀ █░█ █▄░█ █ █▀▀ █▀█ █▀█ █▄░█ █▀▀ █▀█ █▄░█ █▀▀ █ █▀▀ ▀
# █▄█ █▄█ █░▀█ █ █▄▄ █▄█ █▀▄ █░▀█ █▄▄ █▄█ █░▀█ █▀░ █ █▄█ ▄
# -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
# https://docs.gunicorn.org/en/stable/settings.html
# https://github.com/benoitc/gunicorn/blob/master/examples/example_config.py
env = environ.Env(
# set casting, default value:
GUNICORN_SERVER_NAME=(str, 'undisclosed'),
GUNICORN_BIND=(str, '0.0.0.0:8000'),
GUNICORN_BACKLOG=(int, 2048),
GUNICORN_WORKERS=(int, 2),
GUNICORN_WORKER_CLASS=(str, 'sync'),
GUNICORN_WORKER_CONNECTIONS=(int, 1000),
GUNICORN_THREADS=(int, 2),
GUNICORN_TIMEOUT=(int, 3600),
GUNICORN_KEEPALIVE=(int, 2),
GUNICORN_LOGLEVEL=(str, 'info'),
)
environ.Env.read_env(BASE_DIR / '.env')
# Server name:
gunicorn.SERVER = env('GUNICORN_SERVER_NAME')
# Server socket:
bind = env('GUNICORN_BIND')
backlog = env('GUNICORN_BACKLOG')
# Worker processes:
workers = env('GUNICORN_WORKERS')
worker_class = env('GUNICORN_WORKER_CLASS')
worker_connections = env('GUNICORN_WORKER_CONNECTIONS')
threads = env('GUNICORN_THREADS')
timeout = env('GUNICORN_TIMEOUT')
keepalive = env('GUNICORN_KEEPALIVE')
# Logging:
loglevel = env('GUNICORN_LOGLEVEL')
errorlog = '-'
accesslog = '-'
access_log_format = '%({X-Forwarded-For}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' # noqa:E501

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

@@ -1,42 +1,81 @@
import environ import environ
from pathlib import Path from pathlib import Path
from datetime import timedelta
from . import __version__, __status__
env = environ.Env( # █▀█ █▀█ █▀█ ▀█▀ ▀
# set casting default value # █▀▄ █▄█ █▄█ ░█░ ▄
DEBUG=(bool, False) # -- -- -- -- -- --
)
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent 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') environ.Env.read_env(BASE_DIR / '.env')
# █▀▀ █▀█ █▀█ █▀▀ ▀
# █▄▄ █▄█ █▀▄ ██▄ ▄
# -- -- -- -- -- -
VERSION = env('VERSION')
ENVIRONMENT = env('ENVIRONMENT')
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env('SECRET_KEY') SECRET_KEY = env('SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env('DEBUG') DEBUG = env('DEBUG')
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["*"]) ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
# TODO: required for docker image
# CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=["*"])
# https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-trusted-origins
if not DEBUG:
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS")
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'whitenoise.runserver_nostatic',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'collector.apps.CollectorConfig', # main app 'collector.apps.CollectorConfig', # main app
'account.apps.AccountConfig', # account app
'rest_framework', 'rest_framework',
'rest_framework_simplejwt',
'django_filters',
'drf_spectacular',
"crispy_forms", "crispy_forms",
"crispy_bootstrap5", "crispy_bootstrap5",
'django_otp',
'django_otp.plugins.otp_static',
'django_otp.plugins.otp_totp',
'two_factor.plugins.phonenumber', # <- if you want phone number capability
'two_factor',
'django_cleanup.apps.CleanupConfig', # required bottom 'django_cleanup.apps.CleanupConfig', # required bottom
] ]
@@ -46,8 +85,11 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django_otp.middleware.OTPMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'collector.middleware.HttpResponseNotAllowedMiddleware',
] ]
ROOT_URLCONF = 'logs_collector.urls' ROOT_URLCONF = 'logs_collector.urls'
@@ -55,14 +97,18 @@ ROOT_URLCONF = 'logs_collector.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
# default:
'django.template.context_processors.debug', 'django.template.context_processors.debug',
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
# collector:
'collector.context_processors.metadata',
'collector.context_processors.storage_info',
], ],
}, },
}, },
@@ -70,18 +116,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'logs_collector.wsgi.application' 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 # Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
@@ -100,38 +134,174 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/ # https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'Asia/Irkutsk' TIME_ZONE = env('TZ')
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# █▀ ▀█▀ ▄▀█ ▀█▀ █ █▀▀ ▀
# ▄█ ░█░ █▀█ ░█░ █ █▄▄ ▄
# -- -- -- -- -- -- -- -
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/ # https://docs.djangoproject.com/en/4.2/howto/static-files/
# Whitenoise:
# https://whitenoise.readthedocs.io/en/stable/django.html
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'static'
# █▀▄ ▄▀█ ▀█▀ ▄▀█ ▀
# █▄▀ █▀█ ░█░ █▀█ ▄
# -- -- -- -- -- --
# 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": "/download/archives/",
},
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
# █▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ ▄▀█ █▀ █▀▀ ▀
# █▄▀ █▀█ ░█░ █▀█ █▄█ █▀█ ▄█ ██▄ ▄
# -- -- -- -- -- -- -- -- -- -- --
# 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 # Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
MEDIA_ROOT = BASE_DIR / 'media'
MEDIA_URL = 'media/'
MEDIA_ROOT_FOR_SENSITIVE_FILES = BASE_DIR / 'archives'
MEDIA_URL_FOR_SENSITIVE_FILES = '/archives/'
# █▀▀ ▀▄▀ ▀█▀ █▀▀ █▄░█ ▀█▀ █ █▀█ █▄░█ █▀ ▀
# ██▄ █░█ ░█░ ██▄ █░▀█ ░█░ █ █▄█ █░▀█ ▄█ ▄
# -- -- -- -- -- -- -- -- -- -- -- -- -- -
# django-crispy-forms and crispy-bootstrap5 # django-crispy-forms and crispy-bootstrap5
# https://django-crispy-forms.readthedocs.io/en/latest/ # https://django-crispy-forms.readthedocs.io/en/latest/
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5" CRISPY_TEMPLATE_PACK = "bootstrap5"
# https://www.django-rest-framework.org/api-guide/settings/
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'DEFAULT_PARSER_CLASSES': [
'rest_framework.parsers.JSONParser',
'rest_framework.parsers.MultiPartParser'
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
# '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',
}
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': 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),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
"UPDATE_LAST_LOGIN": False,
"ALGORITHM": "HS256",
"SIGNING_KEY": SECRET_KEY,
"VERIFYING_KEY": "",
"AUDIENCE": None,
"ISSUER": None,
"JSON_ENCODER": None,
"JWK_URL": None,
"LEEWAY": 0,
"AUTH_HEADER_TYPES": ("Bearer",),
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
"USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", # noqa:E501
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
"TOKEN_TYPE_CLAIM": "token_type",
"TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
"JTI_CLAIM": "jti",
"SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
"TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer", # noqa:E501
"TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer", # noqa:E501
"TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer", # noqa:E501
"TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer", # noqa:E501
"SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", # noqa:E501
"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,33 +1,46 @@
"""
URL configuration for logs_collector project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from logs_collector import settings from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from two_factor.urls import urlpatterns as tf_urls
from account.utils import AdminSiteOTPRequiredMixinRedirectSetup
# ? 2FA patch (Admin site protection)
admin.site.__class__ = AdminSiteOTPRequiredMixinRedirectSetup
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path(
path('', include('collector.urls', namespace='collector')), 'admin/',
admin.site.urls
),
path(
'',
include('collector.urls', namespace='collector')
),
path(
'',
include(tf_urls)
),
path(
'',
include('account.urls', namespace='account')
),
path(
'api/',
include('collector.api.urls', namespace='collector_api')
),
] ]
# SWAGGER URLS:
if settings.DEBUG: urlpatterns += [
urlpatterns += static( # API PATTERNS
settings.MEDIA_URL, document_root=settings.MEDIA_ROOT path('api/v1/schema/', SpectacularAPIView.as_view(), name='schema'),
) # Optional UI:
path(
'api/v1/schema/swagger/',
SpectacularSwaggerView.as_view(url_name='schema'),
name='swagger-ui'
),
]

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 495 B

After

Width:  |  Height:  |  Size: 495 B

View File

Before

Width:  |  Height:  |  Size: 930 B

After

Width:  |  Height:  |  Size: 930 B

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

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

@@ -0,0 +1,5 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
<link rel="shortcut icon" href="{% static 'img/favicon.ico' %}" />
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% load static %}
<!DOCTYPE html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="stylesheet"
href="{% static '/css/bootstrap.min.css' %}"
rel="stylesheet"
>
<link
rel="apple-touch-icon"
sizes="180x180"
href="{% static '/img/apple-touch-icon.png' %}"
>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="{% static '/img/favicon-32x32.png' %}"
>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="{% static '/img/favicon-16x16.png' %}"
>
<link
rel="manifest"
href="{% static '/img/site.webmanifest' %}"
>
<link
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 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>
</html>

Some files were not shown because too many files have changed in this diff Show More