Merge branch 'develop' into hotfix/get_tickets

This commit is contained in:
Sokurov Idar 2021-05-29 23:59:51 +03:00
commit d943f474b1
35 changed files with 18586 additions and 273 deletions

View File

@ -7,23 +7,25 @@
*.sql.gz *.sql.gz
*.sqlite3 *.sqlite3
.cache .cache
.env .env*
.project .project
.idea .idea
.pydevproject .pydevproject
.idea/workspace.xml
.DS_Store .DS_Store
build/
.git/ .git/
.sass-cache .gitlab/
.vagrant/ .gitignore
venv/
fixtures/
db/
data.json
__pycache__ __pycache__
dist dist
docs
env
logs logs
src/{{ project_name }}/settings/local.py
src/node_modules
web/media
web/static/CACHE
stats
Dockerfile Dockerfile
docker-compose*
.dockerignore
layouts
staticroot
.editorconfig

4
.gitignore vendored
View File

@ -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

View File

@ -1,7 +1,19 @@
image: python:3-alpine image: python:3.9-alpine
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache"
cache:
paths:
- .cache/pip
stages: stages:
- test - test
- style
- build
- deploy
django_test: django_test:
stage: test stage: test
@ -21,3 +33,35 @@ coverage:
artifacts: artifacts:
paths: paths:
- public/coverage - public/coverage
pylint:
stage: style
allow_failure: true
before_script:
- pip install -r requirements/dev.txt
script:
- pylint --reports=yes main
docker:
stage: build
only:
- master
tags:
- docker
before_script:
- docker info
- docker login -u $REGISTRY_USERNAME -p $REGISTRY_PASSWORD gitlab.cazzzer.com:5050
script:
- docker build . -t gitlab.cazzzer.com:5050/cazzzer/zendesk-access-controller:alpine
- docker push gitlab.cazzzer.com:5050/cazzzer/zendesk-access-controller:alpine
after_script:
- docker logout
deploy:
stage: deploy
only:
- master
tags:
- cazzzer-internal
script:
- sudo -u cazzzer /home/cazzzer/deploy_access_controller.sh

View File

@ -1,10 +1,26 @@
FROM python:3.6 FROM python:3.9 as builder
COPY ./ /access_controller # enchant dependency for sphinx
RUN apt-get update && apt-get install -y python-enchant
# copy source files
WORKDIR /access_controller/ WORKDIR /access_controller/
RUN pip install -r requirements/prod.txt COPY ./ /access_controller
RUN python manage.py makemigrations # dev requirements for building sphinx docs
RUN pip install -r requirements/dev.txt -r requirements/prod.txt
# build static and documentation files into build/webserver
RUN ./manage.py collectstatic --no-input && ./documentation.sh
# create production venv to copy to final image
# production venv is built here because `cryptography` requires rust which doesn't ship on alpine
RUN python -m venv venv && venv/bin/pip install -r requirements/prod.txt
# move files necessary to run the app to the build folder (including production venv)
RUN mv venv access_controller main manage.py build
FROM python:3.9-alpine
WORKDIR /access_controller/
COPY --from=builder ["/access_controller/build", "./"]
RUN apk update && apk add postgresql-libs && adduser -D -H -u 5579 actrl && chmod -R -w ./
USER actrl
EXPOSE 8000 EXPOSE 8000
COPY start.sh /var/ CMD . venv/bin/activate && \
CMD bash /var/start.sh python manage.py migrate && \
cp -rT webserver srv && \
daphne -b 0.0.0.0 access_controller.asgi:application

View File

