From 5ac60de2effb491eac53a016346875c1d5ef2c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 13 Apr 2021 16:53:18 +0300 Subject: [PATCH 01/65] Fix some spelling problems --- docs/source/conf.py | 3 +-- docs/source/overview.rst | 15 +++++++++------ main/forms.py | 1 + main/views.py | 11 ++++++----- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 7e99943..3b57f8a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,10 +12,9 @@ # import os import sys -import importlib import inspect import enchant -from enchant import checker + sys.path.insert(0, os.path.abspath('../../')) diff --git a/docs/source/overview.rst b/docs/source/overview.rst index a7ca229..0e3bebb 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -69,12 +69,15 @@ Запрос прав доступа ******************** -На странице запроса прав Вам доступна информация о количестве и списке работающих над тикетами сотрудников, -а также возможность сдать и запросить права. +На странице запроса прав Вам доступна информация о количестве и списке работающих над тикетами сотрудников. + +Если Вы не являетесь инженером, то на данной странице Вы можете запросить права. + +Если Вы являетесь инженером, то права можно сдать. .. image:: _static/request.png -Успешное изменение прав: +Успешное изменение прав - список инженеров пополнился новым пользователем: .. image:: _static/role_change.png @@ -84,9 +87,9 @@ Для администратора существует удобный интерфейс страницы управления, в котором представлены: -* Количество свободных инженерных мест -* Количество и список инженеров и легких агентов -* Возможность группового назначения прав с использованием чек-боксов +* количество свободных инженерных мест +* количество и список инженеров и легких агентов +* возможность группового назначения прав с использованием чекбоксов .. image:: _static/admin_manage.png diff --git a/main/forms.py b/main/forms.py index 613fc34..6b31dbd 100644 --- a/main/forms.py +++ b/main/forms.py @@ -12,6 +12,7 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): :param visible_fields.email: Поле для ввода email, зарегистрированного на Zendesk :type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail` + """ def __init__(self, *args, **kwargs) -> RegistrationFormUniqueEmail: diff --git a/main/views.py b/main/views.py index a58331b..cacca82 100644 --- a/main/views.py +++ b/main/views.py @@ -18,6 +18,7 @@ from django.urls import reverse_lazy, reverse from django.views.generic import FormView from django_registration.views import RegistrationView from django.contrib import messages +import django.utils # Django REST from rest_framework import viewsets @@ -40,7 +41,7 @@ class CustomRegistrationView(RegistrationView): Отображение и логика работы страницы регистрации пользователя. :param form_class: Форма, которую необходимо заполнить для регистрации - :type form_class: :class:`forms.CustomRegistrationForm` + :type form_class: :class:`main.forms.CustomRegistrationForm` :param template_name: Указание пути к html-странице django регистрации :type template_name: :class:`str` :param success_url: Указание пути к html-странице завершения регистрации @@ -48,10 +49,10 @@ class CustomRegistrationView(RegistrationView): :param is_allowed: Определение зарегистрирован ли пользователь с введенным email на Zendesk и принадлежит ли он к организации SYSTEM :type is_allowed: :class:`bool` """ - form_class = CustomRegistrationForm - template_name = 'django_registration/registration_form.html' - success_url = reverse_lazy('django_registration_complete') - is_allowed = True + form_class: CustomRegistrationForm = CustomRegistrationForm + template_name: str = 'django_registration/registration_form.html' + success_url: str = reverse_lazy('django_registration_complete') + is_allowed: bool = True def register(self, form: CustomRegistrationForm) -> User: """ From df97d170bf625568ce1619796c73641c53e90668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 15 Apr 2021 11:49:08 +0300 Subject: [PATCH 02/65] Add autodoc_typehints = 'none' --- docs/source/conf.py | 1 + main/extra_func.py | 1 + main/forms.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 3b57f8a..993b68f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -181,6 +181,7 @@ intersphinx_mapping = { } autodoc_default_flags = ['members'] +autodoc_typehints = "none" # spell checking spelling_lang = 'ru_RU' diff --git a/main/extra_func.py b/main/extra_func.py index 8b75346..e0aa110 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -23,6 +23,7 @@ class ZendeskAdmin: :type token: :class:`str` :param password: Пароль администратора, указанный в env :type password: :class:`str` + """ credentials: dict = { diff --git a/main/forms.py b/main/forms.py index 6b31dbd..dd45b16 100644 --- a/main/forms.py +++ b/main/forms.py @@ -96,7 +96,7 @@ class StatisticForm(forms.Form): :param range_end: Дата и время окончания работы :type range_end: :class:`django.forms.fields.DateField` """ - email = forms.EmailField( + email: str = forms.EmailField( label='Электроная почта', widget=forms.EmailInput( attrs={ From d3471331b1aee89f0863f109f2c9e7fb29c38779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 15 Apr 2021 14:43:54 +0300 Subject: [PATCH 03/65] Add READme docs --- README.md | 189 ++++++++++++++++-------------- docs/source/code.rst | 2 +- docs/source/conf.py | 15 ++- docs/source/index.rst | 1 + docs/source/readme.rst | 4 + docs/source/spelling_wordlist.txt | 24 +++- main/extra_func.py | 4 +- main/models.py | 1 - 8 files changed, 136 insertions(+), 104 deletions(-) create mode 100644 docs/source/readme.rst diff --git a/README.md b/README.md index 14e3869..ffd6e42 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,133 @@ -# ZenDesk Access Controller +************************* +ZenDesk Access Controller +************************* -## Управление правами доступа +****************************** +Управление правами доступа +****************************** -Идея - написать программу(Web приложение), которая будет выдавать права пользователям системы по запросу самого -пользователя. Например, из 12 человек 3 сейчас работают с правами админа, по окончании рабочей смены они сдают -свои права (освобождают места) и другие пользователи могут запросить эти права в свое пользование. +**Идея** - Web приложение, выдает права пользователям системы по запросу самого пользователя. Например, из 12 человек 3 +сейчас работают с правами админа, по окончании рабочей смены они сдают свои права (освобождают места) и другие +пользователи могут права запросить. -Оставшиеся 9 человек получают права легкого агента - без прав редактирования, а только чтение. +Оставшиеся 9 человек получают права легкого агента - без прав редактирования, только чтение. -Из технологий - программа должна взаимодействовать с api системы Zendesk(система обращений клиентов - жалобы), -проверять авторизованного пользователя на права(будет возможность менять права напрямую из Zendesk - нужна -синхронизация прав с приоритетом у Zendesk). +Технологически приложение взаимодействует с **api** системы **Zendesk** (система обращений клиентов - жалобы), +проверяет авторизованного пользователя на права с возможностью менять права напрямую из Zendesk (синхронизация +с приоритетом у Zendesk). -Если руками в самом Zendesk права у пользователя отобрали или наоборот -присвоили, то наша программа обновляет статус пользователя в соответствии с данными синхронизации -(например, раз в минуту). - -Так же в идеале должна быть проверка, что пользователь сайта существует на сайте Zendesk(по токену). - -Сэндбокс Zendesk нам предоставит моя компания, библиотеку для работы с api уже подсказали. -Сама программа (наша) будет обладать админскими правами и реализовывать контроль и выдачу прав другим пользователям. +Присутствует проверка, регистрации пользователя сайта на сайте Zendesk (по токену). *Итого:* 1. Реализовать авторизацию пользователей с проверкой по API на существование такого пользователя 2. Реализовать интерфейс со статистикой рабочих мест(занято, свободно, кто занимает) -3. Реализовать логирование действий(когда взял права, когда отдал - запись в файл и БД) -4. Реализовать передачу прав приложением по запросу от пользователя и замену прав пользователя - у которого права отбираются внутри Zendesk (на легкий агент) +3. Реализовать логгирование действий(когда взял права, когда отдал - запись в файл и БД) +4. Реализовать передачу прав приложением по запросу от пользователя и замену прав пользователя у которого права отбираются внутри Zendesk (на легкий агент) 5. Реализовать синхронизацию по API на проверку прав(не менялись ли в системе Zendesk) 6. Реализовать возможность добавить большее количество админских прав 7. Реализовать возможность добавления легких агентов(права только на просмотр) 8. Реализовать на общей странице текущую информацию о пользователе - текущие права, карточка пользователя - -## Технологический стек: +************************ +Технологический стек: +************************ - Python 3 - Django 3 +************** +Quickstart +************** +``sudo apt install make`` -## Quickstart -```bash -sudo apt install make -pip install --upgrade pip -pip install -r requirements.txt -./manage.py migrate -./manage.py loaddata data.json -./manage.py runserver -``` +``pip install --upgrade pip`` -##ZenDesk Access Controller instruction for eng +``pip install -r requirements.txt`` -##Перед запуском для тестирования: +``./manage.py migrate`` -Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация (SYSTEM) -Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk -При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение +``./manage.py loaddata data.json`` + +``./manage.py runserver`` + +********************************** +Перед запуском для тестирования: +********************************** + +* убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация (SYSTEM) +* для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk +* при запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение + +***************************** +Запуск на локальной машине: +***************************** + +* скопировать репозиторий на локальную машину +* перейти в папку приложения +* активировать виртуальное окружение +* выполнить команду **pip install -r requirements.txt** +* в виртуальное окружение добавить следующие переменные: -##Запуск на локальной машине: - -скопировать репозиторий на локальную машину -перейти в папку приложения -активировать вирутальное окружение -выполнить команду pip install -r requirements.txt -в вирутальное окружение добавить следующие переменные : +| *ACCESS_CONTROLLER_API_EMAIL={EMAIL}* - почта админа в ZenDesk +| *ACCESS_CONTROLLER_API_PASSWORD={PASSWORD}* - пароль админа ZenDesk +| *ACCESS_CONTROLLER_API_TOKEN={API_TOKEN}* - API токен ZenDesk +| *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}* - количество часов в рабочей смене (нужно для статистики, пока не реализовано но требует указания значения) -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: +****************** -выполнить команду python manage.py makemigrations -выполнить команду python manage.py migrate -запустить приложение командой python manage.py runserver (можно указать в параметрах для файла manage.py) -перейти по ссылке в консоли (вероятнее всего откроется по адресу http://127.0.0.1:8000/) +Требуется установленный и настроенный 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/) -##Запуск в 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 -На локальной машине - перед запуском команды 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 +************ +Read more +************ - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) - Zendesk API: [https://developer.zendesk.com/rest_api/docs/](https://developer.zendesk.com/rest_api/docs/) diff --git a/docs/source/code.rst b/docs/source/code.rst index 7479081..1f0bd15 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -6,7 +6,7 @@ Models ******* .. automodule:: main.models - :members: + :members: ****** diff --git a/docs/source/conf.py b/docs/source/conf.py index 993b68f..3330341 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -114,12 +114,15 @@ def skip_queryset(app, what, name, obj, skip, options): return True return skip +def fix_sig(app, what, name, obj, options, signature, return_annotation): + return ("", "") -# 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) +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) + app.connect("autodoc-process-signature", fix_sig) @@ -181,7 +184,7 @@ intersphinx_mapping = { } autodoc_default_flags = ['members'] -autodoc_typehints = "none" +autodoc_typehints = "description" # spell checking spelling_lang = 'ru_RU' diff --git a/docs/source/index.rst b/docs/source/index.rst index 96f9c69..4de50ad 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ overview code + readme todo diff --git a/docs/source/readme.rst b/docs/source/readme.rst new file mode 100644 index 0000000..0cee3b2 --- /dev/null +++ b/docs/source/readme.rst @@ -0,0 +1,4 @@ +READ.me +================== + +.. include:: ../../README.md diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index bd64cf9..da91912 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -45,7 +45,9 @@ start end date Токен +токен токеном +токену аутентифицирован (datetime.time) datetime @@ -82,7 +84,23 @@ Serializer Serializers Сериализатор переадресации - - - +чекбоксов +админских +админские +Python +Docker +докер +докера +Докер +репозиторий +zendesk-access-controller/db +-e +-v +e +v +zendesk +db +юзерами +Read +Zenpy diff --git a/main/extra_func.py b/main/extra_func.py index e0aa110..70b57d3 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -570,7 +570,7 @@ class StatisticData: 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(): @@ -585,7 +585,7 @@ class StatisticData: 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()) diff --git a/main/models.py b/main/models.py index ac6f91c..c723806 100644 --- a/main/models.py +++ b/main/models.py @@ -49,7 +49,6 @@ class RoleChangeLogs(models.Model): """ Модель для логирования изменений ролей пользователя. """ - 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='Присвоенная роль') From bf2c1cc1be8de2d6de6cb5c3c212ab26ca19aa9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 15 Apr 2021 17:22:45 +0300 Subject: [PATCH 04/65] Add Union and Optional types --- main/extra_func.py | 49 +++++++++++++++++++++++++++------------------- main/views.py | 11 ++++++----- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 70b57d3..ea37003 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,5 +1,6 @@ import os from datetime import timedelta, datetime, date +from typing import Optional from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist @@ -47,7 +48,10 @@ class ZendeskAdmin: def get_user_name(self, email: str) -> str: """ - Функция **get_user_name** возвращает имя пользователя по его email + Функция **get_user_name** возвращает имя пользователя по его email. + + :param email: Email пользователя + :return: Имя пользователя """ user = self.admin.users.search(email).values[0] return user.name @@ -72,7 +76,7 @@ class ZendeskAdmin: user = self.admin.users.search(email).values[0] return user.id - def get_user_image(self, email: str) -> str: + def get_user_image(self, email: str) -> Optional[str]: """ Функция возвращает url-ссылку на аватар пользователя по его email. @@ -91,9 +95,9 @@ class ZendeskAdmin: """ return self.admin.users.search(email).values[0] - def get_group(self, name: str) -> str: + def get_group(self, name: str) -> Optional[str]: """ - Функция возвращает группы, к которым принадлежит пользователь. + Функция возвращает группу, к которой принадлежит пользователь. :param name: Имя пользователя :return: Группы пользователя (в случае отсутствия None) @@ -103,7 +107,7 @@ class ZendeskAdmin: return group return None - def get_user_org(self, email: str) -> str: + def get_user_org(self, email: str) -> Optional[str]: """ Функция возвращает организацию, к которой относится пользователь по его email. @@ -204,7 +208,7 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> UserProfil def get_users_list() -> list: """ - Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. + Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. """ zendesk = ZendeskAdmin() @@ -214,9 +218,12 @@ def get_users_list() -> list: return users -def get_tickets_list(email): +def get_tickets_list(email: str) -> list: """ - Функция возвращает список тикетов пользователя Zendesk + Функция возвращает список тикетов пользователя Zendesk. + + :param email: Email пользователя + :return: Список тикетов пользователя """ return ZendeskAdmin().admin.search(assignee=email, type='ticket') @@ -260,6 +267,8 @@ def check_user_auth(email: str, password: str) -> bool: """ Функция проверяет, верны ли входные данные. + :param email: Email пользователя + :param password: Пароль пользователя :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован """ creds = { @@ -293,7 +302,7 @@ def update_user_in_model(profile: UserProfile, zendesk_user: User) -> UserProfil def count_users(users) -> tuple: """ - Функция подсчета количества сотрудников с ролями engineer и light_agent + Функция подсчета количества сотрудников с ролями engineer и light_agent """ engineers, light_agents = 0, 0 for user in users: @@ -304,9 +313,9 @@ def count_users(users) -> tuple: return engineers, light_agents -def update_users_in_model(): +def update_users_in_model() -> list: """ - Обновляет пользователей в модели UserProfile по списку пользователей в организации + Функция обновляет пользователей в модели UserProfile по списку пользователей в организации """ users = get_users_list() for user in users: @@ -318,7 +327,7 @@ def update_users_in_model(): return users -def daterange(start_date, end_date) -> list: +def daterange(start_date: date, end_date: date) -> list: """ Функция возвращает список дней с start_date по end_date, исключая правую границу. @@ -332,7 +341,7 @@ def daterange(start_date, end_date) -> list: return dates -def get_timedelta(log, time=None) -> timedelta: +def get_timedelta(log: RoleChangeLogs, time: datetime =None) -> timedelta: """ Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. @@ -399,7 +408,7 @@ class StatisticData: else: self.statistic = stat - def get_statistic(self) -> dict: + def get_statistic(self) -> Optional[dict]: """ Функция возвращает статистику работы пользователя. @@ -447,7 +456,7 @@ class StatisticData: self.display = display_format return True - def get_data(self) -> list: + def get_data(self) -> Optional[list]: """ Функция возвращает данные - список объектов RoleChangeLogs. """ @@ -515,7 +524,7 @@ class StatisticData: return False return True - def _init_data(self): + def _init_data(self) -> Optional[list]: """ Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. @@ -532,7 +541,7 @@ class StatisticData: except User.DoesNotExist: self.errors += ['Пользователь не найден'] - def _init_statistic(self) -> dict: + def _init_statistic(self) -> Optional[dict]: """ Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. @@ -554,7 +563,7 @@ class StatisticData: if self.data[log_index].new_role == ROLES['engineer']: self.engineer_logic(log_index) - def engineer_logic(self, log_index): + def engineer_logic(self, log_index: int) -> dict: """ Функция обрабатывает основную часть работы инженера """ @@ -568,7 +577,7 @@ class StatisticData: 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): + def post_engineer_logic(self, last_log: RoleChangeLogs): """ Функция обрабатывает случай, когда нам известно что инженер работал и после диапазона """ @@ -583,7 +592,7 @@ class StatisticData: 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): + def prev_engineer_logic(self, first_log: RoleChangeLogs) ->dict: """ Функция обрабатывает случай, когда нам известно что инженер начал работу до диапазона """ diff --git a/main/views.py b/main/views.py index cacca82..29c2796 100644 --- a/main/views.py +++ b/main/views.py @@ -1,6 +1,7 @@ import logging import os from datetime import datetime +from typing import Union, Tuple from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordResetForm @@ -54,7 +55,7 @@ class CustomRegistrationView(RegistrationView): success_url: str = reverse_lazy('django_registration_complete') is_allowed: bool = True - def register(self, form: CustomRegistrationForm) -> User: + def register(self, form: CustomRegistrationForm) -> Union[User, bool]: """ Функция регистрации пользователя. 1. Ввод email пользователя, указанный на Zendesk @@ -94,7 +95,7 @@ class CustomRegistrationView(RegistrationView): self.is_allowed = False @staticmethod - def set_permission(user: User) -> None: + def set_permission(user: User) -> User: """ Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin. @@ -140,7 +141,7 @@ def profile_page(request: WSGIRequest) -> HttpResponse: return render(request, 'pages/profile.html', context) -def auth_user(request: WSGIRequest) -> ZenpyUser: +def auth_user(request: WSGIRequest) -> Tuple: """ Функция возвращает профиль пользователя на Zendesk. @@ -270,7 +271,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin,SuccessMessageMi self.make_light_agents(users) return super().form_valid(form) - def make_engineers(self, users): + def make_engineers(self, users: list) -> list: """ Функция проходит по списку пользователей, проставляя статус "engineer". @@ -280,7 +281,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin,SuccessMessageMi for user in users: make_engineer(user, self.request.user) - def make_light_agents(self, users): + def make_light_agents(self, users: list) -> list: """ Функция проходит по списку пользователей, проставляя статус "light agent". From 015d5f65a7eab00c1c8efad97f77ab9001e52470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sun, 25 Apr 2021 17:51:50 +0300 Subject: [PATCH 05/65] Translate README.md to README.rst --- .env.example | 6 +-- docs/source/conf.py | 5 ++- docs/source/readme.rst | 2 +- main/extra_func.py | 85 +++++++++++++++++++++++++++--------------- main/serializers.py | 2 +- main/views.py | 29 +++++++++----- 6 files changed, 83 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index f86db45..58e7b51 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,6 @@ LICENSE_NO=3 SHIFTH=12 ACTRL_ZENDESK_SUBDOMAIN="ngenix1612197338" -ACTRL_API_EMAIL="email@example.com" -ACTRL_API_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -ACTRL_API_PASSWORD="" +ACTRL_API_EMAIL="stepanenko_olga@mail.ru" +ACTRL_API_TOKEN="X1x4QeNa4xRdul2rTIKhac98AsXMwd5bOGAyZOtU" + diff --git a/docs/source/conf.py b/docs/source/conf.py index cefcc10..ea14247 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -139,7 +139,8 @@ extensions = { 'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram', 'sphinx_autodoc_typehints', - 'sphinxcontrib.spelling' + 'sphinxcontrib.spelling', + } @@ -173,6 +174,7 @@ html_static_path = ['_static'] # -- Extension configuration ------------------------------------------------- + # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. @@ -211,3 +213,4 @@ always_document_param_types = True typehints_document_rtype = True napoleon_attr_annotations = True + diff --git a/docs/source/readme.rst b/docs/source/readme.rst index 0cee3b2..25dfdf6 100644 --- a/docs/source/readme.rst +++ b/docs/source/readme.rst @@ -1,4 +1,4 @@ READ.me ================== -.. include:: ../../README.md +.. include:: ../../README.rst diff --git a/main/extra_func.py b/main/extra_func.py index 86b6739..acaabb5 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,9 +1,11 @@ import logging from datetime import timedelta, datetime, date -from typing import Optional +from typing import Optional, Union from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist +from django.core.handlers.wsgi import WSGIRequest +from django.http import HttpResponseRedirect, HttpResponsePermanentRedirect from django.shortcuts import redirect from django.utils import timezone from zenpy import Zenpy @@ -41,7 +43,7 @@ def make_engineer(user_profile: UserProfile, who_changes: User) -> None: update_role(user_profile, ROLES['engineer']) -def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: +def make_light_agent(user_profile: UserProfile) -> None: """ Функция устанавливает пользователю роль легкого агента. @@ -77,7 +79,7 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: def get_users_list() -> list: """ - Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. + Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. """ zendesk = zenpy @@ -87,14 +89,14 @@ def get_users_list() -> list: return users -def get_tickets_list(email): +def get_tickets_list(email) -> list: """ Функция возвращает список тикетов пользователя Zendesk """ return zenpy.admin.search(assignee=email, type='ticket') -def update_profile(user_profile: UserProfile): +def update_profile(user_profile: UserProfile) -> None: """ Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. @@ -148,7 +150,7 @@ def check_user_auth(email: str, password: str) -> bool: return True -def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): +def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser) -> None: """ Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. @@ -164,7 +166,7 @@ def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): profile.save() -def count_users(users) -> tuple: +def count_users(users: list) -> tuple: """ Функция подсчета количества сотрудников с ролями engineer и light_agent """ @@ -177,7 +179,7 @@ def count_users(users) -> tuple: return engineers, light_agents -def update_users_in_model(): +def update_users_in_model() -> list: """ Обновляет пользователей в модели UserProfile по списку пользователей в организации """ @@ -191,7 +193,7 @@ def update_users_in_model(): return users -def daterange(start_date, end_date) -> list: +def daterange(start_date: timedelta, end_date: timedelta) -> list: """ Функция возвращает список дней с start_date по end_date, исключая правую границу. @@ -205,7 +207,7 @@ def daterange(start_date, end_date) -> list: return dates -def get_timedelta(log, time=None) -> timedelta: +def get_timedelta(log: RoleChangeLogs, time: timedelta=None) -> timedelta: """ Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. @@ -272,7 +274,7 @@ class StatisticData: else: self.statistic = stat - def get_statistic(self) -> dict: + def get_statistic(self) -> Optional[dict]: """ Функция возвращает статистику работы пользователя. @@ -388,7 +390,7 @@ class StatisticData: return False return True - def _init_data(self): + def _init_data(self) -> None: """ Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. @@ -405,7 +407,7 @@ class StatisticData: except User.DoesNotExist: self.errors += ['Пользователь не найден'] - def _init_statistic(self) -> dict: + def _init_statistic(self) -> None: """ Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. @@ -427,9 +429,11 @@ class StatisticData: if self.data[log_index].new_role == ROLES['engineer']: self.engineer_logic(log_index) - def engineer_logic(self, log_index): + def engineer_logic(self, log_index: int) -> None: """ - Функция обрабатывает основную часть работы инженера + Функция обрабатывает основную часть работы инженера. + + :param log_index: Индекс текущего лога """ current_log, next_log = self.data[log_index], self.data[log_index + 1] if current_log.change_time.date() != next_log.change_time.date(): @@ -441,9 +445,11 @@ class StatisticData: 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): + def post_engineer_logic(self, last_log: RoleChangeLogs) -> None: """ - Функция обрабатывает случай, когда нам изветсно что инженер работал и после диапазона + Функция обрабатывает случай, когда нам известно что инженер работал и после диапазона. + + :param 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(): @@ -456,15 +462,17 @@ class StatisticData: 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): + def prev_engineer_logic(self, first_log: RoleChangeLogs) -> None: """ - Функция обрабатывает случай, когда нам изветсно что инженер начал работу до диапазона + Функция обрабатывает случай, когда нам извеcтно, что инженер начал работу до диапазона. + + :param 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: + def fill_daterange(self, first: date, last: date, val: int = 24 * 3600) -> None: """ Функция заполняет диапазон дат значением val (по умолчанию val = кол-во секунд в 1 дне). @@ -475,7 +483,7 @@ class StatisticData: for day in daterange(first, last): self.statistic[day] = val - def clear_statistic(self) -> dict: + def clear_statistic(self) -> None: """ Функция осуществляет обновление всех дней. """ @@ -487,7 +495,12 @@ class DatabaseHandler(logging.Handler): def __init__(self): logging.Handler.__init__(self) - def emit(self, record): + def emit(self, record: logging.LogRecord) -> None: + """ + Функция осуществляет запись об изменении роли пользователя. + + :param record: Запись в сущность main.rolchangelogs + """ database = RoleChangeLogs() users = record.msg if users[1]: @@ -511,7 +524,12 @@ class CsvFormatter(logging.Formatter): def __init__(self): logging.Formatter.__init__(self) - def format(self, record): + def format(self, record: logging.LogRecord) -> str: + """ + Функция форматирует запись смены роли пользователя в строку + :param record: Запись смены роли пользователя. + :return: Строка с записью смены пользователя. + """ users = record.msg if users[1]: user = users[0] @@ -532,12 +550,12 @@ class CsvFormatter(logging.Formatter): return msg -def log(user, admin=0): +def log(user: User, admin: int = 0) -> None: """ - Осуществляет запись логов в базу данных и csv файл - :param admin: - :param user: - :return: + Функция осуществляет запись логов в базу данных и csv файл. + + :param admin: Админ, который меняет роль + :param user: Пользователь, которому изменена роль """ users = [user, admin] logger = logging.getLogger('MY_LOGGER') @@ -552,10 +570,15 @@ def log(user, admin=0): logger.info(users) -def set_session_params_for_work_page(request, count=None, is_confirm=True): +def set_session_params_for_work_page(request: WSGIRequest, count: int = None, is_confirm: bool = True) -> \ + Union[HttpResponsePermanentRedirect, HttpResponseRedirect]: """ - Функция для страницы получения прав - Устанавливает данные сессии о успешности запроса и количестве назначенных тикетов + Функция для страницы получения прав, устанавливает данные сессии о успешности запроса и количестве назначенных тикетов. + + :param request: Получение данных с рабочей страницы пользователя + :param count: Количество запрошенных тикетов + :param is_confirm: Назначение тикетов + :return: Перезагрузка страницы "Управление правами" соответствующего пользователя """ request.session['is_confirm'] = is_confirm request.session['count_tickets'] = count diff --git a/main/serializers.py b/main/serializers.py index 8436b54..f5100b4 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -29,7 +29,7 @@ class ZendeskUserSerializer(serializers.Serializer): email = serializers.EmailField() @staticmethod - def get_zendesk_role(obj): + def get_zendesk_role(obj) -> str: if obj.custom_role_id == ZENDESK_ROLES['engineer']: return 'engineer' elif obj.custom_role_id == ZENDESK_ROLES['light_agent']: diff --git a/main/views.py b/main/views.py index d21a6e9..bc05036 100644 --- a/main/views.py +++ b/main/views.py @@ -67,7 +67,7 @@ class CustomRegistrationView(RegistrationView): } redirect_url = 'done' - def register(self, form: CustomRegistrationForm) -> User: + def register(self, form: CustomRegistrationForm) -> None: """ Функция регистрации пользователя. 1. Ввод email пользователя, указанный на Zendesk @@ -124,7 +124,7 @@ class CustomRegistrationView(RegistrationView): ) user.user_permissions.add(permission) - def get_success_url(self, user: User = None): + def get_success_url(self, user: User = None) -> Dict: """ Функция возвращает url-адрес страницы, куда нужно перейти после успешной/не успешной регистрации. Используется самой django-registration. @@ -135,7 +135,13 @@ class CustomRegistrationView(RegistrationView): return self.urls[self.redirect_url] -def registration_error(request): +def registration_error(request: WSGIRequest) -> HttpResponse: + """ + Функция отображения страницы ошибки регистрации. + + :param request: регистрация + :return: адресация на страницу ошибки + """ return render(request, 'django_registration/registration_error.html') @@ -199,7 +205,7 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse: @login_required() -def work_hand_over(request: WSGIRequest): +def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: """ Функция позволяет текущему пользователю сдать права, а именно сменить в Zendesk роль с "engineer" на "light_agent" @@ -224,7 +230,12 @@ def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: @login_required() -def work_get_tickets(request): +def work_get_tickets(request: WSGIRequest) -> HttpResponse: + """ + + :param request: + :return: + """ zenpy_user = zenpy.get_user(request.user.email) if zenpy_user.role == 'admin' or zenpy_user.custom_role_id == ZENDESK_ROLES['engineer']: tickets = [ticket for ticket in zenpy.admin.search(type="ticket") if @@ -280,7 +291,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM self.make_light_agents(users) return super().form_valid(form) - def make_engineers(self, users): + def make_engineers(self, users: list) -> None: """ Функция проходит по списку пользователей, проставляя статус "engineer". @@ -291,7 +302,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM make_engineer(user, self.request.user) log(user, self.request.user.userprofile) - def make_light_agents(self, users): + def make_light_agents(self, users: list) -> None: """ Функция проходит по списку пользователей, проставляя статус "light agent". @@ -333,7 +344,7 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): return Response(res) @staticmethod - def choose_users(zendesk, model): + def choose_users(zendesk, model) -> list: users = [] for zendesk_user in zendesk: if zendesk_user.name not in [user.name for user in model]: @@ -341,7 +352,7 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): return users @staticmethod - def get_zendesk_users(users): + def get_zendesk_users(users: list) -> ZendeskUserSerializer: zendesk_users = ZendeskUserSerializer( data=[user for user in users if user.role != 'admin'], many=True From aa59eb31ec8408ae1b300ab191a6eadd4b42d4b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sun, 25 Apr 2021 17:59:34 +0300 Subject: [PATCH 06/65] Add m2r to requirements --- docs/source/conf.py | 3 ++- requirements/common.txt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index ea14247..b23bfb4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,7 +14,7 @@ import os import sys import inspect import enchant - +import m2r sys.path.insert(0, os.path.abspath('../../')) @@ -140,6 +140,7 @@ extensions = { 'sphinx.ext.inheritance_diagram', 'sphinx_autodoc_typehints', 'sphinxcontrib.spelling', + # 'm2r' } diff --git a/requirements/common.txt b/requirements/common.txt index 6b3e7fa..095c3af 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -14,3 +14,4 @@ sphinx-rtd-theme==0.5.1 sphinx-autodoc-typehints==1.11.1 pyenchant==3.2.0 sphinxcontrib-spelling==7.1.0 +m2r == 0.2.1 From 8dd8e7640aeac6cda8b87f6495dd6ad42e931282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 27 Apr 2021 09:40:05 +0300 Subject: [PATCH 07/65] Update docs for files (except views) --- docs/source/spelling_wordlist.txt | 1 + main/forms.py | 2 ++ main/models.py | 13 ++++++------ main/serializers.py | 33 +++++++++++++++++++++++++++++-- main/zendesk_admin.py | 12 ++++++++--- 5 files changed, 50 insertions(+), 11 deletions(-) diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index da91912..1582561 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -83,6 +83,7 @@ functions Serializer Serializers Сериализатор +сериализатор переадресации чекбоксов админских diff --git a/main/forms.py b/main/forms.py index 81b2e8a..0f6b44c 100644 --- a/main/forms.py +++ b/main/forms.py @@ -56,6 +56,8 @@ class CustomAuthenticationForm(AuthenticationForm): :param username: Поле для ввода email пользователя :type username: :class:`django.forms.fields.CharField` + :param error_messages: Список ошибок авторизации + :type error_messages: :class:`dict` """ username = forms.CharField( label="Электронная почта", diff --git a/main/models.py b/main/models.py index e48fb1e..456bff9 100644 --- a/main/models.py +++ b/main/models.py @@ -11,13 +11,11 @@ class UserProfile(models.Model): """ Модель профиля пользователя. - Профиль создается и изменяется при создании и изменении модель User. + Профиль создается и изменяется при создании и изменении модель User """ - class Meta: permissions = ( - ('has_control_access', 'Can view admin page'), - ) + ('has_control_access', 'Can view admin page')) user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя') @@ -49,11 +47,13 @@ class RoleChangeLogs(models.Model): """ Модель для логирования изменений ролей пользователя. """ - user = models.ForeignKey(to=User, on_delete=models.CASCADE, 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(default=timezone.now, help_text='Дата и время изменения роли') - changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') + changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', + help_text='Кем была изменена роль') class UnassignedTicketStatus(models.IntegerChoices): @@ -81,3 +81,4 @@ class UnassignedTicket(models.Model): 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 index f5100b4..5ed3138 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -7,6 +7,11 @@ from access_controller.settings import ZENDESK_ROLES class UserSerializer(serializers.HyperlinkedModelSerializer): """ Класс serializer для модели User. + + :param model: Модель, на основании которой создается сериализатор + :type model: :class:`django.contrib.auth.Models` + :param fields: Передаваемые поля + :type email: :class:`list` """ class Meta: model = User @@ -14,7 +19,16 @@ class UserSerializer(serializers.HyperlinkedModelSerializer): class ProfileSerializer(serializers.HyperlinkedModelSerializer): - """Класс serializer для модели профиля пользователя""" + """ + Класс serializer для модели профиля пользователя. + + :param user: Вложенный сериализатор + :type user: :class:`UserSerializer` + :param model: Модель, на основании которой создается сериализатор + :type model: :class:`django.contrib.auth.Models` + :param fields: Передаваемые поля + :type email: :class:`list` + """ user = UserSerializer() class Meta: @@ -23,13 +37,28 @@ class ProfileSerializer(serializers.HyperlinkedModelSerializer): class ZendeskUserSerializer(serializers.Serializer): - """Класс serializer для объектов пользователей из zenpy""" + """ + Класс serializer для объектов пользователей из Zenpy. + + :param name: Имя пользователя + :type name: :class:`str` + :param zendesk_role: Роль из Zendesk + :type zendesk_role: :class:`str` + :param email: Email пользователя + :type email: :class:`str` + """ name = serializers.CharField() zendesk_role = serializers.SerializerMethodField('get_zendesk_role') email = serializers.EmailField() @staticmethod def get_zendesk_role(obj) -> str: + """ + Функция строкового заполнения поля сериализатора zendesk_role. + + :param obj: объект пользователя Zendesk + :return: роль engineer либо light_agent + """ if obj.custom_role_id == ZENDESK_ROLES['engineer']: return 'engineer' elif obj.custom_role_id == ZENDESK_ROLES['light_agent']: diff --git a/main/zendesk_admin.py b/main/zendesk_admin.py index 8ecb877..9d83109 100644 --- a/main/zendesk_admin.py +++ b/main/zendesk_admin.py @@ -10,17 +10,23 @@ from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, class ZendeskAdmin: """ - Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора. + Класс **ZendeskAdmin** содержит описание всего функционала администратора. :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) :type credentials: :class:`Dict[str, str]` + :param admin: Администратор + :type admin: :class:`Zenpy` + :param buffer_group_id: ID буферной группы + :type buffer_group_id: :class:`int` + :param solved_tickets_user_id: ID пользователя, который решил тикет + :type solved_tickets_user_id: :class:`int` """ def __init__(self, credentials: Dict[str, str]): self.credentials = credentials self.admin = self.create_admin() - self.buffer_group_id: int = self.get_group(ZENDESK_GROUPS['buffer']).id - self.solved_tickets_user_id: int = self.get_user(SOLVED_TICKETS_EMAIL).id + self.buffer_group_id= self.get_group(ZENDESK_GROUPS['buffer']).id + self.solved_tickets_user_id = self.get_user(SOLVED_TICKETS_EMAIL).id def check_user(self, email: str) -> bool: """ From 68833ccdd52d2e53561185ad4229a05c8b1ac79d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 27 Apr 2021 09:56:38 +0300 Subject: [PATCH 08/65] Fix models problem --- main/models.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/main/models.py b/main/models.py index 456bff9..ac6f91c 100644 --- a/main/models.py +++ b/main/models.py @@ -11,11 +11,13 @@ class UserProfile(models.Model): """ Модель профиля пользователя. - Профиль создается и изменяется при создании и изменении модель User + Профиль создается и изменяется при создании и изменении модель User. """ + class Meta: permissions = ( - ('has_control_access', 'Can view admin page')) + ('has_control_access', 'Can view admin page'), + ) user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя') @@ -47,13 +49,12 @@ class RoleChangeLogs(models.Model): """ Модель для логирования изменений ролей пользователя. """ - user = models.ForeignKey(to=User, on_delete=models.CASCADE, 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(default=timezone.now, help_text='Дата и время изменения роли') - changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', - help_text='Кем была изменена роль') + changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') class UnassignedTicketStatus(models.IntegerChoices): @@ -80,5 +81,3 @@ 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='Статус тикета') - - From bd8ba97a465c775fd38bd9cfb0fc99838edc24e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 27 Apr 2021 12:10:29 +0300 Subject: [PATCH 09/65] Add pylint, add init-file in access_controller folder to run pylint --- README.md | 2 ++ __init__.py | 0 docs/source/conf.py | 7 ++----- main/models.py | 7 ++++--- requirements/common.txt | 2 ++ 5 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 __init__.py diff --git a/README.md b/README.md index e0b2f61..603f229 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,8 @@ docker run -d -p 8000:8000 \ ## Параметры тестовой песочницы: Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). +## Для проверки pylint используем: +pylint --load-plugins pylint_django ../access_controller ## Read more - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/conf.py b/docs/source/conf.py index b23bfb4..2bd665b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -134,15 +134,12 @@ extensions = { 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode', - 'sphinx.ext.napoleon', 'sphinx_rtd_theme', 'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram', 'sphinx_autodoc_typehints', 'sphinxcontrib.spelling', # 'm2r' - - } @@ -193,7 +190,7 @@ autodoc_typehints = "description" # spell checking spelling_lang = 'ru_RU' tokenizer_lang = 'ru_RU' -spelling_exclude_patterns=['ignored_*'] +spelling_exclude_patterns=['ignored_*', '../../main/models.py'] spelling_show_suggestions = True spelling_show_whole_line=True spelling_warning=True @@ -213,5 +210,5 @@ typehints_fully_qualified = True always_document_param_types = True typehints_document_rtype = True -napoleon_attr_annotations = True + diff --git a/main/models.py b/main/models.py index ac6f91c..0d49acf 100644 --- a/main/models.py +++ b/main/models.py @@ -49,12 +49,13 @@ class RoleChangeLogs(models.Model): """ Модель для логирования изменений ролей пользователя. """ - - user = models.ForeignKey(to=User, on_delete=models.CASCADE, 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(default=timezone.now, help_text='Дата и время изменения роли') - changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') + changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', + help_text='Кем была изменена роль') class UnassignedTicketStatus(models.IntegerChoices): diff --git a/requirements/common.txt b/requirements/common.txt index 095c3af..4417add 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -15,3 +15,5 @@ sphinx-autodoc-typehints==1.11.1 pyenchant==3.2.0 sphinxcontrib-spelling==7.1.0 m2r == 0.2.1 +pylint == 2.8.2 +pylint-django == 2.4.4 From 3f6cf23f4e50718d6f5beab0680be3f2c177c25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 27 Apr 2021 17:28:01 +0300 Subject: [PATCH 10/65] Add autopep8. First draft --- main/views.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/main/views.py b/main/views.py index bc05036..2059a17 100644 --- a/main/views.py +++ b/main/views.py @@ -25,13 +25,15 @@ from main.extra_func import check_user_exist, update_profile, get_user_organizat make_engineer, make_light_agent, get_users_list, update_users_in_model, count_users, \ StatisticData, log, set_session_params_for_work_page from main.zendesk_admin import zenpy -from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm +from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, \ + StatisticForm from main.serializers import ProfileSerializer, ZendeskUserSerializer from .models import UserProfile def setup_context(profile_lit: bool = False, control_lit: bool = False, work_lit: bool = False, - registration_lit: bool = False, login_lit: bool = False, stats_lit: bool = False) -> Dict[str, Any]: + registration_lit: bool = False, login_lit: bool = False, + stats_lit: bool = False) -> Dict[str, Any]: context = { 'profile_lit': profile_lit, @@ -54,7 +56,8 @@ class CustomRegistrationView(RegistrationView): :type template_name: :class:`str` :param success_url: Указание пути к html-странице завершения регистрации :type success_url: :class:`django.utils.functional.lazy..__proxy__` - :param is_allowed: Определение зарегистрирован ли пользователь с введенным email на Zendesk и принадлежит ли он к организации SYSTEM + :param is_allowed: Определение зарегистрирован ли пользователь с введенным email на Zendesk + и принадлежит ли он к организации SYSTEM :type is_allowed: :class:`bool` """ extra_context = setup_context(registration_lit=True) @@ -71,7 +74,8 @@ class CustomRegistrationView(RegistrationView): """ Функция регистрации пользователя. 1. Ввод email пользователя, указанный на Zendesk - 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk и относится к организации SYSTEM, + 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk + и относится к организации SYSTEM, происходит сброс ссылки с установлением пароля на указанный email 3. Создается пользователь class User, а также его профиль. @@ -79,7 +83,8 @@ class CustomRegistrationView(RegistrationView): :return: user """ self.redirect_url = 'done' - if check_user_exist(form.data['email']) and get_user_organization(form.data['email']) == 'SYSTEM': + if check_user_exist(form.data['email']) and \ + get_user_organization(form.data['email']) == 'SYSTEM': forms = PasswordResetForm(self.request.POST) if forms.is_valid(): opts = { @@ -386,7 +391,8 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: if form.is_valid(): start_date, end_date = form.cleaned_data['range_start'], form.cleaned_data['range_end'] interval, show = form.cleaned_data['interval'], form.cleaned_data['display_format'] - data = StatisticData(start_date, end_date, form.cleaned_data['email']) + data = StatisticData(start_date, end_date, + form.cleaned_data['email']) data.set_display(show) data.set_interval(interval) stats = data.get_statistic() From 8e0610840d5191175b47979ef0e622cd799c90b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 27 Apr 2021 19:41:47 +0300 Subject: [PATCH 11/65] Pylint 7.5 --- README.md | 5 ++- main/apps.py | 6 +++ main/extra_func.py | 83 +++++++++++++++++++++++++++-------------- main/forms.py | 3 +- main/models.py | 39 +++++++++++++------ main/views.py | 26 +++++++++---- requirements/common.txt | 1 + 7 files changed, 113 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 603f229..97d63bb 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,10 @@ docker run -d -p 8000:8000 \ Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). ## Для проверки pylint используем: -pylint --load-plugins pylint_django ../access_controller +pylint --load-plugins pylint_django --disable=E5110,C0415 ../access_controller + +## Для приведения файлов к стандарту PEP8 используем: +autopep8 --in-place ## Read more - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) diff --git a/main/apps.py b/main/apps.py index 833bff6..b521b37 100644 --- a/main/apps.py +++ b/main/apps.py @@ -1,5 +1,11 @@ +""" +Стандартный файл Django конфигурации приложения. +""" from django.apps import AppConfig class MainConfig(AppConfig): + """ + Старт приложения + """ name = 'main' diff --git a/main/extra_func.py b/main/extra_func.py index acaabb5..2e759b2 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -38,7 +38,8 @@ def make_engineer(user_profile: UserProfile, who_changes: User) -> None: Функция устанавливает пользователю роль инженера. :param user_profile: Профиль пользователя - :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" + :return: Вызов функции **update_role** с параметрами: + профиль пользователя, роль "engineer" """ update_role(user_profile, ROLES['engineer']) @@ -48,7 +49,8 @@ def make_light_agent(user_profile: UserProfile) -> None: Функция устанавливает пользователю роль легкого агента. :param user_profile: Профиль пользователя - :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" + :return: Вызов функции **update_role** с параметрами: + профиль пользователя, роль "light_agent" """ tickets = get_tickets_list(user_profile.user.email) ticket: ZenpyTicket @@ -56,7 +58,8 @@ def make_light_agent(user_profile: UserProfile) -> None: UnassignedTicket.objects.create( assignee=user_profile.user, ticket_id=ticket.id, - status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED + status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' + else UnassignedTicketStatus.UNASSIGNED ) if ticket.status == 'solved': ticket.assignee_id = zenpy.solved_tickets_user_id @@ -79,7 +82,8 @@ def make_light_agent(user_profile: UserProfile) -> None: def get_users_list() -> list: """ - Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. + Функция **get_users_list** возвращает список + пользователей Zendesk, относящихся к организации SYSTEM. """ zendesk = zenpy @@ -101,7 +105,8 @@ def update_profile(user_profile: UserProfile) -> None: Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. :param user_profile: Профиль пользователя - :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя + :return: Обновленный, в соответствие с текущими данными + в Zendesk, профиль пользователя """ user = zenpy.get_user(user_profile.user.email) user_profile.name = user.name @@ -135,7 +140,8 @@ def check_user_auth(email: str, password: str) -> bool: """ Функция проверяет, верны ли входные данные. - :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован + :raise: :class:`APIException`: исключение, + вызываемое если пользователь не аутентифицирован """ creds = { 'email': email, @@ -207,10 +213,12 @@ def daterange(start_date: timedelta, end_date: timedelta) -> list: return dates -def get_timedelta(log: RoleChangeLogs, time: timedelta=None) -> timedelta: +def get_timedelta(log: RoleChangeLogs, time: timedelta = None) -> timedelta: """ - Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, - который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. + Функция возвращает объект класса timedelta, + который хранит промежуток времени от начала суток до момента, + который находится в log (объект класса RoleChangeLogs) + или в time(datetime.time), если введён. :param log: Лог :param time: Время @@ -278,7 +286,9 @@ class StatisticData: """ Функция возвращает статистику работы пользователя. - :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). None, если были ошибки при создании. + :return: Словарь statistic с применением формата отображения + и интервала работы(если они есть). + None, если были ошибки при создании. """ if self.is_valid_statistic(): stat = self.statistic @@ -369,9 +379,12 @@ class StatisticData: if self.interval == 'months': # Переделываем ключи под формат('начало_месяца - конец_месяца') for key, value in stat.items(): - current_month_start = max(self.start_date, date(year=key.year, month=key.month, day=1)) - current_month_end = min(self.end_date, last_day_of_month(date(year=key.year, month=key.month, day=1))) - index = ' - '.join([str(current_month_start), str(current_month_end)]) + current_month_start = max(self.start_date, date( + year=key.year, month=key.month, day=1)) + current_month_end = min(self.end_date, last_day_of_month( + date(year=key.year, month=key.month, day=1))) + index = ' - '.join([str(current_month_start), + str(current_month_end)]) if new_stat.get(index): new_stat[index] += value else: @@ -392,16 +405,20 @@ class StatisticData: def _init_data(self) -> None: """ - Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. + Функция возвращает логи в диапазоне дат + start_date - end_date для пользователя с указанным email. - :return: Данные о смене статусов пользователя. Если пользователь не найден или интервал времени некорректен - ошибку. + :return: Данные о смене статусов пользователя. + Если пользователь не найден или интервал времени некорректен - ошибку. """ if not self.check_time(): - self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] + self.errors += [ + 'Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] return try: self.data = RoleChangeLogs.objects.filter( - change_time__range=[self.start_date, self.end_date + timedelta(days=1)], + change_time__range=[self.start_date, + self.end_date + timedelta(days=1)], user=User.objects.get(email=self.email), ).order_by('change_time') except User.DoesNotExist: @@ -409,7 +426,8 @@ class StatisticData: def _init_statistic(self) -> None: """ - Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. + Функция заполняет словарь, в котором ключ - дата, + значение - кол-во проработанных в этот день секунд. :return: Статистика работы пользователя (statistic) """ @@ -439,11 +457,14 @@ class StatisticData: 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()) + 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.statistic[current_log.change_time.date( + )] += elapsed_time.total_seconds() def post_engineer_logic(self, last_log: RoleChangeLogs) -> None: """ @@ -451,16 +472,19 @@ class StatisticData: :param last_log: Последний лог """ - self.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date + timedelta(days=1)) + 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) + 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() + self.statistic[self.end_date] = get_timedelta( + None, timezone.now().time()).total_seconds() def prev_engineer_logic(self, first_log: RoleChangeLogs) -> None: """ @@ -470,7 +494,8 @@ class StatisticData: """ 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() + 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) -> None: """ @@ -488,7 +513,8 @@ class StatisticData: Функция осуществляет обновление всех дней. """ self.statistic.clear() - self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) + self.fill_daterange( + self.start_date, self.end_date + timedelta(days=1), 0) class DatabaseHandler(logging.Handler): @@ -571,9 +597,10 @@ def log(user: User, admin: int = 0) -> None: def set_session_params_for_work_page(request: WSGIRequest, count: int = None, is_confirm: bool = True) -> \ - Union[HttpResponsePermanentRedirect, HttpResponseRedirect]: + Union[HttpResponsePermanentRedirect, HttpResponseRedirect]: """ - Функция для страницы получения прав, устанавливает данные сессии о успешности запроса и количестве назначенных тикетов. + Функция для страницы получения прав, устанавливает данные сессии + о успешности запроса и количестве назначенных тикетов. :param request: Получение данных с рабочей страницы пользователя :param count: Количество запрошенных тикетов diff --git a/main/forms.py b/main/forms.py index 0f6b44c..36d2e88 100644 --- a/main/forms.py +++ b/main/forms.py @@ -66,8 +66,7 @@ class CustomAuthenticationForm(AuthenticationForm): error_messages = { 'invalid_login': "Пожалуйста, введите правильные электронную почту и пароль. Оба поля " - "могут быть чувствительны к регистру." - , + "могут быть чувствительны к регистру.", 'inactive': "Аккаунт не активен.", } diff --git a/main/models.py b/main/models.py index 0d49acf..c955636 100644 --- a/main/models.py +++ b/main/models.py @@ -19,11 +19,15 @@ class UserProfile(models.Model): ('has_control_access', 'Can view admin page'), ) - user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') - role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя') - custom_role_id = models.IntegerField(default=0, help_text='Код роли пользователя') + user = models.OneToOneField( + to=User, on_delete=models.CASCADE, help_text='Пользователь') + role = models.CharField(default='None', max_length=100, + help_text='Глобальное имя роли пользователя') + custom_role_id = models.IntegerField( + default=0, help_text='Код роли пользователя') image = models.URLField(null=True, blank=True, help_text='Аватарка') - name = models.CharField(default='None', max_length=100, help_text='Имя пользователя на нашем сайте') + name = models.CharField(default='None', max_length=100, + help_text='Имя пользователя на нашем сайте') @property def zendesk_role(self): @@ -49,12 +53,16 @@ class RoleChangeLogs(models.Model): """ Модель для логирования изменений ролей пользователя. """ - user = models.ForeignKey(to=User, on_delete=models.CASCADE, + 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(default=timezone.now, help_text='Дата и время изменения роли') - changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', + 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='Кем была изменена роль') @@ -64,13 +72,15 @@ class UnassignedTicketStatus(models.IntegerChoices): :param UNASSIGNED: Снят с пользователя, перенесён в буферную группу :param RESTORED: Авторство восстановлено - :param NOT_FOUND: Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются + :param NOT_FOUND: Пока нас не было, тикет испарился из буферной группы. + Дополнительные действия не требуются :param CLOSED: Тикет уже был закрыт. Дополнительные действия не требуются :param SOLVED: Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL """ UNASSIGNED = 0, 'Снят с пользователя, перенесён в буферную группу' RESTORED = 1, 'Авторство восстановлено' - NOT_FOUND = 2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются' + NOT_FOUND = 2, 'Пока нас не было, тикет испарился из ' \ + 'буферной группы. Дополнительные действия не требуются' CLOSED = 3, 'Тикет уже был закрыт. Дополнительные действия не требуются' SOLVED = 4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL' @@ -79,6 +89,11 @@ 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='Статус тикета') + 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/views.py b/main/views.py index 2059a17..68ea8d1 100644 --- a/main/views.py +++ b/main/views.py @@ -1,3 +1,7 @@ +""" +Основной функционал приложения. +""" + from smtplib import SMTPException from typing import Dict, Any @@ -117,7 +121,8 @@ class CustomRegistrationView(RegistrationView): @staticmethod def set_permission(user: User) -> None: """ - Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin. + Функция дает разрешение на просмотр страница администратора, + если пользователь имеет роль admin. :param user: авторизованный пользователь (получает разрешение, имея роль "admin") """ @@ -131,7 +136,8 @@ class CustomRegistrationView(RegistrationView): def get_success_url(self, user: User = None) -> Dict: """ - Функция возвращает url-адрес страницы, куда нужно перейти после успешной/не успешной регистрации. + Функция возвращает url-адрес страницы, куда нужно перейти после + успешной/не успешной регистрации. Используется самой django-registration. :param user: пользователь, пытающийся зарегистрироваться @@ -172,11 +178,13 @@ def profile_page(request: WSGIRequest) -> HttpResponse: @login_required() def work_page(request: WSGIRequest, id: int) -> HttpResponse: """ - Функция отображения страницы "Управления правами" для текущего пользователя (login_required). + Функция отображения страницы "Управления правами" + для текущего пользователя (login_required). :param request: объект пользователя :param id: id пользователя, используется для динамической адресации - :return: адресация на страницу "Управления правами" (либо на страницу "Авторизации", если id и user.id не совпадают + :return: адресация на страницу "Управления правами" + (либо на страницу "Авторизации", если id и user.id не совпадают) """ users = get_users_list() if request.user.id == id: @@ -212,7 +220,8 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse: @login_required() def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: """ - Функция позволяет текущему пользователю сдать права, а именно сменить в Zendesk роль с "engineer" на "light_agent" + Функция позволяет текущему пользователю сдать права, + а именно сменить в Zendesk роль с "engineer" на "light_agent" :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли @@ -224,7 +233,9 @@ def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: @login_required() def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: """ - Функция позволяет текущему пользователю получить права, а именно сменить в Zendesk роль с "light_agent" на "engineer" + Функция позволяет текущему пользователю получить права, + а именно сменить в Zendesk роль с "light_agent" на + "engineer" :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли @@ -371,7 +382,8 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: """ Функция отображения страницы статистики (для "superuser"). - :param request: данные о пользователе: email, время и интервал работы. Данные получаем через forms.StatisticForm + :param request: данные о пользователе: email, время и интервал работы. + Данные получаем через forms.StatisticForm :return: адресация на страницу статистики """ diff --git a/requirements/common.txt b/requirements/common.txt index 4417add..58befa7 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -17,3 +17,4 @@ sphinxcontrib-spelling==7.1.0 m2r == 0.2.1 pylint == 2.8.2 pylint-django == 2.4.4 +autopep8 = 1.5.6 From d8b15d087b2bb72e4ce6d4bfcbe145b5469fe77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 28 Apr 2021 11:53:29 +0300 Subject: [PATCH 12/65] Add pylint settings - .pylintrc file --- README.md | 1 + access_controller/settings.py | 6 ++++++ main/models.py | 21 +++++++-------------- main/views.py | 34 ++++++++++++---------------------- 4 files changed, 26 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 97d63bb..51a76ef 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ docker run -d -p 8000:8000 \ ## Для проверки pylint используем: pylint --load-plugins pylint_django --disable=E5110,C0415 ../access_controller +pylint ../access_controller ## Для приведения файлов к стандарту PEP8 используем: autopep8 --in-place diff --git a/access_controller/settings.py b/access_controller/settings.py index a7585ed..7b1f707 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -13,6 +13,8 @@ import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. +import django as django + BASE_DIR = Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production @@ -140,6 +142,8 @@ ACCOUNT_ACTIVATION_DAYS = 7 LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' + + # Название_приложения.Название_файла.Название_класса_обработчика AUTHENTICATION_BACKENDS = [ 'access_controller.auth.EmailAuthBackend', @@ -177,3 +181,5 @@ ACTRL_ZENDESK_SUBDOMAIN = os.getenv('ACTRL_ZENDESK_SUBDOMAIN') or os.getenv('ZD_ ACTRL_API_EMAIL = os.getenv('ACTRL_API_EMAIL') or os.getenv('ACCESS_CONTROLLER_API_EMAIL') ACTRL_API_TOKEN = os.getenv('ACTRL_API_TOKEN') or os.getenv('ACCESS_CONTROLLER_API_TOKEN') ACTRL_API_PASSWORD = os.getenv('ACTRL_API_PASSWORD') or os.getenv('ACCESS_CONTROLLER_API_PASSWORD') + + diff --git a/main/models.py b/main/models.py index c955636..35daa68 100644 --- a/main/models.py +++ b/main/models.py @@ -19,15 +19,11 @@ class UserProfile(models.Model): ('has_control_access', 'Can view admin page'), ) - user = models.OneToOneField( - to=User, on_delete=models.CASCADE, help_text='Пользователь') - role = models.CharField(default='None', max_length=100, - help_text='Глобальное имя роли пользователя') - custom_role_id = models.IntegerField( - default=0, help_text='Код роли пользователя') + user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') + role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя') + custom_role_id = models.IntegerField(default=0, help_text='Код роли пользователя') image = models.URLField(null=True, blank=True, help_text='Аватарка') - name = models.CharField(default='None', max_length=100, - help_text='Имя пользователя на нашем сайте') + name = models.CharField(default='None', max_length=100, help_text='Имя пользователя на нашем сайте') @property def zendesk_role(self): @@ -89,11 +85,8 @@ class UnassignedTicket(models.Model): """ Модель не распределенного тикета. """ - assignee = models.ForeignKey(to=User, on_delete=models.CASCADE, - related_name='tickets', + 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, + ticket_id = models.IntegerField(help_text='Номер тикера, для которого сняли ответственного') + status = models.IntegerField(choices=UnassignedTicketStatus.choices, default=UnassignedTicketStatus.UNASSIGNED, help_text='Статус тикета') diff --git a/main/views.py b/main/views.py index 68ea8d1..dc0c65a 100644 --- a/main/views.py +++ b/main/views.py @@ -25,12 +25,10 @@ from rest_framework import viewsets from rest_framework.response import Response from access_controller.settings import DEFAULT_FROM_EMAIL, ZENDESK_ROLES, ZENDESK_MAX_AGENTS -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, set_session_params_for_work_page +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, set_session_params_for_work_page from main.zendesk_admin import zenpy -from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, \ - StatisticForm +from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm from main.serializers import ProfileSerializer, ZendeskUserSerializer from .models import UserProfile @@ -78,8 +76,7 @@ class CustomRegistrationView(RegistrationView): """ Функция регистрации пользователя. 1. Ввод email пользователя, указанный на Zendesk - 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk - и относится к организации SYSTEM, + 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk и относится к организации SYSTEM, происходит сброс ссылки с установлением пароля на указанный email 3. Создается пользователь class User, а также его профиль. @@ -87,8 +84,7 @@ class CustomRegistrationView(RegistrationView): :return: user """ self.redirect_url = 'done' - if check_user_exist(form.data['email']) and \ - get_user_organization(form.data['email']) == 'SYSTEM': + if check_user_exist(form.data['email']) and get_user_organization(form.data['email']) == 'SYSTEM': forms = PasswordResetForm(self.request.POST) if forms.is_valid(): opts = { @@ -121,8 +117,7 @@ class CustomRegistrationView(RegistrationView): @staticmethod def set_permission(user: User) -> None: """ - Функция дает разрешение на просмотр страница администратора, - если пользователь имеет роль admin. + Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin. :param user: авторизованный пользователь (получает разрешение, имея роль "admin") """ @@ -136,8 +131,7 @@ class CustomRegistrationView(RegistrationView): def get_success_url(self, user: User = None) -> Dict: """ - Функция возвращает url-адрес страницы, куда нужно перейти после - успешной/не успешной регистрации. + Функция возвращает url-адрес страницы, куда нужно перейти после успешной/не успешной регистрации. Используется самой django-registration. :param user: пользователь, пытающийся зарегистрироваться @@ -178,8 +172,7 @@ def profile_page(request: WSGIRequest) -> HttpResponse: @login_required() def work_page(request: WSGIRequest, id: int) -> HttpResponse: """ - Функция отображения страницы "Управления правами" - для текущего пользователя (login_required). + Функция отображения страницы "Управления правами" для текущего пользователя (login_required). :param request: объект пользователя :param id: id пользователя, используется для динамической адресации @@ -220,8 +213,7 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse: @login_required() def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: """ - Функция позволяет текущему пользователю сдать права, - а именно сменить в Zendesk роль с "engineer" на "light_agent" + Функция позволяет текущему пользователю сдать права, а именно сменить в Zendesk роль с "engineer" на "light_agent". :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли @@ -233,9 +225,8 @@ def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: @login_required() def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: """ - Функция позволяет текущему пользователю получить права, - а именно сменить в Zendesk роль с "light_agent" на - "engineer" + Функция позволяет текущему пользователю получить права, а именно сменить в Zendesk роль с "light_agent" на + "engineer". :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли @@ -382,8 +373,7 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: """ Функция отображения страницы статистики (для "superuser"). - :param request: данные о пользователе: email, время и интервал работы. - Данные получаем через forms.StatisticForm + :param request: данные о пользователе: email, время и интервал работы. Данные получаем через forms.StatisticForm :return: адресация на страницу статистики """ From 3b54a520cb6a730c60988ec8c7de654494257e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 28 Apr 2021 14:15:40 +0300 Subject: [PATCH 13/65] Use pylint for views --- .env.example | 25 ----------- README.md | 1 - data.json | 12 +++--- docs/source/spelling_wordlist.txt | 1 + main/views.py | 72 +++++++++++++++++++++---------- 5 files changed, 57 insertions(+), 54 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index 58e7b51..0000000 --- a/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -ACTRL_DEBUG=1 - -ACTRL_SECRET_KEY="v1i_fb\$_jf2#1v_lcsbu&eon4u-os0^px=s^iycegdycqy&5)6" -ACTRL_HOST="actrl.example.com" - -ACTRL_EMAIL_HOST="smtp.mail.ru" -ACTRL_EMAIL_PORT=2525 -ACTRL_EMAIL_TLS=1 -ACTRL_EMAIL_HOST_USER="djgr.02@mail.ru" -ACTRL_EMAIL_HOST_PASSWORD="djangogroup02" -ACTRL_FROM_EMAIL="djgr.02@mail.ru" -ACTRL_SERVER_EMAIL="djgr.02@mail.ru" - -ENG_CROLE_ID=360005209000 -LA_CROLE_ID=360005208980 -EMPL_GROUP="Поддержка" -BUF_GROUP="Сменная группа" -ST_EMAIL="d.krikov@ngenix.net" -LICENSE_NO=3 -SHIFTH=12 - -ACTRL_ZENDESK_SUBDOMAIN="ngenix1612197338" -ACTRL_API_EMAIL="stepanenko_olga@mail.ru" -ACTRL_API_TOKEN="X1x4QeNa4xRdul2rTIKhac98AsXMwd5bOGAyZOtU" - diff --git a/README.md b/README.md index 51a76ef..cca37dd 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,6 @@ docker run -d -p 8000:8000 \ Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). ## Для проверки pylint используем: -pylint --load-plugins pylint_django --disable=E5110,C0415 ../access_controller pylint ../access_controller ## Для приведения файлов к стандарту PEP8 используем: diff --git a/data.json b/data.json index a4310a4..97678f3 100644 --- a/data.json +++ b/data.json @@ -1,7 +1,7 @@ [ { "model": "auth.user", - "pk": 1, + "pk": 3, "fields": { "password": "pbkdf2_sha256$216000$gHBBCr1jBELf$ZkEDW3IEd8Wij7u8vkv+0Eze32CS01bcaYWhcD9OIC4=", "last_login": null, @@ -19,16 +19,16 @@ }, { "model": "main.userprofile", - "pk": 1, + "pk": 3, "fields": { "name": "ZendeskAdmin", - "user": 1, + "user": 3, "role": "admin" } }, { "model": "auth.user", - "pk": 2, + "pk": 4, "fields": { "password": "pbkdf2_sha256$216000$5qLJgrm2Quq9$KDBNNymVZXkUx0HKBPFst2m83kLe0egPBnkW7KnkORU=", "last_login": null, @@ -46,10 +46,10 @@ }, { "model": "main.userprofile", - "pk": 2, + "pk": 4, "fields": { "name": "UserForAccessTest", - "user": 2, + "user": 4, "role": "agent", "custom_role_id": "360005209000" } diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 1582561..4ccad53 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -104,4 +104,5 @@ db юзерами Read Zenpy +залогинен diff --git a/main/views.py b/main/views.py index dc0c65a..7c58221 100644 --- a/main/views.py +++ b/main/views.py @@ -1,9 +1,5 @@ -""" -Основной функционал приложения. -""" - from smtplib import SMTPException -from typing import Dict, Any +from typing import Dict, Any, Optional from django.contrib import messages from django.contrib.auth.decorators import login_required @@ -17,7 +13,7 @@ from django.contrib.messages.views import SuccessMessageMixin from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponseRedirect, HttpResponse from django.shortcuts import render, redirect -from django.urls import reverse_lazy, reverse +from django.urls import reverse_lazy from django.views.generic import FormView from django_registration.views import RegistrationView # Django REST @@ -34,8 +30,18 @@ from .models import UserProfile def setup_context(profile_lit: bool = False, control_lit: bool = False, work_lit: bool = False, - registration_lit: bool = False, login_lit: bool = False, - stats_lit: bool = False) -> Dict[str, Any]: + registration_lit: bool = False, login_lit: bool = False, stats_lit: bool = False) -> Dict[str, Any]: + """ + Функция добавления в контекст статуса пользователя. + + :param profile_lit: True, при создании профиля пользователя, иначе False + :param control_lit: False + :param work_lit: True, при установке пользователю рабочей роли, иначе False + :param registration_lit: True, при регистрации пользователя, иначе False + :param login_lit: True, если пользователь залогинен, иначе False + :param stats_lit: True, при получении пользователем прав администратора (просмотр статистики), иначе False + :return: Контекст (context) + """ context = { 'profile_lit': profile_lit, @@ -72,7 +78,7 @@ class CustomRegistrationView(RegistrationView): } redirect_url = 'done' - def register(self, form: CustomRegistrationForm) -> None: + def register(self, form: CustomRegistrationForm) -> Optional[User]: """ Функция регистрации пользователя. 1. Ввод email пользователя, указанный на Zendesk @@ -81,7 +87,7 @@ class CustomRegistrationView(RegistrationView): 3. Создается пользователь class User, а также его профиль. :param form: Email пользователя на Zendesk - :return: user + :return: User """ self.redirect_url = 'done' if check_user_exist(form.data['email']) and get_user_organization(form.data['email']) == 'SYSTEM': @@ -109,10 +115,12 @@ class CustomRegistrationView(RegistrationView): return user except SMTPException: self.redirect_url = 'email_sending_error' + return None else: raise ValueError('Непредвиденная ошибка') else: self.redirect_url = 'invalid_zendesk_email' + return None @staticmethod def set_permission(user: User) -> None: @@ -176,8 +184,7 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse: :param request: объект пользователя :param id: id пользователя, используется для динамической адресации - :return: адресация на страницу "Управления правами" - (либо на страницу "Авторизации", если id и user.id не совпадают) + :return: адресация на страницу "Управления правами" (либо на страницу "Авторизации", если id и user.id не совпадают """ users = get_users_list() if request.user.id == id: @@ -213,7 +220,7 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse: @login_required() def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: """ - Функция позволяет текущему пользователю сдать права, а именно сменить в Zendesk роль с "engineer" на "light_agent". + Функция позволяет текущему пользователю сдать права, а именно сменить в Zendesk роль с "engineer" на "light_agent" :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли @@ -225,13 +232,12 @@ def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: @login_required() def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: """ - Функция позволяет текущему пользователю получить права, а именно сменить в Zendesk роль с "light_agent" на - "engineer". + Функция позволяет текущему пользователю получить права, а именно сменить в Zendesk роль с "light_agent" + на "engineer". :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли """ - make_engineer(request.user.userprofile, request.user) return set_session_params_for_work_page(request) @@ -331,12 +337,24 @@ class CustomLoginView(LoginView): class UsersViewSet(viewsets.ReadOnlyModelViewSet): """ - Класс для получения пользователей с помощью api + Класс для получения пользователей с помощью api. + + :param queryset: Список пользователей с ролью 'agent' + :type queryset: :class:`str` + :param serializer_class: Класс сериализатор для модели профиля пользователя + :type serializer_class :class:`ProfileSerializer` """ queryset = UserProfile.objects.filter(role='agent') serializer_class = ProfileSerializer - def list(self, request, *args, **kwargs): + def list(self, request: WSGIRequest, *args, **kwargs) -> Response: + """ + Функция возвращает список пользователей, список пользователей Zendesk, количество engineers и light-agents. + :param request: Запрос + :param args: Аргументы + :param kwargs: Параметры + :return: Список пользователей + """ users = update_users_in_model() count = count_users(users.values) profiles = UserProfile.objects.filter(role='agent') @@ -351,7 +369,13 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): return Response(res) @staticmethod - def choose_users(zendesk, model) -> list: + def choose_users(zendesk: list, model: list) -> list: + """ + Функция формирует список пользователей, которые не зарегистрированы у нас. + :param zendesk: Список пользователей Zendesk + :param model: Список пользователей (модель Userprofile) + :return: Список + """ users = [] for zendesk_user in zendesk: if zendesk_user.name not in [user.name for user in model]: @@ -359,7 +383,12 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): return users @staticmethod - def get_zendesk_users(users: list) -> ZendeskUserSerializer: + def get_zendesk_users(users: list) -> list: + """ + Получение списка пользователей Zendesk, не являющихся админами. + :param users: Список пользователей + :return: Список пользователей, не являющимися администраторами. + """ zendesk_users = ZendeskUserSerializer( data=[user for user in users if user.role != 'admin'], many=True @@ -393,8 +422,7 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: if form.is_valid(): start_date, end_date = form.cleaned_data['range_start'], form.cleaned_data['range_end'] interval, show = form.cleaned_data['interval'], form.cleaned_data['display_format'] - data = StatisticData(start_date, end_date, - form.cleaned_data['email']) + data = StatisticData(start_date, end_date, form.cleaned_data['email']) data.set_display(show) data.set_interval(interval) stats = data.get_statistic() From a7dd7d84e006847a39503ee9f327915599afd56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 28 Apr 2021 15:39:25 +0300 Subject: [PATCH 14/65] Use pylint for extra_func.py --- .pylintrc | 624 +++++++++++++++++++++++++++++++++++++++++++++ main/extra_func.py | 17 +- 2 files changed, 636 insertions(+), 5 deletions(-) create mode 100644 .pylintrc diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..efe65af --- /dev/null +++ b/.pylintrc @@ -0,0 +1,624 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10.0 + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +#pygtk.require(). +init-hook="from pylint.config import find_pylintrc; import os, sys; sys.path.append(os.path.dirname(find_pylintrc()))" + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= pylint_django + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape, + E5110, + C045, + + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +#notes-rgx= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: en (aspell), en_AU +# (aspell), en_CA (aspell), en_GB (aspell), en_US (aspell), fr_CA (myspell), +# fr_MC (myspell), fr_CH (myspell), fr_LU (myspell), fr_FR (myspell), fr_BE +# (myspell), de_DE (myspell), es_VE (myspell), es_MX (myspell), es_CL +# (myspell), es_CR (myspell), es_US (myspell), it_CH (myspell), pt_BR +# (myspell), es_DO (myspell), en_ZA (myspell), es_PY (myspell), es_GT +# (myspell), es_CU (myspell), es_SV (myspell), es_PE (myspell), es_CO +# (myspell), de_CH (myspell), ru_RU (myspell), es_NI (myspell), es_ES +# (myspell), es_HN (myspell), it_IT (myspell), pt_PT (myspell), de_DE_frami +# (myspell), es_AR (myspell), de_CH_frami (myspell), es_PR (myspell), es_UY +# (myspell), de_AT_frami (myspell), de_AT (myspell), es_PA (myspell), fr +# (myspell), es_EC (myspell), es_BO (myspell). +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear and the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/main/extra_func.py b/main/extra_func.py index 2e759b2..8d2ef91 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,3 +1,6 @@ +""" +Вспомогательные функции со списками пользователей, статистикой и т.д. +""" import logging from datetime import timedelta, datetime, date from typing import Optional, Union @@ -44,7 +47,7 @@ def make_engineer(user_profile: UserProfile, who_changes: User) -> None: update_role(user_profile, ROLES['engineer']) -def make_light_agent(user_profile: UserProfile) -> None: +def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: """ Функция устанавливает пользователю роль легкого агента. @@ -66,7 +69,6 @@ def make_light_agent(user_profile: UserProfile) -> None: else: ticket.assignee = None ticket.group_id = zenpy.buffer_group_id - zenpy.admin.tickets.update(tickets.values) attempts, success = 5, False @@ -405,8 +407,7 @@ class StatisticData: def _init_data(self) -> None: """ - Функция возвращает логи в диапазоне дат - start_date - end_date для пользователя с указанным email. + Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. :return: Данные о смене статусов пользователя. Если пользователь не найден или интервал времени некорректен - ошибку. @@ -434,7 +435,7 @@ class StatisticData: self.clear_statistic() if not self.get_data(): self.warnings += ['Не обнаружены изменения роли в данном промежутке'] - return None + return first_log, last_log = self.data[0], self.data[len(self.data) - 1] if first_log.old_role == ROLES['engineer']: @@ -518,6 +519,9 @@ class StatisticData: class DatabaseHandler(logging.Handler): + """ + Класс записи изменений ролей в базу данных. + """ def __init__(self): logging.Handler.__init__(self) @@ -547,6 +551,9 @@ class DatabaseHandler(logging.Handler): class CsvFormatter(logging.Formatter): + """ + Класс преобразования смены ролей пользователей в строковый формат. + """ def __init__(self): logging.Formatter.__init__(self) From e5460d90fc3c572cbd593ce35b5a7098c70a9135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 28 Apr 2021 16:18:55 +0300 Subject: [PATCH 15/65] Use pylint for models.py --- main/extra_func.py | 26 +++++++++++--------------- main/models.py | 41 +++++++++++++++++++++++++++++++---------- main/views.py | 5 +++++ 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 8d2ef91..f215544 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -107,8 +107,7 @@ def update_profile(user_profile: UserProfile) -> None: Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. :param user_profile: Профиль пользователя - :return: Обновленный, в соответствие с текущими данными - в Zendesk, профиль пользователя + :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя """ user = zenpy.get_user(user_profile.user.email) user_profile.name = user.name @@ -142,8 +141,7 @@ def check_user_auth(email: str, password: str) -> bool: """ Функция проверяет, верны ли входные данные. - :raise: :class:`APIException`: исключение, - вызываемое если пользователь не аутентифицирован + :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован """ creds = { 'email': email, @@ -176,7 +174,7 @@ def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser) -> None: def count_users(users: list) -> tuple: """ - Функция подсчета количества сотрудников с ролями engineer и light_agent + Функция подсчета количества сотрудников с ролями engineer и light_agent. """ engineers, light_agents = 0, 0 for user in users: @@ -189,7 +187,7 @@ def count_users(users: list) -> tuple: def update_users_in_model() -> list: """ - Обновляет пользователей в модели UserProfile по списку пользователей в организации + Обновляет пользователей в модели UserProfile по списку пользователей в организации. """ users = get_users_list() for user in users: @@ -217,10 +215,8 @@ def daterange(start_date: timedelta, end_date: timedelta) -> list: def get_timedelta(log: RoleChangeLogs, time: timedelta = None) -> timedelta: """ - Функция возвращает объект класса timedelta, - который хранит промежуток времени от начала суток до момента, - который находится в log (объект класса RoleChangeLogs) - или в time(datetime.time), если введён. + Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, + который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. :param log: Лог :param time: Время @@ -427,8 +423,7 @@ class StatisticData: def _init_statistic(self) -> None: """ - Функция заполняет словарь, в котором ключ - дата, - значение - кол-во проработанных в этот день секунд. + Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. :return: Статистика работы пользователя (statistic) """ @@ -559,7 +554,8 @@ class CsvFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: """ - Функция форматирует запись смены роли пользователя в строку + Функция форматирует запись смены роли пользователя в строку. + :param record: Запись смены роли пользователя. :return: Строка с записью смены пользователя. """ @@ -606,8 +602,8 @@ def log(user: User, admin: int = 0) -> None: def set_session_params_for_work_page(request: WSGIRequest, count: int = None, is_confirm: bool = True) -> \ Union[HttpResponsePermanentRedirect, HttpResponseRedirect]: """ - Функция для страницы получения прав, устанавливает данные сессии - о успешности запроса и количестве назначенных тикетов. + Функция для страницы получения прав, устанавливает данные сессии о успешности запроса и количестве + назначенных тикетов. :param request: Получение данных с рабочей страницы пользователя :param count: Количество запрошенных тикетов diff --git a/main/models.py b/main/models.py index 35daa68..fcde327 100644 --- a/main/models.py +++ b/main/models.py @@ -1,3 +1,8 @@ +""" +Модели, использующиеся в приложении. +""" + + from django.db import models from django.contrib.auth.models import User from django.db.models.signals import post_save @@ -26,7 +31,12 @@ class UserProfile(models.Model): name = models.CharField(default='None', max_length=100, help_text='Имя пользователя на нашем сайте') @property - def zendesk_role(self): + def zendesk_role(self) -> str: + """ + Функция возвращает роль пользователя в Zendesk в формате str, либо UNDERFINED, если пользователь не найден. + + :return: Роль пользователя в Zendesk + """ id = self.custom_role_id for role, r_id in ZENDESK_ROLES.items(): if r_id == id: @@ -35,13 +45,28 @@ class UserProfile(models.Model): @receiver(post_save, sender=User) -def create_user_profile(sender, instance, created, **kwargs): +def create_user_profile(instance, created, **kwargs) -> None: + """ + Функция создания профиля пользователя (Userprofile) при регистрации пользователя. + + :param instance: Экземпляр класса User + :param created: Создание профиля пользователя + :param kwargs: Параметры + :return: Обновленный список объектов профилей пользователей + """ if created: UserProfile.objects.create(user=instance) @receiver(post_save, sender=User) -def save_user_profile(sender, instance, **kwargs): +def save_user_profile(instance, **kwargs) -> None: + """ + Функция записи БД профиля пользователя. + + :param instance: Экземпляр класса User + :param kwargs: Параметры + :return: Запись профиля пользователя + """ instance.userprofile.save() @@ -49,16 +74,12 @@ class RoleChangeLogs(models.Model): """ Модель для логирования изменений ролей пользователя. """ - user = models.ForeignKey(to=User, - on_delete=models.CASCADE, + 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( - default=timezone.now, help_text='Дата и время изменения роли') - changed_by = models.ForeignKey(to=User, - on_delete=models.CASCADE, - related_name='changed_by', + 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='Кем была изменена роль') diff --git a/main/views.py b/main/views.py index 7c58221..15f393e 100644 --- a/main/views.py +++ b/main/views.py @@ -1,3 +1,8 @@ +""" +View функции. +""" + + from smtplib import SMTPException from typing import Dict, Any, Optional From 4666570177d748dd0971c93418feb30acf09d5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 28 Apr 2021 16:42:19 +0300 Subject: [PATCH 16/65] Use pylint for zendesk_admin.py --- .pylintrc | 5 ++++- main/zendesk_admin.py | 11 +++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.pylintrc b/.pylintrc index efe65af..49866c6 100644 --- a/.pylintrc +++ b/.pylintrc @@ -145,7 +145,7 @@ disable=print-statement, exception-escape, comprehension-escape, E5110, - C045, + C0415, # Enable the message, report, category or checker with the given id(s). You can @@ -261,6 +261,9 @@ function-naming-style=snake_case # Good variable names which should always be accepted, separated by a comma. good-names=i, + id, + e, + n, j, k, ex, diff --git a/main/zendesk_admin.py b/main/zendesk_admin.py index 9d83109..6e2a5b3 100644 --- a/main/zendesk_admin.py +++ b/main/zendesk_admin.py @@ -1,3 +1,7 @@ +""" +Функционал работы администратора Zendesk. +""" + from typing import Optional, Dict from zenpy import Zenpy @@ -35,7 +39,7 @@ class ZendeskAdmin: :param email: Email пользователя :return: Является ли зарегистрированным """ - return True if self.admin.search(email, type='user') else False + return bool(self.admin.search(email, type='user')) def get_user(self, email: str) -> ZenpyUser: """ @@ -85,9 +89,8 @@ class ZendeskAdmin: admin = Zenpy(**self.credentials) try: admin.search(self.credentials['email'], type='user') - except APIException: - raise ValueError('invalid access_controller`s login data') - + except APIException as invalid_data: + raise ValueError('invalid access_controller`s login data') from invalid_data return admin From 2e3e567d2d7d2cb441373f26d9cae7e0afe5fb8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 28 Apr 2021 18:57:36 +0300 Subject: [PATCH 17/65] Use pylint for some files --- access_controller/asgi.py | 5 +---- access_controller/urls.py | 1 - main/admin.py | 7 ++++++- main/apiauth.py | 5 +++-- main/forms.py | 3 +++ main/serializers.py | 14 +++++++++++--- main/tests.py | 7 +++++-- main/urls.py | 4 ++++ 8 files changed, 33 insertions(+), 13 deletions(-) diff --git a/access_controller/asgi.py b/access_controller/asgi.py index 11dc22e..824ff57 100644 --- a/access_controller/asgi.py +++ b/access_controller/asgi.py @@ -8,10 +8,7 @@ https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ """ import os - - - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') from django.core.asgi import get_asgi_application +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') application = get_asgi_application() diff --git a/access_controller/urls.py b/access_controller/urls.py index 63dc19f..11e9fc7 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -14,7 +14,6 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.contrib.auth import views from django.urls import path, include from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView, registration_error diff --git a/main/admin.py b/main/admin.py index 8c38f3f..b1e5a2d 100644 --- a/main/admin.py +++ b/main/admin.py @@ -1,3 +1,8 @@ -from django.contrib import admin +""" +Встроенный файл +""" + + +# from django.contrib import admin # Register your models here. diff --git a/main/apiauth.py b/main/apiauth.py index 08a018c..b6488ba 100644 --- a/main/apiauth.py +++ b/main/apiauth.py @@ -1,5 +1,6 @@ -import os - +""" +Авторизация по Zenpy. +""" from zenpy import Zenpy from zenpy.lib.api_objects import User as ZenpyUser diff --git a/main/forms.py b/main/forms.py index 36d2e88..5666255 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,3 +1,6 @@ +""" +Формы. +""" from django import forms from django.contrib.auth.forms import AuthenticationForm from django_registration.forms import RegistrationFormUniqueEmail diff --git a/main/serializers.py b/main/serializers.py index 5ed3138..e72dc7a 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -1,3 +1,6 @@ +""" +Сериализаторы. +""" from django.contrib.auth.models import User from rest_framework import serializers from main.models import UserProfile @@ -61,7 +64,12 @@ class ZendeskUserSerializer(serializers.Serializer): """ if obj.custom_role_id == ZENDESK_ROLES['engineer']: return 'engineer' - elif obj.custom_role_id == ZENDESK_ROLES['light_agent']: + if obj.custom_role_id == ZENDESK_ROLES['light_agent']: return 'light_agent' - else: - return "empty" + return "empty" + + def create(self, validated_data): + pass + + def update(self, instance, validated_data): + pass diff --git a/main/tests.py b/main/tests.py index b733ed1..4a18882 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,2 +1,5 @@ -from django.test import TestCase, Client -import access_controller.settings as sets +""" +Тесты. +""" +# from django.test import TestCase, Client +# import access_controller.settings as sets diff --git a/main/urls.py b/main/urls.py index fffe11d..5c55d9d 100644 --- a/main/urls.py +++ b/main/urls.py @@ -1,3 +1,7 @@ +""" +REST framework adds support for automatic URL routing to Django. +""" + from rest_framework.routers import DefaultRouter from main.views import UsersViewSet From cc83f292dec377a83d74386ac135000294c25f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 28 Apr 2021 19:36:21 +0300 Subject: [PATCH 18/65] Fix autopep8 problem --- .env.example | 25 +++++++++++++++++++++++++ requirements/common.txt | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..58e7b51 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +ACTRL_DEBUG=1 + +ACTRL_SECRET_KEY="v1i_fb\$_jf2#1v_lcsbu&eon4u-os0^px=s^iycegdycqy&5)6" +ACTRL_HOST="actrl.example.com" + +ACTRL_EMAIL_HOST="smtp.mail.ru" +ACTRL_EMAIL_PORT=2525 +ACTRL_EMAIL_TLS=1 +ACTRL_EMAIL_HOST_USER="djgr.02@mail.ru" +ACTRL_EMAIL_HOST_PASSWORD="djangogroup02" +ACTRL_FROM_EMAIL="djgr.02@mail.ru" +ACTRL_SERVER_EMAIL="djgr.02@mail.ru" + +ENG_CROLE_ID=360005209000 +LA_CROLE_ID=360005208980 +EMPL_GROUP="Поддержка" +BUF_GROUP="Сменная группа" +ST_EMAIL="d.krikov@ngenix.net" +LICENSE_NO=3 +SHIFTH=12 + +ACTRL_ZENDESK_SUBDOMAIN="ngenix1612197338" +ACTRL_API_EMAIL="stepanenko_olga@mail.ru" +ACTRL_API_TOKEN="X1x4QeNa4xRdul2rTIKhac98AsXMwd5bOGAyZOtU" + diff --git a/requirements/common.txt b/requirements/common.txt index 58befa7..69294d4 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -17,4 +17,4 @@ sphinxcontrib-spelling==7.1.0 m2r == 0.2.1 pylint == 2.8.2 pylint-django == 2.4.4 -autopep8 = 1.5.6 +autopep8 == 1.5.6 From 3155374185b37a75e4ab172a1969b738ffb2140e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 28 Apr 2021 21:17:41 +0300 Subject: [PATCH 19/65] Spell new docs --- README.md | 15 +++--- docs/source/conf.py | 17 +++---- docs/source/spelling_wordlist.txt | 84 +++++++++++++++++++++++++++++++ main/views.py | 2 +- 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 154ca60..8838171 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# ZenDesk Access Controller - ## Управление правами доступа Идея - написать программу(Web приложение), которая будет выдавать права пользователям системы по запросу самого @@ -39,7 +37,7 @@ ## Quickstart -Перед запуском требуется неообходимо `.env` файл. +Перед запуском необходимо создать `.env` файл. ```bash cp .env.example .env ``` @@ -71,7 +69,7 @@ pip install -r requirements/dev.txt ``` ACTRL_DEBUG={0/1} - включить режим дебага ACTRL_HOST={HOSTNAME} - при запуске без дебага, надо указать домен на котором будет работать приложение -ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} - секретный ключ сгенерированый Django +ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} - секретный ключ сгенерированный Django ACTRL_EMAIL_HOST={SMTP_HOST} - домен почтового сервера через который приложение будет отправлять письма, например "smtp.gmail.com" ACTRL_EMAIL_PORT={SMTP_PORT} - порт для почтового сервера, например 587, 465 , 2525 @@ -133,7 +131,7 @@ docker run -d -p 8000:8000 \ -v {ABSOLUTE_PATH_TO_DB}:/zendesk-access-controller/db \ access_controller:latest ``` -- открываем запущеный контейнер в браузере (можно перейти по ссылке http://localhost:8000/) +- открываем запущенный контейнер в браузере (можно перейти по ссылке http://localhost:8000/) ## Запуск с тестовыми юзерами: @@ -144,7 +142,7 @@ docker run -d -p 8000:8000 \ - Пользователь - `123@test.ru` / `zendeskuser` Не сработает если домен песочницы отличается от `ngenix1612197338` (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента -с этими же почтами, назначить им организацию `SYSTEM`) +с этими же email, назначить им организацию `SYSTEM`) ## Параметры тестовой песочницы: @@ -156,6 +154,11 @@ pylint ../access_controller ## Для приведения файлов к стандарту PEP8 используем: autopep8 --in-place +##Для проверки орфографии: +cd docs +(set -a && source ../.env && make spelling) + + ## Read more - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) - Zendesk API: [https://developer.zendesk.com/rest_api/docs/](https://developer.zendesk.com/rest_api/docs/) diff --git a/docs/source/conf.py b/docs/source/conf.py index 2bd665b..1495347 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,12 +14,11 @@ import os import sys import inspect import enchant -import m2r +import django sys.path.insert(0, os.path.abspath('../../')) -import django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') os.environ.setdefault('DJANGO_CONFIGURATION', 'Dev') @@ -117,13 +116,12 @@ def skip_queryset(app, what, name, obj, skip, options): def fix_sig(app, what, name, obj, options, signature, return_annotation): return ("", "") -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) - app.connect("autodoc-process-signature", fix_sig) - +# 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) +# app.connect("autodoc-process-signature", fix_sig) # Add any Sphinx extension module names here, as strings. They can be @@ -139,7 +137,6 @@ extensions = { 'sphinx.ext.inheritance_diagram', 'sphinx_autodoc_typehints', 'sphinxcontrib.spelling', - # 'm2r' } diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 4ccad53..ad71758 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -83,7 +83,9 @@ functions Serializer Serializers Сериализатор +Сериализаторы сериализатор +сериализатора переадресации чекбоксов админских @@ -105,4 +107,86 @@ db Read Zenpy залогинен +т +д +rolchangelogs +извеcтно +role +View +Model +type +param +rtype +return +UsersViewSet +list +engineers +agents +request +rest +framework +response +Сэндбокс +админскими +логирование +code +block +d +p +ACTRL_DEBUG +дебага +ACTRL_HOST +HOSTNAME +ACTRL_SECRET_KEY +DJANGO_SECRET_KEY +ACTRL_EMAIL_HOST +SMTP_HOST +smtp.gmail.com +ACTRL_EMAIL_PORT +SMTP_PORT +ACTRL_EMAIL_TLS +USE_TLS +TLS +ACTRL_EMAIL_HOST_USER +USERNAME +ACTRL_EMAIL_HOST_PASSWORD +PASSWORD +ACTRL_FROM_EMAIL +EMAIL +ACTRL_SERVER_EMAIL +ACTRL_API_EMAIL +ACTRL_API_PASSWORD +ACTRL_API_TOKEN +API_TOKEN +ACTRL_ZENDESK_SUBDOMAIN +DOMAIN +ENG_CROLE_ID +ENGINEER_CUSTOM_ROLE_ID +custom_role_id +LA_CROLE_ID +LIGHT_AGENT_CUSTOM_ROLE_ID +EMPL_GROUP +EMPLOYEE_GROUP_NAME +ССКС +BUF_GROUP +BUFFER_GROUP_NAME +буферной +тикетами +ST_EMAIL +SOLVED_TICKETS_EMAIL +LICENSE_NO +LICENSE_NO +SHIFTH +SHIFT_HOURS +ABSOLUTE +ABSOLUTE_PATH_TO_DB +PATH +TO +DB +latest +in +place +cd +docs +a diff --git a/main/views.py b/main/views.py index 15f393e..38e7f73 100644 --- a/main/views.py +++ b/main/views.py @@ -347,7 +347,7 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): :param queryset: Список пользователей с ролью 'agent' :type queryset: :class:`str` :param serializer_class: Класс сериализатор для модели профиля пользователя - :type serializer_class :class:`ProfileSerializer` + :type serializer_class: :class:`ProfileSerializer` """ queryset = UserProfile.objects.filter(role='agent') serializer_class = ProfileSerializer From eea8e0aab80536041fcc20de28d4a620cedaa341 Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 29 Apr 2021 20:37:38 +0300 Subject: [PATCH 20/65] Pylint improvements --- README.md | 2 +- access_controller/settings.py | 32 +++++++++++++------------------- main/extra_func.py | 10 ++++------ main/models.py | 16 +++++++--------- main/serializers.py | 4 ++-- main/views.py | 8 ++++---- 6 files changed, 31 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 154ca60..ea118fe 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ docker run -d -p 8000:8000 \ Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). ## Для проверки pylint используем: -pylint ../access_controller +pylint ## Для приведения файлов к стандарту PEP8 используем: autopep8 --in-place diff --git a/access_controller/settings.py b/access_controller/settings.py index 7b1f707..cc29c25 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -13,18 +13,16 @@ import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. -import django as django - BASE_DIR = Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -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! -DEBUG = bool(int(os.getenv('ACTRL_DEBUG',1))) +DEBUG = bool(int(os.getenv('ACTRL_DEBUG', '1'))) ALLOWED_HOSTS = [ '127.0.0.1', @@ -59,13 +57,13 @@ MIDDLEWARE = [ ROOT_URLCONF = 'access_controller.urls' EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = os.getenv('ACTRL_EMAIL_HOST','smtp.gmail.com') -EMAIL_PORT = int(os.getenv('ACTRL_EMAIL_PORT',587)) -EMAIL_USE_TLS = bool(int(os.getenv('ACTRL_EMAIL_TLS',1))) -EMAIL_HOST_USER = os.getenv('ACTRL_EMAIL_HOST_USER','group02django@gmail.com') -EMAIL_HOST_PASSWORD = os.getenv('ACTRL_EMAIL_HOST_PASSWORD','djangogroup02') -DEFAULT_FROM_EMAIL = os.getenv('ACTRL_FROM_EMAIL',EMAIL_HOST_USER) -SERVER_EMAIL = os.getenv('ACTRL_SERVER_EMAIL',EMAIL_HOST_USER) +EMAIL_HOST = os.getenv('ACTRL_EMAIL_HOST', 'smtp.gmail.com') +EMAIL_PORT = int(os.getenv('ACTRL_EMAIL_PORT', '587')) +EMAIL_USE_TLS = bool(int(os.getenv('ACTRL_EMAIL_TLS', '1'))) +EMAIL_HOST_USER = os.getenv('ACTRL_EMAIL_HOST_USER', 'group02django@gmail.com') +EMAIL_HOST_PASSWORD = os.getenv('ACTRL_EMAIL_HOST_PASSWORD', 'djangogroup02') +DEFAULT_FROM_EMAIL = os.getenv('ACTRL_FROM_EMAIL', EMAIL_HOST_USER) +SERVER_EMAIL = os.getenv('ACTRL_SERVER_EMAIL', EMAIL_HOST_USER) TEMPLATES = [ { @@ -142,8 +140,6 @@ ACCOUNT_ACTIVATION_DAYS = 7 LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' - - # Название_приложения.Название_файла.Название_класса_обработчика AUTHENTICATION_BACKENDS = [ 'access_controller.auth.EmailAuthBackend', @@ -154,8 +150,8 @@ AUTHENTICATION_BACKENDS = [ ZENDESK_ROLES = { - 'engineer': int(os.getenv('ENG_CROLE_ID',0)), - 'light_agent': int(os.getenv('LA_CROLE_ID',0)), + 'engineer': int(os.getenv('ENG_CROLE_ID', '0')), + 'light_agent': int(os.getenv('LA_CROLE_ID', '0')), } ZENDESK_GROUPS = { @@ -165,7 +161,7 @@ ZENDESK_GROUPS = { SOLVED_TICKETS_EMAIL = os.getenv('ST_EMAIL') -ZENDESK_MAX_AGENTS = int(os.getenv('LICENSE_NO',0)) +ZENDESK_MAX_AGENTS = int(os.getenv('LICENSE_NO', '0')) REST_FRAMEWORK = { # Use Django's standard `django.contrib.auth` permissions, @@ -175,11 +171,9 @@ REST_FRAMEWORK = { ] } -ONE_DAY = int(os.getenv('SHIFTH',0)) # Количество часов в 1 рабочем дне +ONE_DAY = int(os.getenv('SHIFTH', '0')) # Количество часов в 1 рабочем дне ACTRL_ZENDESK_SUBDOMAIN = os.getenv('ACTRL_ZENDESK_SUBDOMAIN') or os.getenv('ZD_DOMAIN') ACTRL_API_EMAIL = os.getenv('ACTRL_API_EMAIL') or os.getenv('ACCESS_CONTROLLER_API_EMAIL') ACTRL_API_TOKEN = os.getenv('ACTRL_API_TOKEN') or os.getenv('ACCESS_CONTROLLER_API_TOKEN') ACTRL_API_PASSWORD = os.getenv('ACTRL_API_PASSWORD') or os.getenv('ACCESS_CONTROLLER_API_PASSWORD') - - diff --git a/main/extra_func.py b/main/extra_func.py index f215544..9cbbbdf 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -36,7 +36,7 @@ def update_role(user_profile: UserProfile, role: int) -> None: zendesk.admin.users.update(user) -def make_engineer(user_profile: UserProfile, who_changes: User) -> None: +def make_engineer(user_profile: UserProfile) -> None: """ Функция устанавливает пользователю роль инженера. @@ -47,7 +47,7 @@ def make_engineer(user_profile: UserProfile, who_changes: User) -> None: update_role(user_profile, ROLES['engineer']) -def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: +def make_light_agent(user_profile: UserProfile) -> None: """ Функция устанавливает пользователю роль легкого агента. @@ -293,8 +293,7 @@ class StatisticData: stat = self._use_display(stat) stat = self._use_interval(stat) return stat - else: - return None + return None def is_valid_statistic(self) -> bool: """ @@ -336,8 +335,7 @@ class StatisticData: """ if self.is_valid_data(): return self.data - else: - return None + return None def is_valid_data(self) -> bool: """ diff --git a/main/models.py b/main/models.py index fcde327..172549f 100644 --- a/main/models.py +++ b/main/models.py @@ -1,10 +1,8 @@ """ Модели, использующиеся в приложении. """ - - +from django.contrib.auth import get_user_model from django.db import models -from django.contrib.auth.models import User from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone @@ -24,7 +22,7 @@ class UserProfile(models.Model): ('has_control_access', 'Can view admin page'), ) - user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') + user = models.OneToOneField(to=get_user_model(), on_delete=models.CASCADE, help_text='Пользователь') role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя') custom_role_id = models.IntegerField(default=0, help_text='Код роли пользователя') image = models.URLField(null=True, blank=True, help_text='Аватарка') @@ -44,7 +42,7 @@ class UserProfile(models.Model): return 'UNDEFINED' -@receiver(post_save, sender=User) +@receiver(post_save, sender=get_user_model()) def create_user_profile(instance, created, **kwargs) -> None: """ Функция создания профиля пользователя (Userprofile) при регистрации пользователя. @@ -58,7 +56,7 @@ def create_user_profile(instance, created, **kwargs) -> None: UserProfile.objects.create(user=instance) -@receiver(post_save, sender=User) +@receiver(post_save, sender=get_user_model()) def save_user_profile(instance, **kwargs) -> None: """ Функция записи БД профиля пользователя. @@ -74,12 +72,12 @@ class RoleChangeLogs(models.Model): """ Модель для логирования изменений ролей пользователя. """ - user = models.ForeignKey(to=User, on_delete=models.CASCADE, + user = models.ForeignKey(to=get_user_model(), 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(default=timezone.now, help_text='Дата и время изменения роли') - changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', + changed_by = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') @@ -106,7 +104,7 @@ class UnassignedTicket(models.Model): """ Модель не распределенного тикета. """ - assignee = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='tickets', + assignee = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name='tickets', help_text='Пользователь, с которого снят тикет') ticket_id = models.IntegerField(help_text='Номер тикера, для которого сняли ответственного') status = models.IntegerField(choices=UnassignedTicketStatus.choices, default=UnassignedTicketStatus.UNASSIGNED, diff --git a/main/serializers.py b/main/serializers.py index e72dc7a..70c4352 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -1,7 +1,7 @@ """ Сериализаторы. """ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from rest_framework import serializers from main.models import UserProfile from access_controller.settings import ZENDESK_ROLES @@ -17,7 +17,7 @@ class UserSerializer(serializers.HyperlinkedModelSerializer): :type email: :class:`list` """ class Meta: - model = User + model = get_user_model() fields = ['email'] diff --git a/main/views.py b/main/views.py index 15f393e..92fe965 100644 --- a/main/views.py +++ b/main/views.py @@ -230,7 +230,7 @@ def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли """ - make_light_agent(request.user.userprofile, request.user) + make_light_agent(request.user.userprofile) return set_session_params_for_work_page(request) @@ -243,7 +243,7 @@ def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли """ - make_engineer(request.user.userprofile, request.user) + make_engineer(request.user.userprofile) return set_session_params_for_work_page(request) @@ -317,7 +317,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM :return: Обновленный список пользователей """ for user in users: - make_engineer(user, self.request.user) + make_engineer(user) log(user, self.request.user.userprofile) def make_light_agents(self, users: list) -> None: @@ -328,7 +328,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM :return: Обновленный список пользователей """ for user in users: - make_light_agent(user, self.request.user) + make_light_agent(user) log(user, self.request.user.userprofile) From 010dff2f9ddf0a5a861efcdacac2cb0c248ee26a Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 29 Apr 2021 21:08:11 +0300 Subject: [PATCH 21/65] Fix model docs and enchant spelling tokenizer --- docs/source/conf.py | 44 ++++++++++++++++++++------------------------ main/models.py | 8 ++++++-- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 1495347..b4be21c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -16,10 +16,8 @@ import inspect import enchant import django - sys.path.insert(0, os.path.abspath('../../')) - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') os.environ.setdefault('DJANGO_CONFIGURATION', 'Dev') @@ -37,7 +35,6 @@ from django.db.models.query import QuerySet QuerySet.__repr__ = lambda self: self.__class__.__name__ - django.setup() # -- Project information ----------------------------------------------------- @@ -87,6 +84,7 @@ 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 + lines.append('') return lines @@ -113,15 +111,17 @@ def skip_queryset(app, what, name, obj, skip, options): return True return skip -def fix_sig(app, what, name, obj, options, signature, return_annotation): - return ("", "") -# 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) -# app.connect("autodoc-process-signature", fix_sig) +def fix_sig(app, what, name, obj, options, signature, return_annotation): + return "", "" + + +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) + app.connect("autodoc-process-signature", fix_sig) # Add any Sphinx extension module names here, as strings. They can be @@ -139,7 +139,6 @@ extensions = { 'sphinxcontrib.spelling', } - # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -186,17 +185,17 @@ autodoc_typehints = "description" # spell checking spelling_lang = 'ru_RU' -tokenizer_lang = 'ru_RU' -spelling_exclude_patterns=['ignored_*', '../../main/models.py'] +tokenizer_lang = 'en_US' +spelling_exclude_patterns = ['ignored_*', '../../main/models.py'] spelling_show_suggestions = True -spelling_show_whole_line=True -spelling_warning=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 +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 ---------------------------------------------- @@ -206,6 +205,3 @@ set_type_checking_flag = True typehints_fully_qualified = True always_document_param_types = True typehints_document_rtype = True - - - diff --git a/main/models.py b/main/models.py index fcde327..6515fe5 100644 --- a/main/models.py +++ b/main/models.py @@ -17,6 +17,7 @@ class UserProfile(models.Model): Модель профиля пользователя. Профиль создается и изменяется при создании и изменении модель User. + """ class Meta: @@ -33,7 +34,9 @@ class UserProfile(models.Model): @property def zendesk_role(self) -> str: """ - Функция возвращает роль пользователя в Zendesk в формате str, либо UNDERFINED, если пользователь не найден. + Функция возвращает роль пользователя в Zendesk. + + В формате str, либо UNDEFINED, если пользователь не найден :return: Роль пользователя в Zendesk """ @@ -72,7 +75,7 @@ def save_user_profile(instance, **kwargs) -> None: class RoleChangeLogs(models.Model): """ - Модель для логирования изменений ролей пользователя. + Модель для логгирования изменений ролей пользователя """ user = models.ForeignKey(to=User, on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль') @@ -105,6 +108,7 @@ class UnassignedTicketStatus(models.IntegerChoices): class UnassignedTicket(models.Model): """ Модель не распределенного тикета. + """ assignee = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='tickets', help_text='Пользователь, с которого снят тикет') From 9f0ccf23e77f4fbdf55f95a7d4b5d0f7f0e61440 Mon Sep 17 00:00:00 2001 From: Dmitriy Andreev Date: Wed, 5 May 2021 20:43:25 +0300 Subject: [PATCH 22/65] Changes data.json in local --- data.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/data.json b/data.json index 97678f3..2afb1d9 100644 --- a/data.json +++ b/data.json @@ -23,7 +23,8 @@ "fields": { "name": "ZendeskAdmin", "user": 3, - "role": "admin" + "role": "admin", + "user_id": 1 } }, { From 4041790e6feae57eb8ffae537ae11701c6c74ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 11:17:34 +0300 Subject: [PATCH 23/65] Spell new docs, create new README --- README.md | 9 ++++++++- docs/source/spelling_wordlist.txt | 1 + main/models.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8838171..4835b7e 100644 --- a/README.md +++ b/README.md @@ -152,11 +152,18 @@ docker run -d -p 8000:8000 \ pylint ../access_controller ## Для приведения файлов к стандарту PEP8 используем: -autopep8 --in-place +autopep8 --in-place filename ##Для проверки орфографии: cd docs + (set -a && source ../.env && make spelling) +##Для обновления документации: +m2r README.md + +cd docs + +(set -a && source ../.env && make html) ## Read more diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index ad71758..6292fea 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -189,4 +189,5 @@ place cd docs a +Аватарка diff --git a/main/models.py b/main/models.py index 6515fe5..ea3388a 100644 --- a/main/models.py +++ b/main/models.py @@ -112,6 +112,6 @@ class UnassignedTicket(models.Model): """ assignee = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='tickets', help_text='Пользователь, с которого снят тикет') - ticket_id = models.IntegerField(help_text='Номер тикера, для которого сняли ответственного') + ticket_id = models.IntegerField(help_text='Номер тикета, для которого сняли ответственного') status = models.IntegerField(choices=UnassignedTicketStatus.choices, default=UnassignedTicketStatus.UNASSIGNED, help_text='Статус тикета') From cd1196efb2b3722414e83680297c96ba13d562e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 11:26:22 +0300 Subject: [PATCH 24/65] Merge with develop --- README.rst | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 README.rst diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..7a4e6ec --- /dev/null +++ b/README.rst @@ -0,0 +1,201 @@ + +Управление правами доступа +-------------------------- + +Идея - написать программу(Web приложение), которая будет выдавать права пользователям системы по запросу самого +пользователя. Например, из 12 человек 3 сейчас работают с правами админа, по окончании рабочей смены они сдают +свои права (освобождают места) и другие пользователи могут запросить эти права в свое пользование. + +Оставшиеся 9 человек получают права легкого агента - без прав редактирования, а только чтение. + +Из технологий - программа должна взаимодействовать с api системы Zendesk(система обращений клиентов - жалобы), +проверять авторизованного пользователя на права(будет возможность менять права напрямую из Zendesk - нужна +синхронизация прав с приоритетом у Zendesk). + +Если руками в самом Zendesk права у пользователя отобрали или наоборот +присвоили, то наша программа обновляет статус пользователя в соответствии с данными синхронизации +(например, раз в минуту). + +Так же в идеале должна быть проверка, что пользователь сайта существует на сайте Zendesk(по токену). + +Сэндбокс Zendesk нам предоставит моя компания, библиотеку для работы с api уже подсказали. +Сама программа (наша) будет обладать админскими правами и реализовывать контроль и выдачу прав другим пользователям. + +*Итого:* + + +#. Реализовать авторизацию пользователей с проверкой по API на существование такого пользователя +#. Реализовать интерфейс со статистикой рабочих мест(занято, свободно, кто занимает) +#. Реализовать логирование действий(когда взял права, когда отдал - запись в файл и БД) +#. Реализовать передачу прав приложением по запросу от пользователя и замену прав пользователя + у которого права отбираются внутри Zendesk (на легкий агент) +#. Реализовать синхронизацию по API на проверку прав(не менялись ли в системе Zendesk) +#. Реализовать возможность добавить большее количество админских прав +#. Реализовать возможность добавления легких агентов(права только на просмотр) +#. Реализовать на общей странице текущую информацию о пользователе - текущие права, карточка пользователя + +Технологический стек: +--------------------- + + +* Python 3 +* Django 3 + +Quickstart +---------- + +Перед запуском необходимо создать ``.env`` файл. + +.. code-block:: bash + + cp .env.example .env + +Заменить переменные в ``.env`` на актуальные. + +.. code-block:: bash + + sudo apt install make + pip install --upgrade pip + pip install -r requirements/dev.txt + (set -a && source .env && ./manage.py migrate) + (set -a && source .env && ./manage.py loaddata data.json) + (set -a && source .env && ./manage.py runserver) + +Перед запуском для тестирования: +-------------------------------- + +Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация ``SYSTEM`` +Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk +При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение + +Запуск на локальной машине: +--------------------------- + + +* Скопировать репозиторий на локальную машину +* Перейти в папку приложения +* Активировать виртуальное окружение +* Выполнить команду ``pip install -r requirements/dev.txt`` +* В виртуальное окружение добавить следующие переменные: + +.. code-block:: + + ACTRL_DEBUG={0/1} - включить режим дебага + ACTRL_HOST={HOSTNAME} - при запуске без дебага, надо указать домен на котором будет работать приложение + ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} - секретный ключ сгенерированный Django + + ACTRL_EMAIL_HOST={SMTP_HOST} - домен почтового сервера через который приложение будет отправлять письма, например "smtp.gmail.com" + ACTRL_EMAIL_PORT={SMTP_PORT} - порт для почтового сервера, например 587, 465 , 2525 + ACTRL_EMAIL_TLS={USE_TLS} - использовать TLS для подключения к почтовому серверу, 0 или 1 + ACTRL_EMAIL_HOST_USER={USERNAME} - логин с которым приложение входит на почтовый сервер + ACTRL_EMAIL_HOST_PASSWORD={PASSWORD} - пароль/ключ с которым приложение входит на почтовый сервер + ACTRL_FROM_EMAIL={EMAIL} - адрес с которого приложение отправляет письма + ACTRL_SERVER_EMAIL={EMAIL} - адрес на который отвечают пользователя + + ACTRL_API_EMAIL={EMAIL} - почта админа в ZenDesk + ACTRL_API_PASSWORD={PASSWORD} - пароль админа ZenDesk + ACTRL_API_TOKEN={API_TOKEN} - API токен зендеск + ACTRL_ZENDESK_SUBDOMAIN={DOMAIN} - домен ZenDesk + + ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID} - id роли инженера( custom_role_id сотрдника смены) + LA_CROLE_ID={LIGHT_AGENT_CUSTOM_ROLE_ID} - id роли легкого агента (custom_role_id роли -легкий агент) + EMPL_GROUP={EMPLOYEE_GROUP_NAME} - имя группы которой принадлежат сотрудники ССКС + BUF_GROUP={BUFFER_GROUP_NAME} - имя буферной группы для передачи смен(через нее происходит управление тикетами) + ST_EMAIL={SOLVED_TICKETS_EMAIL} - почта на которую будут переназначятся закрытые тикеты + LICENSE_NO={LICENSE_NO} - количество лицензий, отображаемых как доступные в приложении + SHIFTH={SHIFT_HOURS} - количество часов в рабочей смене (нужно для статистики, пока не реализовано но требует указания значения) + + +* Выполнить команду ``python manage.py migrate`` +* Запустить приложение командой ``python manage.py runserver`` (можно указать в параметрах для файла manage.py) +* Перейти по ссылке в консоли (вероятнее всего откроется по адресу http://127.0.0.1:8000/) + +Запуск в Docker: +---------------- + +Требуется установленный и настроенный Docker + + +* Скопировать репозиторий на локальную машину +* В командной строке перейти в папку проекта +* Выполнить команду ``docker build --tag access_controller:latest .`` +* Выполнить команду + .. code-block:: bash + + docker run -d -p 8000:8000 \ + ACTRL_DEBUG={0/1} \ + ACTRL_HOST={HOSTNAME} \ + ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} \ + ACTRL_EMAIL_HOST={SMTP_HOST} \ + ACTRL_EMAIL_PORT={SMTP_PORT} \ + ACTRL_EMAIL_TLS={USE_TLS} \ + ACTRL_EMAIL_HOST_USER={USERNAME} \ + ACTRL_EMAIL_HOST_PASSWORD={PASSWORD} \ + ACTRL_FROM_EMAIL={EMAIL} \ + ACTRL_SERVER_EMAIL={EMAIL} \ + ACTRL_API_EMAIL={EMAIL} \ + ACTRL_API_PASSWORD={PASSWORD} \ + ACTRL_API_TOKEN={API_TOKEN} \ + ACTRL_ZENDESK_SUBDOMAIN={DOMAIN} \ + ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID} \ + LA_CROLE_ID={LIGHT_AGENT_CUSTOM_ROLE_ID} \ + EMPL_GROUP={EMPLOYEE_GROUP_NAME} \ + BUF_GROUP={BUFFER_GROUP_NAME} \ + ST_EMAIL={SOLVED_TICKETS_EMAIL} \ + LICENSE_NO={LICENSE_NO} \ + SHIFTH={SHIFT_HOURS} \ + -v {ABSOLUTE_PATH_TO_DB}:/zendesk-access-controller/db \ + access_controller:latest + +* открываем запущенный контейнер в браузере (можно перейти по ссылке http://localhost:8000/) + +Запуск с тестовыми юзерами: +--------------------------- + +На локальной машине - перед запуском команды ``python manage.py runserver`` выполнить команду ``python manage.py loaddata data.json`` +Это создаст тестового админа и тестового пользователя в приложении для песочницы ZenDesk. + + +* Админ - ``admin@gmail.com`` / ``zendeskadmin`` +* Пользователь - ``123@test.ru`` / ``zendeskuser`` + +Не сработает если домен песочницы отличается от ``ngenix1612197338`` (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента +с этими же email, назначить им организацию ``SYSTEM``\ ) + +Параметры тестовой песочницы: +----------------------------- + +Пример полной конфигурации можно найти в `.env.example <.env.example>`_. Почту и токен админа ZenDesk взять у руководителя (если вы не админ). + +Для проверки pylint используем: +------------------------------- + +pylint ../access_controller + +Для приведения файлов к стандарту PEP8 используем: +-------------------------------------------------- + +autopep8 --in-place filename + +Для проверки орфографии: +------------------------ + +cd docs + +(set -a && source ../.env && make spelling) + +Для обновления документации: +---------------------------- + +m2r README.md + +cd docs + +(set -a && source ../.env && make html) + +Read more +--------- + + +* Zenpy: `http://docs.facetoe.com.au `_ +* Zendesk API: `https://developer.zendesk.com/rest_api/docs/ `_ From ab275fe3f8cc76ac58298b290624595c52b235ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 11:56:11 +0300 Subject: [PATCH 25/65] Remove second argument from light_agent and make_engineer functions --- main/extra_func.py | 14 ++++++-------- main/views.py | 8 ++++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 4dfbcba..61c337b 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -21,13 +21,12 @@ from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, Unassigne from main.zendesk_admin import zenpy -def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None: +def update_role(user_profile: UserProfile, role: int) -> None: """ Функция меняет роль пользователя. :param user_profile: Профиль пользователя :param role: Новая роль - :param who_changes: Пользователь, меняющий роль :return: Пользователь с обновленной ролью """ zendesk = zenpy @@ -35,21 +34,21 @@ def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None user.custom_role_id = role user_profile.custom_role_id = role user_profile.save() - log(user_profile, who_changes.userprofile) + log(user_profile) zendesk.admin.users.update(user) -def make_engineer(user_profile: UserProfile, who_changes: User) -> None: +def make_engineer(user_profile: UserProfile) -> None: """ Функция устанавливает пользователю роль инженера. :param user_profile: Профиль пользователя :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" """ - update_role(user_profile, ROLES['engineer'], who_changes) + update_role(user_profile, ROLES['engineer']) -def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: +def make_light_agent(user_profile: UserProfile) -> None: """ Функция устанавливает пользователю роль легкого агента. @@ -69,14 +68,13 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: else: ticket.assignee = None ticket.group_id = zenpy.buffer_group_id - if tickets.count: zenpy.admin.tickets.update(tickets.values) attempts, success = 5, False while not success and attempts != 0: try: - update_role(user_profile, ROLES['light_agent'], who_changes) + update_role(user_profile, ROLES['light_agent']) success = True except APIException as e: attempts -= 1 diff --git a/main/views.py b/main/views.py index 35fa607..8f58b70 100644 --- a/main/views.py +++ b/main/views.py @@ -230,7 +230,7 @@ def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли """ - make_light_agent(request.user.userprofile, request.user) + make_light_agent(request.user.userprofile) return set_session_params_for_work_page(request) @@ -243,7 +243,7 @@ def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли """ - make_engineer(request.user.userprofile, request.user) + make_engineer(request.user.userprofile) return set_session_params_for_work_page(request) @@ -317,7 +317,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM :return: Обновленный список пользователей """ for user in users: - make_engineer(user, self.request.user) + make_engineer(user) def make_light_agents(self, users: list) -> None: """ @@ -327,7 +327,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM :return: Обновленный список пользователей """ for user in users: - make_light_agent(user, self.request.user) + make_light_agent(user) class CustomLoginView(LoginView): From 9c9b4dd08b7f6f19080e17fbd6e58388ebb78365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 12:26:45 +0300 Subject: [PATCH 26/65] Change User to get_user_model --- access_controller/settings.py | 14 +++++++------- main/extra_func.py | 18 +++++++++--------- main/models.py | 14 +++++++------- main/serializers.py | 4 ++-- main/tests.py | 6 +++--- main/views.py | 9 +++++---- 6 files changed, 33 insertions(+), 32 deletions(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index 60367a3..37a7e63 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -24,7 +24,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = os.getenv('ACTRL_SECRET_KEY', 'empty') # 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', '1'))) ALLOWED_HOSTS = [ '127.0.0.1', @@ -60,8 +60,8 @@ ROOT_URLCONF = 'access_controller.urls' EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST = os.getenv('ACTRL_EMAIL_HOST', 'smtp.gmail.com') -EMAIL_PORT = int(os.getenv('ACTRL_EMAIL_PORT', 587)) -EMAIL_USE_TLS = bool(int(os.getenv('ACTRL_EMAIL_TLS', 1))) +EMAIL_PORT = int(os.getenv('ACTRL_EMAIL_PORT', '587')) +EMAIL_USE_TLS = bool(int(os.getenv('ACTRL_EMAIL_TLS', '1'))) EMAIL_HOST_USER = os.getenv('ACTRL_EMAIL_HOST_USER', 'group02django@gmail.com') EMAIL_HOST_PASSWORD = os.getenv('ACTRL_EMAIL_HOST_PASSWORD', 'djangogroup02') DEFAULT_FROM_EMAIL = os.getenv('ACTRL_FROM_EMAIL', EMAIL_HOST_USER) @@ -154,8 +154,8 @@ AUTHENTICATION_BACKENDS = [ ZENDESK_ROLES = { - 'engineer': int(os.getenv('ENG_CROLE_ID', 0)), - 'light_agent': int(os.getenv('LA_CROLE_ID', 0)), + 'engineer': int(os.getenv('ENG_CROLE_ID', '0')), + 'light_agent': int(os.getenv('LA_CROLE_ID', '0')), } ZENDESK_GROUPS = { @@ -165,7 +165,7 @@ ZENDESK_GROUPS = { SOLVED_TICKETS_EMAIL = os.getenv('ST_EMAIL') -ZENDESK_MAX_AGENTS = int(os.getenv('LICENSE_NO', 0)) +ZENDESK_MAX_AGENTS = int(os.getenv('LICENSE_NO', '0')) REST_FRAMEWORK = { # Use Django's standard `django.contrib.auth` permissions, @@ -175,7 +175,7 @@ REST_FRAMEWORK = { ] } -ONE_DAY = int(os.getenv('SHIFTH', 0)) # Количество часов в 1 рабочем дне +ONE_DAY = int(os.getenv('SHIFTH', '0')) # Количество часов в 1 рабочем дне ACTRL_ZENDESK_SUBDOMAIN = os.getenv('ACTRL_ZENDESK_SUBDOMAIN') or os.getenv('ZD_DOMAIN') ACTRL_API_EMAIL = os.getenv('ACTRL_API_EMAIL') or os.getenv('ACCESS_CONTROLLER_API_EMAIL') diff --git a/main/extra_func.py b/main/extra_func.py index 4c4b9c9..3c796be 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -5,7 +5,7 @@ import logging from datetime import timedelta, datetime, date from typing import Optional, Union -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponseRedirect, HttpResponsePermanentRedirect @@ -21,7 +21,7 @@ from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, Unassigne from main.zendesk_admin import zenpy -def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None: +def update_role(user_profile: UserProfile, role: int, who_changes: get_user_model()) -> None: """ Функция меняет роль пользователя. @@ -39,7 +39,7 @@ def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None zendesk.admin.users.update(user) -def make_engineer(user_profile: UserProfile, who_changes: User) -> None: +def make_engineer(user_profile: UserProfile, who_changes: get_user_model()) -> None: """ Функция устанавливает пользователю роль инженера. @@ -49,7 +49,7 @@ def make_engineer(user_profile: UserProfile, who_changes: User) -> None: update_role(user_profile, ROLES['engineer'], who_changes) -def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: +def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) -> None: """ Функция устанавливает пользователю роль легкого агента. @@ -193,7 +193,7 @@ def update_users_in_model(): users = get_users_list() for user in users: try: - profile = User.objects.get(email=user.email).userprofile + profile = get_user_model().objects.get(email=user.email).userprofile update_user_in_model(profile, user) except ObjectDoesNotExist: pass @@ -409,9 +409,9 @@ class StatisticData: try: self.data = RoleChangeLogs.objects.filter( change_time__range=[self.start_date, self.end_date + timedelta(days=1)], - user=User.objects.get(email=self.email), + user=get_user_model().objects.get(email=self.email), ).order_by('change_time') - except User.DoesNotExist: + except get_user_model().DoesNotExist: self.errors += ['Пользователь не найден'] def _init_statistic(self) -> None: @@ -475,7 +475,7 @@ class StatisticData: :param first_log: Первый лог """ - self.fill_daterange(max(User.objects.get(email=self.email).date_joined.date(), self.start_date), + self.fill_daterange(max(get_user_model().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() @@ -564,7 +564,7 @@ class CsvFormatter(logging.Formatter): return msg -def log(user: User, admin: int = 0) -> None: +def log(user: get_user_model(), admin: int = 0) -> None: """ Функция осуществляет запись логов в базу данных и csv файл. diff --git a/main/models.py b/main/models.py index fcde327..9ec53ca 100644 --- a/main/models.py +++ b/main/models.py @@ -4,7 +4,7 @@ from django.db import models -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone @@ -24,7 +24,7 @@ class UserProfile(models.Model): ('has_control_access', 'Can view admin page'), ) - user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') + user = models.OneToOneField(to=get_user_model(), on_delete=models.CASCADE, help_text='Пользователь') role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя') custom_role_id = models.IntegerField(default=0, help_text='Код роли пользователя') image = models.URLField(null=True, blank=True, help_text='Аватарка') @@ -44,7 +44,7 @@ class UserProfile(models.Model): return 'UNDEFINED' -@receiver(post_save, sender=User) +@receiver(post_save, sender=get_user_model()) def create_user_profile(instance, created, **kwargs) -> None: """ Функция создания профиля пользователя (Userprofile) при регистрации пользователя. @@ -58,7 +58,7 @@ def create_user_profile(instance, created, **kwargs) -> None: UserProfile.objects.create(user=instance) -@receiver(post_save, sender=User) +@receiver(post_save, sender=get_user_model()) def save_user_profile(instance, **kwargs) -> None: """ Функция записи БД профиля пользователя. @@ -74,12 +74,12 @@ class RoleChangeLogs(models.Model): """ Модель для логирования изменений ролей пользователя. """ - user = models.ForeignKey(to=User, on_delete=models.CASCADE, + user = models.ForeignKey(to=get_user_model(), 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(default=timezone.now, help_text='Дата и время изменения роли') - changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', + changed_by = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') @@ -106,7 +106,7 @@ class UnassignedTicket(models.Model): """ Модель не распределенного тикета. """ - assignee = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='tickets', + assignee = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name='tickets', help_text='Пользователь, с которого снят тикет') ticket_id = models.IntegerField(help_text='Номер тикера, для которого сняли ответственного') status = models.IntegerField(choices=UnassignedTicketStatus.choices, default=UnassignedTicketStatus.UNASSIGNED, diff --git a/main/serializers.py b/main/serializers.py index e72dc7a..70c4352 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -1,7 +1,7 @@ """ Сериализаторы. """ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from rest_framework import serializers from main.models import UserProfile from access_controller.settings import ZENDESK_ROLES @@ -17,7 +17,7 @@ class UserSerializer(serializers.HyperlinkedModelSerializer): :type email: :class:`list` """ class Meta: - model = User + model = get_user_model() fields = ['email'] diff --git a/main/tests.py b/main/tests.py index c06bc21..42c8542 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,6 +1,6 @@ from urllib.parse import urlparse -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core import mail from django.test import TestCase, Client from django.urls import reverse @@ -71,13 +71,13 @@ class RegistrationTestCase(TestCase): def test_registration_user_creating(self): with self.settings(EMAIL_BACKEND=self.email_backend): self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) - user = User.objects.get(email=self.any_zendesk_user_email) + user = get_user_model().objects.get(email=self.any_zendesk_user_email) zendesk_user = zenpy.get_user(self.any_zendesk_user_email) self.assertEqual(user.userprofile.name, zendesk_user.name) def test_permissions_applying(self): with self.settings(EMAIL_BACKEND=self.email_backend): self.client.post(reverse('registration'), data={'email': self.zendesk_admin_email}) - user = User.objects.get(email=self.zendesk_admin_email) + user = get_user_model().objects.get(email=self.zendesk_admin_email) self.assertEqual(user.userprofile.role, 'admin') self.assertTrue(user.has_perm('main.has_control_access')) diff --git a/main/views.py b/main/views.py index 8490a65..4a7567b 100644 --- a/main/views.py +++ b/main/views.py @@ -9,7 +9,8 @@ from typing import Dict, Any, Optional from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin -from django.contrib.auth.models import User, Permission +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.views import LoginView @@ -83,7 +84,7 @@ class CustomRegistrationView(RegistrationView): } redirect_url = 'done' - def register(self, form: CustomRegistrationForm) -> Optional[User]: + def register(self, form: CustomRegistrationForm) -> Optional[get_user_model()]: """ Функция регистрации пользователя. 1. Ввод email пользователя, указанный на Zendesk @@ -128,7 +129,7 @@ class CustomRegistrationView(RegistrationView): return None @staticmethod - def set_permission(user: User) -> None: + def set_permission(user: get_user_model()) -> None: """ Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin. @@ -142,7 +143,7 @@ class CustomRegistrationView(RegistrationView): ) user.user_permissions.add(permission) - def get_success_url(self, user: User = None) -> Dict: + def get_success_url(self, user: get_user_model() = None) -> Dict: """ Функция возвращает url-адрес страницы, куда нужно перейти после успешной/не успешной регистрации. Используется самой django-registration. From f24c94914fd310f877df2d6827e1b6c357fa467f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 12:43:44 +0300 Subject: [PATCH 27/65] Change User to get_user_model - fix problems --- access_controller/auth.py | 10 +++++----- main/extra_func.py | 6 ++---- main/views.py | 4 ++-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/access_controller/auth.py b/access_controller/auth.py index be707e1..00b43a2 100644 --- a/access_controller/auth.py +++ b/access_controller/auth.py @@ -1,19 +1,19 @@ from django.contrib.auth.backends import ModelBackend -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model class EmailAuthBackend(ModelBackend): def authenticate(self, request, username=None, password=None, **kwargs): try: - user = User.objects.get(email=username) + user = get_user_model().objects.get(email=username) if user.check_password(password): return user return None - except User.DoesNotExist: + except get_user_model().DoesNotExist: return None def get_user(self, user_id): try: - return User.objects.get(pk=user_id) - except User.DoesNotExist: + return get_user_model().objects.get(pk=user_id) + except get_user_model().DoesNotExist: return None diff --git a/main/extra_func.py b/main/extra_func.py index 3c796be..10ed367 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -292,8 +292,7 @@ class StatisticData: stat = self._use_display(stat) stat = self._use_interval(stat) return stat - else: - return None + return None def is_valid_statistic(self) -> bool: """ @@ -335,8 +334,7 @@ class StatisticData: """ if self.is_valid_data(): return self.data - else: - return None + return None def is_valid_data(self) -> bool: """ diff --git a/main/views.py b/main/views.py index 4a7567b..fe31416 100644 --- a/main/views.py +++ b/main/views.py @@ -109,10 +109,10 @@ class CustomRegistrationView(RegistrationView): 'html_email_template_name': None, 'extra_email_context': None, } - user = User.objects.create_user( + user = get_user_model().objects.create_user( username=form.data['email'], email=form.data['email'], - password=User.objects.make_random_password(length=50) + password=get_user_model().objects.make_random_password(length=50) ) try: update_profile(user.userprofile) From 276c979b73125a3ce201d7db871418e872c94554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 12:51:40 +0300 Subject: [PATCH 28/65] Change make_engineer --- main/extra_func.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 10ed367..cc8451b 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -39,17 +39,17 @@ def update_role(user_profile: UserProfile, role: int, who_changes: get_user_mode zendesk.admin.users.update(user) -def make_engineer(user_profile: UserProfile, who_changes: get_user_model()) -> None: +def make_engineer(user_profile: UserProfile) -> None: """ Функция устанавливает пользователю роль инженера. :param user_profile: Профиль пользователя :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" """ - update_role(user_profile, ROLES['engineer'], who_changes) + update_role(user_profile, ROLES['engineer']) -def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) -> None: +def make_light_agent(user_profile: UserProfile) -> None: """ Функция устанавливает пользователю роль легкого агента. @@ -76,7 +76,7 @@ def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) - attempts, success = 5, False while not success and attempts != 0: try: - update_role(user_profile, ROLES['light_agent'], who_changes) + update_role(user_profile, ROLES['light_agent']) success = True except APIException as e: attempts -= 1 From e0b855d27ad342c4e785eec8ded9280cfc69fc67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 14:37:08 +0300 Subject: [PATCH 29/65] Add ru tokenizer --- .../site-packages/enchant/tokenize/ru.py | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 venv/lib/python3.6/site-packages/enchant/tokenize/ru.py diff --git a/venv/lib/python3.6/site-packages/enchant/tokenize/ru.py b/venv/lib/python3.6/site-packages/enchant/tokenize/ru.py new file mode 100644 index 0000000..7e15379 --- /dev/null +++ b/venv/lib/python3.6/site-packages/enchant/tokenize/ru.py @@ -0,0 +1,185 @@ +# pyenchant +# +# Copyright (C) 2004-2008, Ryan Kelly +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place - Suite 330, +# Boston, MA 02111-1307, USA. +# +# In addition, as a special exception, you are +# given permission to link the code of this program with +# non-LGPL Spelling Provider libraries (eg: a MSFT Office +# spell checker backend) and distribute linked combinations including +# the two. You must obey the GNU Lesser General Public License in all +# respects for all of the code used other than said providers. If you modify +# this file, you may extend this exception to your version of the +# file, but you are not obligated to do so. If you do not wish to +# do so, delete this exception statement from your version. +# +""" + + enchant.tokenize.en: Tokenizer for the English language + + This module implements a PyEnchant text tokenizer for the English + language, based on very simple rules. + +""" + +import unicodedata + +import enchant.tokenize + + +class tokenize(enchant.tokenize.tokenize): # noqa: N801 + """Iterator splitting text into words, reporting position. + + This iterator takes a text string as input, and yields tuples + representing each distinct word found in the text. The tuples + take the form: + + (,) + + Where is the word string found and is the position + of the start of the word within the text. + + The optional argument may be used to specify a + list of additional characters that can form part of a word. + By default, this list contains only the apostrophe ('). Note that + these characters cannot appear at the start or end of a word. + """ + + _DOC_ERRORS = ["pos", "pos"] + + def __init__(self, text, valid_chars=None): + self._valid_chars = valid_chars + self._text = text + self._offset = 0 + # Select proper implementation of self._consume_alpha. + # 'text' isn't necessarily a string (it could be e.g. a mutable array) + # so we can't use isinstance(text, str) to detect unicode. + # Instead we typetest the first character of the text. + # If there's no characters then it doesn't matter what implementation + # we use since it won't be called anyway. + try: + char1 = text[0] + except IndexError: + self._initialize_for_binary() + else: + if isinstance(char1, str): + self._initialize_for_unicode() + else: + self._initialize_for_binary() + + def _initialize_for_binary(self): + self._consume_alpha = self._consume_alpha_b + if self._valid_chars is None: + self._valid_chars = ("'",) + + def _initialize_for_unicode(self): + self._consume_alpha = self._consume_alpha_u + if self._valid_chars is None: + # XXX TODO: this doesn't seem to work correctly with the + # MySpell provider, disabling for now. + # Allow unicode typographic apostrophe + # self._valid_chars = (u"'",u"\u2019") + self._valid_chars = ("'",) + + def _consume_alpha_b(self, text, offset): + """Consume an alphabetic character from the given bytestring. + + Given a bytestring and the current offset, this method returns + the number of characters occupied by the next alphabetic character + in the string. Non-ASCII bytes are interpreted as utf-8 and can + result in multiple characters being consumed. + """ + assert offset < len(text) + if text[offset].isalpha(): + return 1 + elif text[offset] >= "\x80": + return self._consume_alpha_utf8(text, offset) + return 0 + + def _consume_alpha_utf8(self, text, offset): + """Consume a sequence of utf8 bytes forming an alphabetic character.""" + incr = 2 + u = "" + while not u and incr <= 4: + try: + try: + # In the common case this will be a string + u = text[offset : offset + incr].decode("utf8") + except AttributeError: + # Looks like it was e.g. a mutable char array. + try: + s = text[offset : offset + incr].tostring() + except AttributeError: + s = "".join([c for c in text[offset : offset + incr]]) + u = s.decode("utf8") + except UnicodeDecodeError: + incr += 1 + if not u: + return 0 + if u.isalpha(): + return incr + if unicodedata.category(u)[0] == "M": + return incr + return 0 + + def _consume_alpha_u(self, text, offset): + """Consume an alphabetic character from the given unicode string. + + Given a unicode string and the current offset, this method returns + the number of characters occupied by the next alphabetic character + in the string. Trailing combining characters are consumed as a + single letter. + """ + assert offset < len(text) + incr = 0 + if text[offset].isalpha(): + incr = 1 + while offset + incr < len(text): + if unicodedata.category(text[offset + incr])[0] != "M": + break + incr += 1 + return incr + + def next(self): + text = self._text + offset = self._offset + while offset < len(text): + # Find start of next word (must be alpha) + while offset < len(text): + incr = self._consume_alpha(text, offset) + if incr: + break + offset += 1 + cur_pos = offset + # Find end of word using, allowing valid_chars + while offset < len(text): + incr = self._consume_alpha(text, offset) + if not incr: + if text[offset] in self._valid_chars: + incr = 1 + else: + break + offset += incr + # Return if word isn't empty + if cur_pos != offset: + # Make sure word doesn't end with a valid_char + while text[offset - 1] in self._valid_chars: + offset = offset - 1 + self._offset = offset + return (text[cur_pos:offset], cur_pos) + self._offset = offset + raise StopIteration() From b9cb22e2e56449d38ad868935463b39dc44786d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 14:49:45 +0300 Subject: [PATCH 30/65] Commit for lesson --- docs/source/spelling_wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 6292fea..1e9713d 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -190,4 +190,5 @@ cd docs a Аватарка +filename From bb74072c32dc724263f079e213250ec11ee2d325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 15:23:22 +0300 Subject: [PATCH 31/65] Change set_context function, using **kwargs --- main/views.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/main/views.py b/main/views.py index bdff369..e40b5e8 100644 --- a/main/views.py +++ b/main/views.py @@ -35,8 +35,7 @@ from main.serializers import ProfileSerializer, ZendeskUserSerializer from .models import UserProfile -def setup_context(profile_lit: bool = False, control_lit: bool = False, work_lit: bool = False, - registration_lit: bool = False, login_lit: bool = False, stats_lit: bool = False) -> Dict[str, Any]: +def setup_context(**kwargs) -> Dict[str, Any]: """ Функция добавления в контекст статуса пользователя. @@ -48,15 +47,12 @@ def setup_context(profile_lit: bool = False, control_lit: bool = False, work_lit :param stats_lit: True, при получении пользователем прав администратора (просмотр статистики), иначе False :return: Контекст (context) """ - - context = { - 'profile_lit': profile_lit, - 'control_lit': control_lit, - 'work_lit': work_lit, - 'registration_lit': registration_lit, - 'login_lit': login_lit, - 'stats_lit': stats_lit, - } + context = {} + for key in ('profile_lit', 'control_lit', 'work_lit', 'registration_lit', 'login_lit', 'stats_lit'): + if key in kwargs: + context.update({key: True}) + else: + context.update({key: False}) return context From 12db461b5d1c214259521b7f4395e7040c90ce29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 16:42:15 +0300 Subject: [PATCH 32/65] Pylint for views --- .pylintrc | 2 +- access_controller/urls.py | 2 +- main/extra_func.py | 2 +- main/views.py | 16 +++++++++------- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.pylintrc b/.pylintrc index 49866c6..83b9512 100644 --- a/.pylintrc +++ b/.pylintrc @@ -537,7 +537,7 @@ max-branches=12 max-locals=15 # Maximum number of parents for a class (see R0901). -max-parents=7 +max-parents=10 # Maximum number of public methods for a class (see R0904). max-public-methods=20 diff --git a/access_controller/urls.py b/access_controller/urls.py index 2cab267..b474345 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -30,7 +30,7 @@ urlpatterns = [ path('accounts/register/error/', registration_error, name='registration_email_error'), path('accounts/login/', CustomLoginView.as_view(), name='login'), path('accounts/', include('django.contrib.auth.urls')), - path('work/', work_page, name="work"), + 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('work/get_tickets', work_get_tickets, name='work_get_tickets'), diff --git a/main/extra_func.py b/main/extra_func.py index cc8451b..fbbe3f7 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -76,7 +76,7 @@ def make_light_agent(user_profile: UserProfile) -> None: attempts, success = 5, False while not success and attempts != 0: try: - update_role(user_profile, ROLES['light_agent']) + update_role(user_profile, ROLES['light_agent'], get_user_model()) success = True except APIException as e: attempts -= 1 diff --git a/main/views.py b/main/views.py index e40b5e8..72fc682 100644 --- a/main/views.py +++ b/main/views.py @@ -28,7 +28,7 @@ from rest_framework.response import Response from access_controller.settings import DEFAULT_FROM_EMAIL, ZENDESK_ROLES, ZENDESK_MAX_AGENTS 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, set_session_params_for_work_page + get_users_list, update_users_in_model, count_users, StatisticData, set_session_params_for_work_page from main.zendesk_admin import zenpy from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm from main.serializers import ProfileSerializer, ZendeskUserSerializer @@ -120,6 +120,7 @@ class CustomRegistrationView(RegistrationView): return None else: self.redirect_url = 'email_sending_error' + return None else: self.redirect_url = 'invalid_zendesk_email' return None @@ -180,7 +181,7 @@ def profile_page(request: WSGIRequest) -> HttpResponse: @login_required() -def work_page(request: WSGIRequest, id: int) -> HttpResponse: +def work_page(request: WSGIRequest, required_id: int) -> HttpResponse: """ Функция отображения страницы "Управления правами" для текущего пользователя (login_required). @@ -189,7 +190,7 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse: :return: адресация на страницу "Управления правами" (либо на страницу "Авторизации", если id и user.id не совпадают """ users = get_users_list() - if request.user.id == id: + if request.user.id == required_id: if request.session.get('is_confirm', None): messages.success(request, 'Изменения были применены') elif request.session.get('is_confirm', None) is not None: @@ -256,7 +257,7 @@ def work_get_tickets(request: WSGIRequest) -> HttpResponse: tickets = [ticket for ticket in zenpy.admin.search(type="ticket") if ticket.group.name == 'Сменная группа' and ticket.assignee is None] count = 0 - for i in range(len(tickets)): + for i in enumerate(tickets): if i == int(request.GET.get('count_tickets')): return set_session_params_for_work_page(request, count) tickets[i].assignee = zenpy_user @@ -306,7 +307,8 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM self.make_light_agents(users) return super().form_valid(form) - def make_engineers(self, users: list) -> None: + @staticmethod + def make_engineers(users: list) -> None: """ Функция проходит по списку пользователей, проставляя статус "engineer". @@ -315,8 +317,8 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM """ for user in users: make_engineer(user) - - def make_light_agents(self, users: list) -> None: + @staticmethod + def make_light_agents(users: list) -> None: """ Функция проходит по списку пользователей, проставляя статус "light agent". From 8646e668c1181851fd0624ba34185c21e5baf19e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 17:36:38 +0300 Subject: [PATCH 33/65] Pylint for extra_func --- .pylintrc | 2 +- main/extra_func.py | 20 +++++++++++--------- main/views.py | 16 ++++++++-------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.pylintrc b/.pylintrc index 83b9512..07ab0f1 100644 --- a/.pylintrc +++ b/.pylintrc @@ -525,7 +525,7 @@ spelling-store-unknown-words=no max-args=5 # Maximum number of attributes for a class (see R0902). -max-attributes=7 +max-attributes=10 # Maximum number of boolean expressions in an if statement (see R0916). max-bool-expr=5 diff --git a/main/extra_func.py b/main/extra_func.py index fbbe3f7..af1555d 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -39,17 +39,17 @@ def update_role(user_profile: UserProfile, role: int, who_changes: get_user_mode zendesk.admin.users.update(user) -def make_engineer(user_profile: UserProfile) -> None: +def make_engineer(user_profile: UserProfile, who_changes: get_user_model()) -> None: """ Функция устанавливает пользователю роль инженера. :param user_profile: Профиль пользователя :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" """ - update_role(user_profile, ROLES['engineer']) + update_role(user_profile, ROLES['engineer'], who_changes) -def make_light_agent(user_profile: UserProfile) -> None: +def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) -> None: """ Функция устанавливает пользователю роль легкого агента. @@ -76,7 +76,7 @@ def make_light_agent(user_profile: UserProfile) -> None: attempts, success = 5, False while not success and attempts != 0: try: - update_role(user_profile, ROLES['light_agent'], get_user_model()) + update_role(user_profile, ROLES['light_agent'], who_changes) success = True except APIException as e: attempts -= 1 @@ -214,17 +214,17 @@ def daterange(start_date: timedelta, end_date: timedelta) -> list: return dates -def get_timedelta(log: RoleChangeLogs, time: timedelta = None) -> timedelta: +def get_timedelta(current_log: RoleChangeLogs, time: timedelta = None) -> timedelta: """ Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. - :param log: Лог + :param current_log: Лог :param time: Время :return: Сколько времени прошло от начала суток до события """ if time is None: - time = log.change_time.time() + time = current_log.change_time.time() time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) return time @@ -285,7 +285,8 @@ class StatisticData: """ Функция возвращает статистику работы пользователя. - :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). None, если были ошибки при создании. + :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). None, если были + ошибки при создании. """ if self.is_valid_statistic(): stat = self.statistic @@ -399,7 +400,8 @@ class StatisticData: """ Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. - :return: Данные о смене статусов пользователя. Если пользователь не найден или интервал времени некорректен - ошибку. + :return: Данные о смене статусов пользователя. Если пользователь не найден или интервал времени + некорректен - ошибку. """ if not self.check_time(): self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] diff --git a/main/views.py b/main/views.py index 72fc682..ffc2c68 100644 --- a/main/views.py +++ b/main/views.py @@ -228,7 +228,7 @@ def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли """ - make_light_agent(request.user.userprofile) + make_light_agent(request.user.userprofile, request.user) return set_session_params_for_work_page(request) @@ -241,7 +241,7 @@ def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли """ - make_engineer(request.user.userprofile) + make_engineer(request.user.userprofile, request.user) return set_session_params_for_work_page(request) @@ -307,8 +307,8 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM self.make_light_agents(users) return super().form_valid(form) - @staticmethod - def make_engineers(users: list) -> None: + + def make_engineers(self, users: list) -> None: """ Функция проходит по списку пользователей, проставляя статус "engineer". @@ -316,9 +316,9 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM :return: Обновленный список пользователей """ for user in users: - make_engineer(user) - @staticmethod - def make_light_agents(users: list) -> None: + make_engineer(user, self.request.user) + + def make_light_agents(self, users: list) -> None: """ Функция проходит по списку пользователей, проставляя статус "light agent". @@ -326,7 +326,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM :return: Обновленный список пользователей """ for user in users: - make_light_agent(user) + make_light_agent(user, self.request.user) class CustomLoginView(LoginView): From 22d4833ae0171b39f3b0aaa5826d8ce64d1b8199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 17:53:28 +0300 Subject: [PATCH 34/65] Pylint for lesson --- README.md | 2 +- access_controller/settings.py | 4 +--- main/models.py | 3 +-- main/tests.py | 35 ++++++++++++++++++++++++++++++++--- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4835b7e..8885598 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ docker run -d -p 8000:8000 \ Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). ## Для проверки pylint используем: -pylint ../access_controller +pylint ../access_controller (каталог, где лежит проект) ## Для приведения файлов к стандарту PEP8 используем: autopep8 --in-place filename diff --git a/access_controller/settings.py b/access_controller/settings.py index 37a7e63..b1b98a9 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -13,7 +13,7 @@ import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. -import django as django + BASE_DIR = Path(__file__).resolve().parent.parent @@ -181,5 +181,3 @@ ACTRL_ZENDESK_SUBDOMAIN = os.getenv('ACTRL_ZENDESK_SUBDOMAIN') or os.getenv('ZD_ ACTRL_API_EMAIL = os.getenv('ACTRL_API_EMAIL') or os.getenv('ACCESS_CONTROLLER_API_EMAIL') ACTRL_API_TOKEN = os.getenv('ACTRL_API_TOKEN') or os.getenv('ACCESS_CONTROLLER_API_TOKEN') ACTRL_API_PASSWORD = os.getenv('ACTRL_API_PASSWORD') or os.getenv('ACCESS_CONTROLLER_API_PASSWORD') - - diff --git a/main/models.py b/main/models.py index 24db2f2..6c5563a 100644 --- a/main/models.py +++ b/main/models.py @@ -40,9 +40,8 @@ class UserProfile(models.Model): :return: Роль пользователя в Zendesk """ - id = self.custom_role_id for role, r_id in ZENDESK_ROLES.items(): - if r_id == id: + if r_id == self.custom_role_id: return role return 'UNDEFINED' diff --git a/main/tests.py b/main/tests.py index 42c8542..b36c130 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,3 +1,8 @@ +""" +Тесты. +""" + + from urllib.parse import urlparse from django.contrib.auth import get_user_model @@ -11,31 +16,49 @@ from main.zendesk_admin import zenpy class RegistrationTestCase(TestCase): + """ + Класс тестирования регистрации пользователя. + """ fixtures = ['fixtures/data.json'] - def setUp(self): + def setUp(self) -> None: + """ + Функция предтестовых настроек. + """ self.email_backend = 'django.core.mail.backends.locmem.EmailBackend' self.any_zendesk_user_email = 'idar.sokurov.05@mail.ru' self.zendesk_admin_email = 'idar.sokurov.05@mail.ru' self.client = Client() - def test_registration_complete_redirect(self): + def test_registration_complete_redirect(self) -> None: + """ + Функция тестирования успешно завершенной регистрации. + """ with self.settings(EMAIL_BACKEND=self.email_backend): resp = self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) self.assertRedirects(resp, reverse('password_reset_done')) def test_registration_fail_redirect(self): + """ + Функция тестирования неуспешной регистрации. + """ with self.settings(EMAIL_BACKEND=self.email_backend): resp = self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email + 'asd'}) self.assertRedirects(resp, reverse('django_registration_disallowed')) def test_registration_user_already_exist(self): + """ + Функция тестирования попытки регистрации уже зарегистрированного пользователя. + """ with self.settings(EMAIL_BACKEND=self.email_backend) and translation.override('ru'): resp = self.client.post(reverse('registration'), data={'email': '123@test.ru'}) self.assertContains(resp, 'Этот адрес электронной почты уже используется', count=1, status_code=200) def test_registration_email_sending(self): + """ + Функция тестирования отправки email. # TODO: Найти способ лучше проверять сообщения + """ email_template = [ '', 'Вы получили это письмо, потому что вы (или кто-то другой) запросили восстановление пароля ' @@ -62,13 +85,16 @@ class RegistrationTestCase(TestCase): self.assertEqual(mail.outbox[0].from_email, sets.DEFAULT_FROM_EMAIL) message = mail.outbox[0].body.split('\n') - for i in range(len(message)): + for i in enumerate(message): if email_template[i] != 'url': self.assertEqual(message[i], email_template[i]) else: self.assertTrue(urlparse(message[i]).scheme) def test_registration_user_creating(self): + """ + Функция тестирования регистрации пользователя (сверяем имя с именем в Zendesk. + """ with self.settings(EMAIL_BACKEND=self.email_backend): self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) user = get_user_model().objects.get(email=self.any_zendesk_user_email) @@ -76,6 +102,9 @@ class RegistrationTestCase(TestCase): self.assertEqual(user.userprofile.name, zendesk_user.name) def test_permissions_applying(self): + """ + Функция тестирования проверке присвоения роли admin. + """ with self.settings(EMAIL_BACKEND=self.email_backend): self.client.post(reverse('registration'), data={'email': self.zendesk_admin_email}) user = get_user_model().objects.get(email=self.zendesk_admin_email) From 5ea0aa945460b18b9d0c90176ab554aa4a17a942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 6 May 2021 18:17:20 +0300 Subject: [PATCH 35/65] Fix --- access_controller/settings.py | 2 -- main/extra_func.py | 23 ++++++++++++++--------- main/views.py | 1 - 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index 1619d5d..b1b98a9 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -181,5 +181,3 @@ ACTRL_ZENDESK_SUBDOMAIN = os.getenv('ACTRL_ZENDESK_SUBDOMAIN') or os.getenv('ZD_ ACTRL_API_EMAIL = os.getenv('ACTRL_API_EMAIL') or os.getenv('ACCESS_CONTROLLER_API_EMAIL') ACTRL_API_TOKEN = os.getenv('ACTRL_API_TOKEN') or os.getenv('ACCESS_CONTROLLER_API_TOKEN') ACTRL_API_PASSWORD = os.getenv('ACTRL_API_PASSWORD') or os.getenv('ACCESS_CONTROLLER_API_PASSWORD') - - diff --git a/main/extra_func.py b/main/extra_func.py index 2c7e0a4..bfa090b 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -5,7 +5,7 @@ import logging from datetime import timedelta, datetime, date from typing import Optional, Union -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponseRedirect, HttpResponsePermanentRedirect @@ -34,7 +34,6 @@ def update_role(user_profile: UserProfile, role: int, who_changes: get_user_mode user.custom_role_id = role user_profile.custom_role_id = role user_profile.save() - log(user_profile, who_changes.userprofile) zendesk.admin.users.update(user) @@ -54,7 +53,8 @@ def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) - Функция устанавливает пользователю роль легкого агента. :param user_profile: Профиль пользователя - :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" + :return: Вызов функции **update_role** с параметрами: + профиль пользователя, роль "light_agent" """ tickets: SearchResultGenerator = get_tickets_list(user_profile.user.email) ticket: ZenpyTicket @@ -62,7 +62,8 @@ def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) - UnassignedTicket.objects.create( assignee=user_profile.user, ticket_id=ticket.id, - status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED + status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' + else UnassignedTicketStatus.UNASSIGNED ) if ticket.status == 'solved': ticket.assignee_id = zenpy.solved_tickets_user_id @@ -285,8 +286,9 @@ class StatisticData: """ Функция возвращает статистику работы пользователя. - :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). None, если были - ошибки при создании. + :return: Словарь statistic с применением формата отображения + и интервала работы(если они есть). + None, если были ошибки при создании. """ if self.is_valid_statistic(): stat = self.statistic @@ -375,9 +377,12 @@ class StatisticData: if self.interval == 'months': # Переделываем ключи под формат('начало_месяца - конец_месяца') for key, value in stat.items(): - current_month_start = max(self.start_date, date(year=key.year, month=key.month, day=1)) - current_month_end = min(self.end_date, last_day_of_month(date(year=key.year, month=key.month, day=1))) - index = ' - '.join([str(current_month_start), str(current_month_end)]) + current_month_start = max(self.start_date, date( + year=key.year, month=key.month, day=1)) + current_month_end = min(self.end_date, last_day_of_month( + date(year=key.year, month=key.month, day=1))) + index = ' - '.join([str(current_month_start), + str(current_month_end)]) if new_stat.get(index): new_stat[index] += value else: diff --git a/main/views.py b/main/views.py index 7a0eca5..ed5effa 100644 --- a/main/views.py +++ b/main/views.py @@ -307,7 +307,6 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM self.make_light_agents(users) return super().form_valid(form) - def make_engineers(self, users: list) -> None: """ Функция проходит по списку пользователей, проставляя статус "engineer". From 015016bd6efde3ca81feb88e687bf96bdfe12b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Mon, 10 May 2021 20:43:17 +0300 Subject: [PATCH 36/65] Merge with "develop", begin to check again --- .env.example | 4 +- data.json | 12 +- main/extra_func.py | 252 ----------------------------------------- main/models.py | 1 - main/statistic_data.py | 45 ++++---- main/views.py | 19 ++-- 6 files changed, 42 insertions(+), 291 deletions(-) diff --git a/.env.example b/.env.example index 58e7b51..6026a6a 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,8 @@ ACTRL_DEBUG=1 ACTRL_SECRET_KEY="v1i_fb\$_jf2#1v_lcsbu&eon4u-os0^px=s^iycegdycqy&5)6" ACTRL_HOST="actrl.example.com" -ACTRL_EMAIL_HOST="smtp.mail.ru" -ACTRL_EMAIL_PORT=2525 +ACTRL_EMAIL_HOST="smtp.gmail.com" +ACTRL_EMAIL_PORT=587 ACTRL_EMAIL_TLS=1 ACTRL_EMAIL_HOST_USER="djgr.02@mail.ru" ACTRL_EMAIL_HOST_PASSWORD="djangogroup02" diff --git a/data.json b/data.json index 97678f3..a4310a4 100644 --- a/data.json +++ b/data.json @@ -1,7 +1,7 @@ [ { "model": "auth.user", - "pk": 3, + "pk": 1, "fields": { "password": "pbkdf2_sha256$216000$gHBBCr1jBELf$ZkEDW3IEd8Wij7u8vkv+0Eze32CS01bcaYWhcD9OIC4=", "last_login": null, @@ -19,16 +19,16 @@ }, { "model": "main.userprofile", - "pk": 3, + "pk": 1, "fields": { "name": "ZendeskAdmin", - "user": 3, + "user": 1, "role": "admin" } }, { "model": "auth.user", - "pk": 4, + "pk": 2, "fields": { "password": "pbkdf2_sha256$216000$5qLJgrm2Quq9$KDBNNymVZXkUx0HKBPFst2m83kLe0egPBnkW7KnkORU=", "last_login": null, @@ -46,10 +46,10 @@ }, { "model": "main.userprofile", - "pk": 4, + "pk": 2, "fields": { "name": "UserForAccessTest", - "user": 4, + "user": 2, "role": "agent", "custom_role_id": "360005209000" } diff --git a/main/extra_func.py b/main/extra_func.py index 43a1fe9..f13b013 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -244,258 +244,6 @@ def last_day_of_month(day: int) -> int: 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 - self.start_date = start_date - self.end_date = end_date - self.email = user_email - self.errors = list() - self.warnings = list() - self.data = dict() - self.statistic = dict() - self._init_data() - if stat is None: - self._init_statistic() - else: - self.statistic = stat - - def get_statistic(self) -> dict: - """ - Функция возвращает статистику работы пользователя. - - :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). None, если были ошибки при создании. - """ - if self.is_valid_statistic(): - stat = self.statistic - stat = self._use_display(stat) - stat = self._use_interval(stat) - return stat - else: - return None - - def is_valid_statistic(self) -> bool: - """ - Функция проверяет были ли ошибки при создании статистики. - - :return: True, при отсутствии ошибок - """ - return not self.errors and self.statistic - - def set_interval(self, interval: list) -> bool: - """ - Функция проверяет корректность представления интервала работы. - - :param interval: Интервал должен быть указан в днях или месяцах. - :return: True, если указан верно - """ - if interval not in ['months', 'days']: - self.errors += ['Интервал работы должен быть в днях или месяцах'] - return False - self.interval = interval - return True - - def set_display(self, display_format: list) -> bool: - """ - Функция проверяет корректность формата отображения интервала. - - :param display_format: Формат отображения должен быть указан в днях или месяцах. - :return: True, если указан верно - """ - if display_format not in ['days', 'hours']: - self.errors += ['Формат отображения должен быть в часах или днях'] - return False - self.display = display_format - return True - - def get_data(self) -> Optional[dict]: - """ - Функция возвращает данные - список объектов RoleChangeLogs. - """ - if self.is_valid_data(): - return self.data - else: - return None - - def is_valid_data(self) -> bool: - """ - Функция определяет были ли ошибки при получении логов. - - :return: True, если ошибок нет - """ - return not self.errors - - def _use_display(self, stat: list) -> list: - """ - Функция приводит данные к формату отображения. - - :param stat: Список данных статистики пользователя - :return: Обновленный список - """ - if not self.is_valid_statistic() or not self.display: - return stat - new_stat = {} - for key, item in stat.items(): - if self.display == 'hours': - new_stat[key] = item / 3600 - elif self.display == 'days': - new_stat[key] = item / (ONE_DAY * 3600) - return new_stat - - def _use_interval(self, stat: dict) -> dict: - """ - Функция объединяет ключи и значения в соответствии с интервалом работы. - - :param stat: Статистика работы пользователя - :return: Обновленная статистика - """ - if not self.is_valid_statistic() or not self.interval: - return stat - new_stat = {} - if self.interval == 'months': - # Переделываем ключи под формат('начало_месяца - конец_месяца') - for key, value in stat.items(): - current_month_start = max(self.start_date, date(year=key.year, month=key.month, day=1)) - current_month_end = min(self.end_date, last_day_of_month(date(year=key.year, month=key.month, day=1))) - index = ' - '.join([str(current_month_start), str(current_month_end)]) - if new_stat.get(index): - new_stat[index] += value - else: - new_stat[index] = value - elif self.interval == 'days': - new_stat = stat # статистика изначально в днях - return new_stat - - 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 _init_data(self): - """ - Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. - - :return: Данные о смене статусов пользователя. Если пользователь не найден или интервал времени некорректен - ошибку. - """ - if not self.check_time(): - self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] - return - try: - self.data = RoleChangeLogs.objects.filter( - change_time__range=[self.start_date, self.end_date + timedelta(days=1)], - user=User.objects.get(email=self.email), - ).order_by('change_time') - except User.DoesNotExist: - self.errors += ['Пользователь не найден'] - - def _init_statistic(self) -> dict: - """ - Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. - - :return: Статистика работы пользователя (statistic) - """ - self.clear_statistic() - if not self.get_data(): - self.warnings += ['Не обнаружены изменения роли в данном промежутке'] - return None - first_log, last_log = self.data[0], self.data[len(self.data) - 1] - - if first_log.old_role == ROLES['engineer']: - self.prev_engineer_logic(first_log) - - if last_log.new_role == ROLES['engineer']: - self.post_engineer_logic(last_log) - - for log_index in range(len(self.data) - 1): - if self.data[log_index].new_role == ROLES['engineer']: - self.engineer_logic(log_index) - - def engineer_logic(self, log_index): - """ - Функция обрабатывает основную часть работы инженера - """ - 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) -> dict: - """ - Функция осуществляет обновление всех дней. - """ - self.statistic.clear() - self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) - - class DatabaseHandler(logging.Handler): """ Класс записи изменений ролей в базу данных. diff --git a/main/models.py b/main/models.py index 2d1b4cb..c934ab1 100644 --- a/main/models.py +++ b/main/models.py @@ -37,7 +37,6 @@ class UserProfile(models.Model): :return: Роль пользователя в Zendesk """ - id = self.custom_role_id for role, r_id in ZENDESK_ROLES.items(): if r_id == self.custom_role_id: return role diff --git a/main/statistic_data.py b/main/statistic_data.py index fa1ab24..f569c68 100644 --- a/main/statistic_data.py +++ b/main/statistic_data.py @@ -1,7 +1,10 @@ +""" +Обработка статистики. +""" from datetime import date, datetime, timedelta from typing import Optional -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.utils import timezone from access_controller.settings import ONE_DAY, ZENDESK_ROLES as ROLES @@ -50,19 +53,19 @@ class StatisticData: else: self.statistic = stat - def get_statistic(self) -> dict: + def get_statistic(self) -> Optional[dict]: """ Функция возвращает статистику работы пользователя. - :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). None, если были ошибки при создании. + :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). + None, если были ошибки при создании. """ if self.is_valid_statistic(): stat = self.statistic stat = self._use_display(stat) stat = self._use_interval(stat) return stat - else: - return None + return None def is_valid_statistic(self) -> bool: """ @@ -104,8 +107,7 @@ class StatisticData: """ if self.is_valid_data(): return self.data - else: - return None + return None def is_valid_data(self) -> bool: """ @@ -170,7 +172,8 @@ class StatisticData: """ Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. - :return: Данные о смене статусов пользователя. Если пользователь не найден или интервал времени некорректен - ошибку. + :return: Данные о смене статусов пользователя. Если пользователь не найден или + интервал времени некорректен - ошибку. """ if not self.check_time(): self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] @@ -178,12 +181,12 @@ class StatisticData: try: self.data = RoleChangeLogs.objects.filter( change_time__range=[self.start_date, self.end_date + timedelta(days=1)], - user=User.objects.get(email=self.email), + user=get_user_model().objects.get(email=self.email), ).order_by('change_time') - except User.DoesNotExist: + except get_user_model().DoesNotExist: self.errors += ['Пользователь не найден'] - def _init_statistic(self) -> dict: + def _init_statistic(self) -> None: """ Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. @@ -192,18 +195,18 @@ class StatisticData: self.clear_statistic() if not self.get_data(): self.warnings += ['Не обнаружены изменения роли в данном промежутке'] - return None - first_log, last_log = self.data[0], self.data[len(self.data) - 1] + else: + first_log, last_log = self.data[0], self.data[len(self.data) - 1] - if first_log.old_role == ROLES['engineer']: - self.prev_engineer_logic(first_log) + if first_log.old_role == ROLES['engineer']: + self.prev_engineer_logic(first_log) - if last_log.new_role == ROLES['engineer']: - self.post_engineer_logic(last_log) + if last_log.new_role == ROLES['engineer']: + self.post_engineer_logic(last_log) - for log_index in range(len(self.data) - 1): - if self.data[log_index].new_role == ROLES['engineer']: - self.engineer_logic(log_index) + for log_index in range(len(self.data) - 1): + if self.data[log_index].new_role == ROLES['engineer']: + self.engineer_logic(log_index) def engineer_logic(self, log_index): """ @@ -238,7 +241,7 @@ class StatisticData: """ Функция обрабатывает случай, когда нам изветсно что инженер начал работу до диапазона """ - self.fill_daterange(max(User.objects.get(email=self.email).date_joined.date(), self.start_date), + self.fill_daterange(max(get_user_model().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() diff --git a/main/views.py b/main/views.py index 570af70..e8c1317 100644 --- a/main/views.py +++ b/main/views.py @@ -19,7 +19,7 @@ from django.contrib.messages.views import SuccessMessageMixin from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponseRedirect, HttpResponse from django.shortcuts import render, redirect -from django.urls import reverse_lazy, reverse +from django.urls import reverse_lazy from django.views.generic import FormView from django_registration.views import RegistrationView # Django REST @@ -29,12 +29,12 @@ from rest_framework.response import Response 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, \ make_engineer, make_light_agent, get_users_list, update_users_in_model, count_users, \ - log, set_session_params_for_work_page, get_tickets_list_for_group -from .statistic_data import StatisticData + set_session_params_for_work_page, get_tickets_list_for_group from main.zendesk_admin import zenpy -from main.requester import TicketListRequester from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm from main.serializers import ProfileSerializer, ZendeskUserSerializer +from .statistic_data import StatisticData + from .models import UserProfile @@ -69,7 +69,8 @@ class CustomRegistrationView(RegistrationView): :type template_name: :class:`str` :param success_url: Указание пути к html-странице завершения регистрации :type success_url: :class:`django.utils.functional.lazy..__proxy__` - :param is_allowed: Определение зарегистрирован ли пользователь с введенным email на Zendesk и принадлежит ли он к организации SYSTEM + :param is_allowed: Определение зарегистрирован ли пользователь с введенным email на Zendesk и + принадлежит ли он к организации SYSTEM :type is_allowed: :class:`bool` """ extra_context = setup_context(registration_lit=True) @@ -91,7 +92,7 @@ class CustomRegistrationView(RegistrationView): 3. Создается пользователь class User, а также его профиль. :param form: Email пользователя на Zendesk - :return: user + :return: User """ self.redirect_url = 'done' if check_user_exist(form.data['email']) and get_user_organization(form.data['email']) == 'SYSTEM': @@ -235,12 +236,12 @@ def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: @login_required() def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: """ - Функция позволяет текущему пользователю получить права, а именно сменить в Zendesk роль с "light_agent" на "engineer" + Функция позволяет текущему пользователю получить права, а именно сменить в Zendesk роль с "light_agent" + на "engineer". :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли """ - make_engineer(request.user.userprofile, request.user) return set_session_params_for_work_page(request) @@ -311,7 +312,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM self.make_light_agents(users) return super().form_valid(form) - def make_engineers(self, users): + def make_engineers(self, users: list) -> None: """ Функция проходит по списку пользователей, проставляя статус "engineer". From f803d81caf462221b704afa37a56c214a7eb4d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Mon, 10 May 2021 20:49:55 +0300 Subject: [PATCH 37/65] Pylint for extra_func.py --- README.rst | 2 +- main/extra_func.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 7a4e6ec..846db24 100644 --- a/README.rst +++ b/README.rst @@ -170,7 +170,7 @@ Quickstart Для проверки pylint используем: ------------------------------- -pylint ../access_controller +pylint ../access_controller_new Для приведения файлов к стандарту PEP8 используем: -------------------------------------------------- diff --git a/main/extra_func.py b/main/extra_func.py index f13b013..4760bb4 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -2,11 +2,13 @@ Вспомогательные функции со списками пользователей, статистикой и т.д. """ import logging -from datetime import timedelta, datetime, date -from typing import Optional, Union +from datetime import timedelta +from typing import Union from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist +from django.core.handlers.wsgi import WSGIRequest +from django.http import HttpResponseRedirect, HttpResponsePermanentRedirect from django.shortcuts import redirect from django.utils import timezone from zenpy import Zenpy From a6ceae824013b192ee1faa942b58f616ebc70153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 12 May 2021 20:34:52 +0300 Subject: [PATCH 38/65] Pylint 10 --- .pylintrc | 2 +- README.rst | 2 +- access_controller/auth.py | 12 ++++++++ main/requester.py | 21 +++++++++++-- main/tests.py | 65 +++++++++++++++++++++++++++------------ 5 files changed, 77 insertions(+), 25 deletions(-) diff --git a/.pylintrc b/.pylintrc index 07ab0f1..b736d99 100644 --- a/.pylintrc +++ b/.pylintrc @@ -145,7 +145,7 @@ disable=print-statement, exception-escape, comprehension-escape, E5110, - C0415, + C0415 # Enable the message, report, category or checker with the given id(s). You can diff --git a/README.rst b/README.rst index 846db24..a10945a 100644 --- a/README.rst +++ b/README.rst @@ -170,7 +170,7 @@ Quickstart Для проверки pylint используем: ------------------------------- -pylint ../access_controller_new +pylint ../access_controller_new (папка проекта) Для приведения файлов к стандарту PEP8 используем: -------------------------------------------------- diff --git a/access_controller/auth.py b/access_controller/auth.py index 00b43a2..7cfd71b 100644 --- a/access_controller/auth.py +++ b/access_controller/auth.py @@ -1,9 +1,18 @@ +""" +Авторизация пользователя. +""" from django.contrib.auth.backends import ModelBackend from django.contrib.auth import get_user_model class EmailAuthBackend(ModelBackend): + """ + Класс авторизации пользователя по email. + """ def authenticate(self, request, username=None, password=None, **kwargs): + """ + Функция получения пользователя (модель User) по email. + """ try: user = get_user_model().objects.get(email=username) if user.check_password(password): @@ -13,6 +22,9 @@ class EmailAuthBackend(ModelBackend): return None def get_user(self, user_id): + """ + Функция получения пользователя по id. + """ try: return get_user_model().objects.get(pk=user_id) except get_user_model().DoesNotExist: diff --git a/main/requester.py b/main/requester.py index 468abee..d0c57ed 100644 --- a/main/requester.py +++ b/main/requester.py @@ -1,3 +1,6 @@ +""" +Обработка тикетов. +""" import requests from zenpy import TicketApi from zenpy.lib.api_objects import Ticket @@ -6,6 +9,9 @@ from main.zendesk_admin import zenpy class TicketListRequester: + """ + Класс обработки тикетов. + """ def __init__(self): self.email = zenpy.credentials['email'] if zenpy.credentials.get('token'): @@ -15,11 +21,17 @@ class TicketListRequester: self.token_or_password = zenpy.credentials.get('password') self.prefix = f'https://{zenpy.credentials.get("subdomain")}.zendesk.com/api/v2/' - def get_tickets_list_for_user(self, zendesk_user): + def get_tickets_list_for_user(self, zendesk_user: zenpy) -> str: + """ + Функция получения списка тикетов пользователя Zendesk. + """ url = self.prefix + f'users/{zendesk_user.id}/tickets/assigned' return self._get_tickets(url) - def get_tickets_list_for_group(self, group): + def get_tickets_list_for_group(self, group: zenpy) -> list(): + """ + Функция получения списка тикетов группы пользователей Zendesk. + """ url = self.prefix + '/tickets' all_tickets = self._get_tickets(url) tickets = list() @@ -28,7 +40,10 @@ class TicketListRequester: tickets.append(ticket) return tickets - def _get_tickets(self, url): + def _get_tickets(self, url: str) -> list(): + """ + Функция получения полного списка тикетов по url. + """ response = requests.get(url, auth=(self.email, self.token_or_password)) tickets = [] if response.status_code != 200: diff --git a/main/tests.py b/main/tests.py index 0cb93a0..f9fed9a 100644 --- a/main/tests.py +++ b/main/tests.py @@ -98,7 +98,7 @@ class RegistrationTestCase(TestCase): """ with self.settings(EMAIL_BACKEND=self.email_backend): self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) - user = User.objects.get(email=self.any_zendesk_user_email) + user = get_user_model().objects.get(email=self.any_zendesk_user_email) zendesk_user = zenpy.get_user(self.any_zendesk_user_email) self.assertEqual(user.userprofile.name, zendesk_user.name) @@ -108,71 +108,96 @@ class RegistrationTestCase(TestCase): """ with self.settings(EMAIL_BACKEND=self.email_backend): self.client.post(reverse('registration'), data={'email': self.zendesk_admin_email}) - user = User.objects.get(email=self.zendesk_admin_email) + user = get_user_model().objects.get(email=self.zendesk_admin_email) self.assertEqual(user.userprofile.role, 'admin') self.assertTrue(user.has_perm('main.has_control_access')) class MakeEngineerTestCase(TestCase): + """ + Класс тестов для проверки функции назначения роли engineer. + """ fixtures = ['fixtures/test_make_engineer.json'] def setUp(self): + """ + Предустановленные значения для проведения тестов. + """ self.light_agent = '123@test.ru' self.admin = 'admin@gmail.com' self.engineer = 'customer@example.com' self.client = Client() - self.client.force_login(User.objects.get(email=self.light_agent)) + self.client.force_login(get_user_model().objects.get(email=self.light_agent)) self.admin_client = Client() - self.admin_client.force_login(User.objects.get(email=self.admin)) + self.admin_client.force_login(get_user_model().objects.get(email=self.admin)) @patch('main.extra_func.zenpy') - def test_redirect(self, ZenpyMock): - user = User.objects.get(email=self.light_agent) + def test_redirect(self): + """ + Функция проверки переадресации пользователя на рабочую страницу после назначения роли engineer. + """ + user = get_user_model().objects.get(email=self.light_agent) resp = self.client.post(reverse_lazy('work_become_engineer')) self.assertRedirects(resp, reverse('work', args=[user.id])) self.assertEqual(resp.status_code, 302) @patch('main.extra_func.zenpy') - def test_light_agent_make_engineer(self, ZenpyMock): + def test_light_agent_make_engineer(self, zenpy_mock): + """ + Функция проверки назначения light_agent на роль engineer. + """ self.client.post(reverse_lazy('work_become_engineer')) - self.assertEqual(ZenpyMock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_admin_make_engineer(self, ZenpyMock): + def test_admin_make_engineer(self, zenpy_mock): + """ + Функция проверки назначения admin на роль engineer. + """ self.admin_client.post(reverse_lazy('work_become_engineer')) - self.assertEqual(ZenpyMock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_engineer_make_engineer(self, ZenpyMock): + def test_engineer_make_engineer(self, zenpy_mock): + """ + Функция проверки назначения engineer на роль engineer. + """ client = Client() - client.force_login(User.objects.get(email=self.engineer)) + client.force_login(get_user_model().objects.get(email=self.engineer)) client.post(reverse_lazy('work_become_engineer')) - self.assertEqual(ZenpyMock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_control_page_make_one(self, ZenpyMock): + def test_control_page_make_one(self, zenpy_mock): + """ + Функция проверки назначения администратором на роль engineer одного пользователя. + """ self.admin_client.post( reverse_lazy('control'), - data={'users': [User.objects.get(email=self.light_agent).userprofile.id], 'engineer': 'engineer'} + data={'users': [get_user_model().objects.get(email=self.light_agent).userprofile.id], + 'engineer': 'engineer'} ) - call_list = ZenpyMock.update_user.call_args_list + call_list = zenpy_mock.update_user.call_args_list mock_object = call_list[0][0][0] self.assertEqual(len(call_list), 1) self.assertEqual(mock_object.custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_control_page_make_many(self, ZenpyMock): + def test_control_page_make_many(self, zenpy_mock): + """ + Функция проверки назначения администратором на роль engineer нескольких пользователей. + """ self.admin_client.post( reverse_lazy('control'), data={ 'users': [ - User.objects.get(email=self.light_agent).userprofile.id, - User.objects.get(email=self.engineer).userprofile.id, + get_user_model().objects.get(email=self.light_agent).userprofile.id, + get_user_model().objects.get(email=self.engineer).userprofile.id, ], 'engineer': 'engineer' } ) - call_list = ZenpyMock.update_user.call_args_list + call_list = zenpy_mock.update_user.call_args_list mock_objects = list(call_list) self.assertEqual(len(call_list), 2) for obj in mock_objects: From f893bc15fe06cfbc65fe8f86863555d96ac3ef75 Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 13 May 2021 20:38:25 +0300 Subject: [PATCH 39/65] Fix pylint exec cmd --- .pylintrc | 3 +-- README.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.pylintrc b/.pylintrc index b736d99..2fe14cc 100644 --- a/.pylintrc +++ b/.pylintrc @@ -36,7 +36,7 @@ limit-inference-results=100 # List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. -load-plugins= pylint_django +load-plugins=pylint_django # Pickle collected data for later comparisons. persistent=yes @@ -144,7 +144,6 @@ disable=print-statement, deprecated-sys-function, exception-escape, comprehension-escape, - E5110, C0415 diff --git a/README.md b/README.md index 8885598..c3b9666 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ docker run -d -p 8000:8000 \ Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). ## Для проверки pylint используем: -pylint ../access_controller (каталог, где лежит проект) +pylint --django-settings-module=access_controller.access_controller.settings ../access_controller (каталог, где лежит проект) ## Для приведения файлов к стандарту PEP8 используем: autopep8 --in-place filename From 561b7e3a446b4d5391a9283df9d6263c38ee89aa Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 13 May 2021 20:46:10 +0300 Subject: [PATCH 40/65] Mode doc and style requirements in dev file --- requirements/common.txt | 13 ------------- requirements/dev.txt | 12 ++++++++++++ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/requirements/common.txt b/requirements/common.txt index 0ba9415..c8a8d08 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -7,18 +7,5 @@ zenpy~=2.0.24 django_registration==3.1.1 djangorestframework==3.12.2 -# Documentation -Sphinx==3.4.3 -sphinx-rtd-theme==0.5.1 -sphinx-autodoc-typehints==1.11.1 -pyenchant==3.2.0 -sphinxcontrib-spelling==7.1.0 -m2r == 0.2.1 - -# Code style -pylint == 2.8.2 -pylint-django == 2.4.4 -autopep8 == 1.5.6 - # Misc python-dotenv==0.17.1 diff --git a/requirements/dev.txt b/requirements/dev.txt index 73c27d0..0ce1a9e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,3 +1,15 @@ # Development specific dependencies -r common.txt +# Documentation +Sphinx==3.4.3 +sphinx-rtd-theme==0.5.1 +sphinx-autodoc-typehints==1.11.1 +pyenchant==3.2.0 +sphinxcontrib-spelling==7.1.0 +m2r == 0.2.1 + +# Code style +pylint == 2.8.2 +pylint-django == 2.4.4 +autopep8 == 1.5.6 From 354e3f7373d5383b746d7174f958215ad45d7ef1 Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 13 May 2021 20:55:10 +0300 Subject: [PATCH 41/65] Fix tests.py --- main/tests.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/main/tests.py b/main/tests.py index 6d72f2e..d7001af 100644 --- a/main/tests.py +++ b/main/tests.py @@ -115,7 +115,7 @@ class MakeEngineerTestCase(TestCase): self.admin_client.force_login(get_user_model().objects.get(email=self.admin)) @patch('main.extra_func.zenpy') - def test_redirect(self): + def test_redirect(self, zenpy_mock): """ Функция проверки переадресации пользователя на рабочую страницу после назначения роли engineer. """ @@ -123,6 +123,7 @@ class MakeEngineerTestCase(TestCase): resp = self.client.post(reverse_lazy('work_become_engineer')) self.assertRedirects(resp, reverse('work', args=[user.id])) self.assertEqual(resp.status_code, 302) + self.assertFalse(zenpy_mock.called) @patch('main.extra_func.zenpy') def test_light_agent_make_engineer(self, zenpy_mock): @@ -194,7 +195,7 @@ class PasswordResetTestCase(TestCase): self.user = '123@test.ru' self.email_backend = 'django.core.mail.backends.locmem.EmailBackend' self.client = Client() - self.client.force_login(User.objects.get(email=self.user)) + self.client.force_login(get_user_model().objects.get(email=self.user)) def test_redirect(self): with self.settings(EMAIL_BACKEND=self.email_backend): @@ -236,14 +237,14 @@ class PasswordChangeTestCase(TestCase): def setUp(self): self.user = '123@test.ru' self.client = Client() - self.client.force_login(User.objects.get(email=self.user)) + self.client.force_login(get_user_model().objects.get(email=self.user)) self.set_password() def set_password(self): - user: User = User.objects.get(email=self.user) + user: get_user_model() = get_user_model().objects.get(email=self.user) user.set_password('ImpossiblyHardPassword') user.save() - self.client.force_login(User.objects.get(email=self.user)) + self.client.force_login(get_user_model().objects.get(email=self.user)) def test_change_successful(self): self.client.post( @@ -254,7 +255,7 @@ class PasswordChangeTestCase(TestCase): 'new_password2': 'EasyPassword', } ) - user = User.objects.get(email=self.user) + user = get_user_model().objects.get(email=self.user) self.assertTrue(user.check_password('EasyPassword')) def test_invalid_old_password(self): @@ -331,9 +332,9 @@ class GetTicketsTestCase(TestCase): self.light_agent = '123@test.ru' self.engineer = 'customer@example.com' self.client = Client() - self.client.force_login(User.objects.get(email=self.engineer)) + self.client.force_login(get_user_model().objects.get(email=self.engineer)) self.light_agent_client = Client() - self.light_agent_client.force_login(User.objects.get(email=self.light_agent)) + self.light_agent_client.force_login(get_user_model().objects.get(email=self.light_agent)) @patch('main.views.zenpy.get_user') @patch('main.extra_func.zenpy') @@ -342,7 +343,7 @@ class GetTicketsTestCase(TestCase): Функция проверки переадресации пользователя на рабочую страницу. """ GetUserMock.return_value = Mock() - user = User.objects.get(email=self.engineer) + user = get_user_model().objects.get(email=self.engineer) resp = self.client.post(reverse('work_get_tickets')) self.assertRedirects(resp, reverse('work', args=[user.id])) self.assertEqual(resp.status_code, 302) @@ -421,9 +422,9 @@ class ProfileTestCase(TestCase): self.zendesk_agent_email = 'krav-88@mail.ru' self.zendesk_admin_email = 'idar.sokurov.05@mail.ru' self.client = Client() - self.client.force_login(User.objects.get(email=self.zendesk_agent_email)) + self.client.force_login(get_user_model().objects.get(email=self.zendesk_agent_email)) self.admin_client = Client() - self.admin_client.force_login(User.objects.get(email=self.zendesk_admin_email)) + self.admin_client.force_login(get_user_model().objects.get(email=self.zendesk_admin_email)) def test_correct_username(self): """ From 0fd3c0fa1bca193b3e0ef9e46042ffe6fe9f01ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sat, 15 May 2021 14:52:08 +0300 Subject: [PATCH 42/65] Resolve C0415 and E5110 problems --- .pylintrc | 4 ++-- README.rst | 2 +- main/tests.py | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.pylintrc b/.pylintrc index 2fe14cc..79dc22f 100644 --- a/.pylintrc +++ b/.pylintrc @@ -15,7 +15,7 @@ extension-pkg-whitelist= fail-under=10.0 # Files or directories to be skipped. They should be base names, not paths. -ignore=CVS +ignore=CVS, manage.py # Files or directories matching the regex patterns are skipped. The regex # matches against base names, not paths. @@ -144,7 +144,7 @@ disable=print-statement, deprecated-sys-function, exception-escape, comprehension-escape, - C0415 + # Enable the message, report, category or checker with the given id(s). You can diff --git a/README.rst b/README.rst index a10945a..bfd4aff 100644 --- a/README.rst +++ b/README.rst @@ -170,7 +170,7 @@ Quickstart Для проверки pylint используем: ------------------------------- -pylint ../access_controller_new (папка проекта) +pylint --django-settings-module=access_controller_new.access_controller.settings ../access_controller_new Для приведения файлов к стандарту PEP8 используем: -------------------------------------------------- diff --git a/main/tests.py b/main/tests.py index d7001af..61ff9e3 100644 --- a/main/tests.py +++ b/main/tests.py @@ -189,9 +189,15 @@ class MakeEngineerTestCase(TestCase): class PasswordResetTestCase(TestCase): + """ + Класс тестов сброса пароля. + """ fixtures = ['fixtures/test_make_engineer.json'] def setUp(self): + """ + Предустановленные значения для проведения тестов + """ self.user = '123@test.ru' self.email_backend = 'django.core.mail.backends.locmem.EmailBackend' self.client = Client() From aba5d9d563c9e9745cd8af4fca692298c1d8779c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sat, 15 May 2021 15:44:21 +0300 Subject: [PATCH 43/65] Rename parametrs to snack_case. Pylint 10. --- README.rst | 3 + .../registration/password_reset_form.html | 2 +- main/tests.py | 88 ++++++++++++++----- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/README.rst b/README.rst index bfd4aff..e3c1a03 100644 --- a/README.rst +++ b/README.rst @@ -172,6 +172,9 @@ Quickstart pylint --django-settings-module=access_controller_new.access_controller.settings ../access_controller_new +Вместо "access_controller_new" необходимо указать папку проекта. + + Для приведения файлов к стандарту PEP8 используем: -------------------------------------------------- diff --git a/main/templates/registration/password_reset_form.html b/main/templates/registration/password_reset_form.html index 39cf045..f2980e4 100644 --- a/main/templates/registration/password_reset_form.html +++ b/main/templates/registration/password_reset_form.html @@ -12,7 +12,7 @@

Введте свой e-mail адрес для восстановления пароля.

{{ form.as_p }} -

+

{% csrf_token %}
{% endblock %} diff --git a/main/tests.py b/main/tests.py index 61ff9e3..9d87037 100644 --- a/main/tests.py +++ b/main/tests.py @@ -196,7 +196,7 @@ class PasswordResetTestCase(TestCase): def setUp(self): """ - Предустановленные значения для проведения тестов + Предустановленные значения для проведения тестов. """ self.user = '123@test.ru' self.email_backend = 'django.core.mail.backends.locmem.EmailBackend' @@ -204,12 +204,18 @@ class PasswordResetTestCase(TestCase): self.client.force_login(get_user_model().objects.get(email=self.user)) def test_redirect(self): + """ + Функция проверки переадресации на страницу уведомления о сбросе пароля на email. + """ with self.settings(EMAIL_BACKEND=self.email_backend): resp = self.client.post(reverse_lazy('password_reset'), data={'email': self.user}) self.assertRedirects(resp, reverse('password_reset_done')) self.assertEqual(resp.status_code, 302) def test_send_email(self): + """ + Функция проверки содержания и отправки письма для установки пароля. + """ with self.settings(EMAIL_BACKEND=self.email_backend): response: HttpResponseRedirect = \ self.client.post(reverse_lazy('password_reset'), data={'email': self.user}) @@ -225,11 +231,17 @@ class PasswordResetTestCase(TestCase): self.assertEqual(mail.outbox[0].body, correct_body) def test_email_invalid(self): + """ + Функция проверки уведомления клиента о некорректности введенного email. + """ with self.settings(EMAIL_BACKEND=self.email_backend) and translation.override('ru'): resp = self.client.post(reverse_lazy('password_reset'), data={'email': 1}) self.assertContains(resp, 'Введите правильный адрес электронной почты.', count=1, status_code=200) def test_user_does_not_exist(self): + """ + Функция корректности отработки неверно введенного email. + """ with self.settings(EMAIL_BACKEND=self.email_backend): resp = self.client.post(reverse_lazy('password_reset'), data={'email': self.user + str(random.random())}) self.assertRedirects(resp, reverse('password_reset_done')) @@ -238,21 +250,33 @@ class PasswordResetTestCase(TestCase): class PasswordChangeTestCase(TestCase): + """ + Класс тестирования смены пароля. + """ fixtures = ['fixtures/test_make_engineer.json'] def setUp(self): + """ + Предустановленные значения для проведения тестов. + """ self.user = '123@test.ru' self.client = Client() self.client.force_login(get_user_model().objects.get(email=self.user)) self.set_password() def set_password(self): + """ + Пароль, сформированный для тестирования. + """ user: get_user_model() = get_user_model().objects.get(email=self.user) user.set_password('ImpossiblyHardPassword') user.save() self.client.force_login(get_user_model().objects.get(email=self.user)) def test_change_successful(self): + """ + Функция тестирования успешного изменения пароля. + """ self.client.post( reverse_lazy('password_change'), data={ @@ -265,6 +289,9 @@ class PasswordChangeTestCase(TestCase): self.assertTrue(user.check_password('EasyPassword')) def test_invalid_old_password(self): + """ + Функция тестирования отработки неверно введенного старого пароля при смене. + """ with translation.override('ru'): resp = self.client.post( reverse_lazy('password_change'), @@ -277,6 +304,9 @@ class PasswordChangeTestCase(TestCase): self.assertContains(resp, 'Ваш старый пароль введен неправильно', count=1, status_code=200) def test_different_new_passwords(self): + """ + Функция тестирования случая с вводом двух разных новых паролей. + """ with translation.override('ru'): resp = self.client.post( reverse_lazy('password_change'), @@ -289,6 +319,9 @@ class PasswordChangeTestCase(TestCase): self.assertContains(resp, 'Введенные пароли не совпадают', count=1, status_code=200) def test_invalid_new_password1(self): + """ + Функция тестирования случая с неправильно подобранным новым паролем (слишком короткий). + """ with translation.override('ru'): resp = self.client.post( reverse_lazy('password_change'), @@ -301,6 +334,9 @@ class PasswordChangeTestCase(TestCase): self.assertContains(resp, 'Введённый пароль слишком короткий', count=1, status_code=200) def test_invalid_new_password2(self): + """ + Функция тестирования случая с неправильно подобранным новым паролем (употребляются только цифры). + """ with translation.override('ru'): resp = self.client.post( reverse_lazy('password_change'), @@ -313,6 +349,9 @@ class PasswordChangeTestCase(TestCase): self.assertContains(resp, 'Введённый пароль состоит только из цифр', count=1, status_code=200) def test_invalid_new_password3(self): + """ + Функция тестирования случая с неправильно подобранным новым паролем (совпадает с именем пользователя). + """ with translation.override('ru'): resp = self.client.post( reverse_lazy('password_change'), @@ -344,74 +383,75 @@ class GetTicketsTestCase(TestCase): @patch('main.views.zenpy.get_user') @patch('main.extra_func.zenpy') - def test_redirect(self, ZenpyMock, GetUserMock): + def test_redirect(self, zenpy_mock, get_user_mock): """ Функция проверки переадресации пользователя на рабочую страницу. """ - GetUserMock.return_value = Mock() + get_user_mock.return_value = Mock() user = get_user_model().objects.get(email=self.engineer) resp = self.client.post(reverse('work_get_tickets')) self.assertRedirects(resp, reverse('work', args=[user.id])) self.assertEqual(resp.status_code, 302) + self.assertFalse(zenpy_mock.called) @patch('main.views.zenpy') @patch('main.views.get_tickets_list_for_group') - def test_take_one_ticket(self, TicketsMock, ZenpyViewsMock): + def test_take_one_ticket(self, tickets_mock, zenpy_views_mock): """ Функция проверки назначения одного тикета на engineer. """ - TicketsMock.return_value = [Mock()] - ZenpyViewsMock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) + tickets_mock.return_value = [Mock()] + zenpy_views_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) self.client.post(reverse('work_get_tickets'), data={'count_tickets': 1}) - tickets = ZenpyViewsMock.update_tickets.call_args - self.assertEqual(tickets[0][0][0].assignee, ZenpyViewsMock.get_user.return_value) + tickets = zenpy_views_mock.update_tickets.call_args + self.assertEqual(tickets[0][0][0].assignee, zenpy_views_mock.get_user.return_value) @patch('main.views.get_tickets_list_for_group') @patch('main.views.zenpy') - def test_take_many_tickets(self, ZenpyMock, TicketsMock): + def test_take_many_tickets(self, zenpy_mock, tickets_mock): """ Функция проверки назначения нескольких тикетов на engineer. """ - TicketsMock.return_value = [Mock()] * 3 - ZenpyMock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) + tickets_mock.return_value = [Mock()] * 3 + zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) self.client.post(reverse('work_get_tickets'), data={'count_tickets': 3}) - tickets = ZenpyMock.update_tickets.call_args + tickets = zenpy_mock.update_tickets.call_args for ticket in tickets[0][0]: - self.assertEqual(ticket.assignee, ZenpyMock.get_user.return_value) + self.assertEqual(ticket.assignee, zenpy_mock.get_user.return_value) @patch('main.views.zenpy.get_user') @patch('main.views.zenpy') - def test_light_agent_take_ticket(self, ZenpyMock, GetUserMock): + def test_light_agent_take_ticket(self, zenpy_mock, get_user_mock): """ Функция проверки попытки назначения тикета на light_agent. """ - GetUserMock.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['light_agent']) + get_user_mock.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['light_agent']) self.light_agent_client.post(reverse('work_get_tickets'), data={'count_tickets': 3}) - tickets = ZenpyMock.update_tickets.call_args + tickets = zenpy_mock.update_tickets.call_args self.assertIsNone(tickets) @patch('main.views.zenpy') @patch('main.views.get_tickets_list_for_group') - def test_take_zero_tickets(self, TicketsMock, ZenpyMock): + def test_take_zero_tickets(self, tickets_mock, zenpy_mock): """ Функция проверки попытки назначения нуля тикета на engineer. """ - TicketsMock.return_value = [Mock()] * 3 - ZenpyMock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) + tickets_mock.return_value = [Mock()] * 3 + zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) self.client.post(reverse('work_get_tickets'), data={'count_tickets': 0}) - tickets = ZenpyMock.update_tickets.call_args[0][0] + tickets = zenpy_mock.update_tickets.call_args[0][0] self.assertListEqual(tickets, []) @patch('main.views.get_tickets_list_for_group') @patch('main.views.zenpy') - def test_take_invalid_count_tickets(self, ZenpyMock, TicketsMock, ): + def test_take_invalid_count_tickets(self, zenpy_mock, tickets_mock): """ Функция проверки попытки назначения нуля тикетов на engineer. """ - TicketsMock.return_value = [Mock()] * 3 - ZenpyMock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) + tickets_mock.return_value = [Mock()] * 3 + zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) self.client.post(reverse('work_get_tickets'), data={'count_tickets': 'asd'}) - tickets = ZenpyMock.update_tickets.call_args + tickets = zenpy_mock.update_tickets.call_args self.assertIsNone(tickets) From 0b144dd8810246be9bf8fd319a36941d2cb74bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sun, 16 May 2021 11:01:10 +0300 Subject: [PATCH 44/65] Move django-settings for pylint to pylintrc --- .pylintrc | 1 + README.rst | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index 79dc22f..0f1221a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -37,6 +37,7 @@ limit-inference-results=100 # List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. load-plugins=pylint_django +django-settings-module=access_controller_new.access_controller.settings # Pickle collected data for later comparisons. persistent=yes diff --git a/README.rst b/README.rst index e3c1a03..f1b08b5 100644 --- a/README.rst +++ b/README.rst @@ -170,7 +170,7 @@ Quickstart Для проверки pylint используем: ------------------------------- -pylint --django-settings-module=access_controller_new.access_controller.settings ../access_controller_new +pylint ../access_controller_new Вместо "access_controller_new" необходимо указать папку проекта. From 3443c60a5f05542a4716a6a7e087f30f95646ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 19 May 2021 21:12:28 +0300 Subject: [PATCH 45/65] Check spelling --- README.rst | 13 +++++-------- docs/source/spelling_wordlist.txt | 28 ++++++++++++++++++++++++++++ main/extra_func.py | 2 +- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index f1b08b5..fb32f4b 100644 --- a/README.rst +++ b/README.rst @@ -57,9 +57,9 @@ Quickstart sudo apt install make pip install --upgrade pip pip install -r requirements/dev.txt - (set -a && source .env && ./manage.py migrate) - (set -a && source .env && ./manage.py loaddata data.json) - (set -a && source .env && ./manage.py runserver) + ./manage.py migrate + ./manage.py loaddata data.json + ./manage.py runserver Перед запуском для тестирования: -------------------------------- @@ -76,7 +76,7 @@ Quickstart * Перейти в папку приложения * Активировать виртуальное окружение * Выполнить команду ``pip install -r requirements/dev.txt`` -* В виртуальное окружение добавить следующие переменные: +* В файл ``.env`` добавить следующие переменные: .. code-block:: @@ -170,10 +170,7 @@ Quickstart Для проверки pylint используем: ------------------------------- -pylint ../access_controller_new - -Вместо "access_controller_new" необходимо указать папку проекта. - +pylint --django-settings-module=access_controller.access_controller.settings ../access_controller (каталог, где лежит проект) Для приведения файлов к стандарту PEP8 используем: -------------------------------------------------- diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 1e9713d..fb1f974 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -191,4 +191,32 @@ docs a Аватарка filename +work +form +work_get_tickets +get +tickets +Do +takes +whatever +it +to +actually +log +the +specified +logging +record +This +version +is +intended +be +implemented +by +subclasses +so +new + + diff --git a/main/extra_func.py b/main/extra_func.py index 6d68944..880daba 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -104,7 +104,7 @@ def get_tickets_list(email) -> list: def get_tickets_list_for_group(group_name): """ - Функция возвращает список неназначенных, нерешённых тикетов группы Zendesk + Функция возвращает список не назначенных, нерешённых тикетов группы Zendesk """ return TicketListRequester().get_tickets_list_for_group(zenpy.get_group(group_name)) From 1114c916e9c3d3112d5270f94c05d34b74479569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 20 May 2021 16:19:42 +0300 Subject: [PATCH 46/65] Update docs (views, extra_func, forms, models) --- access_controller/settings.py | 2 + docs/source/code.rst | 30 +++ main/apiauth.py | 3 +- main/extra_func.py | 51 +++-- main/forms.py | 2 +- main/models.py | 17 +- main/requester.py | 26 ++- main/serializers.py | 2 +- main/views.py | 60 +++--- .../site-packages/enchant/tokenize/ru.py | 185 ------------------ 10 files changed, 136 insertions(+), 242 deletions(-) delete mode 100644 venv/lib/python3.6/site-packages/enchant/tokenize/ru.py diff --git a/access_controller/settings.py b/access_controller/settings.py index 55af7a5..6293efc 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -11,6 +11,8 @@ https://docs.djangoproject.com/en/3.1/ref/settings/ """ import os from pathlib import Path + + from dotenv import load_dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. diff --git a/docs/source/code.rst b/docs/source/code.rst index 1f0bd15..b07c93c 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -49,3 +49,33 @@ Views :members: +***************** +Обработка тикетов +***************** + +.. automodule:: main.requester + :members: + + +********************* +Обработка статистики +********************* + +.. automodule:: main.statistic_data + :members: + + +********************************* +Функционал администратора Zendesk +********************************* + +.. automodule:: main.zendesk_admin + :members: + + +******** +Тесты +******** + +.. automodule:: main.tests + :members: diff --git a/main/apiauth.py b/main/apiauth.py index b6488ba..c82d797 100644 --- a/main/apiauth.py +++ b/main/apiauth.py @@ -12,10 +12,11 @@ def api_auth() -> dict: Функция создания пользователя с использованием Zendesk API. Получает из env Zendesk - email, token, password пользователя. + Если данные валидны и пользователь Zendesk с указанным email и токеном или паролем существует, создается словарь данных пользователя, полученных через API c Zendesk. - :return: данные пользователя + :return: данные пользователя в виде словаря: id, имя, email, роль, аватар """ credentials = { 'subdomain': ACTRL_ZENDESK_SUBDOMAIN diff --git a/main/extra_func.py b/main/extra_func.py index 880daba..1c87f65 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,9 +1,9 @@ """ -Вспомогательные функции со списками пользователей, статистикой и т.д. +Вспомогательные функции. """ import logging from datetime import timedelta -from typing import Union +from typing import Union, Optional from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist @@ -55,7 +55,8 @@ def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) - Функция устанавливает пользователю роль легкого агента. :param user_profile: Профиль пользователя - :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent". + Предварительно снимаем тикеты, находящие в работы у пользователя. """ tickets: SearchResultGenerator = get_tickets_list(user_profile.user.email) ticket: ZenpyTicket @@ -85,7 +86,7 @@ def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) - def get_users_list() -> list: """ - Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. + Функция возвращает список пользователей Zendesk, относящихся к организации SYSTEM. """ zendesk = zenpy @@ -95,23 +96,29 @@ def get_users_list() -> list: return users -def get_tickets_list(email) -> list: +def get_tickets_list(email: str) -> Optional[list]: """ - Функция возвращает список тикетов пользователя Zendesk + Функция возвращает список тикетов пользователя Zendesk. + + :param email: Email пользователя + :return: Список тикетов пользователя """ return TicketListRequester().get_tickets_list_for_user(zenpy.get_user(email)) -def get_tickets_list_for_group(group_name): +def get_tickets_list_for_group(group_name: str) -> Optional[list]: """ - Функция возвращает список не назначенных, нерешённых тикетов группы Zendesk + Функция возвращает список не назначенных, не решённых тикетов группы Zendesk. + + :param group_name: Название группы пользователя + :return: Список тикетов группы """ return TicketListRequester().get_tickets_list_for_group(zenpy.get_group(group_name)) def update_profile(user_profile: UserProfile) -> None: """ - Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. + Функция обновляет профиль пользователя в БД в соответствии с текущим в Zendesk. :param user_profile: Профиль пользователя :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя @@ -148,6 +155,9 @@ def check_user_auth(email: str, password: str) -> bool: """ Функция проверяет, верны ли входные данные. + :param email: Email пользователя + :param password: Пароль пользователя + :return: Существует ли пользователь :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован """ creds = { @@ -165,7 +175,7 @@ def check_user_auth(email: str, password: str) -> bool: def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser) -> None: """ - Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. + Функция обновляет профиль пользователя в модели при изменении данных пользователя на Zendesk. :param profile: Профиль пользователя :param zendesk_user: Данные пользователя в Zendesk @@ -181,7 +191,10 @@ def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser) -> None: def count_users(users: list) -> tuple: """ - Функция подсчета количества сотрудников с ролями engineer и light_agent + Функция подсчета количества сотрудников с ролями engineer и light_agent. + + :param users: Список пользователей + :return: Количество инженеров, количество light_agents """ engineers, light_agents = 0, 0 for user in users: @@ -194,7 +207,7 @@ def count_users(users: list) -> tuple: def update_users_in_model() -> list: """ - Обновляет пользователей в модели UserProfile по списку пользователей в организации + Обновляет пользователей в модели UserProfile по списку пользователей в организации. """ users = get_users_list() for user in users: @@ -253,7 +266,13 @@ class DatabaseHandler(logging.Handler): def __init__(self): logging.Handler.__init__(self) - def emit(self, record): + def emit(self, record: logging.LogRecord) -> None: + """ + Функция записи в базу данных лога с изменением роли пользователя. + + :param record: Лог смены роли пользователя + :return: Запись в БД лога по смене роли пользователя с указанием новой и старой роли, а также автора изменения + """ database = RoleChangeLogs() users = record.msg if users[1]: @@ -284,7 +303,7 @@ class CsvFormatter(logging.Formatter): """ Функция форматирует запись смены роли пользователя в строку. - :param record: Запись смены роли пользователя. + :param record: Лог смены роли пользователя. :return: Строка с записью смены пользователя. """ users = record.msg @@ -307,7 +326,7 @@ class CsvFormatter(logging.Formatter): return msg -def log(user, admin=None): +def log(user: get_user_model(), admin: get_user_model() = None) -> None: """ Функция осуществляет запись логов в базу данных и csv файл. @@ -335,7 +354,7 @@ def set_session_params_for_work_page(request: WSGIRequest, count: int = None, is :param request: Получение данных с рабочей страницы пользователя :param count: Количество запрошенных тикетов - :param is_confirm: Назначение тикетов + :param is_confirm: Назначены ли тикеты :return: Перезагрузка страницы "Управление правами" соответствующего пользователя """ request.session['is_confirm'] = is_confirm diff --git a/main/forms.py b/main/forms.py index 929f3d9..e4db54a 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,5 +1,5 @@ """ -Формы. +Формы, использующиеся в приложении. """ from django import forms from django.contrib.auth.forms import AuthenticationForm diff --git a/main/models.py b/main/models.py index c934ab1..790a322 100644 --- a/main/models.py +++ b/main/models.py @@ -13,7 +13,6 @@ from access_controller.settings import ZENDESK_ROLES class UserProfile(models.Model): """ Модель профиля пользователя. - Профиль создается и изменяется при создании и изменении модель User. """ @@ -31,11 +30,7 @@ class UserProfile(models.Model): @property def zendesk_role(self) -> str: """ - Функция возвращает роль пользователя в Zendesk. - - В формате str, либо UNDEFINED, если пользователь не найден - - :return: Роль пользователя в Zendesk + Роль пользователя в Zendesk, либо UNDEFINED, если пользователь не найден. """ for role, r_id in ZENDESK_ROLES.items(): if r_id == self.custom_role_id: @@ -44,12 +39,12 @@ class UserProfile(models.Model): @receiver(post_save, sender=get_user_model()) -def create_user_profile(instance, created, **kwargs) -> None: +def create_user_profile(instance: get_user_model(), created: bool, **kwargs) -> None: """ Функция создания профиля пользователя (Userprofile) при регистрации пользователя. :param instance: Экземпляр класса User - :param created: Создание профиля пользователя + :param created: Существует ли пользователь :param kwargs: Параметры :return: Обновленный список объектов профилей пользователей """ @@ -58,7 +53,7 @@ def create_user_profile(instance, created, **kwargs) -> None: @receiver(post_save, sender=get_user_model()) -def save_user_profile(instance, **kwargs) -> None: +def save_user_profile(instance: get_user_model(), **kwargs) -> None: """ Функция записи БД профиля пользователя. @@ -84,7 +79,7 @@ class RoleChangeLogs(models.Model): class UnassignedTicketStatus(models.IntegerChoices): """ - Класс статусов не распределенных тикетов. + Модель статусов нераспределенных тикетов. :param UNASSIGNED: Снят с пользователя, перенесён в буферную группу :param RESTORED: Авторство восстановлено @@ -95,7 +90,7 @@ class UnassignedTicketStatus(models.IntegerChoices): """ UNASSIGNED = 0, 'Снят с пользователя, перенесён в буферную группу' RESTORED = 1, 'Авторство восстановлено' - NOT_FOUND = 2, 'Пока нас не было, тикет испарился из ' \ + NOT_FOUND = 2, 'Пока нас не было, тикет был перенесен из ' \ 'буферной группы. Дополнительные действия не требуются' CLOSED = 3, 'Тикет уже был закрыт. Дополнительные действия не требуются' SOLVED = 4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL' diff --git a/main/requester.py b/main/requester.py index d0c57ed..f5ba9e4 100644 --- a/main/requester.py +++ b/main/requester.py @@ -1,6 +1,8 @@ """ -Обработка тикетов. +Обработка тикетов, составление списков тикетов для пользователя и группы пользователей. """ +from typing import Optional + import requests from zenpy import TicketApi from zenpy.lib.api_objects import Ticket @@ -11,6 +13,13 @@ from main.zendesk_admin import zenpy class TicketListRequester: """ Класс обработки тикетов. + + :param email: Email пользователя + :type display: :class:`str` + :param token_or_password: Токен или пароль + :type display: :class:`str` + :param prefix: Формат строка url страницы Zendesk + :type display: :class:`str` """ def __init__(self): self.email = zenpy.credentials['email'] @@ -21,16 +30,22 @@ class TicketListRequester: self.token_or_password = zenpy.credentials.get('password') self.prefix = f'https://{zenpy.credentials.get("subdomain")}.zendesk.com/api/v2/' - def get_tickets_list_for_user(self, zendesk_user: zenpy) -> str: + def get_tickets_list_for_user(self, zendesk_user: zenpy) -> Optional[list]: """ Функция получения списка тикетов пользователя Zendesk. + + :param zendesk_user: Пользователь Zendesk + :return: Список тикетов, назначенных на данного пользователя в Zendesk """ url = self.prefix + f'users/{zendesk_user.id}/tickets/assigned' return self._get_tickets(url) - def get_tickets_list_for_group(self, group: zenpy) -> list(): + def get_tickets_list_for_group(self, group: zenpy) -> Optional[list]: """ Функция получения списка тикетов группы пользователей Zendesk. + + :param group: Название группы + :return: Список тикетов """ url = self.prefix + '/tickets' all_tickets = self._get_tickets(url) @@ -40,9 +55,12 @@ class TicketListRequester: tickets.append(ticket) return tickets - def _get_tickets(self, url: str) -> list(): + def _get_tickets(self, url: str) -> Optional[list]: """ Функция получения полного списка тикетов по url. + + :param url: Url Zendesk c указанием тикетов, назначенных на пользователя + :return: Список тикетов """ response = requests.get(url, auth=(self.email, self.token_or_password)) tickets = [] diff --git a/main/serializers.py b/main/serializers.py index 70c4352..00b85a6 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -1,5 +1,5 @@ """ -Сериализаторы. +Сериализаторы, используемые в приложении. """ from django.contrib.auth import get_user_model from rest_framework import serializers diff --git a/main/views.py b/main/views.py index 1369d94..e58533c 100644 --- a/main/views.py +++ b/main/views.py @@ -61,7 +61,7 @@ def setup_context(**kwargs) -> Dict[str, Any]: class CustomRegistrationView(RegistrationView): """ - Отображение и логика работы страницы регистрации пользователя. + Класс отображения и логики работы страницы регистрации пользователя. :param form_class: Форма, которую необходимо заполнить для регистрации :type form_class: :class:`forms.CustomRegistrationForm` @@ -86,9 +86,12 @@ class CustomRegistrationView(RegistrationView): def register(self, form: CustomRegistrationForm) -> Optional[get_user_model()]: """ Функция регистрации пользователя. - 1. Ввод email пользователя, указанный на Zendesk + + 1. Ввод email пользователя, указанный на Zendesk. + 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk и относится к организации SYSTEM, - происходит сброс ссылки с установлением пароля на указанный email + происходит сброс ссылки с установлением пароля на указанный email. + 3. Создается пользователь class User, а также его профиль. :param form: Email пользователя на Zendesk @@ -133,7 +136,7 @@ class CustomRegistrationView(RegistrationView): """ Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin. - :param user: авторизованный пользователь (получает разрешение, имея роль "admin") + :param user: Авторизованный пользователь (получает разрешение, имея роль "admin") """ if user.userprofile.role == 'admin': content_type = ContentType.objects.get_for_model(UserProfile) @@ -148,8 +151,8 @@ class CustomRegistrationView(RegistrationView): Функция возвращает url-адрес страницы, куда нужно перейти после успешной/не успешной регистрации. Используется самой django-registration. - :param user: пользователь, пытающийся зарегистрироваться - :return: адресация на страницу успешной регистрации + :param user: Пользователь, пытающийся зарегистрироваться + :return: Адресация на страницу успешной регистрации """ return self.urls[self.redirect_url] @@ -158,8 +161,8 @@ def registration_error(request: WSGIRequest) -> HttpResponse: """ Функция отображения страницы ошибки регистрации. - :param request: регистрация - :return: адресация на страницу ошибки + :param request: Регистрация + :return: Адресация на страницу ошибки """ return render(request, 'django_registration/registration_error.html') @@ -169,8 +172,8 @@ def profile_page(request: WSGIRequest) -> HttpResponse: """ Функция отображения страницы профиля. - :param request: данные пользователя из БД - :return: адресация на страницу пользователя + :param request: Данные пользователя из БД + :return: Адресация на страницу пользователя """ user_profile: UserProfile = request.user.userprofile update_profile(user_profile) @@ -187,9 +190,9 @@ def work_page(request: WSGIRequest, required_id: int) -> HttpResponse: """ Функция отображения страницы "Управления правами" для текущего пользователя (login_required). - :param request: объект пользователя + :param request: Объект пользователя :param id: id пользователя, используется для динамической адресации - :return: адресация на страницу "Управления правами" (либо на страницу "Авторизации", если id и user.id не совпадают + :return: Адресация на страницу "Управления правами" (либо на страницу "Авторизации", если id и user.id не совпадают """ users = get_users_list() if request.user.id == required_id: @@ -227,8 +230,8 @@ def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: """ Функция позволяет текущему пользователю сдать права, а именно сменить в Zendesk роль с "engineer" на "light_agent" - :param request: данные текущего пользователя (login_required) - :return: перезагрузка текущей страницы после выполнения смены роли + :param request: Данные текущего пользователя (login_required) + :return: Перезагрузка текущей страницы после выполнения смены роли """ make_light_agent(request.user.userprofile, request.user) return set_session_params_for_work_page(request) @@ -240,8 +243,8 @@ def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: Функция позволяет текущему пользователю получить права, а именно сменить в Zendesk роль с "light_agent" на "engineer". - :param request: данные текущего пользователя (login_required) - :return: перезагрузка текущей страницы после выполнения смены роли + :param request: Данные текущего пользователя (login_required) + :return: Перезагрузка текущей страницы после выполнения смены роли """ make_engineer(request.user.userprofile, request.user) return set_session_params_for_work_page(request) @@ -250,9 +253,10 @@ def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: @login_required() def work_get_tickets(request: WSGIRequest) -> HttpResponse: """ + Функция получения тикетов в работу. - :param request: - :return: + :param request: Запрос на принятие тикетов в работу + :return: Перезагрузка рабочей страницы """ zenpy_user = zenpy.get_user(request.user.email) @@ -289,6 +293,8 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM :type form_class: :class:`forms.AdminPageUsersForm` :param success_url: Адрес страницы администратора :type success_url: :class:`HttpResponseRedirect` + :param success_message: Уведомление об изменении прав + :type success_url: :class:`str` """ permission_required = 'main.has_control_access' template_name = 'pages/adm_ruleset.html' @@ -333,7 +339,12 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM class CustomLoginView(LoginView): """ - Отображение страницы авторизации пользователя + Класс отображения страницы авторизации пользователя. + + :param extra_context: Добавление в контекст статус пользователя "залогинен" + :type extra_context: :class:`dict` + :param form_class: Форма страницы авторизации + :type form_class: :class: forms.CustomAuthenticationForm """ extra_context = setup_context(login_lit=True) form_class = CustomAuthenticationForm @@ -353,7 +364,8 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): def list(self, request: WSGIRequest, *args, **kwargs) -> Response: """ - Функция возвращает список пользователей, список пользователей Zendesk, количество engineers и light-agents. + Функция возвращает список пользователей Zendesk, количество engineers и light-agents. + :param request: Запрос :param args: Аргументы :param kwargs: Параметры @@ -376,6 +388,7 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): def choose_users(zendesk: list, model: list) -> list: """ Функция формирует список пользователей, которые не зарегистрированы у нас. + :param zendesk: Список пользователей Zendesk :param model: Список пользователей (модель Userprofile) :return: Список @@ -389,7 +402,8 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): @staticmethod def get_zendesk_users(users: list) -> list: """ - Получение списка пользователей Zendesk, не являющихся админами. + Функция получения списка пользователей Zendesk, не являющихся админами. + :param users: Список пользователей :return: Список пользователей, не являющимися администраторами. """ @@ -406,8 +420,8 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: """ Функция отображения страницы статистики (для "superuser"). - :param request: данные о пользователе: email, время и интервал работы. Данные получаем через forms.StatisticForm - :return: адресация на страницу статистики + :param request: Данные о пользователе: email, время и интервал работы. Данные получаем через forms.StFatisticForm + :return: Адресация на страницу статистики """ # if not request.user.has_perm('main.has_control_access'): diff --git a/venv/lib/python3.6/site-packages/enchant/tokenize/ru.py b/venv/lib/python3.6/site-packages/enchant/tokenize/ru.py deleted file mode 100644 index 7e15379..0000000 --- a/venv/lib/python3.6/site-packages/enchant/tokenize/ru.py +++ /dev/null @@ -1,185 +0,0 @@ -# pyenchant -# -# Copyright (C) 2004-2008, Ryan Kelly -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the -# Free Software Foundation, Inc., 59 Temple Place - Suite 330, -# Boston, MA 02111-1307, USA. -# -# In addition, as a special exception, you are -# given permission to link the code of this program with -# non-LGPL Spelling Provider libraries (eg: a MSFT Office -# spell checker backend) and distribute linked combinations including -# the two. You must obey the GNU Lesser General Public License in all -# respects for all of the code used other than said providers. If you modify -# this file, you may extend this exception to your version of the -# file, but you are not obligated to do so. If you do not wish to -# do so, delete this exception statement from your version. -# -""" - - enchant.tokenize.en: Tokenizer for the English language - - This module implements a PyEnchant text tokenizer for the English - language, based on very simple rules. - -""" - -import unicodedata - -import enchant.tokenize - - -class tokenize(enchant.tokenize.tokenize): # noqa: N801 - """Iterator splitting text into words, reporting position. - - This iterator takes a text string as input, and yields tuples - representing each distinct word found in the text. The tuples - take the form: - - (,) - - Where is the word string found and is the position - of the start of the word within the text. - - The optional argument may be used to specify a - list of additional characters that can form part of a word. - By default, this list contains only the apostrophe ('). Note that - these characters cannot appear at the start or end of a word. - """ - - _DOC_ERRORS = ["pos", "pos"] - - def __init__(self, text, valid_chars=None): - self._valid_chars = valid_chars - self._text = text - self._offset = 0 - # Select proper implementation of self._consume_alpha. - # 'text' isn't necessarily a string (it could be e.g. a mutable array) - # so we can't use isinstance(text, str) to detect unicode. - # Instead we typetest the first character of the text. - # If there's no characters then it doesn't matter what implementation - # we use since it won't be called anyway. - try: - char1 = text[0] - except IndexError: - self._initialize_for_binary() - else: - if isinstance(char1, str): - self._initialize_for_unicode() - else: - self._initialize_for_binary() - - def _initialize_for_binary(self): - self._consume_alpha = self._consume_alpha_b - if self._valid_chars is None: - self._valid_chars = ("'",) - - def _initialize_for_unicode(self): - self._consume_alpha = self._consume_alpha_u - if self._valid_chars is None: - # XXX TODO: this doesn't seem to work correctly with the - # MySpell provider, disabling for now. - # Allow unicode typographic apostrophe - # self._valid_chars = (u"'",u"\u2019") - self._valid_chars = ("'",) - - def _consume_alpha_b(self, text, offset): - """Consume an alphabetic character from the given bytestring. - - Given a bytestring and the current offset, this method returns - the number of characters occupied by the next alphabetic character - in the string. Non-ASCII bytes are interpreted as utf-8 and can - result in multiple characters being consumed. - """ - assert offset < len(text) - if text[offset].isalpha(): - return 1 - elif text[offset] >= "\x80": - return self._consume_alpha_utf8(text, offset) - return 0 - - def _consume_alpha_utf8(self, text, offset): - """Consume a sequence of utf8 bytes forming an alphabetic character.""" - incr = 2 - u = "" - while not u and incr <= 4: - try: - try: - # In the common case this will be a string - u = text[offset : offset + incr].decode("utf8") - except AttributeError: - # Looks like it was e.g. a mutable char array. - try: - s = text[offset : offset + incr].tostring() - except AttributeError: - s = "".join([c for c in text[offset : offset + incr]]) - u = s.decode("utf8") - except UnicodeDecodeError: - incr += 1 - if not u: - return 0 - if u.isalpha(): - return incr - if unicodedata.category(u)[0] == "M": - return incr - return 0 - - def _consume_alpha_u(self, text, offset): - """Consume an alphabetic character from the given unicode string. - - Given a unicode string and the current offset, this method returns - the number of characters occupied by the next alphabetic character - in the string. Trailing combining characters are consumed as a - single letter. - """ - assert offset < len(text) - incr = 0 - if text[offset].isalpha(): - incr = 1 - while offset + incr < len(text): - if unicodedata.category(text[offset + incr])[0] != "M": - break - incr += 1 - return incr - - def next(self): - text = self._text - offset = self._offset - while offset < len(text): - # Find start of next word (must be alpha) - while offset < len(text): - incr = self._consume_alpha(text, offset) - if incr: - break - offset += 1 - cur_pos = offset - # Find end of word using, allowing valid_chars - while offset < len(text): - incr = self._consume_alpha(text, offset) - if not incr: - if text[offset] in self._valid_chars: - incr = 1 - else: - break - offset += incr - # Return if word isn't empty - if cur_pos != offset: - # Make sure word doesn't end with a valid_char - while text[offset - 1] in self._valid_chars: - offset = offset - 1 - self._offset = offset - return (text[cur_pos:offset], cur_pos) - self._offset = offset - raise StopIteration() From 029b602f96c26e2962a5a02b1c2c429a7cc80701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 20 May 2021 17:45:25 +0300 Subject: [PATCH 47/65] Update docs (statistic, zendesk_admin) --- main/statistic_data.py | 30 +++++++++++++++++++++--------- main/zendesk_admin.py | 7 +++---- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/main/statistic_data.py b/main/statistic_data.py index f569c68..e5f4fdc 100644 --- a/main/statistic_data.py +++ b/main/statistic_data.py @@ -172,8 +172,8 @@ class StatisticData: """ Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. - :return: Данные о смене статусов пользователя. Если пользователь не найден или - интервал времени некорректен - ошибку. + :return: Данные о смене статусов пользователя. + Если пользователь не найден или интервал времени некорректен - ошибку. """ if not self.check_time(): self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] @@ -208,9 +208,12 @@ class StatisticData: if self.data[log_index].new_role == ROLES['engineer']: self.engineer_logic(log_index) - def engineer_logic(self, log_index): + def engineer_logic(self, log_index: int) -> None: """ - Функция обрабатывает основную часть работы инженера + Функция обрабатывает подсчета времени работы инженера. + + :param log_index: Индекс текущего лога + :return: Дополняет статистику работы инженера временем между текущим и последующим логом """ current_log, next_log = self.data[log_index], self.data[log_index + 1] if current_log.change_time.date() != next_log.change_time.date(): @@ -222,9 +225,12 @@ class StatisticData: 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): + def post_engineer_logic(self, last_log: RoleChangeLogs) -> None: """ - Функция обрабатывает случай, когда нам изветсно что инженер работал и после диапазона + Функция обрабатывает случай, когда пользователя назначили инженером в последнем логе. + + :param last_log: Последний лог изменения роли, в результате которого пользователь назначен инженером. + :return: Дополняет статистику работы """ 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(): @@ -237,9 +243,12 @@ class StatisticData: 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): + def prev_engineer_logic(self, first_log: RoleChangeLogs) -> None: """ - Функция обрабатывает случай, когда нам изветсно что инженер начал работу до диапазона + Функция обрабатывает случай, когда пользователь в первом логе диапазона был назначен легким агентом. + + :param first_log_log: Первый лог в диапазоне, в результате которого пользователь назначен легким агентом. + :return: Дополняет статистику работы """ self.fill_daterange(max(get_user_model().objects.get(email=self.email).date_joined.date(), self.start_date), first_log.change_time.date()) @@ -258,7 +267,10 @@ class StatisticData: def clear_statistic(self) -> dict: """ - Функция осуществляет обновление всех дней. + Функция осуществляет очищает статистику и устанавливает время в диапазоне start_date - end_date в количестве + 24*3600 секунд. + + :return: Обновленная статистика """ self.statistic.clear() self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) diff --git a/main/zendesk_admin.py b/main/zendesk_admin.py index c6a383f..a979ca4 100644 --- a/main/zendesk_admin.py +++ b/main/zendesk_admin.py @@ -32,7 +32,7 @@ class ZendeskAdmin: self.buffer_group_id= self.get_group(ZENDESK_GROUPS['buffer']).id self.solved_tickets_user_id = self.get_user(SOLVED_TICKETS_EMAIL).id - def update_user(self, user: ZenpyUser) -> bool: + def update_user(self, user: ZenpyUser) -> None: """ Функция сохраняет изменение пользователя в Zendesk. @@ -40,7 +40,7 @@ class ZendeskAdmin: """ self.admin.users.update(user) - def update_tickets(self, tickets: List[ZenpyTicket]): + def update_tickets(self, tickets: List[ZenpyTicket]) -> None: """ Функция сохраняет изменение тикетов в Zendesk. @@ -79,7 +79,7 @@ class ZendeskAdmin: return group return None - def get_user_org(self, email: str) -> str: + def get_user_org(self, email: str) -> Optional[str]: """ Функция возвращает организацию, к которой относится пользователь по его email. @@ -96,7 +96,6 @@ class ZendeskAdmin: :raise: :class:`ValueError`: исключение, вызываемое если email не введен в env :raise: :class:`APIException`: исключение, вызываемое если пользователя с таким email не существует в Zendesk """ - if self.credentials.get('email') is None: raise ValueError('access_controller email not in env') From 26f32327b3914a4221130a12f47a7540af4b4989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 20 May 2021 18:49:04 +0300 Subject: [PATCH 48/65] Update REDME --- README.md | 4 ++-- README.rst | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b3376cf..f989953 100644 --- a/README.md +++ b/README.md @@ -157,13 +157,13 @@ autopep8 --in-place filename ##Для проверки орфографии: cd docs -(set -a && source ../.env && make spelling) +make spelling ##Для обновления документации: m2r README.md cd docs -(set -a && source ../.env && make html) +make html ## Read more diff --git a/README.rst b/README.rst index fb32f4b..8c9907d 100644 --- a/README.rst +++ b/README.rst @@ -182,7 +182,7 @@ autopep8 --in-place filename cd docs -(set -a && source ../.env && make spelling) +make spelling Для обновления документации: ---------------------------- @@ -191,7 +191,7 @@ m2r README.md cd docs -(set -a && source ../.env && make html) +make html Read more --------- From 8039e286ef318599db0f1cc9be6b5c6f2c809377 Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 20 May 2021 20:03:13 +0300 Subject: [PATCH 49/65] Fix tests.py, add migration, fix pylint --- .../0018_alter_unassignedticket_ticket_id.py | 18 +++++++++++++ main/tests.py | 27 ++++++++++++------- 2 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 main/migrations/0018_alter_unassignedticket_ticket_id.py diff --git a/main/migrations/0018_alter_unassignedticket_ticket_id.py b/main/migrations/0018_alter_unassignedticket_ticket_id.py new file mode 100644 index 0000000..7899c3c --- /dev/null +++ b/main/migrations/0018_alter_unassignedticket_ticket_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.3 on 2021-05-20 17:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0017_auto_20210408_1943'), + ] + + operations = [ + migrations.AlterField( + model_name='unassignedticket', + name='ticket_id', + field=models.IntegerField(help_text='Номер тикета, для которого сняли ответственного'), + ), + ] diff --git a/main/tests.py b/main/tests.py index 5365782..7946c01 100644 --- a/main/tests.py +++ b/main/tests.py @@ -30,11 +30,11 @@ class UsersBaseTestCase(TestCase): self.admin = 'admin@gmail.com' self.engineer = 'customer@example.com' self.agent_client = Client() - self.agent_client.force_login(User.objects.get(email=self.light_agent)) + self.agent_client.force_login(get_user_model().objects.get(email=self.light_agent)) self.admin_client = Client() - self.admin_client.force_login(User.objects.get(email=self.admin)) + self.admin_client.force_login(get_user_model().objects.get(email=self.admin)) self.engineer_client = Client() - self.engineer_client.force_login(User.objects.get(email=self.engineer)) + self.engineer_client.force_login(get_user_model().objects.get(email=self.engineer)) class RegistrationTestCase(TestCase): @@ -252,7 +252,10 @@ class MakeLightAgentTestCase(UsersBaseTestCase): def test_control_page_make_light_agent_one(self, zenpy_mock, _user_tickets_mock): self.admin_client.post( reverse_lazy('control'), - data={'users': [get_user_model().objects.get(email=self.engineer).userprofile.id], 'light_agent': 'light_agent'} + data={ + 'users': [get_user_model().objects.get(email=self.engineer).userprofile.id], + 'light_agent': 'light_agent' + } ) call_list = zenpy_mock.update_user.call_args_list mock_object = call_list[0][0][0] @@ -330,7 +333,12 @@ class PasswordResetTestCase(UsersBaseTestCase): Функция корректности отработки неверно введенного email. """ with self.settings(EMAIL_BACKEND=self.email_backend): - resp = self.agent_client.post(reverse_lazy('password_reset'), data={'email': self.light_agent + str(random.random())}) + resp = self.agent_client.post( + reverse_lazy('password_reset'), + data={ + 'email': self.light_agent + str(random.random()) + } + ) self.assertRedirects(resp, reverse('password_reset_done')) self.assertEqual(resp.status_code, 302) self.assertEqual(len(mail.outbox), 0) @@ -503,11 +511,11 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.zenpy') @patch('main.views.get_tickets_list_for_group') - def test_take_zero_tickets(self, TicketsMock, zenpy_mock): + def test_take_zero_tickets(self, tickets_mock, zenpy_mock): """ Функция проверки попытки назначения нуля тикета на engineer. """ - TicketsMock.return_value = [Mock()] * 3 + tickets_mock.return_value = [Mock()] * 3 zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) self.engineer_client.post(reverse('work_get_tickets'), data={'count_tickets': 0}) tickets = zenpy_mock.update_tickets.call_args[0][0] @@ -596,9 +604,8 @@ class LoggingTestCase(UsersBaseTestCase): @staticmethod def get_file_output(): - file = open('logs/logs.csv', 'r') - file_output = file.readlines()[-1] - file.close() + with open('logs/logs.csv', 'r') as file: + file_output = file.readlines()[-1] return file_output def test_engineer_with_admin(self): From 45ac48448044b3c716595fdf028d313f0bd6005f Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 20 May 2021 20:41:55 +0300 Subject: [PATCH 50/65] Update statistic docs --- main/extra_func.py | 4 ++-- main/statistic_data.py | 44 ++++++++++++++++++++++++++++-------------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 1c87f65..53bf919 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -2,7 +2,7 @@ Вспомогательные функции. """ import logging -from datetime import timedelta +from datetime import timedelta, date from typing import Union, Optional from django.contrib.auth import get_user_model @@ -219,7 +219,7 @@ def update_users_in_model() -> list: return users -def daterange(start_date: timedelta, end_date: timedelta) -> list: +def daterange(start_date: date, end_date: date) -> list: """ Функция возвращает список дней с start_date по end_date, исключая правую границу. diff --git a/main/statistic_data.py b/main/statistic_data.py index e5f4fdc..cfb506e 100644 --- a/main/statistic_data.py +++ b/main/statistic_data.py @@ -1,6 +1,9 @@ """ Обработка статистики. + +Обнаруживает факт изменения роли пользователя и вычисляет отработанное на смене время. """ + from datetime import date, datetime, timedelta from typing import Optional @@ -14,7 +17,7 @@ from main.models import RoleChangeLogs class StatisticData: """ - Класс для учета статистики интервалов работы пользователей. + Класс для учета статистики времени работы пользователей. Передаваемые параметры: start_date, end_date, email, stat. :param display: Формат отображения времени (часы, минуты) @@ -37,7 +40,7 @@ class StatisticData: :type statistic: :class:`dict` """ - def __init__(self, start_date, end_date, user_email, stat=None): + def __init__(self, start_date, end_date, user_email: str, stat=None): self.display = None self.interval = None self.start_date = start_date @@ -57,7 +60,8 @@ class StatisticData: """ Функция возвращает статистику работы пользователя. - :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). + :return: Словарь statistic с применением формата отображения + и интервала работы (если они есть). None, если были ошибки при создании. """ if self.is_valid_statistic(): @@ -117,7 +121,7 @@ class StatisticData: """ return not self.errors - def _use_display(self, stat: list) -> list: + def _use_display(self, stat: dict) -> dict: """ Функция приводит данные к формату отображения. @@ -136,7 +140,9 @@ class StatisticData: def _use_interval(self, stat: dict) -> dict: """ - Функция объединяет ключи и значения в соответствии с интервалом работы. + Переупаковка результата в соответствии с указанным временным диапазоном + + Сжимает набор дней в месяцы, если указан режим работы "по месяцам" :param stat: Статистика работы пользователя :return: Обновленная статистика @@ -210,7 +216,7 @@ class StatisticData: def engineer_logic(self, log_index: int) -> None: """ - Функция обрабатывает подсчета времени работы инженера. + Функция вычисляет время работы инженера. :param log_index: Индекс текущего лога :return: Дополняет статистику работы инженера временем между текущим и последующим логом @@ -227,7 +233,9 @@ class StatisticData: def post_engineer_logic(self, last_log: RoleChangeLogs) -> None: """ - Функция обрабатывает случай, когда пользователя назначили инженером в последнем логе. + Обработка случая, в котором инженер не закрыл смену. + + В таком случае считается всё время от момента открытия смены до текущего момента. :param last_log: Последний лог изменения роли, в результате которого пользователь назначен инженером. :return: Дополняет статистику работы @@ -245,16 +253,23 @@ class StatisticData: def prev_engineer_logic(self, first_log: RoleChangeLogs) -> None: """ - Функция обрабатывает случай, когда пользователь в первом логе диапазона был назначен легким агентом. + Обработка случая, в котором инженер закрыл смену в отражаемом периоде, а открыл её до этого периода. + + В таком случае должен быть учтён только период от начала отображаемого диапазона до закрытия смены. :param first_log_log: Первый лог в диапазоне, в результате которого пользователь назначен легким агентом. :return: Дополняет статистику работы """ - self.fill_daterange(max(get_user_model().objects.get(email=self.email).date_joined.date(), self.start_date), - first_log.change_time.date()) + self.fill_daterange( + max( + get_user_model().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: + def fill_daterange(self, first: date, last: date, val: int = 24 * 3600) -> None: """ Функция заполняет диапазон дат значением val (по умолчанию val = кол-во секунд в 1 дне). @@ -265,12 +280,11 @@ class StatisticData: for day in daterange(first, last): self.statistic[day] = val - def clear_statistic(self) -> dict: + def clear_statistic(self) -> None: """ - Функция осуществляет очищает статистику и устанавливает время в диапазоне start_date - end_date в количестве - 24*3600 секунд. + Чистка статистики и установка времени по умолчанию. - :return: Обновленная статистика + Устанавливает время смены в 0 """ self.statistic.clear() self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) From 975b6085770f055eff16f6bd053b28cd45575a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sun, 23 May 2021 17:09:52 +0300 Subject: [PATCH 51/65] Add tests docs --- access_controller/settings.py | 1 + main/extra_func.py | 1 - main/tests.py | 345 ++++++++++++++++++++++++++++------ 3 files changed, 288 insertions(+), 59 deletions(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index 6293efc..30875f6 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -10,6 +10,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/3.1/ref/settings/ """ import os + from pathlib import Path diff --git a/main/extra_func.py b/main/extra_func.py index 53bf919..e652a7e 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -29,7 +29,6 @@ def update_role(user_profile: UserProfile, role: int, who_changes: get_user_mode :param user_profile: Профиль пользователя :param role: Новая роль :param who_changes: Пользователь, меняющий роль - :return: Пользователь с обновленной ролью """ zendesk = zenpy user = zendesk.get_user(user_profile.user.email) diff --git a/main/tests.py b/main/tests.py index 60f31ed..4353ddf 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,7 +1,8 @@ import random from unittest.mock import patch, Mock -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model +# from django.contrib.auth.models import User from django.core import mail from django.http import HttpResponseRedirect from django.template.loader import render_to_string @@ -16,47 +17,112 @@ from main.extra_func import log class UsersBaseTestCase(TestCase): - """Базовый класс загружения данных для тестов с пользователями""" + """ + Базовый класс загрузки данных для тестов с пользователями. + + Для тестов используются фикстуры тестовых пользователей (test_users.json). + """ fixtures = ['fixtures/test_users.json'] - def setUp(self): - """Добавление в переменные почт и клиентов для пользователей""" + def setUp(self) -> None: + """ + Функция предустановки значений переменных. + + Добавляем email тестовых пользователей и создаем клиентов для тестов. + + :param light_agent: email тестового пользователя с правами light_agent + :type light_agent: :class:`str` + :param engineer: email тестового пользователя с правами engineer + :type engineer: :class:`str` + :param admin: email тестового пользователя с правами admin + :type admin: :class:`str` + :param agent_client: клиент, залогиненный как пользователь с email light_agent + :type agent_client: :class:`django.test.client.Client` + :param engineer_client: клиент, залогиненный как пользователь с email engineer + :type engineer_client: :class:`django.test.client.Client` + :param admin_client: клиент, залогиненный как пользователь с email admin + :type admin_client: :class:`django.test.client.Client` + """ self.light_agent = '123@test.ru' self.admin = 'admin@gmail.com' - self.engineer = 'customer@example.com' + self.engineer = 'customer@example.com' self.agent_client = Client() - self.agent_client.force_login(User.objects.get(email=self.light_agent)) + self.agent_client.force_login(get_user_model().objects.get(email=self.light_agent)) self.admin_client = Client() - self.admin_client.force_login(User.objects.get(email=self.admin)) + self.admin_client.force_login(get_user_model().objects.get(email=self.admin)) self.engineer_client = Client() - self.engineer_client.force_login(User.objects.get(email=self.engineer)) + self.engineer_client.force_login(get_user_model().objects.get(email=self.engineer)) class RegistrationTestCase(TestCase): + """ + Класс тестирования регистрации. + + Для тестов используются фикстуры с данными пользователей engeneer и light_agent (data.json). + """ fixtures = ['fixtures/data.json'] - def setUp(self): + def setUp(self) -> None: + """ + Функция предустановки значений переменных. + + Добавляем email тестовых пользователей и создаем клиентов для тестов. + + :param email_backend: locmem бэкенд со списком отправленных писем + :type email_backend: :class:`str` + :param any_zendesk_user_email: email пользователя, зарегистрированного на Zendesk + :type any_zendesk_user_email: :class:`str` + :param zendesk_admin_email: email администратора + :type zendesk_admin_email: :class:`str` + :param client: новый клиент + :type client: :class:`django.test.client.Client` + + """ self.email_backend = 'django.core.mail.backends.locmem.EmailBackend' self.any_zendesk_user_email = 'idar.sokurov.05@mail.ru' self.zendesk_admin_email = 'idar.sokurov.05@mail.ru' self.client = Client() - def test_registration_complete_redirect(self): + def test_registration_complete_redirect(self) -> None: + """ + Функция тестирования успешной регистрации пользователя. + + Проверяет, что в случае если email пользователя зарегистрирован на Zendesk, была заполнена форма регистрации + и направлено письмо со ссылкой для завершения регистрации, происходит редирект на страницу завершения + регистрации. + """ with self.settings(EMAIL_BACKEND=self.email_backend): resp = self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) self.assertRedirects(resp, reverse('password_reset_done')) - def test_registration_fail_redirect(self): + def test_registration_fail_redirect(self) -> None: + """ + Функция тестирования неуспешной регистрации пользователя (введен email, не зарегистированный на Zendesk). + + Проверяет, что происходит редирект на страницу "registration disallowed" + """ with self.settings(EMAIL_BACKEND=self.email_backend): resp = self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email + 'asd'}) self.assertRedirects(resp, reverse('django_registration_disallowed')) - def test_registration_user_already_exist(self): + def test_registration_user_already_exist(self) -> None: + """ + Функция тестирования попытки зарегистрироваться, используя email уже зарегистрированного в приложении + пользователя ("123@test.ru"). + + Проверяет, что пользователь получает сообщение "Этот адрес электронной почты уже используется" + """ with self.settings(EMAIL_BACKEND=self.email_backend) and translation.override('ru'): resp = self.client.post(reverse('registration'), data={'email': '123@test.ru'}) self.assertContains(resp, 'Этот адрес электронной почты уже используется', count=1, status_code=200) - def test_registration_send_email(self): + def test_registration_send_email(self) -> None: + """ + Функция тестирования отправки email пользователю при регистрации. + + Проверяет отправку уведомления на указанный пользователем адрес, а также содержание письма (заголовка и тела) + через email locmem backend. + """ with self.settings(EMAIL_BACKEND=self.email_backend): response: HttpResponseRedirect = \ self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) @@ -71,50 +137,88 @@ class RegistrationTestCase(TestCase): correct_body = render_to_string('registration/password_reset_email.html', email_context, response.request) self.assertEqual(mail.outbox[0].body, correct_body) - def test_registration_user_creating(self): + def test_registration_user_creating(self) -> None: + """ + Функция тестирования создания пользователя приложения при регистрации. + + Проверяет соответствие имени созданного пользователя с именем пользователя в Zendesk + """ with self.settings(EMAIL_BACKEND=self.email_backend): self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) - user = User.objects.get(email=self.any_zendesk_user_email) + user = get_user_model().objects.get(email=self.any_zendesk_user_email) zendesk_user = zenpy.get_user(self.any_zendesk_user_email) self.assertEqual(user.userprofile.name, zendesk_user.name) - def test_permissions_applying(self): + def test_permissions_applying(self) -> None: + """ + Функция тестирования создания администратора и присвоения ему соответствующих прав. + + Проверяет, что у созданного пользователя роль "admin" и права "has_control_access". + """ with self.settings(EMAIL_BACKEND=self.email_backend): self.client.post(reverse('registration'), data={'email': self.zendesk_admin_email}) - user = User.objects.get(email=self.zendesk_admin_email) + user = get_user_model().objects.get(email=self.zendesk_admin_email) self.assertEqual(user.userprofile.role, 'admin') self.assertTrue(user.has_perm('main.has_control_access')) class MakeEngineerTestCase(UsersBaseTestCase): + """ + Класс тестирования присвоения пользователю роли engineer. + + В тестах используется @patch('main.extra_func.zenpy') замещающий API Zendesk. + """ @patch('main.extra_func.zenpy') - def test_become_engineer_redirect(self, _zenpy_mock): - user = User.objects.get(email=self.light_agent) + def test_become_engineer_redirect(self, _zenpy_mock: zenpy) -> None: + """ + Функция тестирования редиректа на рабочую страницу тестового пользователя при назначении его инженером. + """ + user = get_user_model().objects.get(email=self.light_agent) resp = self.agent_client.post(reverse_lazy('work_become_engineer')) self.assertRedirects(resp, reverse('work', args=[user.id])) self.assertEqual(resp.status_code, 302) @patch('main.extra_func.zenpy') - def test_light_agent_make_engineer(self, zenpy_mock): + def test_light_agent_make_engineer(self, zenpy_mock: zenpy) -> None: + """ + Функция тестирования назначения легкого агента на роль инженера. + + Проверяет установку роли "engineer" в Zendesk. + """ self.agent_client.post(reverse_lazy('work_become_engineer')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_admin_make_engineer(self, zenpy_mock): + def test_admin_make_engineer(self, zenpy_mock: zenpy) -> None: + """ + Функция тестирования назначения администратора на роль инженера. + + Проверяет установку роли "engineer" в Zendesk. + """ self.admin_client.post(reverse_lazy('work_become_engineer')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_engineer_make_engineer(self, zenpy_mock): + def test_engineer_make_engineer(self, zenpy_mock: zenpy) -> None: + """ + Функция тестирования назначения инженера на роль инженера. + + Проверяет установку роли "engineer" в Zendesk. + """ self.engineer_client.post(reverse_lazy('work_become_engineer')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_control_page_make_engineer_one(self, zenpy_mock): + def test_control_page_make_engineer_one(self, zenpy_mock: zenpy) -> None: + """ + Функция тестирования назначения администратором одного инженера на странице "Управление". + + Проверяет обновление администратором роли пользователя с light_agent на engineer. + """ self.admin_client.post( reverse_lazy('control'), - data={'users': [User.objects.get(email=self.light_agent).userprofile.id], 'engineer': 'engineer'} + data={'users': [get_user_model().objects.get(email=self.light_agent).userprofile.id], 'engineer': 'engineer'} ) call_list = zenpy_mock.update_user.call_args_list mock_object = call_list[0][0][0] @@ -122,13 +226,18 @@ class MakeEngineerTestCase(UsersBaseTestCase): self.assertEqual(mock_object.custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_control_page_make_engineer_many(self, zenpy_mock): + def test_control_page_make_engineer_many(self, zenpy_mock: zenpy) -> None: + """ + Функция тестирования назначения администратором нескольких инженеров на странице "Управление". + + Проверяет обновление администратором ролей двух пользователей с light_agent на engineer. + """ self.admin_client.post( reverse_lazy('control'), data={ 'users': [ - User.objects.get(email=self.light_agent).userprofile.id, - User.objects.get(email=self.engineer).userprofile.id, + get_user_model().objects.get(email=self.light_agent).userprofile.id, + get_user_model().objects.get(email=self.engineer).userprofile.id, ], 'engineer': 'engineer' } @@ -141,18 +250,33 @@ class MakeEngineerTestCase(UsersBaseTestCase): class MakeLightAgentTestCase(UsersBaseTestCase): + """ + Класс тестирования присвоения пользователю роли light_agent. + + В тестах используется @patch('main.extra_func.zenpy') замещающий API Zendesk, а также + @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]), предоставляющий список + тикетов. + """ @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_hand_over_redirect(self, _zenpy_mock, _user_tickets_mock): - user = User.objects.get(email=self.engineer) + def test_hand_over_redirect(self, _zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + """ + Функция тестирования переадресации инженера на рабочую страницу, после сдачи прав. + """ + user = get_user_model().objects.get(email=self.engineer) resp = self.engineer_client.post(reverse_lazy('work_hand_over')) self.assertRedirects(resp, reverse('work', args=[user.id])) self.assertEqual(resp.status_code, 302) @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_engineer_make_light_agent_no_tickets(self, zenpy_mock, _user_tickets_mock): + def test_engineer_make_light_agent_no_tickets(self, zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + """ + Функция тестирования назначения инженера легким агентом, в случае, когда у него в работе нет тикетов. + + Проверяет назначение роли light_agent в Zendesk. + """ self.engineer_client.post(reverse_lazy('work_hand_over')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) @@ -160,7 +284,15 @@ class MakeLightAgentTestCase(UsersBaseTestCase): [Mock(id=1, status='solved'), Mock(id=2, status='open'), Mock(id=3, status='open')] ]) @patch('main.extra_func.zenpy') - def test_engineer_make_light_agent_with_tickets(self, zenpy_mock, _user_tickets_mock): + def test_engineer_make_light_agent_with_tickets(self, zenpy_mock: zenpy, _user_tickets_mock: list): + """ + Функция тестирования назначения инженера легким агентом, в случае, когда у него в работе есть тикеты. + + Для тестирования принимается, что в работе у инженера находится 3 тикета, один в состоянии: решен, + два в состоянии: открыт. + Проверяет распределение тикетов (поместить в решенные или назначить нового ответственного), + а также назначение роли light_agent в Zendesk. + """ zenpy_mock.solved_tickets_user_id = Mock() self.engineer_client.post(reverse_lazy('work_hand_over')) @@ -172,7 +304,12 @@ class MakeLightAgentTestCase(UsersBaseTestCase): @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_admin_make_light_agent_no_tickets(self, zenpy_mock, _user_tickets_mock): + def test_admin_make_light_agent_no_tickets(self, zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + """ + Функция тестирования назначения администратора на роль легкого агента. + + Проверяет назначение роли light_agent в Zendesk. + """ self.admin_client.post(reverse_lazy('work_hand_over')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) @@ -180,7 +317,15 @@ class MakeLightAgentTestCase(UsersBaseTestCase): [Mock(id=1, status='solved'), Mock(id=2, status='open'), Mock(id=3, status='open')] ]) @patch('main.extra_func.zenpy') - def test_admin_make_light_agent_with_tickets(self, zenpy_mock, _user_tickets_mock): + def test_admin_make_light_agent_with_tickets(self, zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + """ + Функция тестирования назначения администратора легким агентом, в случае, когда у него в работе есть тикеты. + + Для тестирования принимается, что в работе находится 3 тикета, один в состоянии: решен, + два в состоянии: открыт. + Проверяет распределение тикетов (поместить в решенные или назначить нового ответственного), + а также назначение роли light_agent в Zendesk. + """ zenpy_mock.solved_tickets_user_id = Mock() self.admin_client.post(reverse_lazy('work_hand_over')) @@ -192,16 +337,26 @@ class MakeLightAgentTestCase(UsersBaseTestCase): @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_light_agent_make_light_agent(self, zenpy_mock, _user_tickets_mock): + def test_light_agent_make_light_agent(self, zenpy_mock: zenpy, _user_tickets_mock: list): + """ + Функция тестирования назначения легкого агента на роль легкого агента. + + Проверяет назначение роли light_agent в Zendesk. + """ self.agent_client.post(reverse_lazy('work_hand_over')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_control_page_make_light_agent_one(self, zenpy_mock, _user_tickets_mock): + def test_control_page_make_light_agent_one(self, zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + """ + Функция тестирования назначения администратором одного легкого агента на странице "Управление". + + Проверяет обновление администратором роли пользователя с engineer на light_agent. + """ self.admin_client.post( reverse_lazy('control'), - data={'users': [User.objects.get(email=self.engineer).userprofile.id], 'light_agent': 'light_agent'} + data={'users': [get_user_model().objects.get(email=self.engineer).userprofile.id], 'light_agent': 'light_agent'} ) call_list = zenpy_mock.update_user.call_args_list mock_object = call_list[0][0][0] @@ -210,13 +365,19 @@ class MakeLightAgentTestCase(UsersBaseTestCase): @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[], []]) @patch('main.extra_func.zenpy') - def test_control_page_make_light_agent_many(self, zenpy_mock, _user_tickets_mock): + def test_control_page_make_light_agent_many(self, zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + """ + Функция тестирования назначения администратором нескольких легких агентов на странице "Управление". + + Проверяет обновление администратором ролей двух пользователей с engineer на light_agent. + """ + self.admin_client.post( reverse_lazy('control'), data={ 'users': [ - User.objects.get(email=self.light_agent).userprofile.id, - User.objects.get(email=self.engineer).userprofile.id, + get_user_model().objects.get(email=self.light_agent).userprofile.id, + get_user_model().objects.get(email=self.engineer).userprofile.id, ], 'light_agent': 'light_agent' } @@ -229,18 +390,32 @@ class MakeLightAgentTestCase(UsersBaseTestCase): class PasswordResetTestCase(UsersBaseTestCase): + """ + Класс тестирования сброса пароля. + """ def setUp(self): super().setUp() self.email_backend = 'django.core.mail.backends.locmem.EmailBackend' - def test_redirect(self): + def test_redirect(self) -> None: + """ + Функция тестирования успешной смены пароля. + + Проверяется переадресация на страницу завершения смены пароля, в случае, когда пользователь существует и на его + email было направлено письмо для сброса пароля. + """ with self.settings(EMAIL_BACKEND=self.email_backend): resp = self.agent_client.post(reverse_lazy('password_reset'), data={'email': self.light_agent}) self.assertRedirects(resp, reverse('password_reset_done')) self.assertEqual(resp.status_code, 302) - def test_send_email(self): + def test_send_email(self) -> None: + """ + Функция тестирования отправки email для сброса пароля. + + Проверяет наличие отправленного письма, и его содержание, сверяет email адресата с email пользователя. + """ with self.settings(EMAIL_BACKEND=self.email_backend): response: HttpResponseRedirect = \ self.agent_client.post(reverse_lazy('password_reset'), data={'email': self.light_agent}) @@ -255,12 +430,22 @@ class PasswordResetTestCase(UsersBaseTestCase): correct_body = render_to_string('registration/password_reset_email.html', email_context, response.request) self.assertEqual(mail.outbox[0].body, correct_body) - def test_email_invalid(self): + def test_email_invalid(self) -> None: + """ + Функция тестирования попытки смены пароля с некорректным email. + + Проверяет уведомление пользователя о неверном адресе электронной почты. + """ with self.settings(EMAIL_BACKEND=self.email_backend) and translation.override('ru'): resp = self.agent_client.post(reverse_lazy('password_reset'), data={'email': 1}) self.assertContains(resp, 'Введите правильный адрес электронной почты.', count=1, status_code=200) - def test_user_does_not_exist(self): + def test_user_does_not_exist(self) -> None: + """ + Функция тестирования попытки смены пароля с email, который не зарегистрирован. + + Проверяет отсутствие отправки письма о смене пароля. + """ with self.settings(EMAIL_BACKEND=self.email_backend): resp = self.agent_client.post(reverse_lazy('password_reset'), data={'email': self.light_agent + str(random.random())}) self.assertRedirects(resp, reverse('password_reset_done')) @@ -269,18 +454,31 @@ class PasswordResetTestCase(UsersBaseTestCase): class PasswordChangeTestCase(UsersBaseTestCase): + """ + Класс тестирования смены пароля. + """ - def setUp(self): + def setUp(self) -> None: super().setUp() self.set_password() - def set_password(self): - user: User = User.objects.get(email=self.light_agent) + def set_password(self) -> None: + """ + Функция предустанавливает тестовому пользователю с ролью light_agent пароль 'ImpossiblyHardPassword' и создает + клиента с соответствующими данным для тестирования. + """ + user = get_user_model().objects.get(email=self.light_agent) user.set_password('ImpossiblyHardPassword') user.save() - self.agent_client.force_login(User.objects.get(email=self.light_agent)) + self.agent_client.force_login(get_user_model().objects.get(email=self.light_agent)) - def test_change_successful(self): + def test_change_successful(self) -> None: + """ + Функция тестирования успешной смены пароля. + + Проверяет установку нового пароля пользователю при вводе корректных данных: старый пароль, новый пароль + (2 раза). + """ self.agent_client.post( reverse_lazy('password_change'), data={ @@ -289,10 +487,15 @@ class PasswordChangeTestCase(UsersBaseTestCase): 'new_password2': 'EasyPassword', } ) - user = User.objects.get(email=self.light_agent) + user = get_user_model().objects.get(email=self.light_agent) self.assertTrue(user.check_password('EasyPassword')) - def test_invalid_old_password(self): + def test_invalid_old_password(self) -> None: + """ + Функция тестирования смены пароля, при неверном вводе старого пароля. + + Проверяет текст уведомления пользователя 'Ваш старый пароль введен неправильно'. + """ with translation.override('ru'): resp = self.agent_client.post( reverse_lazy('password_change'), @@ -304,7 +507,12 @@ class PasswordChangeTestCase(UsersBaseTestCase): ) self.assertContains(resp, 'Ваш старый пароль введен неправильно', count=1, status_code=200) - def test_different_new_passwords(self): + def test_different_new_passwords(self) -> None: + """ + Функция тестирования смены пароля, при вводе несовпадающих новых паролей. + + Проверяет текст уведомления пользователя 'Введенные пароли не совпадают'. + """ with translation.override('ru'): resp = self.agent_client.post( reverse_lazy('password_change'), @@ -317,6 +525,11 @@ class PasswordChangeTestCase(UsersBaseTestCase): self.assertContains(resp, 'Введенные пароли не совпадают', count=1, status_code=200) def test_invalid_new_password1(self): + """ + Функция тестирования попытки смены пароля, когда новый пароль не соответствует требованиям: слишком короткий. + + Проверяет текст уведомления пользователя 'Введённый пароль слишком короткий'. + """ with translation.override('ru'): resp = self.agent_client.post( reverse_lazy('password_change'), @@ -328,7 +541,13 @@ class PasswordChangeTestCase(UsersBaseTestCase): ) self.assertContains(resp, 'Введённый пароль слишком короткий', count=1, status_code=200) - def test_invalid_new_password2(self): + def test_invalid_new_password2(self) -> None: + """ + Функция тестирования попытки смены пароля, когда новый пароль не соответствует требованиям: состоит + только из цифр. + + Проверяет текст уведомления пользователя 'Введённый пароль состоит только из цифр'. + """ with translation.override('ru'): resp = self.agent_client.post( reverse_lazy('password_change'), @@ -341,6 +560,12 @@ class PasswordChangeTestCase(UsersBaseTestCase): self.assertContains(resp, 'Введённый пароль состоит только из цифр', count=1, status_code=200) def test_invalid_new_password3(self): + """ + Функция тестирования попытки смены пароля, когда новый пароль не соответствует требованиям: аналогчен имени + пользователя. + + Проверяет текст уведомления пользователя 'Введённый пароль слишком похож на имя пользователя'. + """ with translation.override('ru'): resp = self.agent_client.post( reverse_lazy('password_change'), @@ -356,6 +581,8 @@ class PasswordChangeTestCase(UsersBaseTestCase): class GetTicketsTestCase(UsersBaseTestCase): """ Класс тестов для проверки функции получения тикетов. + + """ @patch('main.views.zenpy.get_user') @@ -365,7 +592,7 @@ class GetTicketsTestCase(UsersBaseTestCase): Функция проверки переадресации пользователя на рабочую страницу. """ get_user_mock.return_value = Mock() - user = User.objects.get(email=self.engineer) + user = get_user_model().objects.get(email=self.engineer) resp = self.engineer_client.post(reverse('work_get_tickets')) self.assertRedirects(resp, reverse('work', args=[user.id])) self.assertEqual(resp.status_code, 302) @@ -434,6 +661,8 @@ class GetTicketsTestCase(UsersBaseTestCase): class ProfileTestCase(TestCase): """ Класс тестов для проверки синхронизации профиля пользователя. + + """ fixtures = ['fixtures/profile.json'] @@ -444,9 +673,9 @@ class ProfileTestCase(TestCase): self.zendesk_agent_email = 'krav-88@mail.ru' self.zendesk_admin_email = 'idar.sokurov.05@mail.ru' self.client = Client() - self.client.force_login(User.objects.get(email=self.zendesk_agent_email)) + self.client.force_login(get_user_model().objects.get(email=self.zendesk_agent_email)) self.admin_client = Client() - self.admin_client.force_login(User.objects.get(email=self.zendesk_admin_email)) + self.admin_client.force_login(get_user_model().objects.get(email=self.zendesk_admin_email)) def test_correct_username(self): """ @@ -495,9 +724,9 @@ class LoggingTestCase(UsersBaseTestCase): def setUp(self): super().setUp() - self.admin_profile = User.objects.get(email=self.admin).userprofile - self.agent_profile = User.objects.get(email=self.light_agent).userprofile - self.engineer_profile = User.objects.get(email=self.engineer).userprofile + self.admin_profile = get_user_model().objects.get(email=self.admin).userprofile + self.agent_profile = get_user_model().objects.get(email=self.light_agent).userprofile + self.engineer_profile = get_user_model().objects.get(email=self.engineer).userprofile @staticmethod def get_file_output(): From dc47c12efc2c793fc24b73f81a589c251c48a089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sun, 23 May 2021 21:05:06 +0300 Subject: [PATCH 52/65] Fix problem in test for pylint --- README.rst | 2 +- access_controller/settings.py | 2 -- main/tests.py | 23 +++++++++++++++-------- main/views.py | 5 ++++- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index 8c9907d..721f60a 100644 --- a/README.rst +++ b/README.rst @@ -170,7 +170,7 @@ Quickstart Для проверки pylint используем: ------------------------------- -pylint --django-settings-module=access_controller.access_controller.settings ../access_controller (каталог, где лежит проект) +pylint ../access_controller_new (каталог, где лежит проект) Для приведения файлов к стандарту PEP8 используем: -------------------------------------------------- diff --git a/access_controller/settings.py b/access_controller/settings.py index 30875f6..3f15f93 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -12,8 +12,6 @@ https://docs.djangoproject.com/en/3.1/ref/settings/ import os from pathlib import Path - - from dotenv import load_dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. diff --git a/main/tests.py b/main/tests.py index 4353ddf..6ebf157 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,3 +1,8 @@ +""" +Тестирование работы программы. +""" + + import random from unittest.mock import patch, Mock @@ -218,7 +223,8 @@ class MakeEngineerTestCase(UsersBaseTestCase): """ self.admin_client.post( reverse_lazy('control'), - data={'users': [get_user_model().objects.get(email=self.light_agent).userprofile.id], 'engineer': 'engineer'} + data={'users': [get_user_model().objects.get(email=self.light_agent).userprofile.id], + 'engineer': 'engineer'} ) call_list = zenpy_mock.update_user.call_args_list mock_object = call_list[0][0][0] @@ -356,7 +362,8 @@ class MakeLightAgentTestCase(UsersBaseTestCase): """ self.admin_client.post( reverse_lazy('control'), - data={'users': [get_user_model().objects.get(email=self.engineer).userprofile.id], 'light_agent': 'light_agent'} + data={'users': [get_user_model().objects.get(email=self.engineer).userprofile.id], + 'light_agent': 'light_agent'} ) call_list = zenpy_mock.update_user.call_args_list mock_object = call_list[0][0][0] @@ -447,7 +454,8 @@ class PasswordResetTestCase(UsersBaseTestCase): Проверяет отсутствие отправки письма о смене пароля. """ with self.settings(EMAIL_BACKEND=self.email_backend): - resp = self.agent_client.post(reverse_lazy('password_reset'), data={'email': self.light_agent + str(random.random())}) + resp = self.agent_client.post(reverse_lazy('password_reset'), + data={'email': self.light_agent + str(random.random())}) self.assertRedirects(resp, reverse('password_reset_done')) self.assertEqual(resp.status_code, 302) self.assertEqual(len(mail.outbox), 0) @@ -635,11 +643,11 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.zenpy') @patch('main.views.get_tickets_list_for_group') - def test_take_zero_tickets(self, TicketsMock, zenpy_mock): + def test_take_zero_tickets(self, tickets_mock, zenpy_mock): """ Функция проверки попытки назначения нуля тикета на engineer. """ - TicketsMock.return_value = [Mock()] * 3 + tickets_mock.return_value = [Mock()] * 3 zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) self.engineer_client.post(reverse('work_get_tickets'), data={'count_tickets': 0}) tickets = zenpy_mock.update_tickets.call_args[0][0] @@ -730,9 +738,8 @@ class LoggingTestCase(UsersBaseTestCase): @staticmethod def get_file_output(): - file = open('logs/logs.csv', 'r') - file_output = file.readlines()[-1] - file.close() + with open('logs/logs.csv', 'r') as file: + file_output = file.readlines()[-1] return file_output def test_engineer_with_admin(self): diff --git a/main/views.py b/main/views.py index 44ce560..07f4cc5 100644 --- a/main/views.py +++ b/main/views.py @@ -453,5 +453,8 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: context['form'] = form return render(request, 'pages/statistic.html', context) -def registration_failed(request): +def registration_failed(request: WSGIRequest) -> HttpResponse: + """ + Функция отображения страницы "Регистрация закрыта". + """ return render(request, 'pages/registration_failed.html') From fdc1f3b448053d5f337123f178a1d933a448585f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Mon, 24 May 2021 20:22:53 +0300 Subject: [PATCH 53/65] Add tests docs, draft 2 --- main/tests.py | 123 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 100 insertions(+), 23 deletions(-) diff --git a/main/tests.py b/main/tests.py index 6ebf157..8a6ad6c 100644 --- a/main/tests.py +++ b/main/tests.py @@ -590,14 +590,17 @@ class GetTicketsTestCase(UsersBaseTestCase): """ Класс тестов для проверки функции получения тикетов. - + В тестах используются @patch, замещающие работу с API Zendesk. """ @patch('main.views.zenpy.get_user') @patch('main.extra_func.zenpy') - def test_redirect(self, _zenpy_mock, get_user_mock): + def test_redirect(self, _zenpy_mock: zenpy, get_user_mock: get_user_model()) -> None: """ Функция проверки переадресации пользователя на рабочую страницу. + + Проверяет редирект на рабочую страницу, в случае, когда пользователь с правами инженера заполняет форму + принятия тикетов в работу. """ get_user_mock.return_value = Mock() user = get_user_model().objects.get(email=self.engineer) @@ -607,9 +610,12 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.zenpy') @patch('main.views.get_tickets_list_for_group') - def test_take_one_ticket(self, group_tickets_mock, zenpy_mock): + def test_take_one_ticket(self, group_tickets_mock: list, zenpy_mock: zenpy) -> None: """ Функция проверки назначения одного тикета на engineer. + + Проверяет соответствие ответственного за тикет объекта tickets и тестового клиента правами инженера, + направившего запрос на назначение одного тикета. """ group_tickets_mock.return_value = [Mock()] zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) @@ -619,9 +625,12 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.get_tickets_list_for_group') @patch('main.views.zenpy') - def test_take_many_tickets(self, zenpy_mock, group_tickets_mock): + def test_take_many_tickets(self, zenpy_mock: zenpy, group_tickets_mock: list) -> None: """ Функция проверки назначения нескольких тикетов на engineer. + + Проверяет соответствие ответственного за тикеты объекта tickets и тестового клиента правами инженера, + направившего запрос на назначение трех тикетов. """ group_tickets_mock.return_value = [Mock()] * 3 zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) @@ -632,7 +641,7 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.zenpy.get_user') @patch('main.views.zenpy') - def test_light_agent_take_ticket(self, zenpy_mock, get_user_mock): + def test_light_agent_take_ticket(self, zenpy_mock: zenpy, get_user_mock: get_user_model()): """ Функция проверки попытки назначения тикета на light_agent. """ @@ -643,9 +652,11 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.zenpy') @patch('main.views.get_tickets_list_for_group') - def test_take_zero_tickets(self, tickets_mock, zenpy_mock): + def test_take_zero_tickets(self, tickets_mock: list, zenpy_mock: zenpy) -> None: """ - Функция проверки попытки назначения нуля тикета на engineer. + Функция проверки попытки назначения нулевого количества тикетов. + + Проверяет, что список тикетов остался пустым. """ tickets_mock.return_value = [Mock()] * 3 zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) @@ -655,9 +666,12 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.get_tickets_list_for_group') @patch('main.views.zenpy') - def test_take_invalid_count_tickets(self, zenpy_mock, group_tickets_mock): + def test_take_invalid_count_tickets(self, zenpy_mock: zenpy, group_tickets_mock: list) -> None: """ - Функция проверки попытки назначения нуля тикетов на engineer. + Функция проверки попытки назначения некорректного количества тикетов (введении в форму назначения тикетов + не числового значения, а строки). + + Проверяет, отсутствие списка тикетов. """ group_tickets_mock.return_value = [Mock()] * 3 zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) @@ -670,13 +684,24 @@ class ProfileTestCase(TestCase): """ Класс тестов для проверки синхронизации профиля пользователя. - + Для тестов используются фикстуры тестовых пользователей (profile.json). """ fixtures = ['fixtures/profile.json'] - def setUp(self): + def setUp(self) -> None: """ - Предустановленные значения для проведения тестов. + Функция предустановки значений переменных. + + Добавляем email тестовых пользователей Zendesk и создаем клиентов для тестов. + + :param zendesk_agent_email: email тестового пользователя с правами light_agent + :type zendesk_agent_email: :class:`str` + :param zendesk_admin_email: email тестового пользователя с правами admin + :type zendesk_admin_email: :class:`str` + :param client: клиент, залогиненный как пользователь с email zendesk_agent_email + :type client: :class:`django.test.client.Client` + :param admin_client: клиент, залогиненный как пользователь с zendesk_admin_email + :type admin_client: :class:`django.test.client.Client` """ self.zendesk_agent_email = 'krav-88@mail.ru' self.zendesk_admin_email = 'idar.sokurov.05@mail.ru' @@ -685,32 +710,42 @@ class ProfileTestCase(TestCase): self.admin_client = Client() self.admin_client.force_login(get_user_model().objects.get(email=self.zendesk_admin_email)) - def test_correct_username(self): + def test_correct_username(self) -> None: """ Функция проверки синхронизации имени пользователя. + + Проверяет соответствие имени пользователя из контекста страницы профиля имени пользователя в Zendesk. """ resp = self.client.get(reverse('profile')) self.assertEqual(resp.context['profile'].name, zenpy.get_user(self.zendesk_agent_email).name) - def test_correct_email(self): + def test_correct_email(self) -> None: """ Функция проверки синхронизации почты пользователя. + + Проверяет соответствие email пользователя из контекста страницы профиля email пользователя в Zendesk. """ resp = self.client.get(reverse('profile')) self.assertEqual(resp.context['profile'].user.email, zenpy.get_user(self.zendesk_agent_email).email) - def test_correct_role(self): + def test_correct_role(self) -> None: """ Функция проверки синхронизации роли пользователя. + + Проверяет соответствие роли пользователя из контекста страницы профиля роли пользователя в Zendesk. Проверка + осуществляется на примере администратора и агента. """ resp = self.client.get(reverse('profile')) self.assertEqual(resp.context['profile'].role, zenpy.get_user(self.zendesk_agent_email).role) resp = self.admin_client.get(reverse('profile')) self.assertEqual(resp.context['profile'].role, zenpy.get_user(self.zendesk_admin_email).role) - def test_correct_custom_role_id(self): + def test_correct_custom_role_id(self) -> None: """ Функция проверки синхронизации рабочей роли пользователя. + + Проверяет соответствие id рабочей роли пользователя из контекста страницы профиля id + роли пользователя в Zendesk. Проверка осуществляется на примере администратора и агента. """ resp = self.client.get(reverse('profile')) user = zenpy.get_user(self.zendesk_agent_email) @@ -719,9 +754,11 @@ class ProfileTestCase(TestCase): user = zenpy.get_user(self.zendesk_admin_email) self.assertEqual(resp.context['profile'].custom_role_id, user.custom_role_id if user.custom_role_id else 0) - def test_correct_image(self): + def test_correct_image(self) -> None: """ Функция проверки синхронизации изображения пользователя. + + Проверяет соответствие аватарки пользователя из контекста страницы профиля аватарке пользователя в Zendesk. """ resp = self.client.get(reverse('profile')) user = zenpy.get_user(self.zendesk_agent_email) @@ -729,38 +766,78 @@ class ProfileTestCase(TestCase): class LoggingTestCase(UsersBaseTestCase): + """ + Класс тестирования процесса логгирования. + """ - def setUp(self): + def setUp(self) -> None: + """ + Функция предустановки значений переменных. + + Определяем профили пользователей с разными ролями. + + :param admin_profile: профиль тестового пользователя с правами admin + :type admin_profile: :class:`Userprofile` + :param agent_profile: профиль тестового пользователя с правами light_agent + :type agent_profile: :class:`Userprofile` + :param engineer_profile: профиль тестового пользователя с правами engineer + :type engineer_profile: :class:`Userprofile` + """ super().setUp() self.admin_profile = get_user_model().objects.get(email=self.admin).userprofile self.agent_profile = get_user_model().objects.get(email=self.light_agent).userprofile self.engineer_profile = get_user_model().objects.get(email=self.engineer).userprofile @staticmethod - def get_file_output(): + def get_file_output() -> str: + """ + Получение данных из файла логов. + """ with open('logs/logs.csv', 'r') as file: file_output = file.readlines()[-1] return file_output - def test_engineer_with_admin(self): + def test_engineer_with_admin(self) -> None: + """ + Функция проверки корректной записи лога по смене роли инженера в файл. + + Сравнивает запись в файле и созданный лог с переданными значениями профилей инженера и администратора + для смены прав. + """ log(self.engineer_profile, self.admin_profile) file_output = self.get_file_output() self.assertEqual(file_output, f'UserForAccessTest,engineer,' f'{str(timezone.now().today())[:16]},ZendeskAdmin\n') - def test_engineer_without_admin(self): + def test_engineer_without_admin(self) -> None: + """ + Функция проверки корректной записи лога по смене роли инженера в файл без указания администратора. + + Сравнивает запись в файле и созданный лог с переданным значением профиля инженера для смены прав. + """ log(self.engineer_profile) file_output = self.get_file_output() self.assertEqual(file_output, f'UserForAccessTest,engineer,' f'{str(timezone.now().today())[:16]},UserForAccessTest\n') - def test_light_agent_with_admin(self): + def test_light_agent_with_admin(self) -> None: + """ + Функция проверки корректной записи лога по смене роли агента в файл. + + Сравнивает запись в файле и созданный лог с переданными значениями профилей агента и администратора + для смены прав. + """ log(self.agent_profile, self.admin_profile) file_output = self.get_file_output() self.assertEqual(file_output, f'UserForAccessTest,light_agent,' f'{str(timezone.now().today())[:16]},ZendeskAdmin\n') - def test_light_agent_without_admin(self): + def test_light_agent_without_admin(self) -> None: + """ + Функция проверки корректной записи лога по смене роли агента в файл без указания администратора. + + Сравнивает запись в файле и созданный лог с переданным значением профиля агента для смены прав. + """ log(self.agent_profile) file_output = self.get_file_output() self.assertEqual(file_output, f'UserForAccessTest,light_agent,' From 724d246b6befdce8d438df1f0fd659408d6f2c17 Mon Sep 17 00:00:00 2001 From: Iurii Tatishchev Date: Tue, 25 May 2021 09:44:02 -0700 Subject: [PATCH 54/65] Remove venv from git --- .../site-packages/enchant/tokenize/ru.py | 185 ------------------ 1 file changed, 185 deletions(-) delete mode 100644 venv/lib/python3.6/site-packages/enchant/tokenize/ru.py diff --git a/venv/lib/python3.6/site-packages/enchant/tokenize/ru.py b/venv/lib/python3.6/site-packages/enchant/tokenize/ru.py deleted file mode 100644 index 7e15379..0000000 --- a/venv/lib/python3.6/site-packages/enchant/tokenize/ru.py +++ /dev/null @@ -1,185 +0,0 @@ -# pyenchant -# -# Copyright (C) 2004-2008, Ryan Kelly -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the -# Free Software Foundation, Inc., 59 Temple Place - Suite 330, -# Boston, MA 02111-1307, USA. -# -# In addition, as a special exception, you are -# given permission to link the code of this program with -# non-LGPL Spelling Provider libraries (eg: a MSFT Office -# spell checker backend) and distribute linked combinations including -# the two. You must obey the GNU Lesser General Public License in all -# respects for all of the code used other than said providers. If you modify -# this file, you may extend this exception to your version of the -# file, but you are not obligated to do so. If you do not wish to -# do so, delete this exception statement from your version. -# -""" - - enchant.tokenize.en: Tokenizer for the English language - - This module implements a PyEnchant text tokenizer for the English - language, based on very simple rules. - -""" - -import unicodedata - -import enchant.tokenize - - -class tokenize(enchant.tokenize.tokenize): # noqa: N801 - """Iterator splitting text into words, reporting position. - - This iterator takes a text string as input, and yields tuples - representing each distinct word found in the text. The tuples - take the form: - - (,) - - Where is the word string found and is the position - of the start of the word within the text. - - The optional argument may be used to specify a - list of additional characters that can form part of a word. - By default, this list contains only the apostrophe ('). Note that - these characters cannot appear at the start or end of a word. - """ - - _DOC_ERRORS = ["pos", "pos"] - - def __init__(self, text, valid_chars=None): - self._valid_chars = valid_chars - self._text = text - self._offset = 0 - # Select proper implementation of self._consume_alpha. - # 'text' isn't necessarily a string (it could be e.g. a mutable array) - # so we can't use isinstance(text, str) to detect unicode. - # Instead we typetest the first character of the text. - # If there's no characters then it doesn't matter what implementation - # we use since it won't be called anyway. - try: - char1 = text[0] - except IndexError: - self._initialize_for_binary() - else: - if isinstance(char1, str): - self._initialize_for_unicode() - else: - self._initialize_for_binary() - - def _initialize_for_binary(self): - self._consume_alpha = self._consume_alpha_b - if self._valid_chars is None: - self._valid_chars = ("'",) - - def _initialize_for_unicode(self): - self._consume_alpha = self._consume_alpha_u - if self._valid_chars is None: - # XXX TODO: this doesn't seem to work correctly with the - # MySpell provider, disabling for now. - # Allow unicode typographic apostrophe - # self._valid_chars = (u"'",u"\u2019") - self._valid_chars = ("'",) - - def _consume_alpha_b(self, text, offset): - """Consume an alphabetic character from the given bytestring. - - Given a bytestring and the current offset, this method returns - the number of characters occupied by the next alphabetic character - in the string. Non-ASCII bytes are interpreted as utf-8 and can - result in multiple characters being consumed. - """ - assert offset < len(text) - if text[offset].isalpha(): - return 1 - elif text[offset] >= "\x80": - return self._consume_alpha_utf8(text, offset) - return 0 - - def _consume_alpha_utf8(self, text, offset): - """Consume a sequence of utf8 bytes forming an alphabetic character.""" - incr = 2 - u = "" - while not u and incr <= 4: - try: - try: - # In the common case this will be a string - u = text[offset : offset + incr].decode("utf8") - except AttributeError: - # Looks like it was e.g. a mutable char array. - try: - s = text[offset : offset + incr].tostring() - except AttributeError: - s = "".join([c for c in text[offset : offset + incr]]) - u = s.decode("utf8") - except UnicodeDecodeError: - incr += 1 - if not u: - return 0 - if u.isalpha(): - return incr - if unicodedata.category(u)[0] == "M": - return incr - return 0 - - def _consume_alpha_u(self, text, offset): - """Consume an alphabetic character from the given unicode string. - - Given a unicode string and the current offset, this method returns - the number of characters occupied by the next alphabetic character - in the string. Trailing combining characters are consumed as a - single letter. - """ - assert offset < len(text) - incr = 0 - if text[offset].isalpha(): - incr = 1 - while offset + incr < len(text): - if unicodedata.category(text[offset + incr])[0] != "M": - break - incr += 1 - return incr - - def next(self): - text = self._text - offset = self._offset - while offset < len(text): - # Find start of next word (must be alpha) - while offset < len(text): - incr = self._consume_alpha(text, offset) - if incr: - break - offset += 1 - cur_pos = offset - # Find end of word using, allowing valid_chars - while offset < len(text): - incr = self._consume_alpha(text, offset) - if not incr: - if text[offset] in self._valid_chars: - incr = 1 - else: - break - offset += incr - # Return if word isn't empty - if cur_pos != offset: - # Make sure word doesn't end with a valid_char - while text[offset - 1] in self._valid_chars: - offset = offset - 1 - self._offset = offset - return (text[cur_pos:offset], cur_pos) - self._offset = offset - raise StopIteration() From 33421ca11229c95a42ee86b7c15f516bf8d1aeaa Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Tue, 25 May 2021 21:45:28 +0300 Subject: [PATCH 55/65] del inint.py --- __init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __init__.py diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 From 614b82c24d897e4ea25f5fa0f82f609ddf5e93a5 Mon Sep 17 00:00:00 2001 From: Iurii Tatishchev Date: Tue, 25 May 2021 12:22:43 -0700 Subject: [PATCH 56/65] Update README.md pylint section --- README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b3376cf..1438361 100644 --- a/README.md +++ b/README.md @@ -149,22 +149,25 @@ docker run -d -p 8000:8000 \ Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). ## Для проверки pylint используем: -pylint --django-settings-module=access_controller.access_controller.settings ../access_controller (каталог, где лежит проект) +```bash +pylint --django-settings-module=access_controller.settings main +``` ## Для приведения файлов к стандарту PEP8 используем: +```bash autopep8 --in-place filename +``` ##Для проверки орфографии: -cd docs +```bash +(cd docs && make spelling) +``` -(set -a && source ../.env && make spelling) ##Для обновления документации: +```bash m2r README.md - -cd docs - -(set -a && source ../.env && make html) - +(cd docs && make html) +``` ## Read more - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) From bdd5a68b268328ea013a77225f733eb85990ed4b Mon Sep 17 00:00:00 2001 From: Dmitriy Andreev Date: Wed, 26 May 2021 15:34:28 +0300 Subject: [PATCH 57/65] profile look fix --- main/templates/pages/profile.html | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/main/templates/pages/profile.html b/main/templates/pages/profile.html index 4b7016a..2f628f8 100644 --- a/main/templates/pages/profile.html +++ b/main/templates/pages/profile.html @@ -23,7 +23,7 @@ {% block content %}
-
+

Имя пользователя

{{ profile.name }}
@@ -44,7 +46,7 @@ {% elif profile.custom_role_id == ZENDESK_ROLES.light_agent %}
light_agent
{% else %} -
None
+
Без роли
{% endif %}
@@ -52,7 +54,7 @@
{% endblock %} From 72030040b842b47f77ef13329038f0cafb1b4f75 Mon Sep 17 00:00:00 2001 From: Dmitriy Andreev Date: Wed, 26 May 2021 17:05:48 +0300 Subject: [PATCH 58/65] Fix looking of profile, work and control pages --- main/templates/pages/adm_ruleset.html | 12 +-- main/templates/pages/profile.html | 4 +- main/templates/pages/work.html | 123 ++++++++++++++------------ static/main/js/control.js | 4 +- 4 files changed, 74 insertions(+), 69 deletions(-) diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index cbbfc1b..39caf41 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -39,10 +39,10 @@
-
Список сотрудников
+

Список сотрудников

{% block table %} - +
+ From 8b3f8b8b8748504006f38b1abc3f7619ee33d8e1 Mon Sep 17 00:00:00 2001 From: Dmitriy Andreev Date: Wed, 26 May 2021 17:47:32 +0300 Subject: [PATCH 59/65] Page design became better for user. Changes in profile, work, control and statistic pages. --- main/templates/base/base.html | 2 ++ main/templates/pages/statistic.html | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/main/templates/base/base.html b/main/templates/base/base.html index 166195d..e82af52 100644 --- a/main/templates/base/base.html +++ b/main/templates/base/base.html @@ -19,6 +19,8 @@ user-select: none; } + + @media (min-width: 768px) { .bd-placeholder-img-lg { font-size: 3.5rem; diff --git a/main/templates/pages/statistic.html b/main/templates/pages/statistic.html index b467250..82b714a 100644 --- a/main/templates/pages/statistic.html +++ b/main/templates/pages/statistic.html @@ -7,21 +7,21 @@ {% block heading %} Страницы просмотра статистики{% endblock %} {% block content%} -
+
{% csrf_token %}
-
- {{ form.email.label }} +
+

{{ form.email.label }}

{{ form.email }}
-
- {{ form.interval.label }} +
+

{{ form.interval.label }}

{% for radio in form.interval%} @@ -33,8 +33,8 @@
-
- {{ form.display_format.label }} +
+

{{ form.display_format.label }}

{% for radio in form.display_format%} @@ -46,8 +46,8 @@
-
- {{ form.range_start.label}} +
+

{{ form.range_start.label}}

@@ -56,8 +56,8 @@
-
- {{ form.range_end.label}} +
+

{{ form.range_end.label}}

@@ -65,9 +65,9 @@
-
+
- +
From e595156f56753ee0e3f3c09d85ba1f59d431b240 Mon Sep 17 00:00:00 2001 From: Iurii Tatishchev Date: Wed, 26 May 2021 09:10:26 -0700 Subject: [PATCH 60/65] Change custom role ids to BigInt for postgres compatibility. --- access_controller/settings.py | 4 +++- main/models.py | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index 55af7a5..056de17 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -147,7 +147,6 @@ LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' - # Название_приложения.Название_файла.Название_класса_обработчика AUTHENTICATION_BACKENDS = [ 'access_controller.auth.EmailAuthBackend', @@ -185,3 +184,6 @@ ACTRL_ZENDESK_SUBDOMAIN = os.getenv('ACTRL_ZENDESK_SUBDOMAIN') or os.getenv('ZD_ ACTRL_API_EMAIL = os.getenv('ACTRL_API_EMAIL') or os.getenv('ACCESS_CONTROLLER_API_EMAIL') ACTRL_API_TOKEN = os.getenv('ACTRL_API_TOKEN') or os.getenv('ACCESS_CONTROLLER_API_TOKEN') ACTRL_API_PASSWORD = os.getenv('ACTRL_API_PASSWORD') or os.getenv('ACCESS_CONTROLLER_API_PASSWORD') + + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/main/models.py b/main/models.py index c934ab1..828fce2 100644 --- a/main/models.py +++ b/main/models.py @@ -24,7 +24,7 @@ class UserProfile(models.Model): user = models.OneToOneField(to=get_user_model(), on_delete=models.CASCADE, help_text='Пользователь') role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя') - custom_role_id = models.IntegerField(default=0, help_text='Код роли пользователя') + custom_role_id = models.BigIntegerField(default=0, help_text='Код роли пользователя') image = models.URLField(null=True, blank=True, help_text='Аватарка') name = models.CharField(default='None', max_length=100, help_text='Имя пользователя на нашем сайте') @@ -75,8 +75,8 @@ class RoleChangeLogs(models.Model): """ user = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль') - old_role = models.IntegerField(default=0, help_text='Старая роль') - new_role = models.IntegerField(default=0, help_text='Присвоенная роль') + old_role = models.BigIntegerField(default=0, help_text='Старая роль') + new_role = models.BigIntegerField(default=0, help_text='Присвоенная роль') change_time = models.DateTimeField(default=timezone.now, help_text='Дата и время изменения роли') changed_by = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') From 22154ca7fc3580e57a2787f02aa31f992a512d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 26 May 2021 21:13:35 +0300 Subject: [PATCH 61/65] Add tests docs: params for Mock --- main/tests.py | 104 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 79 insertions(+), 25 deletions(-) diff --git a/main/tests.py b/main/tests.py index 8a6ad6c..79afcfc 100644 --- a/main/tests.py +++ b/main/tests.py @@ -7,7 +7,6 @@ import random from unittest.mock import patch, Mock from django.contrib.auth import get_user_model -# from django.contrib.auth.models import User from django.core import mail from django.http import HttpResponseRedirect from django.template.loader import render_to_string @@ -171,13 +170,15 @@ class MakeEngineerTestCase(UsersBaseTestCase): """ Класс тестирования присвоения пользователю роли engineer. - В тестах используется @patch('main.extra_func.zenpy') замещающий API Zendesk. + В тестах используется @patch('main.extra_func.zenpy') Mock для работы с API Zendesk. """ @patch('main.extra_func.zenpy') - def test_become_engineer_redirect(self, _zenpy_mock: zenpy) -> None: + def test_become_engineer_redirect(self, _zenpy_mock: Mock) -> None: """ Функция тестирования редиректа на рабочую страницу тестового пользователя при назначении его инженером. + + :param _zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. """ user = get_user_model().objects.get(email=self.light_agent) resp = self.agent_client.post(reverse_lazy('work_become_engineer')) @@ -185,41 +186,49 @@ class MakeEngineerTestCase(UsersBaseTestCase): self.assertEqual(resp.status_code, 302) @patch('main.extra_func.zenpy') - def test_light_agent_make_engineer(self, zenpy_mock: zenpy) -> None: + def test_light_agent_make_engineer(self, zenpy_mock: Mock) -> None: """ Функция тестирования назначения легкого агента на роль инженера. Проверяет установку роли "engineer" в Zendesk. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. """ self.agent_client.post(reverse_lazy('work_become_engineer')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_admin_make_engineer(self, zenpy_mock: zenpy) -> None: + def test_admin_make_engineer(self, zenpy_mock: Mock) -> None: """ Функция тестирования назначения администратора на роль инженера. Проверяет установку роли "engineer" в Zendesk. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. """ self.admin_client.post(reverse_lazy('work_become_engineer')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_engineer_make_engineer(self, zenpy_mock: zenpy) -> None: + def test_engineer_make_engineer(self, zenpy_mock: Mock) -> None: """ Функция тестирования назначения инженера на роль инженера. Проверяет установку роли "engineer" в Zendesk. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. """ self.engineer_client.post(reverse_lazy('work_become_engineer')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_control_page_make_engineer_one(self, zenpy_mock: zenpy) -> None: + def test_control_page_make_engineer_one(self, zenpy_mock: Mock) -> None: """ Функция тестирования назначения администратором одного инженера на странице "Управление". Проверяет обновление администратором роли пользователя с light_agent на engineer. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. """ self.admin_client.post( reverse_lazy('control'), @@ -232,11 +241,13 @@ class MakeEngineerTestCase(UsersBaseTestCase): self.assertEqual(mock_object.custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_control_page_make_engineer_many(self, zenpy_mock: zenpy) -> None: + def test_control_page_make_engineer_many(self, zenpy_mock: Mock) -> None: """ Функция тестирования назначения администратором нескольких инженеров на странице "Управление". Проверяет обновление администратором ролей двух пользователей с light_agent на engineer. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. """ self.admin_client.post( reverse_lazy('control'), @@ -259,16 +270,19 @@ class MakeLightAgentTestCase(UsersBaseTestCase): """ Класс тестирования присвоения пользователю роли light_agent. - В тестах используется @patch('main.extra_func.zenpy') замещающий API Zendesk, а также - @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]), предоставляющий список - тикетов. + В тестах используется @patch('main.extra_func.zenpy') Mock для работы API Zendesk, а также + @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]), предоставляющий пустой + список в качестве списка тикетов пользователя. """ @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_hand_over_redirect(self, _zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + def test_hand_over_redirect(self, _zenpy_mock: Mock, _user_tickets_Mock: Mock) -> None: """ Функция тестирования переадресации инженера на рабочую страницу, после сдачи прав. + + :param _zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param _user_tickets_Mock: Mock, заменяющий список тикетов пользователя на пустой список. """ user = get_user_model().objects.get(email=self.engineer) resp = self.engineer_client.post(reverse_lazy('work_hand_over')) @@ -277,11 +291,14 @@ class MakeLightAgentTestCase(UsersBaseTestCase): @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_engineer_make_light_agent_no_tickets(self, zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + def test_engineer_make_light_agent_no_tickets(self, zenpy_mock: Mock, _user_tickets_mock: Mock) -> None: """ Функция тестирования назначения инженера легким агентом, в случае, когда у него в работе нет тикетов. Проверяет назначение роли light_agent в Zendesk. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param _user_tickets_mock: Mock, заменяющий список тикетов пользователя на пустой список. """ self.engineer_client.post(reverse_lazy('work_hand_over')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) @@ -290,7 +307,7 @@ class MakeLightAgentTestCase(UsersBaseTestCase): [Mock(id=1, status='solved'), Mock(id=2, status='open'), Mock(id=3, status='open')] ]) @patch('main.extra_func.zenpy') - def test_engineer_make_light_agent_with_tickets(self, zenpy_mock: zenpy, _user_tickets_mock: list): + def test_engineer_make_light_agent_with_tickets(self, zenpy_mock: Mock, _user_tickets_mock: Mock) -> None: """ Функция тестирования назначения инженера легким агентом, в случае, когда у него в работе есть тикеты. @@ -298,6 +315,9 @@ class MakeLightAgentTestCase(UsersBaseTestCase): два в состоянии: открыт. Проверяет распределение тикетов (поместить в решенные или назначить нового ответственного), а также назначение роли light_agent в Zendesk. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param _user_tickets_mock: Mock, заменяющий список тикетов пользователя на пустой список. """ zenpy_mock.solved_tickets_user_id = Mock() self.engineer_client.post(reverse_lazy('work_hand_over')) @@ -310,11 +330,14 @@ class MakeLightAgentTestCase(UsersBaseTestCase): @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_admin_make_light_agent_no_tickets(self, zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + def test_admin_make_light_agent_no_tickets(self, zenpy_mock: Mock, _user_tickets_mock: Mock) -> None: """ Функция тестирования назначения администратора на роль легкого агента. Проверяет назначение роли light_agent в Zendesk. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param _user_tickets_mock: Mock, заменяющий список тикетов пользователя на пустой список. """ self.admin_client.post(reverse_lazy('work_hand_over')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) @@ -331,6 +354,9 @@ class MakeLightAgentTestCase(UsersBaseTestCase): два в состоянии: открыт. Проверяет распределение тикетов (поместить в решенные или назначить нового ответственного), а также назначение роли light_agent в Zendesk. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param _user_tickets_mock: Mock, заменяющий список тикетов пользователя на пустой список. """ zenpy_mock.solved_tickets_user_id = Mock() self.admin_client.post(reverse_lazy('work_hand_over')) @@ -343,22 +369,28 @@ class MakeLightAgentTestCase(UsersBaseTestCase): @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_light_agent_make_light_agent(self, zenpy_mock: zenpy, _user_tickets_mock: list): + def test_light_agent_make_light_agent(self, zenpy_mock: Mock, _user_tickets_mock: Mock) -> None: """ Функция тестирования назначения легкого агента на роль легкого агента. Проверяет назначение роли light_agent в Zendesk. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param _user_tickets_mock: Mock, заменяющий список тикетов пользователя на пустой список. """ self.agent_client.post(reverse_lazy('work_hand_over')) self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_control_page_make_light_agent_one(self, zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + def test_control_page_make_light_agent_one(self, zenpy_mock: Mock, _user_tickets_mock: Mock) -> None: """ Функция тестирования назначения администратором одного легкого агента на странице "Управление". Проверяет обновление администратором роли пользователя с engineer на light_agent. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param _user_tickets_mock: Mock, заменяющий список тикетов пользователя на пустой список. """ self.admin_client.post( reverse_lazy('control'), @@ -372,11 +404,14 @@ class MakeLightAgentTestCase(UsersBaseTestCase): @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[], []]) @patch('main.extra_func.zenpy') - def test_control_page_make_light_agent_many(self, zenpy_mock: zenpy, _user_tickets_mock: list) -> None: + def test_control_page_make_light_agent_many(self, zenpy_mock: Mock, _user_tickets_mock: Mock) -> None: """ Функция тестирования назначения администратором нескольких легких агентов на странице "Управление". Проверяет обновление администратором ролей двух пользователей с engineer на light_agent. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param _user_tickets_mock: Mock, заменяющий список тикетов пользователя на пустой список. """ self.admin_client.post( @@ -590,17 +625,21 @@ class GetTicketsTestCase(UsersBaseTestCase): """ Класс тестов для проверки функции получения тикетов. - В тестах используются @patch, замещающие работу с API Zendesk. + В тестах используются @patch('main.views.zenpy.get_user') и @patch('main.views.zenpy.get_user') + для работы с API Zendesk. """ @patch('main.views.zenpy.get_user') @patch('main.extra_func.zenpy') - def test_redirect(self, _zenpy_mock: zenpy, get_user_mock: get_user_model()) -> None: + def test_redirect(self, _zenpy_mock: Mock, get_user_mock: Mock) -> None: """ Функция проверки переадресации пользователя на рабочую страницу. Проверяет редирект на рабочую страницу, в случае, когда пользователь с правами инженера заполняет форму принятия тикетов в работу. + + :param _zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param get_user_mock: Mock объекта zenpy_user. """ get_user_mock.return_value = Mock() user = get_user_model().objects.get(email=self.engineer) @@ -610,12 +649,15 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.zenpy') @patch('main.views.get_tickets_list_for_group') - def test_take_one_ticket(self, group_tickets_mock: list, zenpy_mock: zenpy) -> None: + def test_take_one_ticket(self, group_tickets_mock: Mock, zenpy_mock: Mock) -> None: """ Функция проверки назначения одного тикета на engineer. Проверяет соответствие ответственного за тикет объекта tickets и тестового клиента правами инженера, направившего запрос на назначение одного тикета. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param group_tickets_mock: Mock списка не назначенных и нерешенных тикетов группы. """ group_tickets_mock.return_value = [Mock()] zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) @@ -625,12 +667,15 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.get_tickets_list_for_group') @patch('main.views.zenpy') - def test_take_many_tickets(self, zenpy_mock: zenpy, group_tickets_mock: list) -> None: + def test_take_many_tickets(self, zenpy_mock: Mock, group_tickets_mock: Mock) -> None: """ Функция проверки назначения нескольких тикетов на engineer. Проверяет соответствие ответственного за тикеты объекта tickets и тестового клиента правами инженера, направившего запрос на назначение трех тикетов. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param group_tickets_mock: Mock списка не назначенных и нерешенных тикетов группы. """ group_tickets_mock.return_value = [Mock()] * 3 zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) @@ -641,9 +686,12 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.zenpy.get_user') @patch('main.views.zenpy') - def test_light_agent_take_ticket(self, zenpy_mock: zenpy, get_user_mock: get_user_model()): + def test_light_agent_take_ticket(self, zenpy_mock: Mock, get_user_mock: Mock) -> None: """ Функция проверки попытки назначения тикета на light_agent. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param get_user_mock: Mock объекта zenpy_user. """ get_user_mock.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['light_agent']) self.agent_client.post(reverse('work_get_tickets'), data={'count_tickets': 3}) @@ -652,11 +700,14 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.zenpy') @patch('main.views.get_tickets_list_for_group') - def test_take_zero_tickets(self, tickets_mock: list, zenpy_mock: zenpy) -> None: + def test_take_zero_tickets(self, tickets_mock: Mock, zenpy_mock: Mock) -> None: """ Функция проверки попытки назначения нулевого количества тикетов. Проверяет, что список тикетов остался пустым. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param tickets_mock: Mock списка тикетов - возвращает пустой список. """ tickets_mock.return_value = [Mock()] * 3 zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) @@ -666,12 +717,15 @@ class GetTicketsTestCase(UsersBaseTestCase): @patch('main.views.get_tickets_list_for_group') @patch('main.views.zenpy') - def test_take_invalid_count_tickets(self, zenpy_mock: zenpy, group_tickets_mock: list) -> None: + def test_take_invalid_count_tickets(self, zenpy_mock: Mock, group_tickets_mock: Mock) -> None: """ Функция проверки попытки назначения некорректного количества тикетов (введении в форму назначения тикетов не числового значения, а строки). Проверяет, отсутствие списка тикетов. + + :param zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. + :param group_tickets_mock: Mock списка не назначенных и нерешенных тикетов группы. """ group_tickets_mock.return_value = [Mock()] * 3 zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) From a908fc4388fa3d872d930bdbcc64491bbbe22267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 27 May 2021 15:24:56 +0300 Subject: [PATCH 62/65] Add user docs --- README.rst | 2 +- docs/source/overview.rst | 17 +++++++++++++++- docs/source/spelling_wordlist.txt | 32 ++++++++++++++++++++++++++++--- main/models.py | 2 +- main/tests.py | 12 ++++++------ main/views.py | 2 +- 6 files changed, 54 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index 721f60a..219cdd7 100644 --- a/README.rst +++ b/README.rst @@ -170,7 +170,7 @@ Quickstart Для проверки pylint используем: ------------------------------- -pylint ../access_controller_new (каталог, где лежит проект) +pylint ../access_controller (каталог, где лежит проект) Для приведения файлов к стандарту PEP8 используем: -------------------------------------------------- diff --git a/docs/source/overview.rst b/docs/source/overview.rst index 0e3bebb..42ef622 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -81,6 +81,12 @@ .. image:: _static/role_change.png +Являясь инженером, Вы можете запросить в работу необходимое количество тикетов. + +.. image:: _static/take_tickets.png + +Назначенные тикеты будут доступны в Zendesk. + ****************************************** Управление правами доступа администратором ****************************************** @@ -97,4 +103,13 @@ .. image:: _static/admin_manage_done.png -.. |copy| unicode:: 0xA9 .. Школа программистов S101, группа 2. 2021гю +Вы можете смотреть статистику работы пользователя. +Для этого на странице статистика необходимо указать: + +* email пользователя +* период, за который необходима статистика +* формат отображения данных + +.. image:: _static/statistic.png + +.. |copy| unicode:: 0xA9 .. Школа программистов S101, группа 2. 2021г. diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index fb1f974..706d045 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -217,6 +217,32 @@ by subclasses so new - - - +тикеты +StatisticForm +patch +zenpy +Mock +редирект +редиректа +предустановки +TicketListRequester +get_tickets_list_for_user +side +effect +for +залогиненный +предустанавливает +переадресация +фикстуры +profile +json +аватарки +аватарке +locmem +бэкенд +has +control +disallowed +test +users +Contents diff --git a/main/models.py b/main/models.py index 790a322..2a664b0 100644 --- a/main/models.py +++ b/main/models.py @@ -79,7 +79,7 @@ class RoleChangeLogs(models.Model): class UnassignedTicketStatus(models.IntegerChoices): """ - Модель статусов нераспределенных тикетов. + Модель статусов не распределенных тикетов. :param UNASSIGNED: Снят с пользователя, перенесён в буферную группу :param RESTORED: Авторство восстановлено diff --git a/main/tests.py b/main/tests.py index 79afcfc..3f2721d 100644 --- a/main/tests.py +++ b/main/tests.py @@ -62,7 +62,7 @@ class RegistrationTestCase(TestCase): """ Класс тестирования регистрации. - Для тестов используются фикстуры с данными пользователей engeneer и light_agent (data.json). + Для тестов используются фикстуры с данными пользователей engineer и light_agent (data.json). """ fixtures = ['fixtures/data.json'] @@ -101,7 +101,7 @@ class RegistrationTestCase(TestCase): def test_registration_fail_redirect(self) -> None: """ - Функция тестирования неуспешной регистрации пользователя (введен email, не зарегистированный на Zendesk). + Функция тестирования не успешной регистрации пользователя (введен email, не зарегистрированный на Zendesk). Проверяет, что происходит редирект на страницу "registration disallowed" """ @@ -277,12 +277,12 @@ class MakeLightAgentTestCase(UsersBaseTestCase): @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) @patch('main.extra_func.zenpy') - def test_hand_over_redirect(self, _zenpy_mock: Mock, _user_tickets_Mock: Mock) -> None: + def test_hand_over_redirect(self, _zenpy_mock: Mock, _user_tickets_mock: Mock) -> None: """ Функция тестирования переадресации инженера на рабочую страницу, после сдачи прав. :param _zenpy_mock: Mock объекта zenpy для функций, работающих с API Zendesk. - :param _user_tickets_Mock: Mock, заменяющий список тикетов пользователя на пустой список. + :param _user_tickets_mock: Mock, заменяющий список тикетов пользователя на пустой список. """ user = get_user_model().objects.get(email=self.engineer) resp = self.engineer_client.post(reverse_lazy('work_hand_over')) @@ -552,7 +552,7 @@ class PasswordChangeTestCase(UsersBaseTestCase): def test_different_new_passwords(self) -> None: """ - Функция тестирования смены пароля, при вводе несовпадающих новых паролей. + Функция тестирования смены пароля, при вводе не совпадающих новых паролей. Проверяет текст уведомления пользователя 'Введенные пароли не совпадают'. """ @@ -604,7 +604,7 @@ class PasswordChangeTestCase(UsersBaseTestCase): def test_invalid_new_password3(self): """ - Функция тестирования попытки смены пароля, когда новый пароль не соответствует требованиям: аналогчен имени + Функция тестирования попытки смены пароля, когда новый пароль не соответствует требованиям: аналогичен имени пользователя. Проверяет текст уведомления пользователя 'Введённый пароль слишком похож на имя пользователя'. diff --git a/main/views.py b/main/views.py index 07f4cc5..d79eec5 100644 --- a/main/views.py +++ b/main/views.py @@ -420,7 +420,7 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: """ Функция отображения страницы статистики (для "superuser"). - :param request: Данные о пользователе: email, время и интервал работы. Данные получаем через forms.StFatisticForm + :param request: Данные о пользователе: email, время и интервал работы. Данные получаем через forms.StatisticForm :return: Адресация на страницу статистики """ From bcae073fffb2fd7acce4a6d8cab7eff1bae64e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 27 May 2021 15:54:12 +0300 Subject: [PATCH 63/65] Merge with dev --- docs/source/_static/statistic.png | Bin 0 -> 93533 bytes docs/source/_static/take_tickets.png | Bin 0 -> 73713 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/source/_static/statistic.png create mode 100644 docs/source/_static/take_tickets.png diff --git a/docs/source/_static/statistic.png b/docs/source/_static/statistic.png new file mode 100644 index 0000000000000000000000000000000000000000..279df9bacedc65ea490b4f341009b0ac7e9fe6b6 GIT binary patch literal 93533 zcmeFZcT`i^7eA`wAR`PgjvxX8qYf${AWEnL0wMweQbI2RN=Ya}=q)NDDoyFV*MtNE zB%y_<2na|g2_%FfJ)uKrA>_w=XTIhA_x^nEt#{VS%H4OJd+#~>?z8tk`?L3b`^Z?2 zhfA32#EBC;`Va1zojAeia^l1(pso)sy>@1p!*9n!`KNty_W5|k{cq~T ziD$pPwEiFJ=Ko`{BPEHi2+!a(0i1*79WOVZj39+uAjO@BnE3aFQN=I2M2OCswoHrsk)A24TIY(8jUq49~o36;7|SM4K?ii_VvgpO3qZpA$$@v~=ZepgzYO^`rORy{-xn@HrPJ=DGNpFYowF29jEZIF81 zx%cFMPBw_8R)#p$a1&dk(&Q?_&@g(3h|d6@rl{#5>KxH&bHVbnZtz4@Tz8rbB9G}3 zcxykHE@zW#kYwXVb{T zxqhPks?QAw+{q0-VcBbx6zl;ze-!AQ)cbgS_jVO#|8S%Q{Z19TNy0@1OWvA)pB!a~ zI!Z>u>^SyGcpdMb4!5URQ6Qh{NxY*B)uH+IYSl`aD!>=bQIAwgLvhr(ecC5EW$k}l zVd#Xbms%Rps$TT3U`UzM9$G1};h~YZ1--JvC`%sth(!i*mwd$317-h!?gJOthdp7j zEup0Kf!j$(h8h5Mv=7~f0$y#xWgi54dvm9{&Lk~)(NqAw_Ezeu3Ux6 zzeFsCrH}zQyGT`REyq_)QKJ!Y_hBz+&C!C&ep%iJ8)*?svMzM|u2TCy?0Hs2vy$`e zJ=ySk@lnL5>^5|F&8#5tA{ThAzw4MkwSh+VwNLazEs&-23A9SR*mBJK0bDVhL260! z3o--jzJpDh+LR@PhP$PCU^&-@>yk7ocY1}%FrQx7;7T~}BAhx2o_tXd*v2C<(&8t| zv%-3RpfO~?(*hoqa=WdZAyp=vpXU8y`Saer_OBBBAk{-d%a|$~qwsJ*FdDE^<&^OU z$M}jvB^TvZSzbUh%4xop8sZuTQBg^&Mf8*yNKjfcZiJSs;DNrfHAub*xJI5`cTe3r zgNe-W5YX$7^R;eFUfQ4|w!vzkSHRvvDrxSd9>hoGu3l?yi41Ecio;s+fpo%x@k8j{ zG(^RJR>?C@f6*(%V~)-{>Ql+%6^bu-ywYG9Uw-xLy^CGY;T!$ZV?y*Eu3U-(cx+ze ziF9p!jobfl+W25Vk$v=op_(0HmS+nfntzt@0@m)hRva-%PO3paQXNBmQTa-aPMJ&S zG3kKAdpmy~n7FDt=BEJ)!$s_X$U2lm2K{T*@IeH{C$b@;F3Rvo=aTm-HU{Mu<99zK z=kxfrItQ^|JqkaY1UwqM)yGBss1rx@satV6U3Y#bL&T6)DW8YUhm_i>wR&*>;5Ejv z#l@P^OsVNfi+M|~0*(IZ2qR;!?6T+r;{mo&gf@%iYe_Wu3E5(IiUJDgff29#!-H>+ zv>0L@C7i1_+|^G%hyH}!4ll4G;%a7PqPSM4Dkz4wCj73Ar%`J7I08FTK$4WxO@`@F z0L>NOUX%$b?}SuVsbV~;_gwKH@*yE?H-$aCni*|?5-A{DgaCFHT(j~1aWe0z4Usjg z;Aa_PK7-#Z)pJtD03Y|WzZk1THftL9D6M`BjJdPZhy>ZM@y5PF>7_&sC$pIbDH_&A zuV$`oKdG3<&?acWx>!`2bR(^3p2wSjH#U|41h=~x<>JPPd8o+X?@U7#I?jJJ+YwnC z1=-vC2ox9}v~yE`wtxC0*HOiM)ZFUO*6=kDy&;Geh0XE%%@yqjex-fIWn0JAh5!~W ze1G~W@Y%w6qJP~>@wLhcBi}~YwA|lZdft*c%wlzT9?RMVI zxb^c}Chd+$Ew9Zb&3gYW*6}A1t?@l)V9i<{(TTevqOq2?GsTixu{XG#@!E4M zF|zS=$zv&#U?56Zv zCEaB-@F06;c84}H^N5>(A< zQ={as>Oqjh_LtD5xch1sgLW(*bV7gp!2lA#KkgWx6S-J z4)1(+xJT3l3EpYL>Mthz)z6m5wf0LLeL+{%2A}c17!-Ya@)B!(g@cE^C&zG-KxkYA z`lTs7t6?(S7tCT-DQ4I-9^_mm#wNNwAzP| zysf+FCR&?*y>IiOxJlVzQJT~8NaY&(V7Y`>YsSVhAj|oZ)*N}d%zppKzbmaUAb*IiFnpBk?ivrqt2yuV}e6f}dn~n>spgRZ#CN0}8oHWW) zwfs;ZvbH~WQFLtENw-h5I#{eG2VHFrM6XObv9as;j>%z?dyr&{zsnnKuDvta;2v3F zNU+UPMQ>Qdx626ih>iZM$6y+VKDN$?GXv61f4Z2>UXH}h0fUxK>>MeoUn@FoE^`IX^Hdx_o(w$Odxp7L`&wug@rpoRV&t64mU(PyNRuVb)s;?X?q;X@%F@RR>*}F zujRI(BECvz4D>M7BJM1k@V1``N0{pqK>M#OWfl5kRaBKR^1#-=<**?rZ{KFnl)3GV zk-X1o?09ONGNT=)n_ah3X#i|>V93=;?Ho?8 zgh7Yk!aFpDgwVj;(pcc89Wb}UEKJaB)06GZ%o8S2X;A=g|41fdT*q3UJB z&|Kh@x&{U|8p+8^fvK-F4>Bt4^%H1B3l5V1!C}vXyuForRg72l>kdc(a?+<-y6^m> z^RHE2)Bke9Nam6lpEpKI8NsH{-s2J6&bP;_j~$H_b?)pV<&<&@SEq-;R7ay@PiCZR zc9PNF$5M^DWwU|L9pI5LsH{Ty0(=q0;!&!w(iBO|-0e(<3)t3vl4UP*Bm0@%8=4Kn zjRKhu@|lEuQp6zC>%61X18p_g$)%??0JFiQl_vaW2h37Zou_Tfsi}OK4qu$M zGed9W1p@-1(}~d#QP<{)(4o=(p@Tq^RK?YJ;I^kAVBNQLRg~5RxT>A=`z?))1aK7u z!eJ5gfsMC?JH({C8^<|0i9Ij_&1G|Wj6C>^mGB(wC`gEG)hg?mtUOm?0QYfFN*l)- zl?Zwdcf7&bp7Xog%zKUAe+wWmgv*CrSg=K`n$R@SM`qi>ocvoo&Ikvb3FnYK!Sn0& z$PsuVtU{EKUQ@h1r!~`39Z^AM!Lwe{bB7KXv8jW9O5hF|1@qX&ulc>%%{{_;V_``& zy^`&|CY`0uFH>G>V(JMy-O6Ge8?0d?zMnZN^@eN7M^NTAB)f5z2R2xbZX&9?Uu56U zULT@1scgJ&-CwON-o59sA3W9x{zvsA%MS>0Wf1dgK;IBNZQ|Kor{#F7odc4{tmA?s z02ZQs)~S8*t5dS!Cp@4E9?N?TXjw5q-45yGWYk;@oC9ju_-X$6G(0lP?r>xuB})?< zcg(X@{DAM5kqV`~`4QDCCYxFv5}U0tVoW7-(2xdw{)nK8)s03Yfg^*V>5_`Nu=y5= z2UncJtg#o#WoyQ65hOd~V9kvd$Eank{oFA`4WD~;b7@2q?_g#=AI$sF)_2Lp^n2d% zsz`i1evV*?e#eG)^;-UY}Ety$>82bYKRI>^MBpr%u#HlPwcS*(Sl) zKS%V9vj=e_?LN@jtG;RP#>>{&AJfCD%5r&Gm+(_!G{yrv_3_#wTKGhvD=Ied3nnTh zXLWV`qqZ2AS!d8v&$v0f#khl2Ib(S{yEB{y5nY+Mc9(D5Tn}JxOdm+v>7Fo1D~(pgdLqk%NvX`Wjf106@s5p~sVzE5342MZlM#<7 zE>VWizN?Ycq{%1?yWOi##hL}P4~r0!7rX9g8d&lTakqsn4Kk${-}|**wUPd&%EvC- z>0SNcgicls8fX>elB4x=RyP(`MM2Ntrmx93hG-3ME|w1MC;tBKA|(1MR9RIuWY zEWC<8&m45GeI=0tN31^uO=!X~O3S2-4giz1uTYl9HcUGe2Hu8z2HCMn6<3dpc6US& z&eU2brzP`qp<-a0NAp3!W0LSnd()^qVMA9ia<#JyKlXL zRF^wqRD9e-M3H;ZRn0ZZoW0a;_k-KkM#@KU4f|Go!=p7Y02UtmdWTkcx&zE*&anHf zu6f#fvSYDrpcnIr=%rTIJTDg%<<9Ngdv|Z`85lkfTJkq`LdLxh)udoua)Qd!t(p^q zdjOFi9wKF$!P?SFGsYk9J2WMk3f563SK%FYWxHgoIrUM@1nznO_F8PY|;GA35 zo@dN$O~JDdOT2-N5E$|`HtiPgkTc@#buVR)Rd+pC%@lssLkWP;EuDUxvlq$Gbyo8U zZU@$sA5|2$+8=5&Zf_oZRR0LkY+{*2ng@Ra-;Uo2x8qI{Rx)SL6Zcvc0{=MV?i~%W zp2qLNc8D&gZo25x*5K(Ji&><7CVR8!h7hqI)XEe*de!ri5hvKEd7SVzl}98@|-- zyRlbc$#oJ^iWYK|(2xgULD>Pe_O4`O(-`kbL554H1YlF%_75A|Jdhq;PTDoXth4VXuSdpN5+Nab+czID^+~oBcjz~ePhL!JCMH^yC zjgH^2-1D{_;eCxx2O$$2xPr&(p5J^6YMPQI64-$9p*f2&c#*2eLcnHg-hpDc`pjI_ zVnv@8z(Q&(GnbH`wv`a1SRP78v*WkHv)$XBb%LJWUOsPYri&T&LL9vDpx4JcaAQz= zd0rTFgI(pufC&%a60%3|!;15sKC<==g}2>@pAf&Rs-%r1GzRS3A-y~~sCG_mVVb%0 zZHs5zb{BF!+h>e9Y-TjO*2}v%6_qC$jAKz-KVT&OIMIedP7!j@p3Ruy~ zcn;Wp`0JfWgV-%{P44Qq(SC-oNWOZD(F9IAr7fX(N9KsOQgD0E#B5QI67VVP2}JdN zQL~AD-yD#hJ`TP9zGB3hwTm`kJjH;VW~>8S)L7P$4UWuO*6OsCOc*q02bTJi>H$2R z;Z-EW6g9)9Caz4BI~WIbBARlHb-!K^7cR5tpJW#Q#$ZZh`VghZF1OR)>>c+Emj(snp1||6`>G9pklUVi#i@5{-?@CgD6L4Vz9Vv)=HuGz5Fyf4v27YDa^z~M z`>{7vLi|V`2etse>th&BnW?f_QY>GBEJgkxh~m$ZTP_Ts;6STs8+j{op$#P*F86Op$z6XE=d$u<=lcYTv8zM&=r6Xki*p(R5Dt%54prP4Uq|GoQ&$paJP=E7+wp?=nN8M`Pg?&A#~&bF2(FL*p8&pUJ`urp!T05CDUGn%>M zwsr$xzF;iDA2R<^<47K!VLf~sVNiKk#LboOrzO6sIMEZ*KMgNE6*Ja%F<0GS5x*0Dvd}S}s5#A95^j0fDe|F>xi^;j0&h8=Qp+_ypKR$Fkk?_uY1g zq5IuStGpeN8{d6&zdi+6b`OdRu%hK7*>TAUuHhPU8QYy5=$!Xp-c)87^3_k#)+sjX zprAT6_kA}R$K5g-5r%AaupN`Kf^fUCo1STHbVdOjcPmyK&@jaB3fCZcZ?7c>POT5d zU4?|!sZB~Lxd#etker}num`=XFW9?FueFWbNZlL>M&Q>S^WlB=OEV>>YTkf&TdQsWsH4dnGm(_FcGXtqG2nWHYVF9+sRU15GykVZM=A>Bi62G zp;OXj@T=GGff&HsFip;CBFJ?wbcL%|<(Y9XMzr#qiPMN$l9FYcfr`(8Cv%$^J4wro z!(RQKQz>&!XQT%YI_rBQ2H#*0p2HTnSKn-`1`+i6Qf`-t5s;?@u}=Q)Yz`a^LJ!j~ z`NrZ2tMjf(1lW8rB$m%3iRH2Y9aKWSl>#Et zL0L|*>CHL}c*YQ_vPK}!Fl+qG%)a_H``MZBaLY39^5iC*5$Aq}N@*IM_d5KFv0)ci z)0nPmiVF$thjO!Qe@%bv>B|AwNQb6SfV6dE@7ws@mOmHx#lx>tS_}48pZ@@w0%3E& z7h#TF_ru4ej;h^d&6-}WdJNsgX56WYp1fvbB5XfC2U^zg7jt&%qM>I^X{EdoyKz)S ziKR~;_|sq!;KV7?d(%LVoa%*5vpy3lIYjCyQ`OtJKEKY-e1#%K8~@<3f>q<||0*^j zUm|*EIeHd6%GcH5EKgoEqrK}1&lr$q47c^>5h02l=;6wDs8#iD$5(-G^OvQlSB-h<%Z$O&mQark>|x%x7I5*Iom1cUm_^3fz1|cNEpgF6DsNeVM6T zgPP4Wp^uHHA*G?o%0wT%xaiB>2dft?E^+D`JOfr$SoKL$k`|#TTD5J*@H`#X`Jw>d zRNy-f0F!0sN6raGrn=8~anG1|&b-`Zj4X~>Xy&0))vw!W<@rt;jJbaXn$&$aU!S>iY5#w~E~ z3nbh|Gbf|+4dme=y*I}^u-g?kI=1%foj#OZp!oEmvTU$fRM-qZd zq?yoaH61ijm~o(Nge_On;~Dsb%)@slotm z-BFvqQ4z++QMHp5X?s3hJ)}>W&yW!D;M}dr;mrG3z|Oaji5pq128m_%@5fetcxQw< zs)($t8|?m0-tj+%3ec~3O>PwF>#klMa`Ebs{@mgDap7ghGCIHKQTZzwPUW;2RXjgo zO}Z%KPU)_8S*~wN(F99^|4Hu8;y&``>B|?J`ZHFl6UU^M`6S7fo~;IJGH*w+DA|?X zZCu1#SDV4}jAQhH=BZBoOrf3mL)cyk&9sclaJtsV@oXfFtLlMPbysyj-Z7M; zxHjre@5a*ct0`-FSa>Scy4o!RAI^7?sKj&)3`ci^A`5B|w}`FHPLQm>B7rbMZi>x5 z=!gZ1i&uF9Z%8;edjA)Q+NX7Nj4JN=yy|6bRYuf_c~ZX;(f;>2)(&cl2w7-}492lnrnndGobub0(vwh}fI)co@lSsuY8112yh{X0pAT9B~@*-}_& zhQLUe=JeM6H^O9Zlg%VGh1D|o%}|eencwbMR^{i5DcRTA%`e{|8QWF|v7UG(K?2cU z@3#uBH>%}q>a{X$Cb(2ggqT?}N5M~i$+;%&(Mz{}ddD}WUDr8?(}%J)9E(=WaCOVM zy!i6M-_F}NGux;B+Y)x&Wx4w+m3_G?a(PNe;fgbj%l6Xgisp6_IZoYqMko&SQ-cMd zk(n{_>G!)N>jK6@RV9u){FPFAU(jAT4#4ufWP%S+!0x8?C#Bb&!Jxi)o9u(W8^=4Y zRt+u_Gl|LA5w+_S%~HD-3Rw4Hucd-YG>K_x2Av9rP>#|Yw+gfsSKO*!4=e@b3T zdS|_LA%9rko2?wFK2|P7=l9Miyt-zWrP1d#&kyt6d{Ej8!}moEDZ%3dXT4;%?kEt$ zWrJ`N-TV0+3Z2SCs~+RJ>JcC&pVm2OZGKOCA5TZr#oOtTuvfz5 z0u1lOg7oy^hZ0ri@g=E7t8Xo0wNAGU1%y2lOY&Up=i;n^N3yZ?K?IFCKS_a?B}DU( zjMNiR(8V_zzf!%91^tByParSIMT9w$KPIuxS(H=15L+-Z{UUs%M8_OZ?O+b1N>$dI z_a$ZgaHU}7vv!zrl%fy*CWh26q%r{fOlpZ<@c<1>5A11D z(AZ0A%b()PUhsoz$7@*Djgr?q-xCgWIK7ymX+oVK(hJQ^4ND-sDt})G;p75|7q|O3 z+otEubSUei!h)ZgnEoy3Q>xSDwt$sWG5#>9J21^ae@M$NF{6~FnP*W2(ts7n1Xsci z0Mx+MZ(5?aqOmEl+cSP!%__cgma-PLVd?~AgmLVTA1hL#{qT5S5p-3QEydD118{Fn z7dF!9pj7cy`rr-Yo?~BuMGNXkCw7M6I-Yc}6L&KbysRS;tknEyj#g!Xdo8sTzvIhm z;#*>9X^!lWQ^HdZXeyM zB(j$5k*6qb>;&zw#-m%tw>wQZH^=9+w>{WnOAw*2R*YMzEEyv12{MO=oU|^<( z#8}7ImQfk5uu5e2GWT%pnm;!pC1C2~7-K_53Ua42TzGh6ID_y>*Z=V@M-1CkJ5Me3 z(I<)5IrGZ!1baD;UKsbmvzmHRLlAF{!}k21={@JtiChgg`b2+1;M{qC5zZ(5HA8uf zGDBGnKsFXw@jD1A8|gI`5tkRgE26RXQnA4xF05NIx3KF2GomcDItuRRkZ11y!*^`e z+=lcp{xUHVaf;Z`9ZI$81A3ME7)f2%@XBkwsoRu3VgcJ(Nez3<7@=M|$V$LUN?pKoGd+T;<7r4efx@^OBc zeNffe9Q6FPsun1OgQ6jtj1KaSH?C@VS+E@9XYCoNmv=x_q|gl}skW}R6JFu6hF#y~ zA3-OYj6}@i`S#Ks6h5;wR0JM0io^AdJiL*B+43`10XxX<-m=#*Q{oh(mS4brUGYuj=}qkRl@0c)he$!JL|Dx_ zI)vseUaIJTK8`XDgP@$iIS6Xh@LT@ddyGL*0YhP<)pE7?r{sw=yWi~0l&tE=l?N*w zP2|eZz$MZZ{Dn?bsT9Ez$$9L|E&4`%KVRtvxn(AuFtaH1WZAXi>SoOhBCIv4X>xS8 z^QT%Va9puPpe9GdSMXfZw>z+-KIyPJKe6=cicV(mppFp@mcP*&JbA8g3fbY;SED>Q z7=P|avqx?*)%{FNtG%{(P;$PP*TddYtETjU($hr${RaS|-*WEAw_o*oGAA7jsnLd& zkfRM_S%b>_jfr5f+n6OodR1<``4jSMZq@McGcsYYSNRHw4uwCm)UfqwUMhmYGk-oC%v%K$VQ4LdFtfmQ{)={Gxb zjQ~3`y`xxeL9PiUNIb>Hu+58lroaf$ywn>;<)Ae6jY-+}u2fI?ryK?13&Mgy$P)c) zfFB55Tg)YG=&Zc52TY(NyGQo|+nf9m(}ZqxN=slhUyc`vd_Xl*6+jskH7rN+U~iT${^>^ z%BTEh>LU+MxC*&8zKurD4ZjUjDuK^9wGQ;S5fEE4G}ipr?YY+)MZq1s1Ko{m;P>%W zkSE6*bQ7=X_imJ}3JO0VwD;!zE>yd&B57#k>eE0PrT3Pixj(F;ihI&*_WNhsn?KIr zi0rdCMVIG96EB3Cc?y4%;#IcO`_jl7zqSR*?L`%!=aBBP7vx>AUwr=I#X=5qrxA8< zUh$&QIl!g5FWZ8>S+y1vvv^jng0bCIjk2m!Itqcy54`gxj2QtVdrWQbrd;G3zBNLI z+oBD&B;6QlP?JSpoBV_1)I67Z5oMv1E)H;{=+XsSE5~$nw#fq(^#Rk$I7)p>qPJ7! zCFv-1y6n3GfNM+RWZ6h%=QG{!qSa&f!%YBv(Hz6quFnro3sa}ej)eX;+$ZcS`b3Ai z2XtWk16u6WVTU|=I%#Xz<<_-qJq z?yYu6?1DE5)&1gNw{VrK@=svn?irq?)oY|HIgE+;zyo%v}@y1#Y8r0beHtFc1lZUE|;?CM}_~`GlmNg+<)Q zkSe#rCdTq{fQ(YP;vf*9ArZMuzAP;vI^JUJG*8U&fBn^AX~Fqj&Z^I_eq_RK3Y5O7R)! zkB%qhHx^c4?Qhy*Jq$#=cc(YG$vGw>7msK!$&oii;g^kWVFHb`{b*AK(fZlx!* z7EU)KI%A&EI=N{+E$;hF!^Jjc{I!b>{k*0$OMp_p17!qA&7MS>>9rDbg$xCsw7Z#7VNfCH`s-Rc3fkN_Ve8!}qNrQ@Z54zw)%xvJA3kiZnJJ-L?~0=KuS&)s!&!7vZ35nwfP0y1FfTfF7%S$}MTq6#b4@nB8cE~Cc{;LAoL7=}aoGTd<=6Tr;7C8`G`Rx8F^QThdU+eLHY z^3d3?{$g{j(JR)c>f_?%;SQp~tSH7p{(Y^gNEGz!zW!=9Z)2Q`|eH@(V{uqm|`VO)`T9O~mSWQ@c|8enjb{Bbi;xIGI98gA~8<||t^R8_Iv z_5v41NAuNx`_)(`P2^|K?U|EYm5?}C^M&BL5zT<*jJ5-Rx|`9_vsm6zA^NTKto2gV zLm9A8-Y}{p77eC{8ReteXpUHzD$jE9kr9YyObHTF%1Jd>^=erd=gR}WLk3HZvE zeR!KPsn4?yBIo&S6&`Z!AAFK)eUuf-hn&AIdy-9j728Ko8H8+?MdN8QHb`U`A5AK0 zO%Md~h&S6iSQ#4sz4Sx0uvF9pe=jTXen4qxRgE>I!IBX$f8ji#)5%uIP^^{%^^Yz) zVV{I#{9!4vHsC6!UL^dXP9X}|!ua!D{2x2-F1^9+oc(&;?WM z0i<^$KYKfEBOMFS+8^|ZkeKP&s|A+o3ZSoe!dGe-@83)2lYlPi>+vD!`Rro5@ZK}E zz=B6#e2>5*$^CG@@33teQ_pa{d$BU$*@mGaYl#FT$7LMs2I7oPu+3XHw4F>1(tuDbKI z;G{@L!PzxdR*$nuh%7VoV|Yn zp%@#2t}2=^)CP2~`7;HLi4`UQ!pHTCr~1x+FrNaGH?%6MksrrncEB_TzVsUxSs_XX^F?HK#^Ox=hQ+-gN}uWaXRn z>nn<+oZRYEe%FFr``LLPWOHjahHlXiNbzm{3|&4i!zHe&CrEm^&_(2N#_Y zuF(AE%#1T4M24CjoGM{mUZKc|zb)#oBCH#A-w~KC?yI|^P*%mbnd5;wdhFi&#t=F? zLgUfJkt}0W!IvFPXT%IsTVs4^Esx>ldlWJIKPjyZfrKwLj;1NwdKuTX#5MR!H{^+J zm+MXP{V$RX=SQ@CBexP&XNhmD_FJ&}iNu2e0b9{lb7sjo{(>jfcJoct*`+ze=@;Gv zm4-6m1}7TsvMIBT=sA|{O%^>@G8!^NHn=_UxdCIZp0I+&=^1jMxf=G={XHSqCa1|C zx6g#yCUHv;8rW2KDEl`5+#N%skyRe)q215KBmEAIOf08TWBACUQ|xq*__!$|a#N+P z_?p!EG)G5Ps0yrV*Ih)RyEDw=TT@Ujzk-bRZHkR|=7lno1sNg@p;oVXNnfAH`rhW{ z3!TZ-Am8r=ooBpz0Cd?_L`(`J--ybS#e?s?eQVP>Os#tjVk%BYj?;a-pLE1SIWI%3A zj3OVqHV-*X|B1g8S6^9Rf`QD?BKnRk`IN2BUuAT6TIJU+6mj@4rzVL+eXFQIo0?WQHPc4mp_p4KpWVAN_=%GsFg)Fb5qPuU; zZZW>hAZ96-@AW5Tc~=Kl6rW2T(y=zTeV6>Z3qQ(P|DZ|yVzG&9Z$jgJ2_)g0(lS0* z7Zu3S&kT4=zExNA(;B_{=-hdaz>xSKUYDx7?03)Vu8o$)n8(5(TYto~VB{mixnzJS z=W_!7PVdQ@YWfhOkNJ~ap~tpsj~(s&=Fw>F+a13?THtgdrfXMg%79`l+im%{Bo|mU z>t+JJtI;TS9;Z=Hljf5NvE>dfH_MrQ2T9I5pxwx^-X!6dR(p}iwVz6R9`cC>8f z)KTyva(O4HB#V6^pVvoWf~&)@XUx_;W-`m!Whc7GxJ3a5Xu{%%k8hx|$}oPb{f=Or zc>k*gbuK#%ySA`;bGZ9F7;0+Vscn$xh!ku(7KXse4rs0MS$R%xraOhpRb;5<%vOtN z(F7kSo$!FtgDm_pteBWqnAF*lCG+Y=%g-|to4J0zo;4F43$$By;ZLa8TgaS`bKD9O z4Z-Lzw?;oDDIZyPHh20(c~h3-_g|bPl8ns(xAlGr@7l{ft}ks^J^L?}rPf8Rb&eJI zm^nUD){mwzM5F{82*=9EoE8uCCM(x=#KxMqvEabzbCDXw8U6_T$#$&@ws1^<3aVVS-nZw=| z07V|nv*hAOk4@Y6pLFfu!Jc>SLpsp`k3`m0_)eTyH@W+ld-!Pi>-8_K)ol`YwRqvL z1A(!8mj!m)j_k{CCTye6-_E4+>CHEdN3}-`C>A_cHiqO2!BqS@G?WRC9!-)I7V2*5 zt{OqTw~;qx!h;)B!<(&pM22`l(}hI`_0N5qz?uiI4)fv(VG3r6({x^fadh~&$T(z{J`hI;m&Y~tCh#J z#LrS2ai=Ru)#^6PDGMW`aL0&WE&HR4aHrr;+covo07q4q1jSDkWYm8KQR2$SMAHb} zZo6%bH(YawPw45&74_>V8XzJE(cRaJExF&?{J`01730`!ca}a+W8|OQ{MZ9ajwfx0 zUoqHMs)!X^5_s%Yl7gBzPh)M0*f#jY`62H~bvKx-?n!COhhse$^%|SOb&f6%&J>hm zG3R&g&dC8bjTpl9+VgYoKeso>9EdEeeCfBO^i?6~>7LWgHJ(rU6*qVNQ%sr@L62qV zWAt)vl+uSJY-cl2ZZOQmr zT^Zq>LuJ@mAM5P*e!gSQ!M^fH5w>0|9WmC?=pj~O1V@K;J;VVpNljRo93S>XpikOa zEk`=j>6fl);r-=%-e}9!G-xEX z{|i4lG=D;jY=mI7+#==l+2lv%DIAs9&{m$RKYe3Yr`?R$2W!)KaT;#A^Lwer6DP;C zPug=eTNIwi2x@f5+R{+*N_Ipuizm9UN$HUqCNny=uoo5Jc}!DNc$NP8)BL|(ZtWfy`p{_ z@|D#3l>3n%?-#kAN)6>tR$L;;Lv@1TlKPn6i16Wu2pd%`lhgY!xA}(VKL46A>!kRb zWM+xjCQ2=G=6nJnmLp(ynd}p`RPKT$_Jvy);yu2lMU27g8WhSh$obg+GR!KioIr2`V!+gUl ze^9CE@M*YKtJd>a)ra|@U7c47x_x0vNvnQY4vI%d2g$*Bl0a z?~Kw34Iu3Kz1W|aabP(PQFQ3;tkgeb?sG5b+VE}uKxPBDKU^Rzm#?}hok~w#%RO@&2QTs_@d>iOeN#sG!%1qYL`sT*p20;5?24MWpiYMGR?5Uy_ zR6jsty~Wy;_DF5xX&BU``ICZ-f%CC13W)i!1k|yS$teA~`AdO|%z;CKed^i!`i_@e zw_6HQZ4V7l?^BLhx+q}lChozZY1i<_r!lL3zF)p^t(qxLg>&JjbUDY@QV|@Pzq{A*sf&*?|yk|dHNc-XEPz_ zI61|NU6d$nvIT+!P?W9v-Wg1~12n#lPJGGzs&g)hGH(9Fzdk-vdRk@|_y>k`cfxcSAImyTB~y0=;&=CJ_Ys;Y*4Qi?Tm8qA{TH zHetwzGtIrJX$%-!F1#xY-Wj^*3APNaouybJL@jkg7%CX=i;~!P-B8N^Veh@7n%bjx zQMYbIL1e2SO<*em0s5WOA=OCYvosF`@T76&!O>XOLBAic9N53O*K1Pa`o}< zds~?Yhfd4o#OdqD$=o3T=I_f5$^3Y5)>}zpo6kH=qki%5dy4&LzW>y>UOsen&Yh+V7-+uy zwajbPrYwo0t3eZO51mZ(Qt!RCH56k2pHH*a@>?`T=gV?Bytb?{b%lPE`)^-`+Gm!p zKn`8*Iz2@=E|4Se*FZUn(Qd7^tC*D-6w~F?+Rhx)CDF#&VNE4mz&R)t^eFG zDeti>bsAN8Tx@QrD5a&DzdUFlVVuGVwUSSb42WnJM5~s*>KZ?8xS^$I+{`I&KK@y` zgVrZ6TbZ1!p#f2wXT&Ft{Lk7mUcz}zlilp}y>rH^$)vNUZ1r!7?s`BV>m!0rHu6OX)uSl~<+3U&pkDJ$j0I5s(3{Jhb zE#XA1c1LE8k)!(xQK8ZFBQUy=%eo&awJT^8N^?q;EX)re8EEmcp$fBbphl>-^Dd4pwP?AX?;MqdpSb5*ftrwjwR{cvkoZQ{+D8-I5!3KDj;dmb5A(VtAa zo1@0Jfx3RDi5Kqr(NNGrX^Z8D_)Q53iME6sM)X2Q$LRhTzowhnM{b8Rg@-|yK~IlC z09=hT$)R^&R%le+?v_>6#%D#X>N!zPlklgdczA3Y#mCG+=HyVL^+T>?l0r$J1zyGB zla2`BoGWNStaI?jeq~3;(&Ow$R@T-Pt^EM<&0R=lde7E)0q1@EEWtxh%0nCEy=hMm zD9fmeG0!e56!g1CH~HWm=_8UG6hX3H@^t5AdJhm6bGUy42 z?A4^|H%>U0;GKfcRQK(;W?Z!*1i6R&9;5z0o|XIxV8&IRJv#>uX$Ayf9syxLNux!E zh{Lreq&*3k7q2A<<6lQQ-7-gEY&}dj+4eb{)d?#urmEluAuKnV^i`%g!(`y1RMBg} zusm@|hDYXh-ypO*vPrW3iSLeoNWOLc>+k>8VD2D(0Lf)$mfj7XkZ<-uNa-i}y1Fn= zlQ%9og&j1_qgDq$q1LNW8KuE7F(zjBTZ0ul2-Q)0F&WsBuAw$Dqr2p{TR~yOFW&B{7=0p z1awUXr+m%hWA4gT?wuAZ)xnI-7WQ;VKg7BoZR*1T{*DS`DU$p4+!{8#0WrZs5B@B9U7aS4&iK>bJ-A=0MtHm z&qU^u2X}M+QMxBEYC6VkYCTXM!QSLs{5G`8__6?7ZM{uk2<)b*Lw$+)M!HT@u~6%- z=oxpPkxTvJVlw)}y4XVBxN@|4c%bKQJ;UJgq2!!7g`zF%bAJv%PS3GEZr1dQ-Yx%z zJNjiNyy*Hfj!QLY?d)oRr&5l|$6DCDd0-3Y)9jYPzcojtGIOC$qB>nfM_)_G&xJzK z_|eYFY|n1c#=>-snKm(rBn*MgRaephZf^2S@UR`YS5jqt7-YYCXdqtQa3Sx+QzxRU zGVay^*|0QmL)QZktziwM9+j~~S^o}J*7`~R>k2j^p zDaVEYHdLIuBrZnqX8%rroEnR)OqnQnn*Nl z_m}N(BJ|#G8|(a#8_6)3*dQvpISws;bz5j9o4K*pOBttLsiW<^Q5>uKK_{{=xW43@ zM47|K*4MgvIu$Gu;hZAqJ!!x+Fv=?U#-X-iM*)dT)qDW)JY!8(oNu{lKPINZzG!l? z_2YaaBVCKN{)4p6#aPXEp>a{OGIjd^1%H3>`J!2>31LMQR22|5&T_STT;+(^lecV! zH34e~Va-dHxjBnH=&a1nK<}NhaHe^ zR|Vy9@X53DE|yqk#Us5_1d*BrG9 zkJRx;=iH$r#o8G97m4Oosst?+OApwyUvwvM%9}bKZ%i%ai%h0rS8TZ96M`0UE-xme zIvYvr1_!_VfV(Syl%`;>9Cf^NWpt@30}-@iUoRCGQM(XDwrTgA+%6p{ELr%b&3dqm ze#xp>2lRzW=yh0B;KQ`cH(FvU<0H}uUY>2Y44tkn4go1l?9!oiNeZU%1>@UunzYt! zo~ibYi60X+`3#fGslDbYa7vtVS-zx;S%E;Wh9vO23~!t>K2<-eH};mp)I$YAN}{Iw z8;s_LW~Fo(q^nt14eWh>6OVjbC5lKiBb)$KvfQA2i6~<$hvK?dRZoyJR1s{-_UG$wb|HX-+(9tPfxh2omH4&D`- zRT1)^$*GCEaU?qMSs0oRG{;ut4Bx;=eQBI6uDidVB}HoGQ4ffGb?2xk7Ow@$$R{ApRRWub?}gr08N6NdNXLtbXGIXiH|4Fu3JGHHd#x){EaGokHgu;xW0 z^>#2Ayt_TPC0wvKAl{i5#@1bT?gqx+RDg4i#8qU7l!jV@f6iRWUcq=5@%vnOJb$-d zK<=9a(GrR^KFp~^hE{c46<6>eFuTWCeSFHTn5Ww{R$YEqje6h`1fBS&`hIEH6(@l> zT=3q>ryM8iieS$^wb;=)lFFQ#h@3f=!-thl;@#L+Wbv{n>ql8VBO{vT&bk$s;+bu{ z0vGBnR=?*MzyVN_0#rE?YTvr~%mI32(~|2h`BL%BI=C|Dj!bAm?+GSj<2{@Sv_5Az z@4Gw;+_RR99X#UvRvAAL+`BUQL_1BkqF0=3^XBrd!0vCEX!S>rnyMMNbiUa0v*p)4 z2MsH>PBz_i^_^;F)dT81o}PS6y`*~hv{4|Vm$#sGkuv2_8Oq|ZyjKY8QR!@a3n4$% z)SePwXO%RfkRU%~2H33weasX^ZV}sSrBm;vY86QH%!LGnDDw)`-L)fd$2aQd#AUsA!n=!~$XC*)t*XnP0A6RA%y&N))wHzbMIK8( zF1F`n9css!i>AeU3aCzQde)o)5f_L0csG`tx*y>5BvP^vdj2%u14E`oLqid#i{}iVtra>j5!(c=>X9cCmYG4CmdppN8pW65OXt*Y{+fvH5zn$YeQ3P7V)~Wp2Zx!d zrNj4Ej?~wFOW0b$-@zn&x=H<{Z{v881^%|25aj(^)iL9!dX<7$8BVClFavLhBpxle zP@0vigxoM{nfs`RY6GC3}4NQ40w;pdZ1wdwUp0$vVzg$NU#IMt7Yq*ZzRkxB%ho4-h+N z^2ImA$6+5DMzil?U-AI(Q1FwCiUEZ)%Y$8h=i`=FE_Z~-SfRJG&ViLFf!&dI);z9* zc_LUV(*l2KeXh#!%ft(h>m{XVUvK9&F2}XcFd#ofZt1Hm^PHM)fus}%y$dA4TK5tI zUL2l=QWP3n(zI*Bw>j5?P}QG9UJVuZUZSYY+>r9zFMr4PskZ!W)N%@jHi~h$KS*6O z-Sjd`u%}#hpHCR6zvs4j0b{X=vUy&d5xB~Wme@+8K5EH#4;OQa|8fV^&O=pQG#~Q& zjv0_~jC>2|p{9+U=Lt~fDtRfB4Au30b&24RkY4!hIc4&SWist+U$1D5KPa@jF8nyV z=GDF}*UG)ivfHx{J6-Y<_~Hj?Ee9bAu~S(azjyenKi&j%1)}O7JGbSYFp}?FCo81s z-v6b@fqqcgI9LmH#)Z^Wu1(rd50@TCA#YoG0iefO+7?$FI3FtdqmKhF4_+f>YG(|&efk%G;d*Ov${>_yi^n!^>Y39IBef_P$Xc9XbsG0yI>SglMI7x z`e%JMXMNio(6(<(TcYAibCn<&qI?Ee5&by>I9Nq{3>fhb8wn96Z-Rj#%?tRX07$BK;>^ak|o3m|ejK$ScJOZtAB9DhKwu@n@d``vTKes|m zw|>j47J=W9lk$12u*SUWR|W>sSZ>*e_F*99;2BQqXm+7`Sbw+Bz|bg3Qq=_6-!B>M znchHN7|LhV43o<86eX;Y)p&CMjk&5Emz0;6cO%~sOVsAayoL5@Nfo`XPd;eCQlmLj zb$>jI@`Q_0>}`OjgT~WQd2a-D+7+KFOJ3phgoVH}QGau62Q{&q{%Gi(!4fG~M1764 zc85#JjwSokL-p+i15oC}B@UdVF({z$YlsGJVODOvv-U_uLD6;^_cS_y=6P%(vK!`` zAn5(L_XoVnspA>0d7-ZNkgEo;@{v1>FmU_0S@fE=zRB`M9}XpaXvH|$TNUN8zN!d< zPS6rOXR*-le@ooyiBAW9NV1<02Q6W>9fE?K#WtHIW)~MRWb8gYrwwBGljM;PDLI~*r?uLq$w-yKU|Tsrp@|{e+Ms{$(xq4Eq>g3NuQma66V~O zO3bjDnFa%Gn*M?uy9h+3%bD8Q2IYv9LU5+xaq)3Ny-7ScSv|Ye3r5@ER(xHp*|m!J z@+iOz=dhuao+avMZvj<0>s7N2jJ}9U%_{BtQr_AmkG&QX$p^nfoS%|7W>8YR{R-t8 zw%_m!gXgUp+OEMMd3FxUi*-i`;=#oiUT)nE;~`6Wq3#!kcCx3*cAK+xcjkkxm=0aY z%m&zoy7uI$QQd5g=jz~pGi{HvjH$Bb*$)}_4B&od!GJo7$B#yWm zTORxxcI(g*2Ed2Jeo?O(^dVVeUZ1MCFOYl!27mHsin{$~;wTW^cjPcw@BSiiX(d)) z-(&r!M_#rT(;`O5lKrSp7qYx7|3-(~C@#T;pY#DajB#=i<`37YUVxWL*U5T}y#`1-oyb6H>yh68|AFFJ(S1nSr%vIC#FuD7X8RE%@ zfFqILRD$fg|HC)>(SJh{+I{C;R1he&k4K5Q+Seg-50R-sxln+U(ay=w{i5}Gn`-& z_xk*V@IP!w#$T_#2>*}e^y@3n{{LHj`u{xHe{=YM=IFnH_esQK^vwQ&4W*q>nE&f8If)x=2N zPu*VvQ~P^}wkND;J?TJ3<-qH^n*!@~^ebQf?5J?X-@Z4ky^qpNJ_^vCO>;j!{p@f` zi4oLbU`pgqT)zmKq>GgnJtc1#C+?JbZCLz1ccO)tL2AX?c6c%9FS+=K4_rfhBkeE8 zC8WopWJN@373w?2yL54fC)o$FQ5O5P5k1O(S+V(r!0)nB2P^phs~-q}ly~a<&)!eC-+aisYg!C8Bvu!D^jEQa za#7v$=Uq}|p|8kz!+o&C;9&LJvGfvkp17POm*KCU5!Y^gM*LUykMV2z4KK3%sKbTy zbs^)gX0I$WAarNRL~PM}a-$R8Cd7e3o++6());q|>fb?Oi4G@f@Q;O<4rHGF-yZa? zcfv#nsuZ>KNIPVSXy~JsmF?un7>9drWIm~%A}Rfu2LT3~`vtl*GuX2Z5nvt@4_=^a z_7VS=NPCgEFlPr!3y3taf35$A&CeVAaZ&=u?{MD)e`Vq};!fMdp?+r>8 z$3G{ms87?FhmIgK$b8A!dF&k>1Rx-Y2Oac`O_kWA<$qPMuo&H}56*kG>tz~(tF-*kxpP09t{#_0QnHSi zbYH10UCLWCY%Kx3nAn3PDX~8WG#+uQ(#2Z&eQnsr$`a z_8Eg#g&6e2UPKD-=$0kvo^Sf4$YnT#ppA6_vzp@?BDfHFBTpuCHW`KK8k>{FGcQ8UU}3-KJ6v7N{;yBd zV^v}N9fje*VN}^cxPAPk1UG_B^tX5N9P3X4=vieC>CX&-iBL?!)DLu(eIczREjW^h>sC(5m#-d>>9w zdOC#JmEY0r_#|fHq63Q^(6zNT{x`WVisAf^7EO-b46}lpzSf8q|2+;TUO;&mLg9(? zHWV}HKXGxip|{JI>AX&`Pj1 z_m5<+H1^o(8}h3s02|whF@1qW3{LdLCPPcgo#OM>GwOb^S3({0Cbo=lAx;LNdgBfz z);!DoHTlS0|E?~=h?!~R_3M$a7BiZ6@yDK3i92NZCdZ#tuxz?xNI0&0E7;%ko3NIiY){{5ywBjw^w1+hB<)k% zSHjlou!VMaas}nd-0la%z6_tqt0otD1s`x)8P$?E**54-5Q+I$ z4xm%2#+9S!RN?UU{lFYV=L3g_)1#6JalIdgEC!^so~PRW!&`6GSQkA6 z9u*jMX^ks_WKbj|uM}(xKuSfWTT67c2)j&I%f z5G_7$Nk4)w(9N%dS8y9FVWM13Z;>)B4b#dK(4qv{%1>-xte-Xd`nGVblIFIIFdWYO zx_8VqT1gzs>|(RuSYcM~+Tx6&J*geZ_W!%QV+t|#IoKj8_}^J|$0Vn%jmDcF^r8>4 z_EZ7Di}?5gWSx{*?%L4^yx;Ji8}`zF+;pdmlePoVY)Ty0Wg8nP2t`Txbj8C%3t*FpoVK#~9n=de%=aci-4#-AD}AncaoeXKxH2>o1ebv=66Ll@Cw>!@ z7|M_5?k~L=(D5;77UsNs9~kWCfI{66njK+<*n7v}($mIR&dW1fX;H>EImlWcR#n)WdlDR;6FvD=JoN=2OGWj>|!5A?dz#q>ONIy^!#{+ z4j^*dg133PnsAsrt{LIk05Tk zhW=kSofCxm3-N)*o;~q-H({l=+HgMdhMa5K zmy#9d4QFuwg`xYFaYn!l5g=(m%~UW0{qe%JD4%n7GBs$8UOYI($!(UHG4i!)Cd$G| zH=0#af*$xNczi+$-SHyC`R(8@nkam4308zMV1-JWztosqU%!FJH zk*Y*Cp5n#WCif_r8=lyEL3i4o=|@<6G$~))AXOYh(0gMeiu_6-( zujl7gwe~@&mSQCijE`3yLPn2;=m^H$!h6KD&%kK z|Cq7Ysy3stlll;NU}3UnKrBf{6fckCe4(?<#m0Ra0Gb?V^;$f=2ed*~T5}K0sItkO zq!aL`Qq@zu3SZlDkHUKpf7HtfD>a6>N>X($O`?t!IZUBihnDK5d^-h=^-SzvaePzq zZ*MFde~((}BR&#;_!E+MW*1ysSB%nX<-H-QhRrS$Hn)7p_WdR_(Bc@(r8@sSvJ(cXLA(1D{P<+mGbQ})})5w@js19 z{&8=s+xIgKu@wPMoZl8bj9QrU^Cm+ETxqFedHIJ}pL~ygpuD-kO+#^~K$H*Lg}xtm zDTl%8c{tA)$;{W9xnmFhZm^R3g?dS}7?q0im; zGN+9hD-AX7uVbjxWfubPrOTH}B>jX8N_j$uJ=lge+nKLXO0bzeiB&+B`5sc(E>MQ# z#P)V0J}J9Qv$jNVbW9yU4 zbjn`!v-J<~sP4R~50TK-Fm+#0lttYTqSc1H`aYaBtH6}=;6`bPYK{{(TAd4pTf+?s z?f5B*8rofMbM09pBRtl9gH(J9kxGYoHSta`DzI+b76&7G#AqQ1X zJ?U7*)(Us=G;zJ}%np15O%nOwQ(DmVg#uWqA7s6-nvOF8^432u7WPys2*?W5_S8q1 zPu4E#|nm?|23~l~I&mkBDVi0b?HYIJ+kDB4NN=suV48lJRAXaR- z)qiy@`*r-{E4|>W><0+)dT+P8mw-U8a%AqSORc{8+Lms#&L6Plo%fKzCOfA*@KtYB z4H01V1WR$Zk(TrbJ!@b+gRC`l)xoiRjfph)a+I)3bJ9!6 zTiXSKl5gIDUS@vw7+o+|5$!f0i-|73(hJJLH}rvCZig-SQw~kTRt?j=E}+;Yn{i%S z$JLph=@S=Ry>tib{l!BM<~bVGe45m3s)jv~w(VF<>7{zwr;Z^7sgm7;nz|ZCaLN3E z;eB^?Pewli@*Pi2eus8p2z)`D+5g!KFz23`ZFrYDmK1(DZ4SD)HaaL*6Km~x@~!8j zQL=taF@n@n&R_pmjqs(7JQO9g(m zQ!yVNvt(S06g%>JAQI(F2p%O@YQYe=zCy=rDT0Ut8@`srW#ia^4V;EOb|!f^8uv{9=PfnJC1 z2aasrO!H|zER2VRQ3)^E#BcsobEuEtdGg2A{E_v7!3n*S4+(L6n3xeiz7wT*gWtHs zB#$~b<>hZz;K@cvQ3d%hE3&e5?~kVkSrltO{QRWbf-sCo)D;AQq|gB-&hw0V?JCxx z#jWBZCiS+o{NPHzPCupEK}KeUG_u?ghYzl`?o(efO$WI1jka%vkNGc@Z=-0Tt;B_T zS1tB(zxPgh8F_{dtz=+))h*PH@XO?Yo7R>v5EOmR$S=~QKJ4wnP;CU=b-_DGWjh2x zR}UJZW;#(-0^eW16W(2VR*R1`Ew#KK%A-nop;E7kY4e_tr1_EZ>`2( zl$TcoIaVL5vQa$uFNhLq3P}5;pyGq^KvsT51L(Gp)6v3Q|JX-7U&X7Ze>TIv^1rnC zHCWgeHwQGbVP-WgU?G)IgwK^LYXpJi8vfs&(xtETEwl7Vk6X)F`w2ZE|g`PbDu zp(Q2D`iF7lnMXJEES=d*KZMb(yqw{Dc|x369Ti0(AsLlQbz3(pONKmQ@bSa*K=wq} zo;T$@a)ECi0#i_m&}z{h1?zN`lY=2-E}Ya-xulpDKgs@24I&%}*-* zb$mU9SYtf_I~*+05CHOzKjaA6F&5A|$UNHiX;tciv#}<0AWv6x5JhjJI4>JCj2X7RvJZhd>*nsPHwsk z5ShMoYQ$)6xaM~_j4=@(m>5<-*$u3DHl6$0M>sF8$KiYDFrOjO3^imt+BPm{dLxs6 zz59{GbPlt+r0X>W(h{nAk|rkQbLbi%J6m+kA~8S!6YU%wWcj@*ul1&!LqNL9jEBHC zgmGo762KHz_RnXlS6gWv$f&SDY~qRjIIbqcSn$c2D=KCNdoz4pBk<95dFR^t(WrXb zkVekzRV)m{sn+kLsaXjttCCODkF*+8Ql5%K$1sFTB(oeI1r= za8~oIvn&;Iq9g{Q36-)X)=}a__i9QERN6N%LO2Rp>HudO%zGJ(FD9P!ACv4gn)f}} z@x+I5sCJ4iKhM$U+Rz6C&K~XuyYCPWERpu=Y@om*qwpAm+x-0fX&-JN25(8F2I!ZToigT=Rp z?ox&3)77H49L|HQ_aGm%p4nKXQZ72*uK(O4f*m+2ynlWL$%Co3NiTJ=+y{fU%U=&= zD&?d)!%QlmSW zF3WH1fod`U4`gZ|(w~uz5cDu79{HvMF)TkeOhBN3m0uOG^Eq;TU~?sWpuguS+L6xa zY2dN9EYi}YQH_{O5wlSVMuS=y5c0ndwv!HlUQ7dD?KUPn-=bW%#N`e%3$tS81MkQ` zg$)4neOI<89aq4pom1iaUy*+ds!!_7jCk&ix3r-G8?f2K;A#4sx2NX~7kQcu(YgK3 z)(58~r5Dy8(|$Yz<80!s`3_2ZPUDP`#|$Z-1q5GJCdeJ+xfgqS?hkEwSX> zo-dKS=_SLehB;XQ2T&PNP|Te(yJLmi5$h~PMvOd?z^9II{$-`2_ka*g*{Y7>WM}&@ z2eJ~E{m)I4{eqb9VMMMnkBnBAln^g{JK#zy?E7%tg=m+d&Dl;*W6De!obJ^s8f(z9 zH%TaO!=>!18hJ(s+FJGwfh*4#>Ft=3?d4li79Gf@hz(83QROW?%j?&1&bE1YMofaU zz~j4i446v(#5}nQK}TmYZLWnW!@el6K-6~P<|I=~W7PK^``9uY5qJoj)O5D0!RIQ_ zA3?=&M`9Z0{{9K1+Mp~~{cnG@1cuyIKbBP7RnKXo9-#6mjP`2twyvb6Lo&x&BKX#G zTk(=`l4{pPf49lpM@QWFTdNFv0kwKU(6GI?KIVFK8gQ8a37k zBR6lgbI#uB`0gO{&um)*SH(Y7L@g>TkEomk@Bi%8Bq*J>@`hzN|ExrOqw2^@I3W?L zbanS$nNS^*z;E7-&BA1a6&DtA+rAEE_Swm3$BfR?nEROW%qBVycNuU;_Ve&``mY$t zH3Teds7F#_P+h@UvAhz=`W$?OPZtKpdY&I@$J7FJitRPR7D5e5sE?9Qs7{jPgN}F@ z`7&P>BKkF@vPze0uci+U&+-h<6S}b;8*w~`Seq2yc$;er#D^5Ezirk!4YZ!I@#l#& z4$OnTh!joo8rEj!cElcony1oA!eR0_f$tJA;q>M#!5C<{1Gr2$Gd?(?zkksax}LT* zuy3*cx;%2gy>44ybdJ#>g`gcT8#rj(SV?`+xvQSv5ntam+G$7lw~6vWZT;xT{g(>1 z+rQ*%p)cmV&*+V+`=JciIJ^2ZWOEb zY@pA#7Aqdby;KE|ZGmV11ES`H_pre%V9+ zfTF%*P&;LSJ+=HI1L$_;$riQ&%*w&0b%Wyn?fMa7aiTEW6Bh^*i z%RCzVeA5c`^CEW;zok*`CL%c#X~%;6FT6QJ%JNR12q|ntrC1-;`3|Og3KyF133w+r zfE!nM#VX5sJGv}0*QCRBed5wQUs&wRC&h$S_67EZn^o%DNpmYxQKj_2zo%;x23^CVTxfy38m>Hl8OTtqrcu8yXBIUvC_jOlEefcaL9v=5D?B zV2gPyV(EMPKEn0mZmWki?kC#R+jvBH;j8359#8NC<~QJpa^b=oMG9-RCSB_&^glI@BH$W(9LE;)`VSA`O0A%7r)wnd}tS3T8N@y#w) zOkIRC7}SKWoJ)f3-o}=Z!#vuQq#a(S&&DZ58FL+KSAbj1rX!zJeSL^wQ+KgO>#rkx z>+ZK%dwh5MzvBnBx>hA0_S?k1q`;U1fy!zN3ETV1)Eb+&le=UMHp~2?K}L)nnTtRe}nfsVL8{Ye1b^=MbAlxpP$St z!_lhTc9k#_Wx!Yle87mFZ}p}3s?{pI0ch$OJ^A`Ctmmv z9~W-BLZ^w5LEdN=I@gUviGm7TTN{Kd@OA|fZvcwQw#Gc*+s?ME`%hkx8L9Ljc4<_p z%`hUAYR@RNu@ay1Ybl$4e9y+QO+)Og*TfU@Vm5VLkdWPM7v#fNs0{c3l#@)in z>J%l4q6+!qCed}?ZhU4Eo^-|9?`%M*;!Mra>oMQ@SD6Uy%DG$L!(*IWdXxv~eOepz zk#u=L>OcQ=ZYKSzUqTX^2RpFut#HvpszMJM3CtkQr>!aa`rck>;jC7P!P-?QveEE*IG`UCPmP5(OmPCf1RMYVl-SkR{ z-iNL<6%X{zrYB$b_C$*coR71{%>0PQE25hXs`{-j1KEOjS7L590viG?3-`MQaEJ{N z9)SdSc7Vh)x`101$*K{OUJ%UB-8NA&y_FO)s)k%MFPyj_NA4OKbukzfsEo*Dw63wV zzN8H3FDuVrW;AEcOlJhVQT}{-)ZDG8#@Q)*>(g_!l%YrWRNJ^nt|?bo-Aka?oSK#> zdu+45pwlKrOIRIw^EQD$0ZBLl;j3=HvK5s?)eU3fke;a>7JsZ7 zjA*bdqu+$nzQ}bQgba=1J?vCZX-SW6N#(ka_ThgmChSSZX0HL?Xmf=>Nbp-7E;>8P z*(j~k4!$*wT4BLM8k&E>J66|M?Z|NJWqI)fFd`(L8xjvURvwmv|K z$@#uRL`~$qahqIG>L7Gk;u@nmdQH`AY>Jmb8i4V|crM26g{7@Z)ntr)aw?85b#~+< zA$wd|9coSM8*4gQ+v^|DmADh(j-Sflc6O8SXnk`bEh3UT`mD@Llo}%2YOZ-I#E2(|Bsqt?$dVzdGN!g8Uj7X{AT49^scs zkeP+ZfAC1M0n5WhQtmMdQm90+xoV&NQ{Zr2laXa7 z>}}C))0JZ8Z(2#brN!JR>jalSxvIAtU7}MQxS% z5Hzu0&GVu8wIfPV~xPI z@LVAltUkdB|5s)$^a0}mw#LRCj}NJu9OWI*A+(3P|G2B3^_=(wqV8t57u3B}9`!-< zJ+ad7x;#V|k@d~nb<7ZWmB_JnT)8d((=ZW!bbO3rOLuA3M^wJY-x$>?ll!Q1U*a58 z+e~Op1kQZ#z?UWB*8LdmKx@L_`;%R8*&!63&eL@ z|8C(~6$o$$H(EuQ`N**sUw^XeiLrQCq`MdcxwT%i2-Br-O9A3;Gk8kwu~- zMfovN#7}AQR_^RRL2;Knc`@fA@QaU!%!!Y}o;8p2>(I(2AYUL(Dd*gDdLQGpIy;UOCm0)8;MAeuY;N*Yjt$cItLjCaW5cEeoG(p2BRu3%a=->GYS&f>g6SZR}Zd zpRIZ5(I{$ahpR{eT%aW8ZQbTT1+Gw}i3{kN(KhJbx)mSv>(N_B6^s4pjfQ1ig8tLz zYEs>!gkI`9dV>1Lk)9hssj@3)9>)Xf>_!7uK<&Mz_go63f0T#~t+4h|WB0Y{NEBB~ znoO6<%3(}U*oB$oPE}E_lDGM9J>D`qo0LyjshK!C;*Hhzwek7!4=#wJF^5L)ndo>{H; zFw%&WjN`Vk?Gc$!F}j>d&O7z_hMLJfV17%M6&Y8=7dl&vMZ<%NtZYruo&E4C*xKLu z<0>|#44V$)=MKX`AmO+l*n2zx&-!&8gF^fSPHt$3-&Fg^kH9DYxF}}XO=yhuEl3M2 z*>}Oe%*UV0yJ z=Bm=q!Mj2eaUZofJ&BoLa#!fj$;7>G3xy7aHkBk=fxE7Wu|3T30UInvZk1~kT&C@V zJS=nlA%%jt>Zh(@G17a=T}!JB;K;9(xl=VOw-s!j)iqZh=eOY^A)$~K&-;7s6($*q zKKms|%lks7>VCF$;{c*o|%KIIa8CKTdWNdU+GWXV@b&yxFM~??bb_C zjS|)a8z%z|poZm^j?hDCw|Y?Bgu%pyMGv6#i1N%ceg)Ds$^6#&S=O zAU)CC;ZBu+6q<)Gu9BNjrT#`=r%UFoi|KO-(#Y6=F}h~+b^Am)MEG2{ZQkihl#4N> zMEd%9dK=Dpj!QLepvg9psdDn;l`8=;b9k46fQ0E(^yn3|okV6*v}oStmM{ zK&Oo4Xd4PS|3+L%toyLm z0}rJ>1IqUNrJH<9-a`i9_ri-qSmu)(q>`@)GbL-&{Wo;uOGVV@K!&t zOgk(qDtTTCHsD4L+U^`nhwXYsIY3**9WsX|TfHvNDkZ1vI|n93^XDX((Vi5kmJG2) z^=-aFsq!OxRevwSXOg~3?qw^d?uI$&pGv>IkxdI88&vH~{UJqsR(G;DQZ&GNs1Uy+ z+Id7jMO_K5h*bov0SAnq1pD)-gHAU$nPR6?8f-yd+<>qt;^AJT4lCbEoUS+6_kx~wbPJ$3L0xqB>&X&jwqf& zHWWnpjbU|2C{35|x&fQ^7L_d2RM7S4(b+w3m8Tuo+#hYjJ~!$#1~M8^B=U$?zzp?f z&Y2)HE-P#LH~ZfmACp?gIHlbQR^DD?hO|`21y=;IQ?A z(~oeSnI`LeY1R%M9@RxX2f2j-kE@_T+3Mw(UECqB~grK}sH@RNf{(WNJ)%L#X z89hJ?+4?GKqG&DL4bKCLr>GGXdV&X*6l8i1-*3CYgD#9-3Zv<_NxDnOZ_D35Ct6(y z4~)G2r5m#Inw)IAg2)iH|D-c}P+=R<`^dS2g-0PUqtE*c<^Lk?J)@f3wl+|%6!+FYtHAHvnpg2ESH%z{1c#@DhF^_tk*blY}FK>K1?~S z5Rp+UAHEh;>xW+E6U&~WCS#A+6Rz~K(e0iPSVLZ*cLTq zdZz9v@1bvZaOfe-iXP`TpispJ4AIlIT^Vd}MlK8tb8OOFhNd)z9kDX;`UJd3M)2l= zR1qVTcUkRA1`54$@Np88wiS4aSv&<=*!MDQv{(8Gv_L1cmKjN+O z4Px1;*`>zFB}=QEL4KL{NZMd&ubNnR2rRWP{2nd7-i)o^1`4h9sg}ivnL(vSV>BCq z`VjL9J#nk+(A`s8Nx>DGETFSy17~6>F3>%ruuUNk4-(L;BB{Dpn*|i}Gz16|>H?-z zAaS+c>)e1&Q^&s1v*ezzhfh&SJRA)@TXM#X7%(s$mpuvKqTqYENMW_I$Q4tHGbtSA zRRSLKsh!DR9BfG;PTGCqz9O-CQ7EH;*<}uTXi8Z*Lz*7b!IZ+%oNl{vw`aCDoX1r5 zhVX#fz&5_g((9@pZLKaPx2=Ae0tsWEXNIM_kvYJ z!#F?BSZgLxr$&rB-sd5{zMrdccu~rWyDg&~?`ylLdfv@tu@7jAZmPoBsYX$TVWMkh zri<>ZFPqIuE;w3z&4nd;>Ycq3jU4gzNvK}mCAfFJzIE5=9W(uco!(!rNwVGaePhRp z1X;`Y5u=GgETwY-iJVkq{nC%d2CybK6I}b!Um`Z^8J<>nx9K6(JEkfs&||F%I0v$V zPc2@s>l4t{vs^izpRy1XIoSIPCBvAOVCp`91NxQVew45&m4(>eY#a{`%kmDP;aPtM zDg`J*$O~yiNMb_i>KEkqi5~`j8-)nJ&IJZdgHi4^-uQ6p<&RQ{X{h6XvwS0Lgr2ir zT3V@JlYW?4C20_e(11@bNn9*T8rci6tm1Lm(~`J$b)-^9;VzZ$M$F%dhl1$Mv0*Wa*pF4W}d=vgtrutb8@MA&F=u|*%oPWjX` z)Jj#LO-v)` zntq~Jhv1d92(A+Z&ZV-BSEa@BZB#`tZ~OGdhY#{*1vYWnYrXx?%>qA%-YsZF75^T1 zKTt#ODXrQEc40otal!`YpsL)Fe77<$L~Mc(P>pOem{E>J%5~-3oiL6T>EwKVu9~sx z`qi}KRlhe_aD|Vw+XhZ59RE1ws;9Q3<7KR4>pk>xazh!jm!_S{WJ8Egl4mX0-EmjVcwyrZO{vC z|4ugekX(uSP`*Z(qlD}Bdg2AhDjFF>93BV$y6n0bFl0NH#|ie1%yxLM(Yk zs5Wv$`~#`LD5J-(7C{h6+z97Mbq|w4yyCbLV&;lnHvG8M5LTh{gykCfQI{_gY+e*| zH=sGe%NnhgpYlbyDQ)-?bPr~FN}iE`E%q*KMORSP1^bk8Ucs_B%q01mpTU94YS_Rd zgR3NM6ZePd*0$Eul_z$g*%JpB>OVc;tJsSC@cvty@h3(o+HB#7QizG>>!{V#s{(<+ zdQ}hPFe6@msIl|=f+vo$!DHq6jWDskQuJ@?#kVf#o15b+ILnWE!It84ALBNYfBIV` zd`hdHmP0gNFU~uCDjkL9Oe-H@Sv{*jn}P9JfK`b{`Y|HnFP zZ}9roIZ}gtVFYZC!MO7Kru@76lLK^P%7O6^bT-7qpyLK9YY9D`zThn^2swB3NM3C1yFD@WhDbOu5|~Y zHG9E1LeYX>I4Yc*XDFEN9^98v`QICw3)BGdNbCIhQgypiI8^?oR@6EbRA(`o<0p;E zoC&KIy$hzizN=iU+mXlu=3x%g)#BxNp`hked)0%wRbY|%G9+`Dv$3Q!;c<2WSII3p z%+L>;>_*Ehp?p6OObPah`HApqh{y%qs|C~RI2L*LxTomhr{_U4apa*a)b8>Mo6SgZkL z`MN8{;;eGI36o431;rM-^;)A8n?#>f^x?2!IU`t2VcOX{Rqu-|n;JqcEaC9r4P0p^ z!HqU{CtV}lO(I%O!>ds>U6OaZI1 zB;f?&h=2VxcW3J)tnCwO5?5dfX@Rg=1@g04u;ZhgQ#Q7)zhLSwpWCP<@oKDrtvZ~7 z0QeGs=NluS{M?7Om1b+$9$q$usAA!x;wB6cN$N1B8{rJ&`Q%Ji!r++18wlT1g+vAZ zXB5B>9+kP@)HcW71=|l>>(CP@wa2H|%a6yW*GE?4z!%94CFd}4woalUrcyMy9YPN> zy!#R#%Qne@q5_onYFP}Pa_Y^7X_q|3mZ}UVzYGJTZg>vs%7ZLn_kYj*baZe_gzI z{(5pDz$K%NRmn5vNEvJ^LIV*=ai@#mNb7q6dS#Q!EV3e<%PWpoGQwkA} zW@o%f=9Am$IVxTsQ#~e&nqC!B1Sdxw`Xi=ja;|-UlG?V0bPj`}_`|{*saN;`j(#4m zNo>&KhT*f0H_#Gb%=a=5b!wlYK;NByZHIO4;^e$rqLh3t-_O<%5h7qqtIFe!Cu`me zw!H^ozqsEANoW{j?R`qFXmfrK+&io0?Lc8~Vrv}>bcx*Fc${+hW>hu|PE z%{7j1@z;jj0DgP!&duHaPISe?tZCDwymFfvt)*qvuo{Q9P9yLrzYatFPC~xK+i)cF zn;|!q##0~4_wKZjn$t`S!jf9=Zq@Qr?Te(9bf40vnv-ve3hZTrwe$v%%_vn8d0US-SRB2 zg_hq^*TyQ1*zq>7o~YN+6p*iZkM9tqqxm6U1gf)BcXk?s=(8qJtvLSAyp0DX4K_!% zJSscEWtE1l2Ld#Ze)u+g>(IaG(;7eIuOq*A!B!FPf`wKaW?l{d`*dGy@F#-oU` z=uO&?bqiuyK z)4OAJ{?uy<5`4xfx3jz)3vpX*mOUtv1GY-mO=SLP-bYkT?4FaTHlk}HL(CblzmJtYW$owCUPxfyLF!rI+3w%s{B23X3L~;&o&T z>x22aaT70Mwqmla;b7bGU>9^7*7Sv27s;J#X)DyP#^Li*#FPe*m26OleGyA2tRS1Y z5iNtng4aKcN{2iWf2L*x(w(EChzg_Vx`abhhCd@*UL5LuVSYBJ$LF&E1rv?*bBeml znPS6|b=h!9plc5sTS*PcH%M*L6&S zHI5jHNqBrX4%T`0%lCC6p+90&fGrGq-V{)nx*d+!t6b3^Y@;sj84}{5-*vu62N%f&Wn?tPKW`>3rB1u<@ z$K>3t$sTubw09@P0zvVdak7Xd3k2>bDbss2u$gm~OYA-%Ci7tJ2O-WUjGhJ~mmM-!m$PsZ8Bks=4Xl{`*D4ngM_MTJ&OV!5G=T9DQ*q zZ#gldg7ehyEejtmjiRTBiNFDk>Chd|^{<4PBGcBXuOBJ6mEg>H@B{bH++;@*a0e2mU4pI_vmo4Kk+ht9MR%G;INM|L&d`DCTXn^EqgKt zHx7bxLcOYTbC)c-dfe2#?@fdjoUc+NzOqg9CpC6SB@og$8yXZOa^&*oSMBr$;XwE<5F&Uh1sitc<~E5@FJ5LOz_bDQQx?wJ z1MZ&{`U7qhDg5Ft@cP~EnY1o>$X5u`aavc*hxFKN>fq@tQ9DXM-7E`p`oi=>Lg;$H zvL+8{d6(V*zHMUr3Cl}#Yc>$mfxSkvkJC@ZS+cMuKBIlf&)jrN)pBA-x$tT7OOyA9{tmFxQnv=rnT{>+GHd z#nOl$_STdxa&nd}*S&BE^)kUi`Qc2)6m6tE?C73iX&a;x>Rt2Z1FF0it&XoSdO={P z_%+|J_h0o5_)_JP<=@y}!KsxAC@ZP~f zoZyCYSnU`J)RM}faPTHdsvigllAq)(azVUd=f`x?1KteHqX0V^7fcKVT8zdg4uNZc~@=yl?+>&@d1L&ui$5YCKG73Q>F*}(A6!}4I9sDeN2f^Hx*c$wjlMj7Sd~YQ; z`)VeEaPP+Dl%Gl!=}U!F(T^Sg1^oWvnH-{6;@l3IdP@tkzJN;qRCh?bDQ7onI4 z5t+T7Ra*UsXk4)NnR=zV(kLa`uQw@2=q9)%K<3s>NC^_mu+}T1ao*;Rp{c=eBHz$M zwTUGzUKoq{^KxyZ%LtfQWtfKgk&Wy3%7=1bg60J~-Cu62Nwh}Nx(mAM4y z+Pe`nemjoB*p=o2lp=l`ZF4BOtJn%osFMW4}f}XD930%ttiF zOs@GLTF!bCBRxder<5oI=k`w23m*v^OiXJO@zQCd@f!Or1&!GxIeV$mfId)P16*MO zpm5=_DNVc;%Oz(E{1q;!Ei#e8K4=204$})zQeFdMXm7OKdqmBhkk_-t++_N@O{@ZW zAq&~?N1Fn9k;-X4!RsA+`c~a~u&;r@9|os^8OTSL50dYuHdjc@a?N*D%J)=R)O-p5 zP3*6B4%0M)%!GT*-EVjd$5 zy;IUEae>~&{07bCHInsPc>L_`U`?VfjG1b~lkDq1_h*NA{x&>;mU~I|c_)M>y^>}e z{Yv$>XJ!I9ZI*>{z9wuUOy%L6Y7U~`?c5ko$)ZAy^5AvL zq$>Tr6Hc*qgsh=~sZmq|6*k2=%lzyD-&ncsn$>9Wmp5UVrdrXIp;8m#$r8_2Q|D7U zuj9$zaC%$&#iDMrhLoDMJWxVILll9s@qu+SiJ)VBIj?vkg0n114oN34pV4Wk*!0I6 zv`CO+if7{MZ8YLP8d~~%?U8IXeKM-6O`Y2yz>( zmYsSYS9iX(+tlVqm$K<*MMCN|tmSy5vJ=PWaI)|Z`uciA*^I|HlrKVLq$06z@Zxkb^aa6lbl zg!zaM)}UGQ?QQ932JF^Qjzi`!S?w_VhiyNh-!a=yq-39JfYT3oQeG9iYQ|rHy6>Mr zTH05+!Cn{mE>&Mg9m!$bx_VuMVZGV}iz-{6;=mfl-2GrH4IuZ$^OkU*RJEn^V<$uS zv8i{DY{@EO45$7>c0fL6LN;tDJP+e87gV+Vy*ug>KZ6kRN86PgeLjnXgj+*N-D>)RCJ^T>Y@D7G>CMneC9eA{Ht-7Xg~e@ zeF|@q9O|kd;E-q7qEJvNivbvzNv%l+b@p=ke(x&xGbNz~WomnjYk88SC3l#5xwP|5 zTfNc2?D@GL4i+P_VI@c-qm$b%!DIo8_w~h3^k2ciHn)08 zT=m}|*e;idRhGEXc})l1*9*0~Y8|a2|130tj0P45trg^+S^60*=z-o1q3@D@(1$pN z9j$}i=0ErzwD0+o6XOI)XZ`f_N5`r`aUmYQ2WX@!iNEi zh-31HQ6O06$clcbnyyTyLG$u=OnO#vJw&aL4*FUhu6NSZImb;*7w~7R?WtU1!?@Q_ zOl?HbSJR)f&3aGBY$D@A0|I&0wuBe#A6|>y>GF?#pO%Z=^4SZXvG;B8-;Nelu z2qOB*h7UK-^YQ$(&P;JtWs%8AczDw%~Ow5_&)t*q!I*RT4&qHny3(3#wX`nRGCNi zhJAZ$5hG9w`H?nL^DU@5BJBNo*qxapfBZpTsDgjYRyg_IEXWP(Zj2gOf_$$7$>55| zT(a8g@5lRVq~>e;s9^voPt^9anQ!Kr+M3^O%t~-PpzP!<{7uslniKJB&1ska?tMJ* zDU9uwi+zg{=iBjUhQD*~4+)u>8TCiR8Cz(}n(#jOI}c(z$mXloBeSBL)MvD1k#UkA z7y<`qI@Tv7CquAfamlx$tFJe&XbD&*gYWb9wR|wNf|{7xfSOt>_#L3PIy*&Fd&>xy z9H0xb{3mP1LXs&Uhshci`)1ttu_Ys2R9Vmvbi@e6XUx2ynx$O~G8@xH=g112o$%;?HQu{=;0rfF)_*b?SOZ!;O4y+?6!I7HXj|Nqg&V9jX>&^fnD$N1k zi8~)+;!_GI-3=OlKHGzMg~k=c>G@w>8g0rx5R+z3{4xE|r^X8%HWljS_qeN�fjx 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 z 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%0A|+iOcxPo=$d^#zb9atV*rK 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 literal 0 HcmV?d00001 From 9f2d9f11ae0622d01f88aa8fae9984c44e689dce Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 27 May 2021 19:36:02 +0300 Subject: [PATCH 64/65] Add migration --- main/migrations/0019_auto_20210527_1935.py | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 main/migrations/0019_auto_20210527_1935.py diff --git a/main/migrations/0019_auto_20210527_1935.py b/main/migrations/0019_auto_20210527_1935.py new file mode 100644 index 0000000..b226c76 --- /dev/null +++ b/main/migrations/0019_auto_20210527_1935.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2.3 on 2021-05-27 16:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0018_alter_unassignedticket_ticket_id'), + ] + + operations = [ + migrations.AlterField( + model_name='rolechangelogs', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='new_role', + field=models.BigIntegerField(default=0, help_text='Присвоенная роль'), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='old_role', + field=models.BigIntegerField(default=0, help_text='Старая роль'), + ), + migrations.AlterField( + model_name='unassignedticket', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='unassignedticket', + name='status', + field=models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет был перенесен из буферной группы. Дополнительные действия не требуются'), (3, 'Тикет уже был закрыт. Дополнительные действия не требуются'), (4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL')], default=0, help_text='Статус тикета'), + ), + migrations.AlterField( + model_name='userprofile', + name='custom_role_id', + field=models.BigIntegerField(default=0, help_text='Код роли пользователя'), + ), + migrations.AlterField( + model_name='userprofile', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] From fbc65359754cf3e7d2b06a6b64d1375f755345c7 Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 27 May 2021 20:16:51 +0300 Subject: [PATCH 65/65] Fix pylint, add documentation gen script --- .gitignore | 1 + .pylintrc | 2 +- README.md | 18 +++-- README.rst | 201 ----------------------------------------------- documentation.sh | 7 ++ 5 files changed, 19 insertions(+), 210 deletions(-) delete mode 100644 README.rst create mode 100755 documentation.sh diff --git a/.gitignore b/.gitignore index 2d1bab8..9d4839f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # Created by https://www.toptal.com/developers/gitignore/api/django,pycharm+all,python,linux,macos,windows # Edit at https://www.toptal.com/developers/gitignore?templates=django,pycharm+all,python,linux,macos,windows +README.rst ### Django ### *.log diff --git a/.pylintrc b/.pylintrc index 0f1221a..d0114f8 100644 --- a/.pylintrc +++ b/.pylintrc @@ -37,7 +37,7 @@ limit-inference-results=100 # List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. load-plugins=pylint_django -django-settings-module=access_controller_new.access_controller.settings +django-settings-module=access_controller.settings # Pickle collected data for later comparisons. persistent=yes diff --git a/README.md b/README.md index f989953..f8d617f 100644 --- a/README.md +++ b/README.md @@ -149,22 +149,24 @@ docker run -d -p 8000:8000 \ Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). ## Для проверки pylint используем: -pylint --django-settings-module=access_controller.access_controller.settings ../access_controller (каталог, где лежит проект) +```bash +pylint main +``` ## Для приведения файлов к стандарту PEP8 используем: +```bash autopep8 --in-place filename +``` ##Для проверки орфографии: +```bash cd docs - make spelling +``` ##Для обновления документации: -m2r README.md - -cd docs - -make html - +```bash +./documentation.sh +``` ## Read more - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) diff --git a/README.rst b/README.rst deleted file mode 100644 index 219cdd7..0000000 --- a/README.rst +++ /dev/null @@ -1,201 +0,0 @@ - -Управление правами доступа --------------------------- - -Идея - написать программу(Web приложение), которая будет выдавать права пользователям системы по запросу самого -пользователя. Например, из 12 человек 3 сейчас работают с правами админа, по окончании рабочей смены они сдают -свои права (освобождают места) и другие пользователи могут запросить эти права в свое пользование. - -Оставшиеся 9 человек получают права легкого агента - без прав редактирования, а только чтение. - -Из технологий - программа должна взаимодействовать с api системы Zendesk(система обращений клиентов - жалобы), -проверять авторизованного пользователя на права(будет возможность менять права напрямую из Zendesk - нужна -синхронизация прав с приоритетом у Zendesk). - -Если руками в самом Zendesk права у пользователя отобрали или наоборот -присвоили, то наша программа обновляет статус пользователя в соответствии с данными синхронизации -(например, раз в минуту). - -Так же в идеале должна быть проверка, что пользователь сайта существует на сайте Zendesk(по токену). - -Сэндбокс Zendesk нам предоставит моя компания, библиотеку для работы с api уже подсказали. -Сама программа (наша) будет обладать админскими правами и реализовывать контроль и выдачу прав другим пользователям. - -*Итого:* - - -#. Реализовать авторизацию пользователей с проверкой по API на существование такого пользователя -#. Реализовать интерфейс со статистикой рабочих мест(занято, свободно, кто занимает) -#. Реализовать логирование действий(когда взял права, когда отдал - запись в файл и БД) -#. Реализовать передачу прав приложением по запросу от пользователя и замену прав пользователя - у которого права отбираются внутри Zendesk (на легкий агент) -#. Реализовать синхронизацию по API на проверку прав(не менялись ли в системе Zendesk) -#. Реализовать возможность добавить большее количество админских прав -#. Реализовать возможность добавления легких агентов(права только на просмотр) -#. Реализовать на общей странице текущую информацию о пользователе - текущие права, карточка пользователя - -Технологический стек: ---------------------- - - -* Python 3 -* Django 3 - -Quickstart ----------- - -Перед запуском необходимо создать ``.env`` файл. - -.. code-block:: bash - - cp .env.example .env - -Заменить переменные в ``.env`` на актуальные. - -.. code-block:: bash - - sudo apt install make - pip install --upgrade pip - pip install -r requirements/dev.txt - ./manage.py migrate - ./manage.py loaddata data.json - ./manage.py runserver - -Перед запуском для тестирования: --------------------------------- - -Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация ``SYSTEM`` -Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk -При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение - -Запуск на локальной машине: ---------------------------- - - -* Скопировать репозиторий на локальную машину -* Перейти в папку приложения -* Активировать виртуальное окружение -* Выполнить команду ``pip install -r requirements/dev.txt`` -* В файл ``.env`` добавить следующие переменные: - -.. code-block:: - - ACTRL_DEBUG={0/1} - включить режим дебага - ACTRL_HOST={HOSTNAME} - при запуске без дебага, надо указать домен на котором будет работать приложение - ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} - секретный ключ сгенерированный Django - - ACTRL_EMAIL_HOST={SMTP_HOST} - домен почтового сервера через который приложение будет отправлять письма, например "smtp.gmail.com" - ACTRL_EMAIL_PORT={SMTP_PORT} - порт для почтового сервера, например 587, 465 , 2525 - ACTRL_EMAIL_TLS={USE_TLS} - использовать TLS для подключения к почтовому серверу, 0 или 1 - ACTRL_EMAIL_HOST_USER={USERNAME} - логин с которым приложение входит на почтовый сервер - ACTRL_EMAIL_HOST_PASSWORD={PASSWORD} - пароль/ключ с которым приложение входит на почтовый сервер - ACTRL_FROM_EMAIL={EMAIL} - адрес с которого приложение отправляет письма - ACTRL_SERVER_EMAIL={EMAIL} - адрес на который отвечают пользователя - - ACTRL_API_EMAIL={EMAIL} - почта админа в ZenDesk - ACTRL_API_PASSWORD={PASSWORD} - пароль админа ZenDesk - ACTRL_API_TOKEN={API_TOKEN} - API токен зендеск - ACTRL_ZENDESK_SUBDOMAIN={DOMAIN} - домен ZenDesk - - ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID} - id роли инженера( custom_role_id сотрдника смены) - LA_CROLE_ID={LIGHT_AGENT_CUSTOM_ROLE_ID} - id роли легкого агента (custom_role_id роли -легкий агент) - EMPL_GROUP={EMPLOYEE_GROUP_NAME} - имя группы которой принадлежат сотрудники ССКС - BUF_GROUP={BUFFER_GROUP_NAME} - имя буферной группы для передачи смен(через нее происходит управление тикетами) - ST_EMAIL={SOLVED_TICKETS_EMAIL} - почта на которую будут переназначятся закрытые тикеты - LICENSE_NO={LICENSE_NO} - количество лицензий, отображаемых как доступные в приложении - SHIFTH={SHIFT_HOURS} - количество часов в рабочей смене (нужно для статистики, пока не реализовано но требует указания значения) - - -* Выполнить команду ``python manage.py migrate`` -* Запустить приложение командой ``python manage.py runserver`` (можно указать в параметрах для файла manage.py) -* Перейти по ссылке в консоли (вероятнее всего откроется по адресу http://127.0.0.1:8000/) - -Запуск в Docker: ----------------- - -Требуется установленный и настроенный Docker - - -* Скопировать репозиторий на локальную машину -* В командной строке перейти в папку проекта -* Выполнить команду ``docker build --tag access_controller:latest .`` -* Выполнить команду - .. code-block:: bash - - docker run -d -p 8000:8000 \ - ACTRL_DEBUG={0/1} \ - ACTRL_HOST={HOSTNAME} \ - ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} \ - ACTRL_EMAIL_HOST={SMTP_HOST} \ - ACTRL_EMAIL_PORT={SMTP_PORT} \ - ACTRL_EMAIL_TLS={USE_TLS} \ - ACTRL_EMAIL_HOST_USER={USERNAME} \ - ACTRL_EMAIL_HOST_PASSWORD={PASSWORD} \ - ACTRL_FROM_EMAIL={EMAIL} \ - ACTRL_SERVER_EMAIL={EMAIL} \ - ACTRL_API_EMAIL={EMAIL} \ - ACTRL_API_PASSWORD={PASSWORD} \ - ACTRL_API_TOKEN={API_TOKEN} \ - ACTRL_ZENDESK_SUBDOMAIN={DOMAIN} \ - ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID} \ - LA_CROLE_ID={LIGHT_AGENT_CUSTOM_ROLE_ID} \ - EMPL_GROUP={EMPLOYEE_GROUP_NAME} \ - BUF_GROUP={BUFFER_GROUP_NAME} \ - ST_EMAIL={SOLVED_TICKETS_EMAIL} \ - LICENSE_NO={LICENSE_NO} \ - SHIFTH={SHIFT_HOURS} \ - -v {ABSOLUTE_PATH_TO_DB}:/zendesk-access-controller/db \ - access_controller:latest - -* открываем запущенный контейнер в браузере (можно перейти по ссылке http://localhost:8000/) - -Запуск с тестовыми юзерами: ---------------------------- - -На локальной машине - перед запуском команды ``python manage.py runserver`` выполнить команду ``python manage.py loaddata data.json`` -Это создаст тестового админа и тестового пользователя в приложении для песочницы ZenDesk. - - -* Админ - ``admin@gmail.com`` / ``zendeskadmin`` -* Пользователь - ``123@test.ru`` / ``zendeskuser`` - -Не сработает если домен песочницы отличается от ``ngenix1612197338`` (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента -с этими же email, назначить им организацию ``SYSTEM``\ ) - -Параметры тестовой песочницы: ------------------------------ - -Пример полной конфигурации можно найти в `.env.example <.env.example>`_. Почту и токен админа ZenDesk взять у руководителя (если вы не админ). - -Для проверки pylint используем: -------------------------------- - -pylint ../access_controller (каталог, где лежит проект) - -Для приведения файлов к стандарту PEP8 используем: --------------------------------------------------- - -autopep8 --in-place filename - -Для проверки орфографии: ------------------------- - -cd docs - -make spelling - -Для обновления документации: ----------------------------- - -m2r README.md - -cd docs - -make html - -Read more ---------- - - -* Zenpy: `http://docs.facetoe.com.au `_ -* Zendesk API: `https://developer.zendesk.com/rest_api/docs/ `_ diff --git a/documentation.sh b/documentation.sh new file mode 100755 index 0000000..bfb2cd0 --- /dev/null +++ b/documentation.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +m2r README.md +cd docs +make html +cd .. +rm README.rst

@@ -71,7 +71,7 @@
-
Инженеров:
+
Инженеров:
{{ engineers }} @@ -79,7 +79,7 @@
-
Легких агентов:
+
Легких агентов:
{{ light_agents }} @@ -93,11 +93,11 @@ {% block buttons %}
- -
diff --git a/main/templates/pages/profile.html b/main/templates/pages/profile.html index 2f628f8..d5f95b3 100644 --- a/main/templates/pages/profile.html +++ b/main/templates/pages/profile.html @@ -32,7 +32,7 @@ >
@@ -54,7 +54,7 @@
{% endblock %} diff --git a/main/templates/pages/work.html b/main/templates/pages/work.html index bd46341..8144b1b 100644 --- a/main/templates/pages/work.html +++ b/main/templates/pages/work.html @@ -16,66 +16,73 @@ {% endblock %} {% block content %} -
-
-

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

-
+

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

-
-
-
Список сотрудников с правами инженера
- - - - - - - {% for engineer in engineers %} - - - - - {% endfor %} - -
EmailName
{{ engineer.email }}{{ engineer.name }}
-
-
-
-
-
-
-
инженеров:
-
-
- {{ engineers|length }} -
-
-
-
легких агентов:
-
-
- {{ agents|length }} -
- -
-
-
- -
-
- {% csrf_token %} - {{ get_tickets_form.count_tickets }} - -
-
- {% for message in messages %} - - {% endfor %} -
+
+
+

Список сотрудников с правами инженера

+ + + + + + + {% for engineer in engineers %} + + + + + {% endfor %} + +
EmailName
{{ engineer.email }}{{ engineer.name }}
+
+
+
+
+
+
инженеров:
+
+
+ {{ engineers|length }} +
+
+
+
легких агентов:
+
+
+ {{ agents|length }} +
+ +
+
+
+
+ + + +
+
+ +
+
+ +
+
+
+
+ +{% for message in messages %} + +{% endfor %} {% endblock %} diff --git a/static/main/js/control.js b/static/main/js/control.js index 6dd9172..c7a9d06 100644 --- a/static/main/js/control.js +++ b/static/main/js/control.js @@ -21,9 +21,7 @@ class ModelUserTableRow extends React.Component { name="users" /> -
- {this.props.user.name} - {this.props.user.name} {this.props.user.user.email} {this.props.user.zendesk_role}