Merge branch 'feature/react_test' into 'develop'
Feature/react test See merge request 2020-2021/online/s101/group-02/access_controller!85
This commit is contained in:
commit
e80c327d85
4
.gitignore
vendored
4
.gitignore
vendored
@ -373,4 +373,8 @@ $RECYCLE.BIN/
|
|||||||
# Windows shortcuts
|
# Windows shortcuts
|
||||||
*.lnk
|
*.lnk
|
||||||
|
|
||||||
|
### react ###
|
||||||
|
main/control_page_js_modules/node_modules
|
||||||
|
static/main/js/control_page/dist
|
||||||
|
node_modules
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/django,pycharm+all,python,linux,macos,windows
|
# End of https://www.toptal.com/developers/gitignore/api/django,pycharm+all,python,linux,macos,windows
|
||||||
|
29
README.md
29
README.md
@ -41,6 +41,15 @@
|
|||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
Установить модули для работы js
|
||||||
|
```bash
|
||||||
|
sudo apt install npm
|
||||||
|
cd main/control_page_js_modules/
|
||||||
|
npm install
|
||||||
|
sudo npm -g install npx
|
||||||
|
npx webpack
|
||||||
|
```
|
||||||
|
|
||||||
Заменить переменные в `.env` на актуальные.
|
Заменить переменные в `.env` на актуальные.
|
||||||
```bash
|
```bash
|
||||||
sudo apt install make
|
sudo apt install make
|
||||||
@ -57,6 +66,26 @@ pip install -r requirements/dev.txt
|
|||||||
Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk
|
Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk
|
||||||
При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение
|
При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение
|
||||||
|
|
||||||
|
Для запуска тестов страницы управления:
|
||||||
|
1. Установить npm и npx
|
||||||
|
```bash
|
||||||
|
sudo apt install npm
|
||||||
|
```
|
||||||
|
2. Перейти в static папку со страницей управления:
|
||||||
|
```bash
|
||||||
|
cd main/control_page_js_modules/
|
||||||
|
```
|
||||||
|
3. Выполнить установку модулей для js
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
sudo npm -g install npx
|
||||||
|
npx webpack
|
||||||
|
```
|
||||||
|
4. Тестирование в той же папке
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Запуск на локальной машине:
|
## Запуск на локальной машине:
|
||||||
- Скопировать репозиторий на локальную машину
|
- Скопировать репозиторий на локальную машину
|
||||||
|
230
README.rst
Normal file
230
README.rst
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
|
||||||
|
Управление правами доступа
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
Идея - написать программу(Web приложение), которая будет выдавать права пользователям системы по запросу самого
|
||||||
|
пользователя. Например, из 12 человек 3 сейчас работают с правами админа, по окончании рабочей смены они сдают
|
||||||
|
свои права (освобождают места) и другие пользователи могут запросить эти права в свое пользование.
|
||||||
|
|
||||||
|
Оставшиеся 9 человек получают права легкого агента - без прав редактирования, а только чтение.
|
||||||
|
|
||||||
|
Из технологий - программа должна взаимодействовать с api системы Zendesk(система обращений клиентов - жалобы),
|
||||||
|
проверять авторизованного пользователя на права(будет возможность менять права напрямую из Zendesk - нужна
|
||||||
|
синхронизация прав с приоритетом у Zendesk).
|
||||||
|
|
||||||
|
Если руками в самом Zendesk права у пользователя отобрали или наоборот
|
||||||
|
присвоили, то наша программа обновляет статус пользователя в соответствии с данными синхронизации
|
||||||
|
(например, раз в минуту).
|
||||||
|
|
||||||
|
Так же в идеале должна быть проверка, что пользователь сайта существует на сайте Zendesk(по токену).
|
||||||
|
|
||||||
|
Сэндбокс Zendesk нам предоставит моя компания, библиотеку для работы с api уже подсказали.
|
||||||
|
Сама программа (наша) будет обладать админскими правами и реализовывать контроль и выдачу прав другим пользователям.
|
||||||
|
|
||||||
|
*Итого:*
|
||||||
|
|
||||||
|
|
||||||
|
#. Реализовать авторизацию пользователей с проверкой по API на существование такого пользователя
|
||||||
|
#. Реализовать интерфейс со статистикой рабочих мест(занято, свободно, кто занимает)
|
||||||
|
#. Реализовать логирование действий(когда взял права, когда отдал - запись в файл и БД)
|
||||||
|
#. Реализовать передачу прав приложением по запросу от пользователя и замену прав пользователя
|
||||||
|
у которого права отбираются внутри Zendesk (на легкий агент)
|
||||||
|
#. Реализовать синхронизацию по API на проверку прав(не менялись ли в системе Zendesk)
|
||||||
|
#. Реализовать возможность добавить большее количество админских прав
|
||||||
|
#. Реализовать возможность добавления легких агентов(права только на просмотр)
|
||||||
|
#. Реализовать на общей странице текущую информацию о пользователе - текущие права, карточка пользователя
|
||||||
|
|
||||||
|
Технологический стек:
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
|
||||||
|
* Python 3
|
||||||
|
* Django 3
|
||||||
|
|
||||||
|
Quickstart
|
||||||
|
----------
|
||||||
|
|
||||||
|
Перед запуском необходимо создать ``.env`` файл.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
Установить модули для работы js
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
sudo apt install npm
|
||||||
|
cd main/control_page_js_modules/
|
||||||
|
npm install
|
||||||
|
sudo npm -g install npx
|
||||||
|
npx webpack
|
||||||
|
|
||||||
|
Заменить переменные в ``.env`` на актуальные.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
sudo apt install make
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements/dev.txt
|
||||||
|
./manage.py migrate
|
||||||
|
./manage.py loaddata data.json
|
||||||
|
./manage.py runserver
|
||||||
|
|
||||||
|
Перед запуском для тестирования:
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация ``SYSTEM``
|
||||||
|
Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk
|
||||||
|
При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение
|
||||||
|
|
||||||
|
Для запуска тестов страницы управления:
|
||||||
|
1. Установить npm и npx
|
||||||
|
.. code-block:: bash
|
||||||
|
sudo apt install npm
|
||||||
|
|
||||||
|
2. Перейти в static папку со страницей управления:
|
||||||
|
.. code-block:: bash
|
||||||
|
cd main/control_page_js_modules/
|
||||||
|
|
||||||
|
3. Выполнить установку модулей для js
|
||||||
|
.. code-block:: bash
|
||||||
|
npm install
|
||||||
|
sudo npm -g install npx
|
||||||
|
npx webpack
|
||||||
|
|
||||||
|
4. Тестирование в той же папке
|
||||||
|
.. code-block:: bash
|
||||||
|
npm test
|
||||||
|
|
||||||
|
|
||||||
|
Запуск на локальной машине:
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
* Скопировать репозиторий на локальную машину
|
||||||
|
* Перейти в папку приложения
|
||||||
|
* Активировать виртуальное окружение
|
||||||
|
* Выполнить команду ``pip install -r requirements/dev.txt``
|
||||||
|
* В файл ``.env`` добавить следующие переменные:
|
||||||
|
|
||||||
|
.. code-block::
|
||||||
|
|
||||||
|
ACTRL_DEBUG={0/1} - включить режим дебага
|
||||||
|
ACTRL_HOST={HOSTNAME} - при запуске без дебага, надо указать домен на котором будет работать приложение
|
||||||
|
ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} - секретный ключ сгенерированный Django
|
||||||
|
|
||||||
|
ACTRL_EMAIL_HOST={SMTP_HOST} - домен почтового сервера через который приложение будет отправлять письма, например "smtp.gmail.com"
|
||||||
|
ACTRL_EMAIL_PORT={SMTP_PORT} - порт для почтового сервера, например 587, 465 , 2525
|
||||||
|
ACTRL_EMAIL_TLS={USE_TLS} - использовать TLS для подключения к почтовому серверу, 0 или 1
|
||||||
|
ACTRL_EMAIL_HOST_USER={USERNAME} - логин с которым приложение входит на почтовый сервер
|
||||||
|
ACTRL_EMAIL_HOST_PASSWORD={PASSWORD} - пароль/ключ с которым приложение входит на почтовый сервер
|
||||||
|
ACTRL_FROM_EMAIL={EMAIL} - адрес с которого приложение отправляет письма
|
||||||
|
ACTRL_SERVER_EMAIL={EMAIL} - адрес на который отвечают пользователя
|
||||||
|
|
||||||
|
ACTRL_API_EMAIL={EMAIL} - почта админа в ZenDesk
|
||||||
|
ACTRL_API_PASSWORD={PASSWORD} - пароль админа ZenDesk
|
||||||
|
ACTRL_API_TOKEN={API_TOKEN} - API токен зендеск
|
||||||
|
ACTRL_ZENDESK_SUBDOMAIN={DOMAIN} - домен ZenDesk
|
||||||
|
|
||||||
|
ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID} - id роли инженера( custom_role_id сотрдника смены)
|
||||||
|
LA_CROLE_ID={LIGHT_AGENT_CUSTOM_ROLE_ID} - id роли легкого агента (custom_role_id роли -легкий агент)
|
||||||
|
EMPL_GROUP={EMPLOYEE_GROUP_NAME} - имя группы которой принадлежат сотрудники ССКС
|
||||||
|
BUF_GROUP={BUFFER_GROUP_NAME} - имя буферной группы для передачи смен(через нее происходит управление тикетами)
|
||||||
|
ST_EMAIL={SOLVED_TICKETS_EMAIL} - почта на которую будут переназначятся закрытые тикеты
|
||||||
|
LICENSE_NO={LICENSE_NO} - количество лицензий, отображаемых как доступные в приложении
|
||||||
|
SHIFTH={SHIFT_HOURS} - количество часов в рабочей смене (нужно для статистики, пока не реализовано но требует указания значения)
|
||||||
|
|
||||||
|
|
||||||
|
* Выполнить команду ``python manage.py migrate``
|
||||||
|
* Запустить приложение командой ``python manage.py runserver`` (можно указать в параметрах для файла manage.py)
|
||||||
|
* Перейти по ссылке в консоли (вероятнее всего откроется по адресу http://127.0.0.1:8000/)
|
||||||
|
|
||||||
|
Запуск в Docker:
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Требуется установленный и настроенный Docker
|
||||||
|
|
||||||
|
|
||||||
|
* Скопировать репозиторий на локальную машину
|
||||||
|
* В командной строке перейти в папку проекта
|
||||||
|
* Выполнить команду ``docker build --tag access_controller:latest .``
|
||||||
|
* Выполнить команду
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
docker run -d -p 8000:8000 \
|
||||||
|
ACTRL_DEBUG={0/1} \
|
||||||
|
ACTRL_HOST={HOSTNAME} \
|
||||||
|
ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} \
|
||||||
|
ACTRL_EMAIL_HOST={SMTP_HOST} \
|
||||||
|
ACTRL_EMAIL_PORT={SMTP_PORT} \
|
||||||
|
ACTRL_EMAIL_TLS={USE_TLS} \
|
||||||
|
ACTRL_EMAIL_HOST_USER={USERNAME} \
|
||||||
|
ACTRL_EMAIL_HOST_PASSWORD={PASSWORD} \
|
||||||
|
ACTRL_FROM_EMAIL={EMAIL} \
|
||||||
|
ACTRL_SERVER_EMAIL={EMAIL} \
|
||||||
|
ACTRL_API_EMAIL={EMAIL} \
|
||||||
|
ACTRL_API_PASSWORD={PASSWORD} \
|
||||||
|
ACTRL_API_TOKEN={API_TOKEN} \
|
||||||
|
ACTRL_ZENDESK_SUBDOMAIN={DOMAIN} \
|
||||||
|
ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID} \
|
||||||
|
LA_CROLE_ID={LIGHT_AGENT_CUSTOM_ROLE_ID} \
|
||||||
|
EMPL_GROUP={EMPLOYEE_GROUP_NAME} \
|
||||||
|
BUF_GROUP={BUFFER_GROUP_NAME} \
|
||||||
|
ST_EMAIL={SOLVED_TICKETS_EMAIL} \
|
||||||
|
LICENSE_NO={LICENSE_NO} \
|
||||||
|
SHIFTH={SHIFT_HOURS} \
|
||||||
|
-v {ABSOLUTE_PATH_TO_DB}:/zendesk-access-controller/db \
|
||||||
|
access_controller:latest
|
||||||
|
|
||||||
|
* открываем запущенный контейнер в браузере (можно перейти по ссылке http://localhost:8000/)
|
||||||
|
|
||||||
|
Запуск с тестовыми юзерами:
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
На локальной машине - перед запуском команды ``python manage.py runserver`` выполнить команду ``python manage.py loaddata data.json``
|
||||||
|
Это создаст тестового админа и тестового пользователя в приложении для песочницы ZenDesk.
|
||||||
|
|
||||||
|
|
||||||
|
* Админ - ``admin@gmail.com`` / ``zendeskadmin``
|
||||||
|
* Пользователь - ``123@test.ru`` / ``zendeskuser``
|
||||||
|
|
||||||
|
Не сработает если домен песочницы отличается от ``ngenix1612197338`` (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента
|
||||||
|
с этими же email, назначить им организацию ``SYSTEM``\ )
|
||||||
|
|
||||||
|
Параметры тестовой песочницы:
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Пример полной конфигурации можно найти в `.env.example <.env.example>`_. Почту и токен админа ZenDesk взять у руководителя (если вы не админ).
|
||||||
|
|
||||||
|
Для проверки pylint используем:
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
pylint ../access_controller (каталог, где лежит проект)
|
||||||
|
|
||||||
|
Для приведения файлов к стандарту PEP8 используем:
|
||||||
|
--------------------------------------------------
|
||||||
|
|
||||||
|
autopep8 --in-place filename
|
||||||
|
|
||||||
|
Для проверки орфографии:
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
cd docs
|
||||||
|
|
||||||
|
make spelling
|
||||||
|
|
||||||
|
Для обновления документации:
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
m2r README.md
|
||||||
|
|
||||||
|
cd docs
|
||||||
|
|
||||||
|
make html
|
||||||
|
|
||||||
|
Read more
|
||||||
|
---------
|
||||||
|
|
||||||
|
|
||||||
|
* Zenpy: `http://docs.facetoe.com.au <http://docs.facetoe.com.au>`_
|
||||||
|
* Zendesk API: `https://developer.zendesk.com/rest_api/docs/ <https://developer.zendesk.com/rest_api/docs/>`_
|
@ -141,6 +141,10 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'staticroot')
|
|||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
os.path.join(BASE_DIR, 'static'),
|
os.path.join(BASE_DIR, 'static'),
|
||||||
]
|
]
|
||||||
|
STATICFILES_FINDERS = [
|
||||||
|
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||||
|
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||||
|
]
|
||||||
|
|
||||||
ACCOUNT_ACTIVATION_DAYS = 7
|
ACCOUNT_ACTIVATION_DAYS = 7
|
||||||
|
|
||||||
|
8
main/control_page_js_modules/.babelrc
Normal file
8
main/control_page_js_modules/.babelrc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||||
|
"plugins": [
|
||||||
|
["@babel/plugin-transform-runtime", {
|
||||||
|
"regenerator": true
|
||||||
|
}], "@babel/plugin-syntax-jsx"
|
||||||
|
]
|
||||||
|
}
|
98
main/control_page_js_modules/__tests__/control.test.js
Normal file
98
main/control_page_js_modules/__tests__/control.test.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {render, unmountComponentAtNode} from "react-dom";
|
||||||
|
import {act} from "react-dom/test-utils";
|
||||||
|
import {Table} from "../src/control";
|
||||||
|
import * as test_data from "./test_users.json"
|
||||||
|
import axios from "axios";
|
||||||
|
import MockAdapter from "axios-mock-adapter";
|
||||||
|
|
||||||
|
let mock = null
|
||||||
|
let container = null
|
||||||
|
beforeEach(() => {
|
||||||
|
mock = new MockAdapter(axios);
|
||||||
|
mock.onGet('/api/users').reply(200, test_data)
|
||||||
|
container = document.createElement('div')
|
||||||
|
container.id = "table"
|
||||||
|
document.body.appendChild(container)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unmountComponentAtNode(container);
|
||||||
|
mock.restore()
|
||||||
|
container.remove();
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no rows without axios request", () => {
|
||||||
|
act(() => {
|
||||||
|
render(<Table/>, container);
|
||||||
|
});
|
||||||
|
let tbody = container.querySelector("#tbody")
|
||||||
|
expect(tbody.getElementsByTagName('tr').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows valid number of free workplaces", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
render(<Table/>, container)
|
||||||
|
})
|
||||||
|
let element = container.querySelector('#licences_remaining')
|
||||||
|
let licences = Number(element.innerHTML.replace(/Свободных мест: /, ''))
|
||||||
|
expect(licences).toEqual(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Pretext must be deleted on render", () => {
|
||||||
|
act(() => {
|
||||||
|
render(<Table/>, container)
|
||||||
|
})
|
||||||
|
expect(document.body).not.toContain(container.querySelector('#loading'))
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has valid number of table rows with axios request", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
render(<Table/>, container)
|
||||||
|
})
|
||||||
|
let tbody = container.querySelector("#tbody")
|
||||||
|
expect(tbody.getElementsByTagName('tr').length)
|
||||||
|
.toEqual(test_data.users.length + test_data.zendesk_users.length)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("show valid number for engineers and light agents", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
render(<Table/>, container)
|
||||||
|
})
|
||||||
|
let engineers = container.querySelector('#engineers')
|
||||||
|
let agents = container.querySelector('#agents')
|
||||||
|
expect(Number(engineers.textContent)).toEqual(test_data.engineers)
|
||||||
|
expect(Number(agents.textContent)).toEqual(test_data.light_agents)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("called one request on mount", async () => {
|
||||||
|
let requests = jest.spyOn(Table.prototype, "getUsers")
|
||||||
|
await act(async () => {
|
||||||
|
render(<Table/>, container)
|
||||||
|
})
|
||||||
|
expect(requests).toHaveBeenCalledTimes(1)
|
||||||
|
requests.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("checkbox count equals users from db count", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
render(<Table/>, container)
|
||||||
|
})
|
||||||
|
let tbody = container.querySelector("#tbody")
|
||||||
|
let checkboxes = tbody.querySelectorAll("input[type='checkbox']")
|
||||||
|
let users = test_data.users
|
||||||
|
expect(checkboxes.length).toEqual(users.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("requests occur every one minute", async () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
let requests = jest.spyOn(Table.prototype, "getUsers")
|
||||||
|
await act(async () => {
|
||||||
|
render(<Table/>, container)
|
||||||
|
})
|
||||||
|
jest.advanceTimersByTime(60000)
|
||||||
|
expect(requests).toHaveBeenCalledTimes(2)
|
||||||
|
jest.useRealTimers()
|
||||||
|
requests.mockRestore()
|
||||||
|
})
|
32
main/control_page_js_modules/__tests__/test_users.json
Normal file
32
main/control_page_js_modules/__tests__/test_users.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"email": "123@test.ru"
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"name": "UserForAccessTest",
|
||||||
|
"zendesk_role": "light_agent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"engineers": 2,
|
||||||
|
"light_agents": 2,
|
||||||
|
"zendesk_users": [
|
||||||
|
{
|
||||||
|
"name": "Степаненко Ольга s101",
|
||||||
|
"zendesk_role": "engineer",
|
||||||
|
"email": "stepanenko_olga@mail.ru"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEST",
|
||||||
|
"zendesk_role": "engineer",
|
||||||
|
"email": "akovalev1305@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vasua",
|
||||||
|
"zendesk_role": "light_agent",
|
||||||
|
"email": "krav-88@mail.ru"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max_agents": 3
|
||||||
|
}
|
9
main/control_page_js_modules/jest.config.js
Normal file
9
main/control_page_js_modules/jest.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
verbose: true,
|
||||||
|
testPathIgnorePatterns: [
|
||||||
|
"./node_modules/"
|
||||||
|
],
|
||||||
|
roots: [
|
||||||
|
"./__tests__"
|
||||||
|
],
|
||||||
|
}
|
17600
main/control_page_js_modules/package-lock.json
generated
Normal file
17600
main/control_page_js_modules/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
main/control_page_js_modules/package.json
Normal file
28
main/control_page_js_modules/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "control_page",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "../static/main/js/control_page/dist/index_bundle.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/cli": "^7.13.16",
|
||||||
|
"@babel/core": "^7.13.16",
|
||||||
|
"@babel/plugin-transform-runtime": "^7.13.15",
|
||||||
|
"@babel/preset-env": "^7.13.15",
|
||||||
|
"@babel/preset-react": "^7.13.13",
|
||||||
|
"axios": "^0.21.1",
|
||||||
|
"axios-mock-adapter": "^1.19.0",
|
||||||
|
"babel-loader": "^8.2.2",
|
||||||
|
"jest": "^26.6.3",
|
||||||
|
"jsx": "^0.9.89",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-dom": "^17.0.2",
|
||||||
|
"save-dev": "0.0.1-security",
|
||||||
|
"webpack": "^5.36.2",
|
||||||
|
"webpack-cli": "^4.6.0"
|
||||||
|
}
|
||||||
|
}
|
183
main/control_page_js_modules/src/control.js
Normal file
183
main/control_page_js_modules/src/control.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import React from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
function FreeWorkplaces(props) {
|
||||||
|
return (
|
||||||
|
<div className="new-section">
|
||||||
|
<p className="row page-description" id="licences_remaining">Свободных мест: {props.count}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkersCount(props) {
|
||||||
|
return (
|
||||||
|
<div className="row justify-content-center new-section d-flex align-items-center">
|
||||||
|
<div className="col-5">
|
||||||
|
<div className="info">
|
||||||
|
<div className="info-row">
|
||||||
|
<div className="info-target px-4">Инженеров:</div>
|
||||||
|
<div className="info-quantity">
|
||||||
|
<div className="status-circle-small light-green"></div>
|
||||||
|
<span className="info-quantity-value" id="engineers">{props.engineers}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="info-row">
|
||||||
|
<div className="info-target px-4">Легких агентов:</div>
|
||||||
|
<div className="info-quantity">
|
||||||
|
<div className="status-circle-small light-yellow"></div>
|
||||||
|
<span className="info-quantity-value" id="agents">{props.light_agents}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-5">
|
||||||
|
<button type="submit" name="engineer" className="btn default-button btn-warning btn-block btn-sm py-3">
|
||||||
|
Назначить выбранных на роль инженера
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="light_agent" className="btn default-button btn-warning btn-block btn-sm py-3">
|
||||||
|
Назначить выбранных на роль легкого агента
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ModelUserTableRow extends React.Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<tr className={"table-dark"}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
value={this.props.user.id}
|
||||||
|
className="form-check-input"
|
||||||
|
name="users"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="#">{this.props.user.name}</a>
|
||||||
|
</td>
|
||||||
|
<td>{this.props.user.user.email}</td>
|
||||||
|
<td>{this.props.user.zendesk_role}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ModelUserTableRows extends React.Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
this.props.users.map((user, key) => (
|
||||||
|
<ModelUserTableRow user={user} key={key} />
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ZendeskUserTableRow extends React.Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<tr className={"table-secondary text-secondary"}>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<a href="#" style={{ color: "grey", fontStyle: "italic" }}>
|
||||||
|
{this.props.user.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "grey", fontStyle: "italic" }}>
|
||||||
|
{this.props.user.email}
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "grey", fontStyle: "italic" }}>
|
||||||
|
{this.props.user.zendesk_role}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ZendeskUserTableRows extends React.Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
this.props.users.map((user, key) => (
|
||||||
|
<ZendeskUserTableRow user={user} key={key} />
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Table extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
users: [],
|
||||||
|
engineers: null,
|
||||||
|
light_agents: null,
|
||||||
|
zendesk_users: [],
|
||||||
|
max_agents: null,
|
||||||
|
renderLoad: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsers() {
|
||||||
|
await axios.get("/api/users").then((response) => {
|
||||||
|
this.setState({
|
||||||
|
users: response.data.users,
|
||||||
|
engineers: response.data.engineers,
|
||||||
|
light_agents: response.data.light_agents,
|
||||||
|
zendesk_users: response.data.zendesk_users,
|
||||||
|
max_agents: response.data.max_agents,
|
||||||
|
renderLoad: false
|
||||||
|
});
|
||||||
|
return response
|
||||||
|
}).catch(reason => {
|
||||||
|
console.log(reason)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.getUsers().then(() => {})
|
||||||
|
.catch(reason => {
|
||||||
|
console.log(reason)
|
||||||
|
});
|
||||||
|
this.interval = setInterval(() => {
|
||||||
|
this.getUsers().catch(reason => {
|
||||||
|
console.log(reason)
|
||||||
|
})
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FreeWorkplaces count={Math.max(this.state.max_agents - this.state.engineers, 0)}/>
|
||||||
|
<table className="table table-dark">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
id="head-checkbox"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody">
|
||||||
|
<ModelUserTableRows users={this.state.users}/>
|
||||||
|
<ZendeskUserTableRows users={this.state.zendesk_users}/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{this.state.renderLoad === true ? <p id="loading">Данные загружаются...</p> : null}
|
||||||
|
<WorkersCount engineers={this.state.engineers} light_agents={this.state.light_agents}/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
17
main/control_page_js_modules/src/index.js
Normal file
17
main/control_page_js_modules/src/index.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import {Table} from "./control"
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
|
||||||
|
function headCheckbox() {
|
||||||
|
let headCheckbox = document.getElementById("head-checkbox");
|
||||||
|
headCheckbox.addEventListener("click", () => {
|
||||||
|
let checkboxes = document.getElementsByName("users");
|
||||||
|
for (let checkbox of checkboxes)
|
||||||
|
checkbox.checked = headCheckbox.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ReactDOM.render(<Table />, document.getElementById("table"));
|
||||||
|
headCheckbox();
|
31
main/control_page_js_modules/webpack.config.js
Normal file
31
main/control_page_js_modules/webpack.config.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: './src/index.js',
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.(js)$/,
|
||||||
|
exclude: path.resolve(__dirname, 'node_modules/'),
|
||||||
|
use: {
|
||||||
|
loader: 'babel-loader',
|
||||||
|
options: {
|
||||||
|
presets: ['@babel/preset-env', "@babel/preset-react"],
|
||||||
|
plugins: [["@babel/plugin-transform-runtime", {"regenerator": true}]],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: [
|
||||||
|
'.js',
|
||||||
|
'.jsx'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.resolve('../../static/main/js/control_page', 'dist'),
|
||||||
|
filename: 'index_bundle.js'
|
||||||
|
},
|
||||||
|
mode: 'development',
|
||||||
|
}
|
@ -12,22 +12,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
|
|
||||||
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
|
|
||||||
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
|
||||||
|
|
||||||
<script src="{% static 'main/js/control.js'%}" type="text/babel"></script>
|
|
||||||
<script src="{% static 'main/js/notifications.js' %}"></script> {# Для #}
|
<script src="{% static 'main/js/notifications.js' %}"></script> {# Для #}
|
||||||
<script src="{% static 'modules/notifications/dist/notifications.js' %}"></script> {# Уведомлений #}
|
<script src="{% static 'modules/notifications/dist/notifications.js' %}"></script> {# Уведомлений #}
|
||||||
{% endblock%}
|
{% endblock%}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-md">
|
<div class="container-md">
|
||||||
|
|
||||||
<div class="new-section">
|
|
||||||
<p class="row page-description" id="licences_remaining">Свободных Мест:</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<script>create_notification('{{message}}','','{{message.tags}}',2000)</script>
|
<script>create_notification('{{message}}','','{{message.tags}}',2000)</script>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -35,78 +25,21 @@
|
|||||||
{% block form %}
|
{% block form %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="row justify-content-center new-section">
|
<div class="row justify-content-center new-section">
|
||||||
|
|
||||||
<div class="col-10">
|
<div class="col-10">
|
||||||
<h3 class="py-4 text-center font-weight-bold">Список сотрудников</h3>
|
<h3 class="py-4 text-center font-weight-bold mb-0">Список сотрудников</h3>
|
||||||
|
|
||||||
{% block table %}
|
{% block table %}
|
||||||
<table class="table table-dark">
|
<div id="table"></div>
|
||||||
|
|
||||||
<thead>
|
|
||||||
<th>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="form-check-input"
|
|
||||||
id="head-checkbox"
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Role</th>
|
|
||||||
</thead>
|
|
||||||
<tbody id="tbody"></tbody>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<p id="loading">Данные загружаются...</p>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block count %}
|
|
||||||
<div class="row justify-content-center new-section">
|
|
||||||
<div class="col-5">
|
|
||||||
<div class="info">
|
|
||||||
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-target px-4"><h6>Инженеров:</h6></div>
|
|
||||||
<div class="info-quantity">
|
|
||||||
<div class="status-circle-small light-green"></div>
|
|
||||||
<span class="info-quantity-value">{{ engineers }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-target px-4"><h6>Легких агентов:</h6></div>
|
|
||||||
<div class="info-quantity">
|
|
||||||
<div class="status-circle-small light-yellow"></div>
|
|
||||||
<span class="info-quantity-value">{{ light_agents }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block buttons %}
|
|
||||||
<div class="col-5">
|
|
||||||
|
|
||||||
<button type="submit" name="engineer" class="btn default-button btn-warning btn-block btn-sm py-3">
|
|
||||||
Назначить выбранных на роль инженера
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button type="submit" name="light_agent" class="btn default-button btn-warning btn-block btn-sm py-3">
|
|
||||||
Назначить выбранных на роль легкого агента
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'main/js/control_page/dist/index_bundle.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ from django.core import mail
|
|||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
|
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils import translation, timezone
|
from django.utils import translation, timezone
|
||||||
|
|
||||||
@ -896,3 +897,24 @@ class LoggingTestCase(UsersBaseTestCase):
|
|||||||
file_output = self.get_file_output()
|
file_output = self.get_file_output()
|
||||||
self.assertEqual(file_output, f'UserForAccessTest,light_agent,'
|
self.assertEqual(file_output, f'UserForAccessTest,light_agent,'
|
||||||
f'{str(timezone.now().today())[:16]},UserForAccessTest\n')
|
f'{str(timezone.now().today())[:16]},UserForAccessTest\n')
|
||||||
|
|
||||||
|
|
||||||
|
class ControlAccessTests(TestCase):
|
||||||
|
"""
|
||||||
|
Класс тестов для проверки доступа к странице управления
|
||||||
|
"""
|
||||||
|
fixtures = ['fixtures/data.json']
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.User = get_user_model()
|
||||||
|
self.client = Client()
|
||||||
|
|
||||||
|
def test_admin_has_perm(self):
|
||||||
|
self.client.force_login(self.User.objects.get(email='admin@gmail.com'))
|
||||||
|
self.response = self.client.get(reverse('control'))
|
||||||
|
self.assertEqual(self.response.status_code, 200)
|
||||||
|
|
||||||
|
def test_engineer_doesnt_have_perm(self):
|
||||||
|
self.client.force_login(self.User.objects.get(email='123@test.ru'))
|
||||||
|
self.response = self.client.get(reverse('control'))
|
||||||
|
self.assertEqual(self.response.status_code, 403)
|
||||||
|
@ -1,138 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
function head_checkbox() {
|
|
||||||
let head_checkbox = document.getElementById("head-checkbox");
|
|
||||||
head_checkbox.addEventListener("click", () => {
|
|
||||||
let checkboxes = document.getElementsByName("users");
|
|
||||||
for (let checkbox of checkboxes) checkbox.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// React
|
|
||||||
class ModelUserTableRow extends React.Component {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<tr className={"table-dark"}>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
value={this.props.user.id}
|
|
||||||
className="form-check-input"
|
|
||||||
name="users"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>{this.props.user.name}</td>
|
|
||||||
<td>{this.props.user.user.email}</td>
|
|
||||||
<td>{this.props.user.zendesk_role}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ModelUserTableRows extends React.Component {
|
|
||||||
render() {
|
|
||||||
return ReactDOM.createPortal(
|
|
||||||
this.props.users.map((user, key) => (
|
|
||||||
<ModelUserTableRow user={user} key={key} />
|
|
||||||
)),
|
|
||||||
document.getElementById("tbody")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ZendeskUserTableRow extends React.Component {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<tr className={"table-secondary text-secondary"}>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<a href="#" style={{ color: "grey", fontStyle: "italic" }}>
|
|
||||||
{this.props.user.name}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td style={{ color: "grey", fontStyle: "italic" }}>
|
|
||||||
{this.props.user.email}
|
|
||||||
</td>
|
|
||||||
<td style={{ color: "grey", fontStyle: "italic" }}>
|
|
||||||
{this.props.user.zendesk_role}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ZendeskUserTableRows extends React.Component {
|
|
||||||
render() {
|
|
||||||
return ReactDOM.createPortal(
|
|
||||||
this.props.users.map((user, key) => (
|
|
||||||
<ZendeskUserTableRow user={user} key={key} />
|
|
||||||
)),
|
|
||||||
document.getElementById("tbody")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TableBody extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
users: [],
|
|
||||||
engineers: 0,
|
|
||||||
light_agents: 0,
|
|
||||||
zendesk_users: [],
|
|
||||||
max_agents: 3,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
change_elemnts_html() {
|
|
||||||
let elements = document.querySelectorAll(".info-quantity-value");
|
|
||||||
let licences = document.getElementById("licences_remaining");
|
|
||||||
elements[0].innerHTML = this.state.engineers;
|
|
||||||
elements[1].innerHTML = this.state.light_agents;
|
|
||||||
let max_licences = Math.max(
|
|
||||||
this.state.max_agents - this.state.engineers,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
licences.innerHTML = "Свободных мест: " + max_licences;
|
|
||||||
}
|
|
||||||
|
|
||||||
async get_users() {
|
|
||||||
await axios.get("/api/users").then((response) => {
|
|
||||||
this.setState({
|
|
||||||
users: response.data.users,
|
|
||||||
engineers: response.data.engineers,
|
|
||||||
light_agents: response.data.light_agents,
|
|
||||||
zendesk_users: response.data.zendesk_users,
|
|
||||||
max_agents: response.data.max_agents,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this.change_elemnts_html();
|
|
||||||
}
|
|
||||||
|
|
||||||
delete_pretext() {
|
|
||||||
document.getElementById("loading").remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.get_users().then(() => this.delete_pretext());
|
|
||||||
this.interval = setInterval(() => {
|
|
||||||
this.get_users();
|
|
||||||
}, 60000);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<ModelUserTableRows users={this.state.users} />
|
|
||||||
<ZendeskUserTableRows users={this.state.zendesk_users} />
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.render(<TableBody />, document.getElementById("tbody"));
|
|
||||||
head_checkbox();
|
|
0
static/main/js/control_page/.gitkeep
Normal file
0
static/main/js/control_page/.gitkeep
Normal file
Loading…
x
Reference in New Issue
Block a user