@ -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
```
## Запуск на локальной машине: ## Запуск на локальной машине:
- Скопировать репозиторий на локальную машину - Скопировать репозиторий на локальную машину

0
README.rst Normal file
View File

View File

@ -15,8 +15,6 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
# 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
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
@ -29,7 +27,7 @@ load_dotenv()
SECRET_KEY = os.getenv('ACTRL_SECRET_KEY', 'empty') SECRET_KEY = os.getenv('ACTRL_SECRET_KEY', 'empty')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = bool(int(os.getenv('ACTRL_DEBUG', '1'))) DEBUG = bool(int(os.getenv('ACTRL_DEBUG', '0')))
ALLOWED_HOSTS = [ ALLOWED_HOSTS = [
'127.0.0.1', '127.0.0.1',
@ -94,13 +92,24 @@ WSGI_APPLICATION = 'access_controller.wsgi.application'
# Database # Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases # https://docs.djangoproject.com/en/3.1/ref/settings/#databases
if DEBUG:
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db' / 'zd_db.sqlite3' 'NAME': BASE_DIR / 'db' / 'zd_db.sqlite3'
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('ACTRL_DB_NAME'),
'USER': os.getenv('ACTRL_DB_USER'),
'PASSWORD': os.getenv('ACTRL_DB_PASSWORD'),
'HOST': os.getenv('ACTRL_DB_HOST'),
'PORT': os.getenv('ACTRL_DB_PORT'),
}
} }
}
# Password validation # Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
@ -137,10 +146,14 @@ USE_TZ = True
# https://docs.djangoproject.com/en/3.1/howto/static-files/ # https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticroot') STATIC_ROOT = BASE_DIR / 'build' / 'webserver' / 'static'
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

View File

@ -39,6 +39,7 @@ urlpatterns = [
path('registration_failed/', registration_failed, name='registration_failed'), path('registration_failed/', registration_failed, name='registration_failed'),
path('control/', AdminPageView.as_view(), name='control'), path('control/', AdminPageView.as_view(), name='control'),
path('statistic/', statistic_page, name='statistic'), path('statistic/', statistic_page, name='statistic'),
] ]
# Django REST # Django REST

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
version: '3.8'
services:
web:
image: gitlab.cazzzer.com:5050/cazzzer/zendesk-access-controller:alpine
env_file:
- .env.prod
volumes:
- static:/access_controller/srv
expose:
- 8000
depends_on:
- db
db:
image: postgres:13-alpine
volumes:
- postgres_data:/var/lib/postgresql/data/
expose:
- 5432
env_file:
- .env.prod.db
nginx:
build: ./nginx
volumes:
- static:/srv/access_controller
ports:
- 8004:80
depends_on:
- web
volumes:
postgres_data:
static:

View File

@ -11,11 +11,15 @@
# documentation root, use os.path.abspath to make it absolute, like shown here. # documentation root, use os.path.abspath to make it absolute, like shown here.
# #
import os import os
import sphinx.events
import sys import sys
import inspect import inspect
import enchant import enchant
import django import django
sphinx.events.core_events['autodoc-process-signature'] = ''
sys.path.insert(0, os.path.abspath('../../')) sys.path.insert(0, os.path.abspath('../../'))
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings')
@ -47,6 +51,8 @@ author = 'SHP S101, group 2'
release = 'v0.01' release = 'v0.01'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
def process_django_models(app, what, name, obj, options, lines): def process_django_models(app, what, name, obj, options, lines):
@ -112,8 +118,9 @@ def skip_queryset(app, what, name, obj, skip, options):
return skip return skip
def fix_sig(app, what, name, obj, options, signature, return_annotation): def process_signature(app, what: str, name: str, obj, options, signature, return_annotation):
return "", "" if not callable(obj):
return
def setup(app): def setup(app):
@ -121,7 +128,7 @@ def setup(app):
app.connect('autodoc-process-docstring', process_django_models) app.connect('autodoc-process-docstring', process_django_models)
app.connect('autodoc-skip-member', skip_queryset) app.connect('autodoc-skip-member', skip_queryset)
app.connect('autodoc-process-docstring', process_modules) app.connect('autodoc-process-docstring', process_modules)
app.connect("autodoc-process-signature", fix_sig) app.connect("autodoc-process-signature", process_signature)
# Add any Sphinx extension module names here, as strings. They can be # Add any Sphinx extension module names here, as strings. They can be

View File

@ -1,7 +1,23 @@
#!/usr/bin/env bash #!/usr/bin/env sh
m2r README.md retry() {
cd docs retries=$1
make html shift
cd ..
count=0
until "$@"; do
exit=$?
count=$((count + 1))
if [ $count -lt $retries ]; then
echo "Retry $count/$retries exited $exit, retrying..."
else
echo "Retry $count/$retries exited $exit, no more retries left."
return $exit
fi
done
return 0
}
m2r README.md --overwrite
sphinx-build -b html docs/source build/webserver/docs
rm README.rst rm README.rst

View File

@ -0,0 +1,8 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": [
["@babel/plugin-transform-runtime", {
"regenerator": true
}], "@babel/plugin-syntax-jsx"
]
}

View 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()
})

View 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
}

View File

@ -0,0 +1,9 @@
module.exports = {
verbose: true,
testPathIgnorePatterns: [
"./node_modules/"
],
roots: [
"./__tests__"
],
}

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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>
);
}
}

View 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();

View 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',
}

View File

@ -6,6 +6,8 @@ from datetime import timedelta, date
from typing import Union, Optional from typing import Union, Optional
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.handlers.wsgi import WSGIRequest from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponseRedirect, HttpResponsePermanentRedirect from django.http import HttpResponseRedirect, HttpResponsePermanentRedirect
@ -123,6 +125,7 @@ def update_profile(user_profile: UserProfile) -> None:
:return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя
""" """
user = zenpy.get_user(user_profile.user.email) user = zenpy.get_user(user_profile.user.email)
update_permission(user_profile, user)
user_profile.name = user.name user_profile.name = user.name
user_profile.role = user.role user_profile.role = user.role
user_profile.custom_role_id = user.custom_role_id if user.custom_role_id else 0 user_profile.custom_role_id = user.custom_role_id if user.custom_role_id else 0
@ -130,6 +133,52 @@ def update_profile(user_profile: UserProfile) -> None:
user_profile.save() user_profile.save()
def update_permission(user_profile: UserProfile, user: ZenpyUser):
"""
Функция обновляет права доступа пользователя в БД.
:param user_profile: Профиль пользователя
:param user: Данные пользователя в Zendesk
"""
if user_profile.role != user.role:
user_profile.role = user.role
user_profile.save()
set_permission(user_profile.user)
del_permission(user_profile.user)
def set_permission(user: get_user_model()) -> None:
"""
Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin.
:param user: Авторизованный пользователь (получает разрешение, имея роль "admin")
"""
if user.userprofile.role == 'admin':
content_type = ContentType.objects.get_for_model(UserProfile)
permission = Permission.objects.get(
codename='has_control_access',
content_type=content_type,
)
user.user_permissions.add(permission)
user.save()
def del_permission(user: get_user_model()) -> None:
"""
Функция забирает разрешение на просмотр страница администратора, если пользователь не имеет роль admin.
:param user: Авторизованный пользователь (теряет разрешение, не имея роль "admin")
"""
if user.userprofile.role == 'agent' and user.has_perm('main.has_control_access'):
content_type = ContentType.objects.get_for_model(UserProfile)
permission = Permission.objects.get(
codename='has_control_access',
content_type=content_type,
)
user.user_permissions.remove(permission)
user.save()
def check_user_exist(email: str) -> bool: def check_user_exist(email: str) -> bool:
""" """
Функция проверяет, существует ли пользователь. Функция проверяет, существует ли пользователь.
@ -180,6 +229,7 @@ def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser) -> None:
:param zendesk_user: Данные пользователя в Zendesk :param zendesk_user: Данные пользователя в Zendesk
:return: Обновленный профиль пользователя :return: Обновленный профиль пользователя
""" """
update_permission(profile, zendesk_user)
profile.name = zendesk_user.name profile.name = zendesk_user.name
profile.role = zendesk_user.role profile.role = zendesk_user.role
profile.image = zendesk_user.photo['content_url'] if zendesk_user.photo else None profile.image = zendesk_user.photo['content_url'] if zendesk_user.photo else None
@ -262,6 +312,7 @@ class DatabaseHandler(logging.Handler):
""" """
Класс записи изменений ролей в базу данных. Класс записи изменений ролей в базу данных.
""" """
def __init__(self): def __init__(self):
logging.Handler.__init__(self) logging.Handler.__init__(self)
@ -295,6 +346,7 @@ class CsvFormatter(logging.Formatter):
""" """
Класс преобразования смены ролей пользователей в строковый формат. Класс преобразования смены ролей пользователей в строковый формат.
""" """
def __init__(self): def __init__(self):
logging.Formatter.__init__(self) logging.Formatter.__init__(self)
@ -346,7 +398,7 @@ def log(user: get_user_model(), admin: get_user_model() = None) -> None:
def set_session_params_for_work_page(request: WSGIRequest, count: int = None, is_confirm: bool = True) -> \ def set_session_params_for_work_page(request: WSGIRequest, count: int = None, is_confirm: bool = True) -> \
Union[HttpResponsePermanentRedirect, HttpResponseRedirect]: Union[HttpResponsePermanentRedirect, HttpResponseRedirect]:
""" """
Функция для страницы получения прав, устанавливает данные сессии о успешности запроса и количестве Функция для страницы получения прав, устанавливает данные сессии о успешности запроса и количестве
назначенных тикетов. назначенных тикетов.

View File

@ -9,6 +9,8 @@
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/css/bootstrap.min.css" <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/css/bootstrap.min.css"
integrity="sha384-r4NyP46KrjDleawBgD5tp8Y7UzmLA05oM1iAEQ17CSuDqnUK2+k9luXQOfXJCJ4I" crossorigin="anonymous"> integrity="sha384-r4NyP46KrjDleawBgD5tp8Y7UzmLA05oM1iAEQ17CSuDqnUK2+k9luXQOfXJCJ4I" crossorigin="anonymous">
<link rel="icon" href="{% static 'main/favs/favicon.ico'%}" type="image/x-icon">
<style> <style>
.bd-placeholder-img { .bd-placeholder-img {

View File

@ -42,6 +42,8 @@
href="{{ work_url }}">Запрос прав</a> href="{{ work_url }}">Запрос прав</a>
{% endif %} {% endif %}
<a class="btn btn-secondary" href="{% url 'logout' %}">Выйти</a> <a class="btn btn-secondary" href="{% url 'logout' %}">Выйти</a>
<a class="btn btn-secondary"
href="https://actrl.cazzzer.com/docs/index.html"> Документация</a>
</div> </div>
{% else %} {% else %}
<div class="btn-group" role="group" aria-label="Basic example" style="margin-right: 9px"> <div class="btn-group" role="group" aria-label="Basic example" style="margin-right: 9px">
@ -59,6 +61,9 @@
class="btn btn-secondary" class="btn btn-secondary"
{% endif %} {% endif %}
href="{{ registration_url }}">Зарегистрироваться</a> href="{{ registration_url }}">Зарегистрироваться</a>
{% url 'documentation' as documentation_url %}
<a class="btn btn-secondary"
href="{{ documentation_url }}">Документация</a>
</div> </div>
{% endif %} {% endif %}
</nav> </nav>

View File

@ -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 %}

View File

@ -0,0 +1,252 @@
<!DOCTYPE html>
<html class="writer-html5" lang="ru" >
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Документация контроллера прав доступа &mdash; документация ZenDesk Access Controller v0.01</title>
<link rel="stylesheet" href="_static/css/theme.css" type="text/css" />
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/graphviz.css" type="text/css" />
<!--[if lt IE 9]>
<script src="_static/js/html5shiv.min.js"></script>
<![endif]-->
<script type="text/javascript" id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/translations.js"></script>
<script type="text/javascript" src="_static/js/theme.js"></script>
<link rel="index" title="Алфавитный указатель" href="genindex.html" />
<link rel="search" title="Поиск" href="search.html" />
<link rel="next" title="Документация пользователя" href="overview.html" />
</head>
<body class="wy-body-for-nav">
<div class="wy-grid-for-nav">
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" >
<a href="#" class="icon icon-home"> ZenDesk Access Controller
</a>
<div role="search">
<form id="rtd-search-form" class="wy-form" action="search.html" method="get">
<input type="text" name="q" placeholder="Поиск в документации" />
<input type="hidden" name="check_keywords" value="yes" />
<input type="hidden" name="area" value="default" />
</form>
</div>
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
<p class="caption"><span class="caption-text">Contents:</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="overview.html">Документация пользователя</a></li>
<li class="toctree-l1"><a class="reference internal" href="code.html">Документация разработчика</a></li>
<li class="toctree-l1"><a class="reference internal" href="readme.html">READ.me</a></li>
<li class="toctree-l1"><a class="reference internal" href="todo.html">Что необходимо доделать?</a></li>
</ul>
</div>
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
<nav class="wy-nav-top" aria-label="top navigation">
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="#">ZenDesk Access Controller</a>
</nav>
<div class="wy-nav-content">
<div class="rst-content">
<div role="navigation" aria-label="breadcrumbs navigation">
<ul class="wy-breadcrumbs">
<li><a href="#" class="icon icon-home"></a> &raquo;</li>
<li>Документация контроллера прав доступа</li>
<li class="wy-breadcrumbs-aside">
<a href="_sources/index.rst.txt" rel="nofollow"> Просмотреть исходный код страницы</a>
</li>
</ul>
<hr/>
</div>
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<div class="section" id="id1">
<h1>Документация контроллера прав доступа<a class="headerlink" href="#id1" title="Ссылка на этот заголовок"></a></h1>
<div class="toctree-wrapper compound">
<p class="caption"><span class="caption-text">Contents:</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="overview.html">Документация пользователя</a><ul>
<li class="toctree-l2"><a class="reference internal" href="overview.html#id2">Управление правами доступа</a></li>
<li class="toctree-l2"><a class="reference internal" href="overview.html#id3">Главная страница</a></li>
<li class="toctree-l2"><a class="reference internal" href="overview.html#id4">Регистрация</a></li>
<li class="toctree-l2"><a class="reference internal" href="overview.html#id5">Авторизация</a></li>
<li class="toctree-l2"><a class="reference internal" href="overview.html#id6">Профиль</a></li>
<li class="toctree-l2"><a class="reference internal" href="overview.html#id7">Запрос прав доступа</a></li>
<li class="toctree-l2"><a class="reference internal" href="overview.html#id8">Управление правами доступа администратором</a></li>
</ul>
</li>
<li class="toctree-l1"><a class="reference internal" href="code.html">Документация разработчика</a><ul>
<li class="toctree-l2"><a class="reference internal" href="code.html#module-main.models">Models</a></li>
<li class="toctree-l2"><a class="reference internal" href="code.html#module-main.forms">Forms</a></li>
<li class="toctree-l2"><a class="reference internal" href="code.html#module-main.extra_func">Extra Functions</a></li>
<li class="toctree-l2"><a class="reference internal" href="code.html#module-main.serializers">Serializers</a></li>
<li class="toctree-l2"><a class="reference internal" href="code.html#module-main.views">Views</a></li>
<li class="toctree-l2"><a class="reference internal" href="code.html#module-main.requester">Обработка тикетов</a></li>
<li class="toctree-l2"><a class="reference internal" href="code.html#module-main.statistic_data">Обработка статистики</a></li>
<li class="toctree-l2"><a class="reference internal" href="code.html#module-main.zendesk_admin">Функционал администратора Zendesk</a></li>
<li class="toctree-l2"><a class="reference internal" href="code.html#module-main.tests">Тесты</a></li>
</ul>
</li>
<li class="toctree-l1"><a class="reference internal" href="readme.html">READ.me</a></li>
<li class="toctree-l1"><a class="reference internal" href="todo.html">Что необходимо доделать?</a><ul>
<li class="toctree-l2"><a class="reference internal" href="todo.html#todos">TODOs</a></li>
</ul>
</li>
</ul>
</div>
</div>
<div class="section" id="indices-and-tables">
<h1>Indices and tables<a class="headerlink" href="#indices-and-tables" title="Ссылка на этот заголовок"></a></h1>
<ul class="simple">
<li><p><a class="reference internal" href="genindex.html"><span class="std std-ref">Index</span></a></p></li>
<li><p><a class="reference internal" href="py-modindex.html"><span class="std std-ref">Module Index</span></a></p></li>
<li><p><a class="reference internal" href="search.html"><span class="std std-ref">Search Page</span></a></p></li>
</ul>
</div>
</div>
</div>
<footer>
<div class="rst-footer-buttons" role="navigation" aria-label="footer navigation">
<a href="overview.html" class="btn btn-neutral float-right" title="Документация пользователя" accesskey="n" rel="next">Следующая <span class="fa fa-arrow-circle-right" aria-hidden="true"></span></a>
</div>
<hr/>
<div role="contentinfo">
<p>
&#169; Copyright 2021, SHP S101, group 2.
</p>
</div>
Собрано при помощи <a href="https://www.sphinx-doc.org/">Sphinx</a> с использованием
<a href="https://github.com/readthedocs/sphinx_rtd_theme">темы,</a>
предоставленной <a href="https://readthedocs.org">Read the Docs</a>.
</footer>
</div>
</div>
</section>
</div>
<script type="text/javascript">
jQuery(function () {
SphinxRtdTheme.Navigation.enable(true);
});
</script>
</body>
</html>

View File

@ -88,7 +88,11 @@
<tr> <tr>
<td scope="col">&nbsp;</td> <td scope="col">&nbsp;</td>
{% for date in log_stats.keys %} {% for date in log_stats.keys %}
{% if interval == 'days' %}
<td scope="col">{{ date | date:'d.m' }}</td> <td scope="col">{{ date | date:'d.m' }}</td>
{% else %}
<td scope="col">{{ date.1 | date:'F' }}</td>
{% endif %}
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>

View File

@ -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)

View File

@ -29,7 +29,7 @@ from rest_framework.response import Response
from access_controller.settings import DEFAULT_FROM_EMAIL, ZENDESK_ROLES, ZENDESK_MAX_AGENTS, ZENDESK_GROUPS from access_controller.settings import DEFAULT_FROM_EMAIL, ZENDESK_ROLES, ZENDESK_MAX_AGENTS, ZENDESK_GROUPS
from main.extra_func import check_user_exist, update_profile, get_user_organization, \ from main.extra_func import check_user_exist, update_profile, get_user_organization, \
make_engineer, make_light_agent, get_users_list, update_users_in_model, count_users, \ make_engineer, make_light_agent, get_users_list, update_users_in_model, count_users, \
set_session_params_for_work_page, get_tickets_list_for_group set_session_params_for_work_page, get_tickets_list_for_group, set_permission
from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, \ from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, \
StatisticForm, WorkGetTicketsForm StatisticForm, WorkGetTicketsForm
from main.serializers import ProfileSerializer, ZendeskUserSerializer from main.serializers import ProfileSerializer, ZendeskUserSerializer
@ -118,7 +118,7 @@ class CustomRegistrationView(RegistrationView):
) )
try: try:
update_profile(user.userprofile) update_profile(user.userprofile)
self.set_permission(user) set_permission(user)
forms.save(**opts) forms.save(**opts)
return user return user
except SMTPException: except SMTPException:
@ -131,21 +131,6 @@ class CustomRegistrationView(RegistrationView):
self.redirect_url = 'invalid_zendesk_email' self.redirect_url = 'invalid_zendesk_email'
return None return None
@staticmethod
def set_permission(user: get_user_model()) -> None:
"""
Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin.
:param user: Авторизованный пользователь (получает разрешение, имея роль "admin")
"""
if user.userprofile.role == 'admin':
content_type = ContentType.objects.get_for_model(UserProfile)
permission = Permission.objects.get(
codename='has_control_access',
content_type=content_type,
)
user.user_permissions.add(permission)
def get_success_url(self, user: get_user_model() = None) -> Dict: def get_success_url(self, user: get_user_model() = None) -> Dict:
""" """
Функция возвращает url-адрес страницы, куда нужно перейти после успешной/не успешной регистрации. Функция возвращает url-адрес страницы, куда нужно перейти после успешной/не успешной регистрации.
@ -447,6 +432,7 @@ def statistic_page(request: WSGIRequest) -> HttpResponse:
context['errors'] = data.errors context['errors'] = data.errors
if data.warnings: if data.warnings:
context['warnings'] = data.warnings context['warnings'] = data.warnings
context['interval'] = data.interval
context['log_stats'] = stats if not context['errors'] else None context['log_stats'] = stats if not context['errors'] else None
elif request.method == 'GET': elif request.method == 'GET':
form = StatisticForm() form = StatisticForm()
@ -458,3 +444,5 @@ def registration_failed(request: WSGIRequest) -> HttpResponse:
Функция отображения страницы "Регистрация закрыта". Функция отображения страницы "Регистрация закрыта".
""" """
return render(request, 'pages/registration_failed.html') return render(request, 'pages/registration_failed.html')

4
nginx/Dockerfile Normal file
View File

@ -0,0 +1,4 @@
FROM nginx:1.21-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d

21
nginx/nginx.conf Normal file
View File

@ -0,0 +1,21 @@
upstream actrl {
server web:8000;
}
server {
listen 80;
location / {
proxy_pass http://actrl;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
}
location ~ /(static|docs)/ {
root /srv/access_controller/;
autoindex off;
}
}

View File

@ -1,5 +1,6 @@
# Production specific dependencies # Production specific dependencies
-r common.txt -r common.txt
psycopg2==2.8.6
daphne==3.0.2 daphne==3.0.2
Twisted[tls,http2]==21.2.0 Twisted[tls,http2]==21.2.0

View File

@ -1,6 +0,0 @@
cd /access_controller/
python manage.py migrate
python manage.py collectstatic --noinput
daphne -b 0.0.0.0 access_controller.asgi:application

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

View File

@ -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();

View File