diff --git a/README.md b/README.md index db34259..14e3869 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,82 @@ sudo apt install make pip install --upgrade pip pip install -r requirements.txt ./manage.py migrate -./manage.py shell -c "from django.contrib.auth import get_user_model; get_user_model().objects.create_superuser('vasya', '1@abc.net', 'promprog')" +./manage.py loaddata data.json ./manage.py runserver ``` -Создать токен -Указать почту и токен в окружении +##ZenDesk Access Controller instruction for eng + +##Перед запуском для тестирования: + +Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация (SYSTEM) +Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk +При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение + + +##Запуск на локальной машине: + +скопировать репозиторий на локальную машину +перейти в папку приложения +активировать вирутальное окружение +выполнить команду pip install -r requirements.txt +в вирутальное окружение добавить следующие переменные : + + +ACCESS_CONTROLLER_API_EMAIL={EMAIL} - почта админа в ZenDesk +ACCESS_CONTROLLER_API_PASSWORD={PASSWORD} - пароль админа ZenDesk +ACCESS_CONTROLLER_API_TOKEN={API_TOKEN} - API токен зендеск +ZD_DOMAIN={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 makemigrations +выполнить команду python manage.py migrate +запустить приложение командой python manage.py runserver (можно указать в параметрах для файла manage.py) +перейти по ссылке в консоли (вероятнее всего откроется по адресу http://127.0.0.1:8000/) + + +##Запуск в Docker: +Требуется установленный и настроеный Docker + +скопировать репозиторий на локальную машину +в командной строке перейти в папку проекта +выполнить команду docker build . +выполнить команду docker images (нам нужен id созданного образа) +выполнить команду docker run -d -p 8000:8000 -e ACCESS_CONTROLLER_API_EMAIL={EMAIL} -e ACCESS_CONTROLLER_API_PASSWORD={PASSWORD} +...(перечисляем все параметры виртуального окружени разделяя их -e) -v {абсолютный путь к папке, в которой будет размещена база}:/zendesk-access-controller/db {id образа докера} +открываем запущеный контейнер в браузере (можно перейти по ссылке http://localhost:8000/) + + +##Запуск с тестовыми юзерами: + +На локальной машине - перед запуском команды python manage.py runserver выполнить команду python manage.py loaddata data.json +Это создаст тестового админа и тестового пользователя в приложении для песочницы ZenDesk. Админ - admin@gmail.com / zendeskadmin , пользователь - 123@test.ru / zendeskuser . +Не сработает если домен песочницы отличается от ngenix1612197338 (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента +с этими же почтами, назначить им организацию (SYSTEM)) + + +##Параметры тестовой песочницы: + +ACCESS_CONTROLLER_API_EMAIL={EMAIL} - почта админа в ZenDesk - взять у роководителя(если вы не админ) +ACCESS_CONTROLLER_API_PASSWORD={PASSWORD} - пароль админа ZenDesk - взять у роководителя(если вы не админ) +ACCESS_CONTROLLER_API_TOKEN={API_TOKEN} - API токен зендеск - взять у роководителя(если вы не админ) +ZD_DOMAIN=ngenix1612197338 +ENG_CROLE_ID=360005209000 +LA_CROLE_ID=360005208980 +EMPL_GROUP=Поддержка +BUF_GROUP=Сменная группа +ST_EMAIL=d.krikov@ngenix.net + +LICENSE_NO=3 +SHIFTH=12 + ## Read more - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) diff --git a/access_controller/asgi.py b/access_controller/asgi.py index 7f60a1a..11dc22e 100644 --- a/access_controller/asgi.py +++ b/access_controller/asgi.py @@ -9,8 +9,9 @@ https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ import os -from django.core.asgi import get_asgi_application + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') +from django.core.asgi import get_asgi_application application = get_asgi_application() diff --git a/access_controller/settings.py b/access_controller/settings.py index b96d66a..92b3d29 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -24,7 +24,7 @@ SECRET_KEY = 'v1i_fb$_jf2#1v_lcsbu&eon4u-os0^px=s^iycegdycqy&5)6' # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['127.0.0.1'] # Application definition @@ -36,6 +36,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django_registration', + 'rest_framework', 'main', ] @@ -149,4 +150,21 @@ ZENDESK_ROLES = { 'light_agent': 360005208980, } +ZENDESK_GROUPS = { + 'employees': 'Поддержка', + 'buffer': 'Сменная группа', +} + +SOLVED_TICKETS_EMAIL = 'd.krikov@ngenix.net' + +ZENDESK_MAX_AGENTS = 3 + +REST_FRAMEWORK = { + # Use Django's standard `django.contrib.auth` permissions, + # or allow read-only access for unauthenticated users. + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + ] +} + ONE_DAY = 12 # Количество часов в 1 рабочем дне diff --git a/access_controller/urls.py b/access_controller/urls.py index bca3495..e174717 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -20,18 +20,22 @@ from django.urls import path, include from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView from main.views import work_page, work_hand_over, work_become_engineer, \ AdminPageView, statistic_page +from main.urls import router + urlpatterns = [ path('admin/', admin.site.urls, name='admin'), path('', main_page, name='index'), path('accounts/profile/', profile_page, name='profile'), path('accounts/register/', CustomRegistrationView.as_view(), name='registration'), - path('accounts/login/', CustomLoginView.as_view(extra_context={}), name='login',), # TODO add extra context + path('accounts/login/', CustomLoginView.as_view(), name='login'), path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('django_registration.backends.one_step.urls')), path('work/', work_page, name="work"), path('work/hand_over/', work_hand_over, name="work_hand_over"), path('work/become_engineer/', work_become_engineer, name="work_become_engineer"), + path('accounts/', include('django_registration.backends.activation.urls')), + path('accounts/login/', include('django.contrib.auth.urls')), path('control/', AdminPageView.as_view(), name='control'), path('statistic/', statistic_page, name='statistic') ] @@ -58,3 +62,8 @@ urlpatterns += [ name='password_reset_complete' ), ] + +# Django REST +urlpatterns += [ + path('api/', include(router.urls)) +] diff --git a/docs/Makefile b/docs/Makefile index 461302a..827600b 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,14 +3,17 @@ # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build +ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source +SPHINXOPTS = +SPHINXBUILD = sphinx-build SOURCEDIR = source BUILDDIR = build + # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @echo " spelling to check for typos in documentation" .PHONY: help Makefile @@ -19,5 +22,10 @@ help: %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + spelling: - $(SPHINXBUILD) -b spelling -W $(ALLSPHINXOPTS) $(BUILDDIR)/spelling + $(SPHINXBUILD) -b spelling -W $(SOURCEDIR) $(BUILDDIR)/spelling + @echo + @echo "Check finished. Wrong words can be found in " \ + "build/spelling/output.txt." + diff --git a/docs/source/_static/login.png b/docs/source/_static/login.png new file mode 100644 index 0000000..2cf59cf Binary files /dev/null and b/docs/source/_static/login.png differ diff --git a/docs/source/_static/main.png b/docs/source/_static/main.png new file mode 100644 index 0000000..fcfd4f9 Binary files /dev/null and b/docs/source/_static/main.png differ diff --git a/docs/source/_static/main_logined.png b/docs/source/_static/main_logined.png new file mode 100644 index 0000000..5105101 Binary files /dev/null and b/docs/source/_static/main_logined.png differ diff --git a/docs/source/_static/main_logined_agent.png b/docs/source/_static/main_logined_agent.png new file mode 100644 index 0000000..e4f76d1 Binary files /dev/null and b/docs/source/_static/main_logined_agent.png differ diff --git a/docs/source/_static/permission_management.png b/docs/source/_static/permission_management.png new file mode 100644 index 0000000..c1897ed Binary files /dev/null and b/docs/source/_static/permission_management.png differ diff --git a/docs/source/_static/permission_request.png b/docs/source/_static/permission_request.png new file mode 100644 index 0000000..8752e91 Binary files /dev/null and b/docs/source/_static/permission_request.png differ diff --git a/docs/source/_static/profile.png b/docs/source/_static/profile.png new file mode 100644 index 0000000..b84d139 Binary files /dev/null and b/docs/source/_static/profile.png differ diff --git a/docs/source/_static/registration.png b/docs/source/_static/registration.png new file mode 100644 index 0000000..b6e8111 Binary files /dev/null and b/docs/source/_static/registration.png differ diff --git a/docs/source/code.rst b/docs/source/code.rst index 85d207b..7479081 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -1,10 +1,9 @@ Документация разработчика ========================= - -****** +******* Models -****** +******* .. automodule:: main.models :members: @@ -26,9 +25,27 @@ Extra Functions :members: +*************** +Serializers +*************** + +.. automodule:: main.serializers + :members: + + +*************** +API functions +*************** + +.. automodule:: main.apiauth + :members: + + ***** Views ***** .. automodule:: main.views :members: + + diff --git a/docs/source/conf.py b/docs/source/conf.py index c9efe47..7e99943 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,6 +14,9 @@ import os import sys import importlib import inspect +import enchant +from enchant import checker + sys.path.insert(0, os.path.abspath('../../')) @@ -35,10 +38,7 @@ ManagerDescriptor.__get__ = lambda self, *args, **kwargs: self.manager from django.db.models.query import QuerySet QuerySet.__repr__ = lambda self: self.__class__.__name__ -try: - import enchant # NoQA -except ImportError: - enchant = None + django.setup() @@ -52,8 +52,6 @@ author = 'SHP S101, group 2' release = 'v0.01' -# Django sphinx setup by https://gist.github.com/codingjoe/314bda5a07ff3b41f247 - # -- General configuration --------------------------------------------------- def process_django_models(app, what, name, obj, options, lines): @@ -91,7 +89,6 @@ def process_django_models(app, what, name, obj, options, lines): lines.append(':type %s: %s.%s' % (field.attname, module, field_type.__name__)) if enchant is not None: lines += spelling_white_list - print('ok') return lines @@ -119,18 +116,18 @@ def skip_queryset(app, what, name, obj, skip, options): return skip -def setup(app): - # Register the docstring processor with sphinx - app.connect('autodoc-process-docstring', process_django_models) - app.connect('autodoc-skip-member', skip_queryset) - if enchant is not None: - app.connect('autodoc-process-docstring', process_modules) +# def setup(app): +# # Register the docstring processor with sphinx +# app.connect('autodoc-process-docstring', process_django_models) +# app.connect('autodoc-skip-member', skip_queryset) +# app.connect('autodoc-process-docstring', process_modules) + # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [ +extensions = { 'sphinx.ext.todo', 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', @@ -138,12 +135,11 @@ extensions = [ 'sphinx_rtd_theme', 'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram', - 'sphinx_autodoc_typehints' + 'sphinx_autodoc_typehints', + 'sphinxcontrib.spelling' -] +} -if enchant is not None: - extensions.append('sphinxcontrib.spelling') # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -188,16 +184,23 @@ intersphinx_mapping = { autodoc_default_flags = ['members'] # spell checking -spelling_lang = 'en_US' -spelling_word_list_filename = 'spelling_wordlist.txt' +spelling_lang = 'ru_RU' +tokenizer_lang = 'ru_RU' +spelling_exclude_patterns=['ignored_*'] spelling_show_suggestions = True +spelling_show_whole_line=True +spelling_warning=True spelling_ignore_pypi_package_names = True +spelling_ignore_wiki_words=True +spelling_ignore_acronyms=True +spelling_ignore_python_builtins=True +spelling_ignore_importable_modules=True +spelling_ignore_contributor_names=True # -- Options for todo extension ---------------------------------------------- # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True - set_type_checking_flag = True typehints_fully_qualified = True always_document_param_types = True diff --git a/docs/source/index.rst b/docs/source/index.rst index 83cf46a..96f9c69 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,7 +3,7 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to ZenDesk Access Controller's documentation! +Документация контроллера прав доступа ===================================================== .. toctree:: @@ -15,7 +15,6 @@ Welcome to ZenDesk Access Controller's documentation! todo - Indices and tables ================== diff --git a/docs/source/overview.rst b/docs/source/overview.rst index 447e273..fb3c627 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -2,17 +2,88 @@ Документация пользователя ========================= +****************************** **Управление правами доступа** +****************************** -**ZenDesk Access Controller** - Web-приложение, для выдачи прав пользователям системы по запросу самого пользователя. +**ZenDesk Access Controller** - web-приложение, для выдачи прав пользователям системы по запросу самого пользователя. Например, из 12 человек 3 сейчас работают с правами администратора, по окончании рабочей смены они сдают свои права (освобождают места) и другие пользователи могут запросить эти права в свое пользование. Оставшиеся 9 человек получают права легкого агента - без прав редактирования, а только чтение. -**Интерфейс пользователя:** +***************** +Главная страница +***************** +Меню главной страницы предоставляет Вам выбор: +* **"Войти"** - если Вы уже являетесь зарегистрированным пользователем +* **"Зарегистрироваться"** - при первом входе + +.. image:: _static/main.png + +Внимание! Для регистрации используется email с сайта Zendesk. Регистрация по каждому email +возможна один раз + +**После авторизации пользователь может выбрать из следующих разделов меню:** + +* **"Профиль"** - просмотреть свои данные и запросить права доступа +* **"Запрос прав"** - получение прав для работы с тикетами или **"Управление"** - доступно для администратора и предоставляет возможность группового назначения ролей пользователям + +.. image:: _static/main_logined_agent.png + +************* +Регистрация +************* + +Для регистрации необходимо ввести email, который указан Вами в Zendesk. + +.. image:: _static/registration.png + +На электронную почту придет ссылка, пройдя по которой, Вам необходимо задать пароль. + +*********** +Авторизация +*********** + +Для входа необходимо ввести email и пароль + +.. image:: _static/login.png + +Если Вы не помните пароль необходимо пройти по ссылке "Забыли пароль" и указать email. +На Вашу почту придет ссылка для установки нового пароля. + +******** +Профиль +******** + +Профиль пользователя - это Ваша рабочая страница. + +Здесь Вы можете просмотреть информацию пользователя (Ваши данные с Zendesk) и запросить права доступа для работы с тикетами. + +.. image:: _static/profile.png + +******************** +Запрос прав доступа +******************** + +На странице запроса прав Вам доступна информация о количестве и списке работающих над тикетами сотрудников, +а также возможность сдать и запросить права. + +.. image:: _static/permission_request.png + +****************************************** +Управление правами доступа администратором +****************************************** + +Для администратора существует удобный интерфейс страницы управления, в котором представлены: + +* Количество свободных инженерных мест +* Количество и список инженеров и легких агентов +* Возможность группового назначения прав с использованием чек-боксов + +.. image:: _static/permission_management.png .. |copy| unicode:: 0xA9 .. Школа программистов S101, группа 2. 2021гю diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt new file mode 100644 index 0000000..f190ae2 --- /dev/null +++ b/docs/source/spelling_wordlist.txt @@ -0,0 +1,86 @@ +тикетами +тикета +тикетов +тикет +web +Indices +and +tables +Models +логирования +User +user +superuser +light +light_agent +admin +agent +bootstrap +form +control +Zendesk +email +Extra +Functions +env +ID +url +None +token +password +engineer +SYSTEM +start_date +end_date +timedelta +log +RoleChangeLogs +time(datetime.time) +stat +statistic +True +False +val +start +end +date +Токен +токеном +аутентифицирован +(datetime.time) +datetime +time +serializer +валидны +html +subdomain +логгирования +логгирование +forms +StatisticForm +Userprofile +login +login_required +required +id +prom +home +PycharmProjects +Access +access +controler +controller +main +views +py +docstring +of +page +API +functions +Serializer +Serializers + + + + diff --git a/layouts/statistic/statistic.png b/layouts/statistic/statistic.png new file mode 100644 index 0000000..931b7e1 Binary files /dev/null and b/layouts/statistic/statistic.png differ diff --git a/main/apiauth.py b/main/apiauth.py index 4e3f761..c24e85f 100644 --- a/main/apiauth.py +++ b/main/apiauth.py @@ -4,7 +4,16 @@ from zenpy import Zenpy from zenpy.lib.api_objects import User as ZenpyUser -def api_auth(): +def api_auth() -> dict: + """ + Функция создания пользователя с использованием Zendesk API. + + Получает из env Zendesk - email, token, password пользователя. + Если данные валидны и пользователь Zendesk с указанным email и токеном или паролем существует, + создается словарь данных пользователя, полученных через API c Zendesk. + + :return: данные пользователя + """ credentials = { 'subdomain': 'ngenix1612197338' } diff --git a/main/extra_func.py b/main/extra_func.py index 11c539e..821e11a 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -3,27 +3,28 @@ import os from datetime import timedelta, datetime, date from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from zenpy import Zenpy from zenpy.lib.exception import APIException -from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY -from main.models import UserProfile, RoleChangeLogs +from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY, ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL +from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus class ZendeskAdmin: """ - Класс **ZendeskAdmin** существует, чтобы в каждой фунциии отдельно не проверять аккаунт администратора + Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора. - :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) - :type credentials: :class:`dict` - :param email: Email администратора, указанный в env - :type email: :class:`str` - :param token: Токен администратора (формируется в Zendesk, указывается в env) - :type token: :class:`str` - :param password: Пароль администратора, указанный в env - :type password: :class:`str` + :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) + :type credentials: :class:`dict` + :param email: Email администратора, указанный в env + :type email: :class:`str` + :param token: Токен администратора (формируется в Zendesk, указывается в env) + :type token: :class:`str` + :param password: Пароль администратора, указанный в env + :type password: :class:`str` """ credentials: dict = { @@ -32,19 +33,16 @@ class ZendeskAdmin: email: str = os.getenv('ACCESS_CONTROLLER_API_EMAIL') token: str = os.getenv('ACCESS_CONTROLLER_API_TOKEN') password: str = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') - _instance = None - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance def __init__(self): self.create_admin() def check_user(self, email: str) -> bool: """ - Функция **check_user** осуществляет проверку существования пользователя в Zendesk по email + Функция осуществляет проверку существования пользователя в Zendesk по email. + + :param email: Email пользователя + :return: Является ли зарегистрированным """ return True if self.admin.search(email, type='user') else False @@ -57,49 +55,73 @@ class ZendeskAdmin: def get_user_role(self, email: str) -> str: """ - Функция **get_user_role** возвращает роль пользователя по его email + Функция возвращает роль пользователя по его email. + + :param email: Email пользователя + :return: Роль пользователя """ user = self.admin.users.search(email).values[0] return user.role def get_user_id(self, email: str) -> str: """ - Функция **get_user_id** возвращает id пользователя по его email + Функция возвращает id пользователя по его email + + :param email: Email пользователя + :return: ID пользователя """ user = self.admin.users.search(email).values[0] return user.id def get_user_image(self, email: str) -> str: """ - Функция **get_user_image** возвращает url-ссылку на аватар пользователя по его email + Функция возвращает url-ссылку на аватар пользователя по его email. + + :param email: Email пользователя + :return: Аватар пользователя """ user = self.admin.users.search(email).values[0] return user.photo['content_url'] if user.photo else None def get_user(self, email: str): """ - Функция **get_user** возвращает пользователя (объект) по его email + Функция возвращает пользователя (объект) по его email. - :param email: email пользователя - :return: email пользователя, найденного в БД + :param email: Email пользователя + :return: Объект пользователя, найденного в БД """ return self.admin.users.search(email).values[0] + def get_group(self, name: str) -> str: + """ + Функция возвращает группы, к которым принадлежит пользователь. + + :param name: Имя пользователя + :return: Группы пользователя (в случае отсутствия None) + """ + groups = self.admin.search(name) + for group in groups: + return group + return None + def get_user_org(self, email: str) -> str: """ - Функция **get_user_org** возвращает организацию, к которой относится пользователь по его email + Функция возвращает организацию, к которой относится пользователь по его email. + + :param email: Email пользователя + :return: Организация пользователя """ user = self.admin.users.search(email).values[0] return user.organization.name if user.organization else None def create_admin(self) -> Zenpy: """ - Функция **Create_admin()** создает администратора, проверяя наличие вводимых данных в env. + Функция создает администратора, проверяя наличие вводимых данных в env. - :param credentials: В список полномочий администратора вносятся email, token, password из env - :type credentials: :class:`dict` - :raise: :class:`ValueError`: исключение, вызываемое если email не введен в env - :raise: :class:`APIException`: исключение, вызываемое если пользователя с таким email не существует в Zendesk + :param credentials: В список полномочий администратора вносятся email, token, password из env + :type credentials: :class:`dict` + :raise: :class:`ValueError`: исключение, вызываемое если email не введен в env + :raise: :class:`APIException`: исключение, вызываемое если пользователя с таким email не существует в Zendesk """ if self.email is None: @@ -121,43 +143,78 @@ class ZendeskAdmin: def update_role(user_profile: UserProfile, role: int) -> UserProfile: """ - Функция **update_role** меняет роль пользователя. + Функция меняет роль пользователя. + + :param user_profile: Профиль пользователя + :param role: Новая роль + :return: Пользователь с обновленной ролью """ zendesk = ZendeskAdmin() user = zendesk.get_user(user_profile.user.email) user.custom_role_id = role user_profile.custom_role_id = role - zendesk.admin.users.update(user) user_profile.save() + zendesk.admin.users.update(user) -def make_engineer(user_profile: UserProfile) -> UserProfile: +def make_engineer(user_profile: UserProfile, who_changes: User) -> UserProfile: """ - Функция **make_engineer** устанавливапет пользователю роль инженера. + Функция устанавливает пользователю роль инженера. + + :param user_profile: Профиль пользователя + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" """ update_role(user_profile, ROLES['engineer']) -def make_light_agent(user_profile: UserProfile) -> UserProfile: +def make_light_agent(user_profile: UserProfile, who_changes: User) -> UserProfile: """ - Функция **make_light_agent** устанавливапет пользователю роль легкого агента. + Функция устанавливает пользователю роль легкого агента. + + :param user_profile: Профиль пользователя + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" """ + tickets = get_tickets_list(user_profile.user.email) + for ticket in tickets: + UnassignedTicket.objects.create( + assignee=user_profile.user, + ticket_id=ticket.id, + status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED + ) + if ticket.status == 'solved': + ticket.assignee = ZendeskAdmin().get_user(SOLVED_TICKETS_EMAIL) + else: + ticket.assignee = None + ticket.group = ZendeskAdmin().get_group(ZENDESK_GROUPS['buffer']) + ZendeskAdmin().admin.tickets.update(ticket) update_role(user_profile, ROLES['light_agent']) def get_users_list() -> list: """ - Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации. + Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. """ zendesk = ZendeskAdmin() - admin = zendesk.get_user(zendesk.email) - org = next(zendesk.admin.users.organizations(user=admin)) - return zendesk.admin.organizations.users(org) + + # У пользователей должна быть организация SYSTEM + org = next(zendesk.admin.search(type='organization', name='SYSTEM')) + users = zendesk.admin.organizations.users(org) + return users + + +def get_tickets_list(email): + """ + Функция возвращает список тикетов пользователя Zendesk + """ + return ZendeskAdmin().admin.search(assignee=email, type='ticket') def update_profile(user_profile: UserProfile) -> UserProfile: """ - Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk + Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. + + :param user_profile: Профиль пользователя + :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя """ user = ZendeskAdmin().get_user(user_profile.user.email) user_profile.name = user.name @@ -169,21 +226,93 @@ def update_profile(user_profile: UserProfile) -> UserProfile: def check_user_exist(email: str) -> bool: """ - Функция проверяет, существует ли пользователь + Функция проверяет, существует ли пользователь. + + :param email: Email пользователя + :return: Зарегистрирован ли пользователь в Zendesk """ return ZendeskAdmin().check_user(email) def get_user_organization(email: str) -> str: """ - Функция возвращает организацию пользователя + Функция возвращает организацию пользователя. + + :param email: Email пользователя + :return: Организация пользователя """ return ZendeskAdmin().get_user_org(email) +def check_user_auth(email: str, password: str) -> bool: + """ + Функция проверяет, верны ли входные данные. + + :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован + """ + creds = { + 'email': email, + 'password': password, + 'subdomain': 'ngenix1612197338', + } + try: + user = Zenpy(**creds) + user.search(email, type='user') + except APIException: + return False + return True + + +def update_user_in_model(profile: UserProfile, zendesk_user: User) -> UserProfile: + """ + Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. + + :param profile: Профиль пользователя + :param zendesk_user: Данные пользователя в Zendesk + :return: Обновленный профиль пользователя + """ + profile.name = zendesk_user.name + profile.role = zendesk_user.role + profile.image = zendesk_user.photo['content_url'] if zendesk_user.photo else None + if zendesk_user.custom_role_id is not None: + profile.custom_role_id = int(zendesk_user.custom_role_id) + profile.save() + + +def count_users(users) -> tuple: + """ + Функция подсчета количества сотрудников с ролями engineer и light_agent + """ + engineers, light_agents = 0, 0 + for user in users: + if user.custom_role_id == ROLES['engineer']: + engineers += 1 + elif user.custom_role_id == ROLES['light_agent']: + light_agents += 1 + return engineers, light_agents + + +def update_users_in_model(): + """ + Обновляет пользователей в модели UserProfile по списку пользователей в организации + """ + users = get_users_list() + for user in users: + try: + profile = User.objects.get(email=user.email).userprofile + update_user_in_model(profile, user) + except ObjectDoesNotExist: + pass + return users + + def daterange(start_date, end_date) -> list: """ - Возвращает список дней с start_date по end_date исключая правую границу + Функция возвращает список дней с start_date по end_date, исключая правую границу. + + :param start_date: Начальная дата + :param end_date: Конечная дата + :return: Список дней, не включая конечную дату """ dates = [] for n in range(int((end_date - start_date).days)): @@ -193,8 +322,12 @@ def daterange(start_date, end_date) -> list: def get_timedelta(log, time=None) -> timedelta: """ - Возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, - который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён + Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, + который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. + + :param log: Лог + :param time: Время + :return: Сколько времени прошло от начала суток до события """ if time is None: time = log.change_time.time() @@ -202,15 +335,42 @@ def get_timedelta(log, time=None) -> timedelta: return time -def last_day_of_month(day): +def last_day_of_month(day: int) -> int: """ - Возвращает последний день любого месяца + Функция возвращает последний день текущего месяца. + + :param day: Текущий день + :return: Последний день месяца """ next_month = day.replace(day=28) + timedelta(days=4) return next_month - timedelta(days=next_month.day) class StatisticData: + """ + Класс для учета статистики интервалов работы пользователей. + Передаваемые параметры: start_date, end_date, email, stat. + + :param display: Формат отображения времени (часы, минуты) + :type display: :class:`list` + :param interval: Интервал времени в часах и минутах + :type interval: :class:`list` + :param start_date: Дата начала работы + :type start_date: :class:`date` + :param end_date: Дата окончания работы + :type end_date: :class:`date` + :param email: Email пользователя + :type email: :class:`str` + :param errors: Список ошибок + :type errors: :class:`list` + :param warnings: Список предупреждений + :type warnings: :class:`list` + :param data: Ретроспектива смены ролей пользователя + :type data: :class:`dict` + :param statistic: Интервалы работы пользователя + :type statistic: :class:`dict` + """ + def __init__(self, start_date, end_date, user_email, stat=None): self.display = None self.interval = None @@ -221,16 +381,17 @@ class StatisticData: self.warnings = list() self.data = dict() self.statistic = dict() - self._set_data() + self._init_data() if stat is None: - self._set_statistic() + self._init_statistic() else: self.statistic = stat - def get_statistic(self): + def get_statistic(self) -> dict: """ - Вернуть словарь statistic с применением формата отображения и интеравала работы(если они есть) - None, если были ошибки при создании + Функция возвращает статистику работы пользователя. + + :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). None, если были ошибки при создании. """ if self.is_valid_statistic(): stat = self.statistic @@ -240,15 +401,20 @@ class StatisticData: else: return None - def is_valid_statistic(self): + def is_valid_statistic(self) -> bool: """ - Были ли ошибки при создании статистики + Функция проверяет были ли ошибки при создании статистики. + + :return: True, при отсутствии ошибок """ return not self.errors and self.statistic - def set_interval(self, interval): + def set_interval(self, interval: list) -> bool: """ - Устанавливает интервал работы + Функция проверяет корректность представления интервала работы. + + :param interval: Интервал должен быть указан в днях или месяцах. + :return: True, если указан верно """ if interval not in ['months', 'days']: self.errors += ['Интервал работы должен быть в днях или месяцах'] @@ -256,9 +422,12 @@ class StatisticData: self.interval = interval return True - def set_display(self, display_format): + def set_display(self, display_format: list) -> bool: """ - Устанавливает формат отображения + Функция проверяет корректность формата отображения интервала. + + :param display_format: Формат отображения должен быть указан в днях или месяцах. + :return: True, если указан верно """ if display_format not in ['days', 'hours']: self.errors += ['Формат отображения должен быть в часах или днях'] @@ -266,26 +435,29 @@ class StatisticData: self.display = display_format return True - def get_data(self): + def get_data(self) -> list: """ - Вернуть данные - data - массив объектов RoleChangeLogs, является списком логов пользователя - data может быть пустым списком + Функция возвращает данные - список объектов RoleChangeLogs. """ if self.is_valid_data(): return self.data else: return None - def is_valid_data(self): + def is_valid_data(self) -> bool: """ - Были ли ошибки при получении логов + Функция определяет были ли ошибки при получении логов. + + :return: True, если ошибок нет """ return not self.errors - def _use_display(self, stat): + def _use_display(self, stat: list) -> list: """ - Приводит данные к формату отображения + Функция приводит данные к формату отображения. + + :param stat: Список данных статистики пользователя + :return: Обновленный список """ if not self.is_valid_statistic() or not self.display: return stat @@ -297,9 +469,12 @@ class StatisticData: new_stat[key] = item / (ONE_DAY * 3600) return new_stat - def _use_interval(self, stat): + def _use_interval(self, stat: dict) -> dict: """ - Объединяет ключи и значения в соответствии с интервалом работы + Функция объединяет ключи и значения в соответствии с интервалом работы. + + :param stat: Статистика работы пользователя + :return: Обновленная статистика """ if not self.is_valid_statistic() or not self.interval: return stat @@ -318,17 +493,21 @@ class StatisticData: new_stat = stat # статистика изначально в днях return new_stat - def check_time(self): + def check_time(self) -> bool: """ - Проверка на правильность введенного времени + Функция проверяет корректность введенного времени. + + :return: True, если время указано корректно. Иначе, False """ if self.end_date < self.start_date or self.end_date > datetime.now().date(): return False return True - def _set_data(self): + def _init_data(self): """ - Получение логов в диапазоне дат start_date-end_date для пользователя с почтой email + Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. + + :return: Данные о смене статусов пользователя. Если пользователь не найден или интервал времени некорректен - ошибку. """ if not self.check_time(): self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] @@ -341,9 +520,11 @@ class StatisticData: except User.DoesNotExist: self.errors += ['Пользователь не найден'] - def _set_statistic(self): + def _init_statistic(self) -> dict: """ - Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд + Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. + + :return: Статистика работы пользователя (statistic) """ self.clear_statistic() if not self.get_data(): @@ -352,38 +533,66 @@ class StatisticData: first_log, last_log = self.data[0], self.data[len(self.data) - 1] if first_log.old_role == ROLES['engineer']: - self.fill_daterange(self.start_date, first_log.change_time.date()) - self.statistic[first_log.change_time.date()] += get_timedelta(first_log).total_seconds() + self.prev_engineer_logic(first_log) if last_log.new_role == ROLES['engineer']: - self.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date + timedelta(days=1)) - self.statistic[last_log.change_time.date()] += (timedelta(days=1) - get_timedelta(last_log)).total_seconds() - if self.end_date == datetime.now().date(): - self.statistic[self.end_date] = get_timedelta(None, datetime.now().time()).total_seconds() + self.post_engineer_logic(last_log) for log_index in range(len(self.data) - 1): if self.data[log_index].new_role == ROLES['engineer']: - current_log, next_log = self.data[log_index], self.data[log_index + 1] - if current_log.change_time.date() != next_log.change_time.date(): - self.statistic[current_log.change_time.date()] += ( - timedelta(days=1) - get_timedelta(current_log)).total_seconds() - self.statistic[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() - self.fill_daterange(current_log.change_time.date() + timedelta(days=1), next_log.change_time.date()) - else: - elapsed_time = next_log.change_time - current_log.change_time - self.statistic[current_log.change_time.date()] += elapsed_time.total_seconds() + self.engineer_logic(log_index) - def fill_daterange(self, first, last, val=24 * 3600): + def engineer_logic(self, log_index): """ - Заполение диапазона дат значением val - по умолчанию val = кол-во секунд в 1 дне + Функция обрабатывает основную часть работы инженера + """ + current_log, next_log = self.data[log_index], self.data[log_index + 1] + if current_log.change_time.date() != next_log.change_time.date(): + self.statistic[current_log.change_time.date()] += ( + timedelta(days=1) - get_timedelta(current_log)).total_seconds() + self.statistic[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() + self.fill_daterange(current_log.change_time.date() + timedelta(days=1), next_log.change_time.date()) + else: + elapsed_time = next_log.change_time - current_log.change_time + self.statistic[current_log.change_time.date()] += elapsed_time.total_seconds() + + def post_engineer_logic(self, last_log): + """ + Функция обрабатывает случай, когда нам изветсно что инженер работал и после диапазона + """ + self.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date + timedelta(days=1)) + if last_log.change_time.date() == timezone.now().date(): + self.statistic[last_log.change_time.date()] += ( + get_timedelta(None, timezone.now().time()) - get_timedelta(last_log) + ).total_seconds() + else: + self.statistic[last_log.change_time.date()] += ( + timedelta(days=1) - get_timedelta(last_log)).total_seconds() + if self.end_date == timezone.now().date(): + self.statistic[self.end_date] = get_timedelta(None, timezone.now().time()).total_seconds() + + def prev_engineer_logic(self, first_log): + """ + Функция обрабатывает случай, когда нам изветсно что инженер начал работу до диапазона + """ + self.fill_daterange(max(User.objects.get(email=self.email).date_joined.date(), self.start_date), + first_log.change_time.date()) + self.statistic[first_log.change_time.date()] += get_timedelta(first_log).total_seconds() + + def fill_daterange(self, first: date, last: date, val: int = 24 * 3600) -> dict: + """ + Функция заполняет диапазон дат значением val (по умолчанию val = кол-во секунд в 1 дне). + + :param first: Начальная дата интервала + :param last: Последняя дата интервала + :param val: Количество секунд в одном дне """ for day in daterange(first, last): self.statistic[day] = val - def clear_statistic(self): + def clear_statistic(self) -> dict: """ - Обнуление всех дней + Функция осуществляет обновление всех дней. """ self.statistic.clear() self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) diff --git a/main/forms.py b/main/forms.py index a8c2ec1..613fc34 100644 --- a/main/forms.py +++ b/main/forms.py @@ -2,19 +2,18 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm from django_registration.forms import RegistrationFormUniqueEmail -from access_controller.settings import ZENDESK_ROLES from main.models import UserProfile class CustomRegistrationForm(RegistrationFormUniqueEmail): """ - Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` + Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` + с добавлением bootstrap-класса "form-control". - с добавлением bootstrap-класса "form-control" - - :param visible_fields.email: Поле для ввода email, зарегистирированного на Zendesk - :type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail` + :param visible_fields.email: Поле для ввода email, зарегистрированного на Zendesk + :type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail` """ + def __init__(self, *args, **kwargs) -> RegistrationFormUniqueEmail: super().__init__(*args, **kwargs) for visible in self.visible_fields(): @@ -32,10 +31,10 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): class AdminPageUsers(forms.Form): """ - Форма для установки статусов engineer или light_agent пользователям + Форма для установки статусов engineer или light_agent пользователям. - :param users: Поле для установки статуса - :type users: :class:`ModelMultipleChoiceField` + :param users: Поле для установки статуса + :type users: :class:`ModelMultipleChoiceField` """ users = forms.ModelMultipleChoiceField( @@ -52,8 +51,11 @@ class AdminPageUsers(forms.Form): class CustomAuthenticationForm(AuthenticationForm): """ - Форма для авторизации :class:`django.contrib.auth.forms.AuthenticationForm` - с изменением поля username на email + Форма для авторизации :class:`django.contrib.auth.forms.AuthenticationForm` + с изменением поля username на email. + + :param username: Поле для ввода email пользователя + :type username: :class:`django.forms.fields.CharField` """ username = forms.CharField( label="Электронная почта", @@ -68,29 +70,74 @@ class CustomAuthenticationForm(AuthenticationForm): } +INTERVAL_CHOICES = [ + ('days', 'Дни'), + ('months', 'Месяцы') +] +DISPLAY_CHOICES = [ + ('hours', 'Часы'), + ('days', 'Дни/Смены') +] + + class StatisticForm(forms.Form): + """ + Форма отображения интервалов работы пользователя. + + :param email: Поле для ввода email пользователя + :type email: :class:`django.forms.fields.EmailField` + :param interval: Расчет интервала рабочего времени + :type interval: :class:`django.forms.fields.CharField` + :param display_format: Формат отображения данных + :type display_format: :class:`django.forms.fields.CharField` + :param range_start: Дата и время начала работы + :type range_start: :class:`django.forms.fields.DateField` + :param range_end: Дата и время окончания работы + :type range_end: :class:`django.forms.fields.DateField` + """ email = forms.EmailField( label='Электроная почта', - ) - interval = forms.CharField( # TODO: Переделать под html страницу - label='Интервал работы', - ) - display_format = forms.CharField( # TODO: Переделать под html страницу - label='Формат отображения', - ) - range_start = forms.DateField( # TODO: Переделать под html страницу - label='Начало диапазона', - widget=forms.DateInput( + widget=forms.EmailInput( attrs={ - 'type': 'date', + 'placeholder': 'example@ngenix.ru', + 'class': 'form-control', + 'style': 'background-color:#f2f2f2;' } ), ) - range_end = forms.DateField( # TODO: Переделать под html страницу - label='Конец диапазона', + interval = forms.ChoiceField( + label='Выберите интервалы времени работы', + choices=INTERVAL_CHOICES, + widget=forms.RadioSelect( + attrs={ + 'class': 'btn-check', + } + ) + ) + display_format = forms.ChoiceField( + label='Выберите формат отображения', + choices=DISPLAY_CHOICES, + widget=forms.RadioSelect( + attrs={ + 'class': 'btn-check', + } + ) + ) + range_start = forms.DateField( + label='Начало статистики', widget=forms.DateInput( attrs={ 'type': 'date', + 'class': 'btn btn-secondary text-primary bg-white', + } + ), + ) + range_end = forms.DateField( + label='Конец статистики', + widget=forms.DateInput( + attrs={ + 'type': 'date', + 'class': 'btn btn-secondary text-primary bg-white', } ), ) diff --git a/main/migrations/0012_auto_20210311_2027.py b/main/migrations/0012_auto_20210311_2027.py new file mode 100644 index 0000000..113e51e --- /dev/null +++ b/main/migrations/0012_auto_20210311_2027.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.6 on 2021-03-11 17:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('main', '0011_auto_20210311_1734'), + ] + + operations = [ + migrations.RemoveField( + model_name='rolechangelogs', + name='name', + ), + migrations.CreateModel( + name='UnassignedTicket', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ticket_id', models.IntegerField(help_text='Номер тикера, для которого сняли ответственного')), + ('status', models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются')], default=0)), + ('assignee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/main/migrations/0013_auto_20210311_2040.py b/main/migrations/0013_auto_20210311_2040.py new file mode 100644 index 0000000..5648813 --- /dev/null +++ b/main/migrations/0013_auto_20210311_2040.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.6 on 2021-03-11 17:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0012_auto_20210311_2027'), + ] + + operations = [ + migrations.AlterField( + model_name='unassignedticket', + name='status', + field=models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются'), (3, 'Тикет уже был закрыт. Дополнительные действия не требуются')], default=0), + ), + ] diff --git a/main/migrations/0012_auto_20210312_1225.py b/main/migrations/0014_auto_20210314_1455.py similarity index 61% rename from main/migrations/0012_auto_20210312_1225.py rename to main/migrations/0014_auto_20210314_1455.py index 6d33580..77db2ec 100644 --- a/main/migrations/0012_auto_20210312_1225.py +++ b/main/migrations/0014_auto_20210314_1455.py @@ -1,12 +1,13 @@ -# Generated by Django 3.1.6 on 2021-03-12 09:25 +# Generated by Django 3.1.6 on 2021-03-14 11:55 from django.db import migrations, models +import django.utils.timezone class Migration(migrations.Migration): dependencies = [ - ('main', '0011_auto_20210311_1734'), + ('main', '0013_auto_20210311_2040'), ] operations = [ @@ -15,6 +16,11 @@ class Migration(migrations.Migration): name='custom_role_id', field=models.IntegerField(default=0, help_text='Код роли пользователя'), ), + migrations.AlterField( + model_name='rolechangelogs', + name='change_time', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Дата и время изменения роли'), + ), migrations.AlterField( model_name='userprofile', name='role', diff --git a/main/migrations/0015_auto_20210321_1600.py b/main/migrations/0015_auto_20210321_1600.py new file mode 100644 index 0000000..79b5726 --- /dev/null +++ b/main/migrations/0015_auto_20210321_1600.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.6 on 2021-03-21 13:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0014_auto_20210314_1455'), + ] + + operations = [ + migrations.AlterField( + model_name='unassignedticket', + name='status', + field=models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются'), (3, 'Тикет уже был закрыт. Дополнительные действия не требуются'), (4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL')], default=0), + ), + ] diff --git a/main/migrations/0015_auto_20210330_0007.py b/main/migrations/0015_auto_20210330_0007.py new file mode 100644 index 0000000..91398ba --- /dev/null +++ b/main/migrations/0015_auto_20210330_0007.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.6 on 2021-03-29 21:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0014_auto_20210314_1455'), + ] + + operations = [ + migrations.AlterField( + model_name='unassignedticket', + name='status', + field=models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются'), (3, 'Тикет уже был закрыт. Дополнительные действия не требуются'), (4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL')], default=0), + ), + ] diff --git a/main/migrations/0016_merge_20210330_0043.py b/main/migrations/0016_merge_20210330_0043.py new file mode 100644 index 0000000..efb1d45 --- /dev/null +++ b/main/migrations/0016_merge_20210330_0043.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.6 on 2021-03-29 21:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0015_auto_20210330_0007'), + ('main', '0015_auto_20210321_1600'), + ] + + operations = [ + ] diff --git a/main/models.py b/main/models.py index 381a7df..ac6f91c 100644 --- a/main/models.py +++ b/main/models.py @@ -4,9 +4,15 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from access_controller.settings import ZENDESK_ROLES + class UserProfile(models.Model): - """Модель профиля пользователя""" + """ + Модель профиля пользователя. + + Профиль создается и изменяется при создании и изменении модель User. + """ class Meta: permissions = ( @@ -19,6 +25,14 @@ class UserProfile(models.Model): image = models.URLField(null=True, blank=True, help_text='Аватарка') name = models.CharField(default='None', max_length=100, help_text='Имя пользователя на нашем сайте') + @property + def zendesk_role(self): + id = self.custom_role_id + for role, r_id in ZENDESK_ROLES.items(): + if r_id == id: + return role + return 'UNDEFINED' + @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): @@ -32,12 +46,38 @@ def save_user_profile(sender, instance, **kwargs): class RoleChangeLogs(models.Model): - """Модель для логирования изменений ролей пользователя""" - user = models.ForeignKey(to=User, on_delete=models.CASCADE, - help_text='Пользователь, которому присвоили другую роль') - name = models.TextField(help_text='Имя пользователя') + """ + Модель для логирования изменений ролей пользователя. + """ + + user = models.ForeignKey(to=User, on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль') old_role = models.IntegerField(default=0, help_text='Старая роль') new_role = models.IntegerField(default=0, help_text='Присвоенная роль') - change_time = models.DateTimeField(help_text='Дата и время изменения роли', default=timezone.now) - changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', - help_text='Кем была изменена роль') + change_time = models.DateTimeField(default=timezone.now, help_text='Дата и время изменения роли') + changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') + + +class UnassignedTicketStatus(models.IntegerChoices): + """ + Класс статусов не распределенных тикетов. + + :param UNASSIGNED: Снят с пользователя, перенесён в буферную группу + :param RESTORED: Авторство восстановлено + :param NOT_FOUND: Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются + :param CLOSED: Тикет уже был закрыт. Дополнительные действия не требуются + :param SOLVED: Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL + """ + UNASSIGNED = 0, 'Снят с пользователя, перенесён в буферную группу' + RESTORED = 1, 'Авторство восстановлено' + NOT_FOUND = 2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются' + CLOSED = 3, 'Тикет уже был закрыт. Дополнительные действия не требуются' + SOLVED = 4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL' + + +class UnassignedTicket(models.Model): + """ + Модель не распределенного тикета. + """ + assignee = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='tickets', help_text='Пользователь, с которого снят тикет') + ticket_id = models.IntegerField(help_text='Номер тикера, для которого сняли ответственного') + status = models.IntegerField(choices=UnassignedTicketStatus.choices, default=UnassignedTicketStatus.UNASSIGNED, help_text='Статус тикета') diff --git a/main/serializers.py b/main/serializers.py new file mode 100644 index 0000000..cfc70a8 --- /dev/null +++ b/main/serializers.py @@ -0,0 +1,21 @@ +from django.contrib.auth.models import User +from rest_framework import serializers +from main.models import UserProfile + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + """ + Класс serializer для модели User. + """ + class Meta: + model = User + fields = ['email'] + + +class ProfileSerializer(serializers.HyperlinkedModelSerializer): + """Сериализатор для модели профиля пользователя""" + user = UserSerializer() + + class Meta: + model = UserProfile + fields = ['user', 'id', 'name', 'zendesk_role'] diff --git a/main/templates/base/base.html b/main/templates/base/base.html index 2aebfe0..166195d 100644 --- a/main/templates/base/base.html +++ b/main/templates/base/base.html @@ -27,6 +27,7 @@ {% block extra_css %}{% endblock %} + {% block extra_scripts %}{% endblock %} diff --git a/main/templates/base/success_messages.html b/main/templates/base/success_messages.html new file mode 100644 index 0000000..fdef313 --- /dev/null +++ b/main/templates/base/success_messages.html @@ -0,0 +1,14 @@ +
+ {% for message in messages %} + + {% endfor %} +
diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index 387cd73..1fb9baf 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -10,11 +10,19 @@ {% endblock %} +{% block extra_scripts %} + + + + +{% endblock%} + {% block content %}
-
-

Основная информация о странице

-
+ +
+

Свободных Мест: {{ licences_remaining }}

+
{% block form %}
@@ -22,14 +30,6 @@
- {% block hidden_form %} -
- {% for field in form.users %} - {{ field.tag }} - {% endfor %} -
- {% endblock %} -
Список сотрудников
@@ -37,27 +37,16 @@ - + - - - - {% for user in users %} - - - - - - - - {% endfor %} + -
IDName Email RoleName(link to profile) Checked
{{ user.id }}{{ user.user.email }}{{ user.role }}{{ user.name }}
- {% endblock%} +

Данные загружаются...

+ {% endblock %}
@@ -85,7 +74,9 @@
+ {% endblock %} + {% block buttons %}
-
+ {% endblock %} + - {% endblock %} {% endblock %} + + {% include 'base/success_messages.html' %} - + {% endblock %} diff --git a/main/templates/pages/profile.html b/main/templates/pages/profile.html index a0f21f9..1dd6005 100644 --- a/main/templates/pages/profile.html +++ b/main/templates/pages/profile.html @@ -37,7 +37,13 @@
Электронная почта {{ profile.user.email }}

-
Текущая роль {{ profile.role }}
+
Текущая роль + {% if profile.custom_role_id == ZENDESK_ROLES.engineer %} + engineer + {% elif profile.custom_role_id == ZENDESK_ROLES.light_agent %} + light_agent + {% endif %} +
diff --git a/main/templates/pages/stat.html b/main/templates/pages/stat.html deleted file mode 100644 index 9279123..0000000 --- a/main/templates/pages/stat.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends 'base/base.html' %} - -{% load static %} - -{% block title %}{{ pagename }}{% endblock %} - -{% block heading %} Пример страницы статистики(палками не бейти плиз){% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
- {% csrf_token %} - {% for field in form %} - {{field.label}} - {{field}} -
- {% endfor %} - -
-
    - {% for error in errors %} -
  • {{error}}
  • - {% endfor %} -
- {%if form.errors%} -
    - {% for field, errors in form.errors.items %} - {% for error in errors %} -
  • {{error}}
  • - {% endfor %} - {% endfor %} -
- {%endif%} -
    - {% for warning in warnings %} -
  • {{warning}}
  • - {% endfor %} -
- {% for key,val in log_stats.items %} -

{{key}} | {{val}}

-
- {% endfor %} -
-{% endblock %} - diff --git a/main/templates/pages/statistic.html b/main/templates/pages/statistic.html new file mode 100644 index 0000000..9a9219b --- /dev/null +++ b/main/templates/pages/statistic.html @@ -0,0 +1,106 @@ +{% extends 'base/base.html' %} + +{% load static %} + +{% block title %}{{ pagename }}{% endblock %} + +{% block heading %} Страницы просмотра статистики{% endblock %} + +{% block content%} +
+
+
+ {% csrf_token %} +
+
+ {{ form.email.label }} +
+
+ {{ form.email }} +
+
+
+
+ {{ form.interval.label }} +
+
+ {% for radio in form.interval%} + {{ radio.tag }} + + {% endfor %} +
+
+
+
+ {{ form.display_format.label }} +
+
+ {% for radio in form.display_format%} + {{ radio.tag }} + + {% endfor %} +
+
+
+
+ {{ form.range_start.label}} +
+
+
+ {{ form.range_start}} +
+
+
+
+
+ {{ form.range_end.label}} +
+
+
+ {{ form.range_end}} +
+
+
+
+
+ +
+
+
+
+
    + {% for error in errors %} +
  • {{error}}
  • + {% endfor %} +
+
    + {% for warning in warnings %} +
  • {{warning}}
  • + {% endfor %} +
+
+ + + + + {% for date in log_stats.keys %} + + {% endfor %} + + + + + + {% for time in log_stats.values %} + + {% endfor %} + + +
Пользователи/Даты{{date}}
{{ form.email.value }}{{time}}
+
+
+{% endblock %} diff --git a/main/templates/pages/work.html b/main/templates/pages/work.html index a588ef5..67c5846 100644 --- a/main/templates/pages/work.html +++ b/main/templates/pages/work.html @@ -14,7 +14,7 @@
-

Основаная информация о странице

+

Свободных Мест: {{ licences_remaining }}

+ {% include 'base/success_messages.html' %}
{% endblock %} diff --git a/main/urls.py b/main/urls.py new file mode 100644 index 0000000..fffe11d --- /dev/null +++ b/main/urls.py @@ -0,0 +1,6 @@ +from rest_framework.routers import DefaultRouter +from main.views import UsersViewSet + + +router = DefaultRouter() +router.register(r'users', UsersViewSet) diff --git a/main/views.py b/main/views.py index 52e89e5..0446ea7 100644 --- a/main/views.py +++ b/main/views.py @@ -9,6 +9,7 @@ from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.views import LoginView from django.contrib.contenttypes.models import ContentType +from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import PermissionDenied from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponseRedirect, HttpResponse @@ -16,22 +17,36 @@ from django.shortcuts import render, get_list_or_404, redirect from django.urls import reverse_lazy, reverse from django.views.generic import FormView from django_registration.views import RegistrationView -from zenpy import Zenpy +from django.contrib import messages + +# Django REST +from rest_framework import viewsets +from rest_framework.response import Response + from zenpy.lib.api_objects import User as ZenpyUser -from access_controller.settings import EMAIL_HOST_USER, ZENDESK_ROLES -from main.extra_func import check_user_exist, update_profile, get_user_organization, make_engineer, make_light_agent, \ - get_users_list, StatisticData, log +from access_controller.settings import EMAIL_HOST_USER, ZENDESK_ROLES, ZENDESK_MAX_AGENTS +from main.extra_func import ZendeskAdmin +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, \ + StatisticData, log from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm +from main.serializers import ProfileSerializer from .models import UserProfile + class CustomRegistrationView(RegistrationView): """ - Отображение и логика работы страницы регистрации пользователя + Отображение и логика работы страницы регистрации пользователя. - 1. Ввод email пользователя, указанный на Zendesk - 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk и относится к определенной организации, происходит сброс ссылки с установлением пароля на указанный email - 3. Создается пользователь class User, а также его профиль + :param form_class: Форма, которую необходимо заполнить для регистрации + :type form_class: :class:`forms.CustomRegistrationForm` + :param template_name: Указание пути к html-странице django регистрации + :type template_name: :class:`str` + :param success_url: Указание пути к html-странице завершения регистрации + :type success_url: :class:`django.utils.functional.lazy..__proxy__` + :param is_allowed: Определение зарегистрирован ли пользователь с введенным email на Zendesk и принадлежит ли он к организации SYSTEM + :type is_allowed: :class:`bool` """ form_class = CustomRegistrationForm template_name = 'django_registration/registration_form.html' @@ -39,6 +54,16 @@ class CustomRegistrationView(RegistrationView): is_allowed = True def register(self, form: CustomRegistrationForm) -> User: + """ + Функция регистрации пользователя. + 1. Ввод email пользователя, указанный на Zendesk + 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk и относится к организации SYSTEM, + происходит сброс ссылки с установлением пароля на указанный email + 3. Создается пользователь class User, а также его профиль. + + :param form: Email пользователя на Zendesk + :return: user + """ self.is_allowed = True if check_user_exist(form.data['email']) and get_user_organization(form.data['email']) == 'SYSTEM': forms = PasswordResetForm(self.request.POST) @@ -68,9 +93,11 @@ class CustomRegistrationView(RegistrationView): self.is_allowed = False @staticmethod - def set_permission(user) -> None: + def set_permission(user: User) -> None: """ - Дает разрешение на просмотр страница администратора, если пользователь имеет роль admin + Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin. + + :param user: авторизованный пользователь (получает разрешение, имея роль "admin") """ if user.userprofile.role == 'admin': content_type = ContentType.objects.get_for_model(UserProfile) @@ -82,8 +109,11 @@ class CustomRegistrationView(RegistrationView): def get_success_url(self, user: User = None) -> success_url: """ - Возвращает url-адрес страницы, куда нужно перейти после успешной/неуспешной регистрации - Используется самой django-registration + Функция возвращает url-адрес страницы, куда нужно перейти после успешной/не успешной регистрации. + Используется самой django-registration. + + :param user: пользователь, пытающийся зарегистрироваться + :return: адресация на страницу успешной регистрации """ if self.is_allowed: return reverse_lazy('password_reset_done') @@ -94,30 +124,42 @@ class CustomRegistrationView(RegistrationView): @login_required() def profile_page(request: WSGIRequest) -> HttpResponse: """ - Отображение страницы профиля + Функция отображения страницы профиля. + + :param request: данные пользователя из БД + :return: адресация на страницу пользователя """ user_profile: UserProfile = request.user.userprofile update_profile(user_profile) context = { 'profile': user_profile, - 'pagename': 'Страница профиля' + 'pagename': 'Страница профиля', + 'ZENDESK_ROLES': ZENDESK_ROLES, } return render(request, 'pages/profile.html', context) -def auth_user(request): - admin_creds = { - 'email': os.environ.get('ACCESS_CONTROLLER_API_EMAIL'), - 'subdomain': 'ngenix1612197338', - 'token': os.environ.get('ACCESS_CONTROLLER_API_TOKEN'), - } - admin = Zenpy(**admin_creds) +def auth_user(request: WSGIRequest) -> ZenpyUser: + """ + Функция возвращает профиль пользователя на Zendesk. + + :param request: email, subdomain и token пользователя + :return: объект пользователя Zendesk + """ + admin = ZendeskAdmin().admin zenpy_user: ZenpyUser = admin.users.search(request.user.email).values[0] return zenpy_user, admin @login_required() -def work_page(request, id): +def work_page(request: WSGIRequest, id: int) -> HttpResponse: + """ + Функция отображения страницы "Управления правами" для текущего пользователя (login_required). + + :param request: объект пользователя + :param id: id пользователя, используется для динамической адресации + :return: адресация на страницу "Управления правами" (либо на страницу "Авторизации", если id и user.id не совпадают + """ users = get_users_list() if request.user.id == id: engineers = [] @@ -132,101 +174,124 @@ def work_page(request, id): context = { 'engineers': engineers, 'agents': light_agents, + 'messages': messages.get_messages(request), + 'licences_remaining': max(0, ZENDESK_MAX_AGENTS - len(engineers)), 'pagename': 'Управление правами' } return render(request, 'pages/work.html', context) return redirect("login") -@login_required() -def work_hand_over(request): - zenpy_user, admin = auth_user(request) +def user_update(zenpy_user: User, admin: User, request: WSGIRequest) -> UserProfile: + """ + Функция устанавливает пользователю роль "agent" (изменяет профиль). + :param zenpy_user: Пользователь Zendesk + :param admin: Пользователь + :param request: Запрос установки роли "agent" в Userprofile + :return: Обновленный профиль пользователя + """ + + admin.users.update(zenpy_user) + request.user.userprofile.role = "agent" + request.user.userprofile.save() + messages.success(request, "Права были изменены") + + +@login_required() +def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: + """ + Функция позволяет текущему пользователю (login_required) сдать права, а именно сменить в Zendesk роль с "engineer" на "light agent" + и установить роль "agent" в БД. Действия выполняются, если исходная роль пользователя "engineer". + + :param request: данные текущего пользователя (login_required) + :return: перезагрузка текущей страницы после выполнения смены роли + """ + zenpy_user, admin = auth_user(request) if zenpy_user.custom_role_id == ZENDESK_ROLES['engineer']: zenpy_user.custom_role_id = ZENDESK_ROLES['light_agent'] - admin.users.update(zenpy_user) - request.user.userprofile.role = "agent" - request.user.userprofile.custom_role_id = ZENDESK_ROLES['light_agent'] - request.user.userprofile.save() + user_update(zenpy_user, admin, request) log(request.user.userprofile) return HttpResponseRedirect(reverse('work', args=(request.user.id,))) @login_required() -def work_become_engineer(request): +def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: + """ + Функция меняет роль пользователя в Zendesk на "engineer" и присваивает роль "agent" в БД (в случае, если исходная роль пользователя была "light_agent"). + + :param request: данные текущего пользователя (login_required) + :return: перезагрузка текущей страницы после выполнения смены роли + """ zenpy_user, admin = auth_user(request) if zenpy_user.custom_role_id == ZENDESK_ROLES['light_agent']: zenpy_user.custom_role_id = ZENDESK_ROLES['engineer'] - admin.users.update(zenpy_user) - request.user.userprofile.role = "agent" - request.user.userprofile.custom_role_id = ZENDESK_ROLES['engineer'] - request.user.userprofile.save() + user_update(zenpy_user, admin, request) log(request.user.userprofile) return HttpResponseRedirect(reverse('work', args=(request.user.id,))) def main_page(request): - """ - Отображение логгирования на главной странице - """ - logger = logging.getLogger('main.index') - logger.info('Index page opened') return render(request, 'pages/index.html') -class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): +class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin,SuccessMessageMixin, FormView): + """ + Класс отображения страницы администратора. + + :param permission_required: Права доступа к странице администратора + :type permission_required: :class:`str` + :param template_name: HTML-шаблон страницы администратора + :type template_name: :class:`str` + :param form_class: Форма страницы администратора + :type form_class: :class:`forms.AdminPageUsersForm` + :param success_url: Адрес страницы администратора + :type success_url: :class:`HttpResponseRedirect` + """ permission_required = 'main.has_control_access' template_name = 'pages/adm_ruleset.html' form_class = AdminPageUsers success_url = '/control/' + success_message = "Права были изменены." def form_valid(self, form: AdminPageUsers) -> AdminPageUsers: """ - Функция установки ролей пользователям + Функция обновления страницы AdminPageUsers. + + :param form: Форма страницы администратора + :return: Обновленная страница (пользователям проставлены новые статусы) """ + users = form.cleaned_data['users'] if 'engineer' in self.request.POST: - self.make_engineers(form.cleaned_data['users']) + self.make_engineers(users) elif 'light_agent' in self.request.POST: - self.make_light_agents(form.cleaned_data['users']) + self.make_light_agents(users) return super().form_valid(form) - # @staticmethod def make_engineers(self, users): - [make_engineer(user) for user in users] - [log(user, self.request.user.userprofile) for user in users] - - # @staticmethod - def make_light_agents(self, users): - [make_light_agent(user) for user in users] - [log(user, self.request.user.userprofile) for user in users] - - @staticmethod - def count_users(users) -> tuple: """ - Функция подсчета количества сотрудников с ролями engineer и light_a + Функция проходит по списку пользователей, проставляя статус "engineer". - .. todo:: - this func counts users from all zendesk instead of just from a model: + :param users: Список пользователей + :return: Обновленный список пользователей """ - engineers, light_agents = 0, 0 for user in users: - if user.custom_role_id == ZENDESK_ROLES['engineer']: - engineers += 1 - elif user.custom_role_id == ZENDESK_ROLES['light_agent']: - light_agents += 1 - return engineers, light_agents + make_engineer(user, self.request.user) + log(user, self.request.user.userprofile) + + def make_light_agents(self, users): + for user in users: + make_light_agent(user, self.request.user) + log(user, self.request.user.userprofile) def get_context_data(self, **kwargs) -> dict: """ Функция формирования контента страницы администратора (с проверкой прав доступа) """ - if self.request.user.userprofile.role != 'admin': - raise PermissionDenied context = super().get_context_data(**kwargs) - context['users'] = get_list_or_404( - UserProfile, role='agent') - context['engineers'], context['light_agents'] = self.count_users(get_users_list()) - return context # TODO: need to get profile page url + context['engineers'], context['light_agents'] = count_users(get_users_list()) + context['licences_remaining'] = max(0, ZENDESK_MAX_AGENTS - context['engineers']) + return context class CustomLoginView(LoginView): @@ -236,10 +301,36 @@ class CustomLoginView(LoginView): form_class = CustomAuthenticationForm +class UsersViewSet(viewsets.ReadOnlyModelViewSet): + """ + Класс для получения пользователей с помощью api + """ + queryset = UserProfile.objects.filter(role='agent') + serializer_class = ProfileSerializer + + def list(self, request, *args, **kwargs): + users = update_users_in_model().values + count = count_users(users) + profiles = UserProfile.objects.filter(role='agent') + serializer = self.get_serializer(profiles, many=True) + return Response({ + 'users': serializer.data, + 'engineers': count[0], + 'light_agents': count[1] + }) + + + @login_required() -def statistic_page(request): - if not request.user.is_superuser: - return redirect('index') +def statistic_page(request: WSGIRequest) -> HttpResponse: + """ + Функция отображения страницы статистики (для "superuser"). + + :param request: данные о пользователе: email, время и интервал работы. Данные получаем через forms.StatisticForm + :return: адресация на страницу статистики + """ + if not request.user.has_perm('main.has_control_access'): + return PermissionDenied context = { 'pagename': 'страница статистики', 'errors': list(), @@ -261,4 +352,4 @@ def statistic_page(request): if request.method == 'GET': form = StatisticForm() context['form'] = form - return render(request, 'pages/stat.html', context) + return render(request, 'pages/statistic.html', context) diff --git a/requirements.txt b/requirements.txt index 7a4f941..16ae562 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,9 @@ Django==3.1.6 Pillow==8.1.0 zenpy~=2.0.24 django_registration==3.1.1 +djangorestframework==3.12.2 +daphne==3.0.1 + # Documentation Sphinx==3.4.3 diff --git a/static/main/css/work.css b/static/main/css/work.css index 790ac6d..d20922d 100644 --- a/static/main/css/work.css +++ b/static/main/css/work.css @@ -13,8 +13,7 @@ background: #45729C; } */ .form-check-input { - border-radius: 0px; - background-image: url("../img/check.png"); + border-radius: 0; width: 30px; height: 30px; background-size: 20px auto; @@ -125,4 +124,4 @@ padding: 10px; background: #3B91D4; color: white; -} \ No newline at end of file +} diff --git a/static/main/js/control.js b/static/main/js/control.js index 1fd4f9c..55d1d1a 100644 --- a/static/main/js/control.js +++ b/static/main/js/control.js @@ -1,9 +1,71 @@ "use strict"; -let checkboxes = document.getElementsByName("users"); -let fields = document.querySelectorAll(".checkbox_field"); -if (checkboxes.length == fields.length) { - for (let i = 0; i < fields.length; ++i) { - let el = checkboxes[i].cloneNode(true); - fields[i].appendChild(el); + +// React +class TableRow extends React.Component { + render() { + return ( + + + {this.props.user.name} + + {this.props.user.user.email} + {this.props.user.zendesk_role} + + + + + ); } } + +class TableBody extends React.Component { + constructor(props) { + super(props); + this.state = { + users: [], + engineers: 0, + light_agents: 0, + }; + } + + 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, + }); + let elements = document.querySelectorAll(".info-quantity-value"); + elements[0].innerHTML = this.state.engineers; + elements[1].innerHTML = this.state.light_agents; + }); + } + + 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 this.state.users.map((user, key) => ( + + )); + } +} + +ReactDOM.render(, document.getElementById("tbody"));