From 9342499d870785413e33fe2410e00cc7ec0acf29 Mon Sep 17 00:00:00 2001 From: Vadim Melnikov Date: Wed, 17 Feb 2021 21:57:24 +0300 Subject: [PATCH 01/16] Added registration success layout --- .../registration_success.png | Bin 0 -> 29999 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 layouts/registration_success/registration_success.png diff --git a/layouts/registration_success/registration_success.png b/layouts/registration_success/registration_success.png new file mode 100644 index 0000000000000000000000000000000000000000..67e7074f266ce099205b5f7962dafa288fd92a2b GIT binary patch literal 29999 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 Date: Thu, 18 Mar 2021 18:48:58 +0300 Subject: [PATCH 02/16] Added work v2 layout --- layouts/work/workv2.png | Bin 0 -> 57390 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 layouts/work/workv2.png diff --git a/layouts/work/workv2.png b/layouts/work/workv2.png new file mode 100644 index 0000000000000000000000000000000000000000..620ddb176030607f367c5b2538b33835d06b942a GIT binary patch literal 57390 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 hySO04=zyS0)8tI-%No3N&iw?v>EAZet Date: Fri, 23 Apr 2021 00:31:43 +0300 Subject: [PATCH 03/16] Add custom ticket requests --- main/extra_func.py | 267 ++--------------------------------------- main/requester.py | 28 +++++ main/statistic_data.py | 261 ++++++++++++++++++++++++++++++++++++++++ main/views.py | 6 +- main/zendesk_admin.py | 1 - 5 files changed, 300 insertions(+), 263 deletions(-) create mode 100644 main/requester.py create mode 100644 main/statistic_data.py diff --git a/main/extra_func.py b/main/extra_func.py index 86b6739..3f36ef4 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,6 +1,5 @@ import logging -from datetime import timedelta, datetime, date -from typing import Optional +from datetime import timedelta from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist @@ -10,8 +9,9 @@ from zenpy import Zenpy from zenpy.lib.exception import APIException from zenpy.lib.api_objects import User as ZenpyUser, Ticket as ZenpyTicket -from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY, ACTRL_ZENDESK_SUBDOMAIN +from access_controller.settings import ZENDESK_ROLES as ROLES, ACTRL_ZENDESK_SUBDOMAIN from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus +from main.requester import TicketListRequester from main.zendesk_admin import zenpy @@ -61,10 +61,9 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: else: ticket.assignee = None ticket.group_id = zenpy.buffer_group_id - - zenpy.admin.tickets.update(tickets.values) - - attempts, success = 5, False + if tickets: + zenpy.admin.tickets.update(tickets) + attempts, success = 20, False while not success and attempts != 0: try: update_role(user_profile, ROLES['light_agent']) @@ -91,7 +90,7 @@ def get_tickets_list(email): """ Функция возвращает список тикетов пользователя Zendesk """ - return zenpy.admin.search(assignee=email, type='ticket') + return TicketListRequester().get_tickets_list_for_user(zenpy.get_user(email)) def update_profile(user_profile: UserProfile): @@ -231,258 +230,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): def __init__(self): logging.Handler.__init__(self) diff --git a/main/requester.py b/main/requester.py new file mode 100644 index 0000000..35f81b6 --- /dev/null +++ b/main/requester.py @@ -0,0 +1,28 @@ +import requests +from zenpy import TicketApi +from zenpy.lib.api_objects import Ticket + +from main.zendesk_admin import zenpy + + +class TicketListRequester: + def __init__(self): + self.email = zenpy.credentials['email'] + if zenpy.credentials.get('token'): + self.token_or_password = zenpy.credentials.get('token') + self.email += '/token' + else: + self.token_or_password = zenpy.credentials.get('password') + + def get_tickets_list_for_user(self, zendesk_user): + url = f'https://ngenix1612197338.zendesk.com/api/v2/users/{zendesk_user.id}/tickets/assigned' + return self._get_tickets(url) + + def _get_tickets(self, url): + response = requests.get(url, auth=(self.email, self.token_or_password)) + tickets = [] + if response.status_code!=200: + return None + for ticket in response.json()['tickets']: + tickets.append(Ticket(api=TicketApi, **ticket)) + return tickets diff --git a/main/statistic_data.py b/main/statistic_data.py new file mode 100644 index 0000000..fa1ab24 --- /dev/null +++ b/main/statistic_data.py @@ -0,0 +1,261 @@ +from datetime import date, datetime, timedelta +from typing import Optional + +from django.contrib.auth.models import User +from django.utils import timezone + +from access_controller.settings import ONE_DAY, ZENDESK_ROLES as ROLES +from main.extra_func import last_day_of_month, get_timedelta, daterange +from main.models import RoleChangeLogs + + +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) diff --git a/main/views.py b/main/views.py index d21a6e9..8432e33 100644 --- a/main/views.py +++ b/main/views.py @@ -23,8 +23,10 @@ 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 + log, set_session_params_for_work_page +from .statistic_data import StatisticData from main.zendesk_admin import zenpy +from .requester import TicketListRequester from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm from main.serializers import ProfileSerializer, ZendeskUserSerializer from .models import UserProfile @@ -234,8 +236,8 @@ def work_get_tickets(request): if i == int(request.GET.get('count_tickets')): return set_session_params_for_work_page(request, count) tickets[i].assignee = zenpy_user - zenpy.admin.tickets.update(tickets[i]) count += 1 + zenpy.admin.tickets.update(tickets) return set_session_params_for_work_page(request, count) return set_session_params_for_work_page(request, is_confirm=False) diff --git a/main/zendesk_admin.py b/main/zendesk_admin.py index 8ecb877..283d91b 100644 --- a/main/zendesk_admin.py +++ b/main/zendesk_admin.py @@ -3,7 +3,6 @@ from typing import Optional, Dict from zenpy import Zenpy from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup from zenpy.lib.exception import APIException - from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD, \ ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL From cf2e9ccf212a8f439551a26c5c1ef57f1152b861 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Thu, 6 May 2021 00:41:20 +0300 Subject: [PATCH 04/16] Add get group tickets requests --- main/extra_func.py | 14 ++++++++++---- main/requester.py | 14 ++++++++++++-- main/views.py | 16 ++++++++++------ 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 44c4d58..e5c6259 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -62,10 +62,9 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: else: ticket.assignee = None ticket.group_id = zenpy.buffer_group_id - - zenpy.admin.tickets.update(tickets.values) - - attempts, success = 5, False + if tickets: + zenpy.admin.tickets.update(tickets) + attempts, success = 20, False while not success and attempts != 0: try: update_role(user_profile, ROLES['light_agent']) @@ -95,6 +94,13 @@ def get_tickets_list(email): return TicketListRequester().get_tickets_list_for_user(zenpy.get_user(email)) +def get_tickets_list_for_group(group_name): + """ + Функция возвращает список неназначенных, нерешённых тикетов группы Zendesk + """ + return TicketListRequester().get_tickets_list_for_group(zenpy.get_group(group_name)) + + def update_profile(user_profile: UserProfile): """ Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. diff --git a/main/requester.py b/main/requester.py index 35f81b6..468abee 100644 --- a/main/requester.py +++ b/main/requester.py @@ -13,15 +13,25 @@ class TicketListRequester: self.email += '/token' else: 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): - url = f'https://ngenix1612197338.zendesk.com/api/v2/users/{zendesk_user.id}/tickets/assigned' + url = self.prefix + f'users/{zendesk_user.id}/tickets/assigned' return self._get_tickets(url) + def get_tickets_list_for_group(self, group): + url = self.prefix + '/tickets' + all_tickets = self._get_tickets(url) + tickets = list() + for ticket in all_tickets: + if (ticket.status != 'solved') and (ticket.group_id == group.id) and (ticket.assignee_id is None): + tickets.append(ticket) + return tickets + def _get_tickets(self, url): response = requests.get(url, auth=(self.email, self.token_or_password)) tickets = [] - if response.status_code!=200: + if response.status_code != 200: return None for ticket in response.json()['tickets']: tickets.append(Ticket(api=TicketApi, **ticket)) diff --git a/main/views.py b/main/views.py index 8432e33..f6d0a9a 100644 --- a/main/views.py +++ b/main/views.py @@ -20,13 +20,13 @@ from django_registration.views import RegistrationView 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 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 + log, set_session_params_for_work_page, get_tickets_list_for_group from .statistic_data import StatisticData from main.zendesk_admin import zenpy -from .requester import TicketListRequester +from main.requester import TicketListRequester from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm from main.serializers import ProfileSerializer, ZendeskUserSerializer from .models import UserProfile @@ -229,15 +229,19 @@ def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: def work_get_tickets(request): 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 - ticket.group.name == 'Сменная группа' and ticket.assignee is None] + tickets = get_tickets_list_for_group(ZENDESK_GROUPS['buffer']) + assigned_tickets = [] count = 0 for i in range(len(tickets)): if i == int(request.GET.get('count_tickets')): + if assigned_tickets: + zenpy.admin.tickets.update(assigned_tickets) return set_session_params_for_work_page(request, count) tickets[i].assignee = zenpy_user + assigned_tickets.append(tickets[i]) count += 1 - zenpy.admin.tickets.update(tickets) + if assigned_tickets: + zenpy.admin.tickets.update(assigned_tickets) return set_session_params_for_work_page(request, count) return set_session_params_for_work_page(request, is_confirm=False) From 66339c4f6be83776c46e1c7aabe93f6e2f29c46c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=BE=D0=BA=D1=83=D1=80=D0=BE=D0=B2=20=D0=98=D0=B4?= =?UTF-8?q?=D0=B0=D1=80?= Date: Wed, 5 May 2021 23:44:24 +0000 Subject: [PATCH 05/16] Add registration tests --- access_controller/settings.py | 26 +++++------ access_controller/urls.py | 1 + fixtures/data.json | 57 ++++++++++++++++++++++++ main/tests.py | 81 +++++++++++++++++++++++++++++++++++ main/views.py | 2 +- 5 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 fixtures/data.json diff --git a/access_controller/settings.py b/access_controller/settings.py index a7585ed..a74ad7f 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -19,10 +19,10 @@ BASE_DIR = Path(__file__).resolve().parent.parent # 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', @@ -57,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 = [ { @@ -150,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 = { @@ -161,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, @@ -171,7 +171,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/access_controller/urls.py b/access_controller/urls.py index 63dc19f..7df57b3 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -22,6 +22,7 @@ from main.views import work_page, work_hand_over, work_become_engineer, work_get AdminPageView, statistic_page from main.urls import router + urlpatterns = [ path('admin/', admin.site.urls, name='admin'), path('', main_page, name='index'), diff --git a/fixtures/data.json b/fixtures/data.json new file mode 100644 index 0000000..a4310a4 --- /dev/null +++ b/fixtures/data.json @@ -0,0 +1,57 @@ +[ + { + "model": "auth.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$216000$gHBBCr1jBELf$ZkEDW3IEd8Wij7u8vkv+0Eze32CS01bcaYWhcD9OIC4=", + "last_login": null, + "is_superuser": true, + "username": "admin@gmail.com", + "first_name": "", + "last_name": "", + "email": "admin@gmail.com", + "is_staff": true, + "is_active": true, + "date_joined": "2021-03-10T16:38:56.303Z", + "groups": [], + "user_permissions": [33] + } + }, + { + "model": "main.userprofile", + "pk": 1, + "fields": { + "name": "ZendeskAdmin", + "user": 1, + "role": "admin" + } + }, + { + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$216000$5qLJgrm2Quq9$KDBNNymVZXkUx0HKBPFst2m83kLe0egPBnkW7KnkORU=", + "last_login": null, + "is_superuser": false, + "username": "123@test.ru", + "first_name": "", + "last_name": "", + "email": "123@test.ru", + "is_staff": false, + "is_active": true, + "date_joined": "2021-03-10T16:38:56.303Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "main.userprofile", + "pk": 2, + "fields": { + "name": "UserForAccessTest", + "user": 2, + "role": "agent", + "custom_role_id": "360005209000" + } + } +] diff --git a/main/tests.py b/main/tests.py index b733ed1..c06bc21 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,2 +1,83 @@ +from urllib.parse import urlparse + +from django.contrib.auth.models import User +from django.core import mail from django.test import TestCase, Client +from django.urls import reverse +from django.utils import translation + import access_controller.settings as sets +from main.zendesk_admin import zenpy + + +class RegistrationTestCase(TestCase): + fixtures = ['fixtures/data.json'] + + def setUp(self): + 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): + 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): + # TODO: Найти способ лучше проверять сообщения + email_template = [ + '', + 'Вы получили это письмо, потому что вы (или кто-то другой) запросили восстановление пароля ' + 'от учётной записи на сайте testserver, которая связана с этим адресом электронной почты.', + '', + 'Пожалуйста, перейдите на эту страницу и введите новый пароль:', + '', + 'url', + '', + f'Ваше имя пользователя (на случай, если вы его забыли): {self.any_zendesk_user_email}', + '', + 'Спасибо, что используете наш сайт!', + '', + 'Команда сайта testserver', + '', + '', + '', + ] + with self.settings(EMAIL_BACKEND=self.email_backend) and translation.override('ru'): + self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, [self.zendesk_admin_email]) + self.assertEqual(mail.outbox[0].from_email, sets.DEFAULT_FROM_EMAIL) + + message = mail.outbox[0].body.split('\n') + for i in range(len(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): + 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) + 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) + 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 d21a6e9..bd57eab 100644 --- a/main/views.py +++ b/main/views.py @@ -105,7 +105,7 @@ class CustomRegistrationView(RegistrationView): except SMTPException: self.redirect_url = 'email_sending_error' else: - raise ValueError('Непредвиденная ошибка') + self.redirect_url = 'email_sending_error' else: self.redirect_url = 'invalid_zendesk_email' From f77256937ed2ad25c53d7100fbfc5cf261960f45 Mon Sep 17 00:00:00 2001 From: Timofey Mazurov Date: Thu, 6 May 2021 02:44:53 +0300 Subject: [PATCH 06/16] Added logging for work pages --- main/extra_func.py | 10 ++++++---- main/views.py | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 0c93ba1..c41625a 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -16,12 +16,13 @@ from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, Unassigne from main.zendesk_admin import zenpy -def update_role(user_profile: UserProfile, role: int) -> None: +def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None: """ Функция меняет роль пользователя. :param user_profile: Профиль пользователя :param role: Новая роль + :param who_changes: Пользователь, меняющий роль :return: Пользователь с обновленной ролью """ zendesk = zenpy @@ -29,6 +30,7 @@ def update_role(user_profile: UserProfile, role: int) -> None: 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) @@ -39,7 +41,7 @@ def make_engineer(user_profile: UserProfile, who_changes: User) -> 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, who_changes: User) -> None: @@ -69,7 +71,7 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> 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'], who_changes) success = True except APIException as e: attempts -= 1 @@ -534,7 +536,7 @@ class CsvFormatter(logging.Formatter): return msg -def log(user, admin=0): +def log(user, admin=None): """ Осуществляет запись логов в базу данных и csv файл :param admin: diff --git a/main/views.py b/main/views.py index d21a6e9..67ab7ea 100644 --- a/main/views.py +++ b/main/views.py @@ -289,7 +289,6 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM """ for user in users: make_engineer(user, self.request.user) - log(user, self.request.user.userprofile) def make_light_agents(self, users): """ @@ -300,7 +299,6 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM """ for user in users: make_light_agent(user, self.request.user) - log(user, self.request.user.userprofile) class CustomLoginView(LoginView): From 0086d4909e37666cd5c6229235711e1d4ab45413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=BE=D0=BA=D1=83=D1=80=D0=BE=D0=B2=20=D0=98=D0=B4?= =?UTF-8?q?=D0=B0=D1=80?= Date: Thu, 6 May 2021 15:22:57 +0000 Subject: [PATCH 07/16] Feature/tests/make_eng from work and control pages --- access_controller/urls.py | 1 - fixtures/test_make_engineer.json | 85 +++++++++++++++++++++++++++ main/extra_func.py | 2 +- main/templates/pages/adm_ruleset.html | 4 +- main/tests.py | 69 +++++++++++++++++++++- main/zendesk_admin.py | 9 ++- 6 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 fixtures/test_make_engineer.json diff --git a/access_controller/urls.py b/access_controller/urls.py index 7df57b3..2cab267 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/fixtures/test_make_engineer.json b/fixtures/test_make_engineer.json new file mode 100644 index 0000000..1154342 --- /dev/null +++ b/fixtures/test_make_engineer.json @@ -0,0 +1,85 @@ +[ + { + "model": "auth.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$216000$gHBBCr1jBELf$ZkEDW3IEd8Wij7u8vkv+0Eze32CS01bcaYWhcD9OIC4=", + "last_login": null, + "is_superuser": true, + "username": "admin@gmail.com", + "first_name": "", + "last_name": "", + "email": "admin@gmail.com", + "is_staff": true, + "is_active": true, + "date_joined": "2021-03-10T16:38:56.303Z", + "groups": [], + "user_permissions": [33] + } + }, + { + "model": "main.userprofile", + "pk": 1, + "fields": { + "name": "ZendeskAdmin", + "user": 1, + "role": "admin" + } + }, + { + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$216000$5qLJgrm2Quq9$KDBNNymVZXkUx0HKBPFst2m83kLe0egPBnkW7KnkORU=", + "last_login": null, + "is_superuser": false, + "username": "123@test.ru", + "first_name": "", + "last_name": "", + "email": "123@test.ru", + "is_staff": false, + "is_active": true, + "date_joined": "2021-03-10T16:38:56.303Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "main.userprofile", + "pk": 2, + "fields": { + "name": "UserForAccessTest", + "user": 2, + "role": "agent", + "custom_role_id": "360005209000" + } + }, + { + "model": "auth.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$216000$5qLJgrm2Quq9$KDBNNymVZXkUx0HKBPFst2m83kLe0egPBnkW7KnkORU=", + "last_login": null, + "is_superuser": false, + "username": "customer@example.com", + "first_name": "", + "last_name": "", + "email": "customer@example.com", + "is_staff": false, + "is_active": true, + "date_joined": "2021-04-15T16:38:56.303Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "main.userprofile", + "pk": 3, + "fields": { + "name": "UserForAccessTest", + "user": 3, + "role": "agent", + "custom_role_id": "360005209000" + } + } +] diff --git a/main/extra_func.py b/main/extra_func.py index c41625a..4494fc7 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -31,7 +31,7 @@ def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None user_profile.custom_role_id = role user_profile.save() log(user_profile, who_changes.userprofile) - zendesk.admin.users.update(user) + zendesk.update_user(user) def make_engineer(user_profile: UserProfile, who_changes: User) -> None: diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index dc3cf54..cbbfc1b 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -17,9 +17,9 @@ - - + {# Для #} + {# Уведомлений #} {% endblock%} {% block content %}
diff --git a/main/tests.py b/main/tests.py index c06bc21..99d58cc 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,9 +1,10 @@ +from unittest.mock import patch from urllib.parse import urlparse from django.contrib.auth.models import User from django.core import mail from django.test import TestCase, Client -from django.urls import reverse +from django.urls import reverse, reverse_lazy from django.utils import translation import access_controller.settings as sets @@ -81,3 +82,69 @@ class RegistrationTestCase(TestCase): user = User.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): + 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.admin_client = Client() + self.admin_client.force_login(User.objects.get(email=self.admin)) + + @patch('main.extra_func.zenpy') + def test_redirect(self, ZenpyMock): + user = User.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): + self.client.post(reverse_lazy('work_become_engineer')) + self.assertEqual(ZenpyMock.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): + 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']) + + @patch('main.extra_func.zenpy') + def test_engineer_make_engineer(self, ZenpyMock): + client = Client() + client.force_login(User.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']) + + @patch('main.extra_func.zenpy') + def test_control_page_make_one(self, ZenpyMock): + self.admin_client.post( + reverse_lazy('control'), + data={'users': [User.objects.get(email=self.light_agent).userprofile.id], 'engineer': 'engineer'} + ) + call_list = ZenpyMock.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): + 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, + ], + 'engineer': 'engineer' + } + ) + call_list = ZenpyMock.update_user.call_args_list + mock_objects = list(call_list) + self.assertEqual(len(call_list), 2) + for obj in mock_objects: + self.assertEqual(obj[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) diff --git a/main/zendesk_admin.py b/main/zendesk_admin.py index 8ecb877..2d12700 100644 --- a/main/zendesk_admin.py +++ b/main/zendesk_admin.py @@ -1,5 +1,4 @@ from typing import Optional, Dict - from zenpy import Zenpy from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup from zenpy.lib.exception import APIException @@ -22,6 +21,14 @@ class ZendeskAdmin: 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 + def update_user(self, user: ZenpyUser) -> bool: + """ + Функция сохраняет изменение пользователя в Zendesk. + + :param user: Пользователь с изменёнными данными + """ + self.admin.users.update(user) + def check_user(self, email: str) -> bool: """ Функция осуществляет проверку существования пользователя в Zendesk по email. From 6bc1c6d1089ceadd24bcd10a809816a51abb5068 Mon Sep 17 00:00:00 2001 From: Iurii Tatishchev Date: Thu, 6 May 2021 08:45:15 -0700 Subject: [PATCH 08/16] Move main helper files to main/lib. --- main/apiauth.py | 49 ------- main/extra_func.py | 319 ----------------------------------------- main/requester.py | 2 +- main/statistic_data.py | 261 --------------------------------- main/tests.py | 14 +- main/views.py | 11 +- main/zendesk_admin.py | 99 ------------- 7 files changed, 13 insertions(+), 742 deletions(-) delete mode 100644 main/apiauth.py delete mode 100644 main/extra_func.py delete mode 100644 main/statistic_data.py delete mode 100644 main/zendesk_admin.py diff --git a/main/apiauth.py b/main/apiauth.py deleted file mode 100644 index 08a018c..0000000 --- a/main/apiauth.py +++ /dev/null @@ -1,49 +0,0 @@ -import os - -from zenpy import Zenpy -from zenpy.lib.api_objects import User as ZenpyUser - -from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD - - -def api_auth() -> dict: - """ - Функция создания пользователя с использованием Zendesk API. - - Получает из env Zendesk - email, token, password пользователя. - Если данные валидны и пользователь Zendesk с указанным email и токеном или паролем существует, - создается словарь данных пользователя, полученных через API c Zendesk. - - :return: данные пользователя - """ - credentials = { - 'subdomain': ACTRL_ZENDESK_SUBDOMAIN - } - email = ACTRL_API_EMAIL - token = ACTRL_API_TOKEN - password = ACTRL_API_PASSWORD - - if email is None: - raise ValueError('access_controller email not in env') - credentials['email'] = email - - # prefer token, use password if token not provided - if token: - credentials['token'] = token - elif password: - credentials['password'] = password - else: - raise ValueError('access_controller token or password not in env') - - zenpy_client = Zenpy(**credentials) - zenpy_user: ZenpyUser = zenpy_client.users.search(email).values[0] - - user = { - 'id': zenpy_user.id, - 'name': zenpy_user.name, # Zendesk doesn't have separate first and last name fields - 'email': zenpy_user.email, - 'role': zenpy_user.role, # str like 'admin' or 'agent', not id - 'photo': zenpy_user.photo['content_url'] if zenpy_user.photo is not None else None, - } - - return user diff --git a/main/extra_func.py b/main/extra_func.py deleted file mode 100644 index e6a2a97..0000000 --- a/main/extra_func.py +++ /dev/null @@ -1,319 +0,0 @@ -import logging -from datetime import timedelta - -from django.contrib.auth.models import User -from django.core.exceptions import ObjectDoesNotExist -from django.shortcuts import redirect -from django.utils import timezone -from zenpy import Zenpy -from zenpy.lib.exception import APIException -from zenpy.lib.api_objects import User as ZenpyUser, Ticket as ZenpyTicket -from zenpy.lib.generator import SearchResultGenerator - -from access_controller.settings import ZENDESK_ROLES as ROLES, ACTRL_ZENDESK_SUBDOMAIN -from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus -from main.requester import TicketListRequester -from main.zendesk_admin import zenpy - - -def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None: - """ - Функция меняет роль пользователя. - - :param user_profile: Профиль пользователя - :param role: Новая роль - :param who_changes: Пользователь, меняющий роль - :return: Пользователь с обновленной ролью - """ - zendesk = zenpy - user = zendesk.get_user(user_profile.user.email) - user.custom_role_id = role - user_profile.custom_role_id = role - user_profile.save() - log(user_profile, who_changes.userprofile) - zendesk.update_user(user) - - -def make_engineer(user_profile: UserProfile, who_changes: User) -> None: - """ - Функция устанавливает пользователю роль инженера. - - :param user_profile: Профиль пользователя - :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" - """ - update_role(user_profile, ROLES['engineer'], who_changes) - - -def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: - """ - Функция устанавливает пользователю роль легкого агента. - - :param user_profile: Профиль пользователя - :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" - """ - tickets: SearchResultGenerator = get_tickets_list(user_profile.user.email) - ticket: ZenpyTicket - for ticket in tickets: - UnassignedTicket.objects.create( - assignee=user_profile.user, - ticket_id=ticket.id, - status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED - ) - if ticket.status == 'solved': - ticket.assignee_id = zenpy.solved_tickets_user_id - else: - ticket.assignee = None - ticket.group_id = zenpy.buffer_group_id - if tickets: - zenpy.admin.tickets.update(tickets) - attempts, success = 20, False - while not success and attempts != 0: - try: - update_role(user_profile, ROLES['light_agent'], who_changes) - success = True - except APIException as e: - attempts -= 1 - if attempts == 0: - raise e - - -def get_users_list() -> list: - """ - Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. - """ - zendesk = zenpy - - # У пользователей должна быть организация SYSTEM - org = next(zendesk.admin.search(type='organization', name='SYSTEM')) - users = zendesk.admin.organizations.users(org) - return users - - -def get_tickets_list(email): - """ - Функция возвращает список тикетов пользователя Zendesk - """ - return TicketListRequester().get_tickets_list_for_user(zenpy.get_user(email)) - - -def get_tickets_list_for_group(group_name): - """ - Функция возвращает список неназначенных, нерешённых тикетов группы Zendesk - """ - return TicketListRequester().get_tickets_list_for_group(zenpy.get_group(group_name)) - - -def update_profile(user_profile: UserProfile): - """ - Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. - - :param user_profile: Профиль пользователя - :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя - """ - user = zenpy.get_user(user_profile.user.email) - user_profile.name = user.name - user_profile.role = user.role - user_profile.custom_role_id = user.custom_role_id if user.custom_role_id else 0 - user_profile.image = user.photo['content_url'] if user.photo else None - user_profile.save() - - -def check_user_exist(email: str) -> bool: - """ - Функция проверяет, существует ли пользователь. - - :param email: Email пользователя - :return: Зарегистрирован ли пользователь в Zendesk - """ - return zenpy.check_user(email) - - -def get_user_organization(email: str) -> str: - """ - Функция возвращает организацию пользователя. - - :param email: Email пользователя - :return: Организация пользователя - """ - return zenpy.get_user_org(email) - - -def check_user_auth(email: str, password: str) -> bool: - """ - Функция проверяет, верны ли входные данные. - - :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован - """ - creds = { - 'email': email, - 'password': password, - 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, - } - try: - user = Zenpy(**creds) - user.search(email, type='user') - except APIException: - return False - return True - - -def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): - """ - Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. - - :param profile: Профиль пользователя - :param zendesk_user: Данные пользователя в Zendesk - :return: Обновленный профиль пользователя - """ - profile.name = zendesk_user.name - profile.role = zendesk_user.role - profile.image = zendesk_user.photo['content_url'] if zendesk_user.photo else None - if zendesk_user.custom_role_id is not None: - profile.custom_role_id = int(zendesk_user.custom_role_id) - profile.save() - - -def count_users(users) -> tuple: - """ - Функция подсчета количества сотрудников с ролями engineer и light_agent - """ - engineers, light_agents = 0, 0 - for user in users: - if user.custom_role_id == ROLES['engineer']: - engineers += 1 - elif user.custom_role_id == ROLES['light_agent']: - light_agents += 1 - return engineers, light_agents - - -def update_users_in_model(): - """ - Обновляет пользователей в модели UserProfile по списку пользователей в организации - """ - users = get_users_list() - for user in users: - try: - profile = User.objects.get(email=user.email).userprofile - update_user_in_model(profile, user) - except ObjectDoesNotExist: - pass - return users - - -def daterange(start_date, end_date) -> list: - """ - Функция возвращает список дней с start_date по end_date, исключая правую границу. - - :param start_date: Начальная дата - :param end_date: Конечная дата - :return: Список дней, не включая конечную дату - """ - dates = [] - for n in range(int((end_date - start_date).days)): - dates.append(start_date + timedelta(n)) - return dates - - -def get_timedelta(log, time=None) -> timedelta: - """ - Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, - который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. - - :param log: Лог - :param time: Время - :return: Сколько времени прошло от начала суток до события - """ - if time is None: - time = log.change_time.time() - time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) - return time - - -def last_day_of_month(day: int) -> int: - """ - Функция возвращает последний день текущего месяца. - - :param day: Текущий день - :return: Последний день месяца - """ - next_month = day.replace(day=28) + timedelta(days=4) - return next_month - timedelta(days=next_month.day) - - -class DatabaseHandler(logging.Handler): - def __init__(self): - logging.Handler.__init__(self) - - def emit(self, record): - database = RoleChangeLogs() - users = record.msg - if users[1]: - user = users[0] - admin = users[1] - elif not users[1]: - user = users[0] - admin = users[0] - database.name = user.name - database.user = user.user - database.changed_by = admin.user - if user.custom_role_id == ROLES['engineer']: - database.old_role = ROLES['light_agent'] - elif user.custom_role_id == ROLES['light_agent']: - database.old_role = ROLES['engineer'] - database.new_role = user.custom_role_id - database.save() - - -class CsvFormatter(logging.Formatter): - def __init__(self): - logging.Formatter.__init__(self) - - def format(self, record): - users = record.msg - if users[1]: - user = users[0] - admin = users[1] - elif not users[1]: - user = users[0] - admin = users[0] - msg = '' - msg += user.name - if user.custom_role_id == ROLES['engineer']: - msg += ',engineer,' - elif user.custom_role_id == ROLES['light_agent']: - msg += ',light_agent,' - time = str(timezone.now().today()) - msg += time[:16] - msg += ',' - msg += admin.name - return msg - - -def log(user, admin=None): - """ - Осуществляет запись логов в базу данных и csv файл - :param admin: - :param user: - :return: - """ - users = [user, admin] - logger = logging.getLogger('MY_LOGGER') - if not logger.hasHandlers(): - dbhandler = DatabaseHandler() - csvformatter = CsvFormatter() - csvhandler = logging.FileHandler('logs/logs.csv', "a") - csvhandler.setFormatter(csvformatter) - logger.addHandler(dbhandler) - logger.addHandler(csvhandler) - logger.setLevel('INFO') - logger.info(users) - - -def set_session_params_for_work_page(request, count=None, is_confirm=True): - """ - Функция для страницы получения прав - Устанавливает данные сессии о успешности запроса и количестве назначенных тикетов - """ - request.session['is_confirm'] = is_confirm - request.session['count_tickets'] = count - return redirect('work', request.user.id) diff --git a/main/requester.py b/main/requester.py index 468abee..d3bdd18 100644 --- a/main/requester.py +++ b/main/requester.py @@ -2,7 +2,7 @@ import requests from zenpy import TicketApi from zenpy.lib.api_objects import Ticket -from main.zendesk_admin import zenpy +from main.lib.zendesk_admin import zenpy class TicketListRequester: diff --git a/main/statistic_data.py b/main/statistic_data.py deleted file mode 100644 index fa1ab24..0000000 --- a/main/statistic_data.py +++ /dev/null @@ -1,261 +0,0 @@ -from datetime import date, datetime, timedelta -from typing import Optional - -from django.contrib.auth.models import User -from django.utils import timezone - -from access_controller.settings import ONE_DAY, ZENDESK_ROLES as ROLES -from main.extra_func import last_day_of_month, get_timedelta, daterange -from main.models import RoleChangeLogs - - -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) diff --git a/main/tests.py b/main/tests.py index 99d58cc..6271ae8 100644 --- a/main/tests.py +++ b/main/tests.py @@ -8,7 +8,7 @@ from django.urls import reverse, reverse_lazy from django.utils import translation import access_controller.settings as sets -from main.zendesk_admin import zenpy +from main.lib.zendesk_admin import zenpy class RegistrationTestCase(TestCase): @@ -96,31 +96,31 @@ class MakeEngineerTestCase(TestCase): self.admin_client = Client() self.admin_client.force_login(User.objects.get(email=self.admin)) - @patch('main.extra_func.zenpy') + @patch('main.lib.extra_func.zenpy') def test_redirect(self, ZenpyMock): user = User.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') + @patch('main.lib.extra_func.zenpy') def test_light_agent_make_engineer(self, ZenpyMock): self.client.post(reverse_lazy('work_become_engineer')) self.assertEqual(ZenpyMock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) - @patch('main.extra_func.zenpy') + @patch('main.lib.extra_func.zenpy') def test_admin_make_engineer(self, ZenpyMock): 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']) - @patch('main.extra_func.zenpy') + @patch('main.lib.extra_func.zenpy') def test_engineer_make_engineer(self, ZenpyMock): client = Client() client.force_login(User.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']) - @patch('main.extra_func.zenpy') + @patch('main.lib.extra_func.zenpy') def test_control_page_make_one(self, ZenpyMock): self.admin_client.post( reverse_lazy('control'), @@ -131,7 +131,7 @@ class MakeEngineerTestCase(TestCase): self.assertEqual(len(call_list), 1) self.assertEqual(mock_object.custom_role_id, sets.ZENDESK_ROLES['engineer']) - @patch('main.extra_func.zenpy') + @patch('main.lib.extra_func.zenpy') def test_control_page_make_many(self, ZenpyMock): self.admin_client.post( reverse_lazy('control'), diff --git a/main/views.py b/main/views.py index 467e925..4355141 100644 --- a/main/views.py +++ b/main/views.py @@ -13,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 @@ -21,12 +21,11 @@ from rest_framework import viewsets 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, \ +from main.lib.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 -from main.zendesk_admin import zenpy -from main.requester import TicketListRequester + set_session_params_for_work_page, get_tickets_list_for_group +from main.lib.statistic_data import StatisticData +from main.lib.zendesk_admin import zenpy from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm from main.serializers import ProfileSerializer, ZendeskUserSerializer from .models import UserProfile diff --git a/main/zendesk_admin.py b/main/zendesk_admin.py deleted file mode 100644 index 2a689ce..0000000 --- a/main/zendesk_admin.py +++ /dev/null @@ -1,99 +0,0 @@ -from typing import Optional, Dict -from zenpy import Zenpy -from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup -from zenpy.lib.exception import APIException -from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD, \ - ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL - - -class ZendeskAdmin: - """ - Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора. - - :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) - :type credentials: :class:`Dict[str, str]` - """ - - 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 - - def update_user(self, user: ZenpyUser) -> bool: - """ - Функция сохраняет изменение пользователя в Zendesk. - - :param user: Пользователь с изменёнными данными - """ - self.admin.users.update(user) - - def check_user(self, email: str) -> bool: - """ - Функция осуществляет проверку существования пользователя в Zendesk по email. - - :param email: Email пользователя - :return: Является ли зарегистрированным - """ - return True if self.admin.search(email, type='user') else False - - def get_user(self, email: str) -> ZenpyUser: - """ - Функция возвращает пользователя (объект) по его email. - - :param email: Email пользователя - :return: Объект пользователя, найденного в БД - """ - return self.admin.users.search(email).values[0] - - def get_group(self, name: str) -> Optional[ZenpyGroup]: - """ - Функция возвращает группу по названию - - :param name: Имя пользователя - :return: Группы пользователя (в случае отсутствия None) - """ - groups = self.admin.search(name, type='group') - for group in groups: - return group - return None - - def get_user_org(self, email: str) -> str: - """ - Функция возвращает организацию, к которой относится пользователь по его email. - - :param email: Email пользователя - :return: Организация пользователя - """ - user = self.admin.users.search(email).values[0] - return user.organization.name if user.organization else None - - def create_admin(self) -> Zenpy: - """ - Функция создает администратора, проверяя наличие вводимых данных в env. - - :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') - - if self.credentials.get('token') is None and self.credentials.get('password') is None: - raise ValueError('access_controller token or password not in env') - - admin = Zenpy(**self.credentials) - try: - admin.search(self.credentials['email'], type='user') - except APIException: - raise ValueError('invalid access_controller`s login data') - - return admin - - -zenpy = ZendeskAdmin({ - 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, - 'email': ACTRL_API_EMAIL, - 'token': ACTRL_API_TOKEN, - 'password': ACTRL_API_PASSWORD, -}) From d426e9a2c44c257f4fce20803015bec47d6bd206 Mon Sep 17 00:00:00 2001 From: Iurii Tatishchev Date: Thu, 6 May 2021 08:46:41 -0700 Subject: [PATCH 09/16] fix --- main/lib/__init__.py | 0 main/lib/__pycache__/__init__.cpython-39.pyc | Bin 0 -> 200 bytes .../lib/__pycache__/extra_func.cpython-39.pyc | Bin 0 -> 11882 bytes .../__pycache__/statistic_data.cpython-39.pyc | Bin 0 -> 11471 bytes .../__pycache__/zendesk_admin.cpython-39.pyc | Bin 0 -> 4804 bytes main/lib/extra_func.py | 319 ++++++++++++++++++ main/lib/statistic_data.py | 261 ++++++++++++++ main/lib/zendesk_admin.py | 99 ++++++ 8 files changed, 679 insertions(+) create mode 100644 main/lib/__init__.py create mode 100644 main/lib/__pycache__/__init__.cpython-39.pyc create mode 100644 main/lib/__pycache__/extra_func.cpython-39.pyc create mode 100644 main/lib/__pycache__/statistic_data.cpython-39.pyc create mode 100644 main/lib/__pycache__/zendesk_admin.cpython-39.pyc create mode 100644 main/lib/extra_func.py create mode 100644 main/lib/statistic_data.py create mode 100644 main/lib/zendesk_admin.py diff --git a/main/lib/__init__.py b/main/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main/lib/__pycache__/__init__.cpython-39.pyc b/main/lib/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70ee7f6ed0f60b941986434ab357ce214c10c586 GIT binary patch literal 200 zcmYe~<>g`k0z&ryk0@&Ee@O9 Q{FKt1R6CF}J_9iW0F?tVr2qf` literal 0 HcmV?d00001 diff --git a/main/lib/__pycache__/extra_func.cpython-39.pyc b/main/lib/__pycache__/extra_func.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..506e57c529b5d7bf576e43a4ca5b8b21158116e7 GIT binary patch literal 11882 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 a0}p(kQlm{aOu>hA=~~aU3ZKgFfBp+R{3Hed literal 0 HcmV?d00001 diff --git a/main/lib/extra_func.py b/main/lib/extra_func.py new file mode 100644 index 0000000..ad251cd --- /dev/null +++ b/main/lib/extra_func.py @@ -0,0 +1,319 @@ +import logging +from datetime import timedelta + +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import redirect +from django.utils import timezone +from zenpy import Zenpy +from zenpy.lib.exception import APIException +from zenpy.lib.api_objects import User as ZenpyUser, Ticket as ZenpyTicket +from zenpy.lib.generator import SearchResultGenerator + +from access_controller.settings import ZENDESK_ROLES as ROLES, ACTRL_ZENDESK_SUBDOMAIN +from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus +from main.requester import TicketListRequester +from main.lib.zendesk_admin import zenpy + + +def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None: + """ + Функция меняет роль пользователя. + + :param user_profile: Профиль пользователя + :param role: Новая роль + :param who_changes: Пользователь, меняющий роль + :return: Пользователь с обновленной ролью + """ + zendesk = zenpy + user = zendesk.get_user(user_profile.user.email) + user.custom_role_id = role + user_profile.custom_role_id = role + user_profile.save() + log(user_profile, who_changes.userprofile) + zendesk.update_user(user) + + +def make_engineer(user_profile: UserProfile, who_changes: User) -> None: + """ + Функция устанавливает пользователю роль инженера. + + :param user_profile: Профиль пользователя + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" + """ + update_role(user_profile, ROLES['engineer'], who_changes) + + +def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: + """ + Функция устанавливает пользователю роль легкого агента. + + :param user_profile: Профиль пользователя + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" + """ + tickets: SearchResultGenerator = get_tickets_list(user_profile.user.email) + ticket: ZenpyTicket + for ticket in tickets: + UnassignedTicket.objects.create( + assignee=user_profile.user, + ticket_id=ticket.id, + status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED + ) + if ticket.status == 'solved': + ticket.assignee_id = zenpy.solved_tickets_user_id + else: + ticket.assignee = None + ticket.group_id = zenpy.buffer_group_id + if tickets: + zenpy.admin.tickets.update(tickets) + attempts, success = 20, False + while not success and attempts != 0: + try: + update_role(user_profile, ROLES['light_agent'], who_changes) + success = True + except APIException as e: + attempts -= 1 + if attempts == 0: + raise e + + +def get_users_list() -> list: + """ + Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. + """ + zendesk = zenpy + + # У пользователей должна быть организация SYSTEM + org = next(zendesk.admin.search(type='organization', name='SYSTEM')) + users = zendesk.admin.organizations.users(org) + return users + + +def get_tickets_list(email): + """ + Функция возвращает список тикетов пользователя Zendesk + """ + return TicketListRequester().get_tickets_list_for_user(zenpy.get_user(email)) + + +def get_tickets_list_for_group(group_name): + """ + Функция возвращает список неназначенных, нерешённых тикетов группы Zendesk + """ + return TicketListRequester().get_tickets_list_for_group(zenpy.get_group(group_name)) + + +def update_profile(user_profile: UserProfile): + """ + Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. + + :param user_profile: Профиль пользователя + :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя + """ + user = zenpy.get_user(user_profile.user.email) + user_profile.name = user.name + user_profile.role = user.role + user_profile.custom_role_id = user.custom_role_id if user.custom_role_id else 0 + user_profile.image = user.photo['content_url'] if user.photo else None + user_profile.save() + + +def check_user_exist(email: str) -> bool: + """ + Функция проверяет, существует ли пользователь. + + :param email: Email пользователя + :return: Зарегистрирован ли пользователь в Zendesk + """ + return zenpy.check_user(email) + + +def get_user_organization(email: str) -> str: + """ + Функция возвращает организацию пользователя. + + :param email: Email пользователя + :return: Организация пользователя + """ + return zenpy.get_user_org(email) + + +def check_user_auth(email: str, password: str) -> bool: + """ + Функция проверяет, верны ли входные данные. + + :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован + """ + creds = { + 'email': email, + 'password': password, + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, + } + try: + user = Zenpy(**creds) + user.search(email, type='user') + except APIException: + return False + return True + + +def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): + """ + Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. + + :param profile: Профиль пользователя + :param zendesk_user: Данные пользователя в Zendesk + :return: Обновленный профиль пользователя + """ + profile.name = zendesk_user.name + profile.role = zendesk_user.role + profile.image = zendesk_user.photo['content_url'] if zendesk_user.photo else None + if zendesk_user.custom_role_id is not None: + profile.custom_role_id = int(zendesk_user.custom_role_id) + profile.save() + + +def count_users(users) -> tuple: + """ + Функция подсчета количества сотрудников с ролями engineer и light_agent + """ + engineers, light_agents = 0, 0 + for user in users: + if user.custom_role_id == ROLES['engineer']: + engineers += 1 + elif user.custom_role_id == ROLES['light_agent']: + light_agents += 1 + return engineers, light_agents + + +def update_users_in_model(): + """ + Обновляет пользователей в модели UserProfile по списку пользователей в организации + """ + users = get_users_list() + for user in users: + try: + profile = User.objects.get(email=user.email).userprofile + update_user_in_model(profile, user) + except ObjectDoesNotExist: + pass + return users + + +def daterange(start_date, end_date) -> list: + """ + Функция возвращает список дней с start_date по end_date, исключая правую границу. + + :param start_date: Начальная дата + :param end_date: Конечная дата + :return: Список дней, не включая конечную дату + """ + dates = [] + for n in range(int((end_date - start_date).days)): + dates.append(start_date + timedelta(n)) + return dates + + +def get_timedelta(log, time=None) -> timedelta: + """ + Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, + который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. + + :param log: Лог + :param time: Время + :return: Сколько времени прошло от начала суток до события + """ + if time is None: + time = log.change_time.time() + time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) + return time + + +def last_day_of_month(day: int) -> int: + """ + Функция возвращает последний день текущего месяца. + + :param day: Текущий день + :return: Последний день месяца + """ + next_month = day.replace(day=28) + timedelta(days=4) + return next_month - timedelta(days=next_month.day) + + +class DatabaseHandler(logging.Handler): + def __init__(self): + logging.Handler.__init__(self) + + def emit(self, record): + database = RoleChangeLogs() + users = record.msg + if users[1]: + user = users[0] + admin = users[1] + elif not users[1]: + user = users[0] + admin = users[0] + database.name = user.name + database.user = user.user + database.changed_by = admin.user + if user.custom_role_id == ROLES['engineer']: + database.old_role = ROLES['light_agent'] + elif user.custom_role_id == ROLES['light_agent']: + database.old_role = ROLES['engineer'] + database.new_role = user.custom_role_id + database.save() + + +class CsvFormatter(logging.Formatter): + def __init__(self): + logging.Formatter.__init__(self) + + def format(self, record): + users = record.msg + if users[1]: + user = users[0] + admin = users[1] + elif not users[1]: + user = users[0] + admin = users[0] + msg = '' + msg += user.name + if user.custom_role_id == ROLES['engineer']: + msg += ',engineer,' + elif user.custom_role_id == ROLES['light_agent']: + msg += ',light_agent,' + time = str(timezone.now().today()) + msg += time[:16] + msg += ',' + msg += admin.name + return msg + + +def log(user, admin=None): + """ + Осуществляет запись логов в базу данных и csv файл + :param admin: + :param user: + :return: + """ + users = [user, admin] + logger = logging.getLogger('MY_LOGGER') + if not logger.hasHandlers(): + dbhandler = DatabaseHandler() + csvformatter = CsvFormatter() + csvhandler = logging.FileHandler('logs/logs.csv', "a") + csvhandler.setFormatter(csvformatter) + logger.addHandler(dbhandler) + logger.addHandler(csvhandler) + logger.setLevel('INFO') + logger.info(users) + + +def set_session_params_for_work_page(request, count=None, is_confirm=True): + """ + Функция для страницы получения прав + Устанавливает данные сессии о успешности запроса и количестве назначенных тикетов + """ + request.session['is_confirm'] = is_confirm + request.session['count_tickets'] = count + return redirect('work', request.user.id) diff --git a/main/lib/statistic_data.py b/main/lib/statistic_data.py new file mode 100644 index 0000000..2e1061d --- /dev/null +++ b/main/lib/statistic_data.py @@ -0,0 +1,261 @@ +from datetime import date, datetime, timedelta +from typing import Optional + +from django.contrib.auth.models import User +from django.utils import timezone + +from access_controller.settings import ONE_DAY, ZENDESK_ROLES as ROLES +from main.lib.extra_func import last_day_of_month, get_timedelta, daterange +from main.models import RoleChangeLogs + + +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) diff --git a/main/lib/zendesk_admin.py b/main/lib/zendesk_admin.py new file mode 100644 index 0000000..2a689ce --- /dev/null +++ b/main/lib/zendesk_admin.py @@ -0,0 +1,99 @@ +from typing import Optional, Dict +from zenpy import Zenpy +from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup +from zenpy.lib.exception import APIException +from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD, \ + ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL + + +class ZendeskAdmin: + """ + Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора. + + :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) + :type credentials: :class:`Dict[str, str]` + """ + + 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 + + def update_user(self, user: ZenpyUser) -> bool: + """ + Функция сохраняет изменение пользователя в Zendesk. + + :param user: Пользователь с изменёнными данными + """ + self.admin.users.update(user) + + def check_user(self, email: str) -> bool: + """ + Функция осуществляет проверку существования пользователя в Zendesk по email. + + :param email: Email пользователя + :return: Является ли зарегистрированным + """ + return True if self.admin.search(email, type='user') else False + + def get_user(self, email: str) -> ZenpyUser: + """ + Функция возвращает пользователя (объект) по его email. + + :param email: Email пользователя + :return: Объект пользователя, найденного в БД + """ + return self.admin.users.search(email).values[0] + + def get_group(self, name: str) -> Optional[ZenpyGroup]: + """ + Функция возвращает группу по названию + + :param name: Имя пользователя + :return: Группы пользователя (в случае отсутствия None) + """ + groups = self.admin.search(name, type='group') + for group in groups: + return group + return None + + def get_user_org(self, email: str) -> str: + """ + Функция возвращает организацию, к которой относится пользователь по его email. + + :param email: Email пользователя + :return: Организация пользователя + """ + user = self.admin.users.search(email).values[0] + return user.organization.name if user.organization else None + + def create_admin(self) -> Zenpy: + """ + Функция создает администратора, проверяя наличие вводимых данных в env. + + :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') + + if self.credentials.get('token') is None and self.credentials.get('password') is None: + raise ValueError('access_controller token or password not in env') + + admin = Zenpy(**self.credentials) + try: + admin.search(self.credentials['email'], type='user') + except APIException: + raise ValueError('invalid access_controller`s login data') + + return admin + + +zenpy = ZendeskAdmin({ + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, + 'email': ACTRL_API_EMAIL, + 'token': ACTRL_API_TOKEN, + 'password': ACTRL_API_PASSWORD, +}) From be1bfdd259c6c2d8dd1987178d1ce78e2e9ee9f5 Mon Sep 17 00:00:00 2001 From: Iurii Tatishchev Date: Thu, 6 May 2021 08:47:15 -0700 Subject: [PATCH 10/16] Revert "Move main helper files to main/lib." This reverts commit 6bc1c6d1089ceadd24bcd10a809816a51abb5068. --- main/apiauth.py | 49 +++++++ main/extra_func.py | 319 +++++++++++++++++++++++++++++++++++++++++ main/requester.py | 2 +- main/statistic_data.py | 261 +++++++++++++++++++++++++++++++++ main/tests.py | 14 +- main/views.py | 11 +- main/zendesk_admin.py | 99 +++++++++++++ 7 files changed, 742 insertions(+), 13 deletions(-) create mode 100644 main/apiauth.py create mode 100644 main/extra_func.py create mode 100644 main/statistic_data.py create mode 100644 main/zendesk_admin.py diff --git a/main/apiauth.py b/main/apiauth.py new file mode 100644 index 0000000..08a018c --- /dev/null +++ b/main/apiauth.py @@ -0,0 +1,49 @@ +import os + +from zenpy import Zenpy +from zenpy.lib.api_objects import User as ZenpyUser + +from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD + + +def api_auth() -> dict: + """ + Функция создания пользователя с использованием Zendesk API. + + Получает из env Zendesk - email, token, password пользователя. + Если данные валидны и пользователь Zendesk с указанным email и токеном или паролем существует, + создается словарь данных пользователя, полученных через API c Zendesk. + + :return: данные пользователя + """ + credentials = { + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN + } + email = ACTRL_API_EMAIL + token = ACTRL_API_TOKEN + password = ACTRL_API_PASSWORD + + if email is None: + raise ValueError('access_controller email not in env') + credentials['email'] = email + + # prefer token, use password if token not provided + if token: + credentials['token'] = token + elif password: + credentials['password'] = password + else: + raise ValueError('access_controller token or password not in env') + + zenpy_client = Zenpy(**credentials) + zenpy_user: ZenpyUser = zenpy_client.users.search(email).values[0] + + user = { + 'id': zenpy_user.id, + 'name': zenpy_user.name, # Zendesk doesn't have separate first and last name fields + 'email': zenpy_user.email, + 'role': zenpy_user.role, # str like 'admin' or 'agent', not id + 'photo': zenpy_user.photo['content_url'] if zenpy_user.photo is not None else None, + } + + return user diff --git a/main/extra_func.py b/main/extra_func.py new file mode 100644 index 0000000..e6a2a97 --- /dev/null +++ b/main/extra_func.py @@ -0,0 +1,319 @@ +import logging +from datetime import timedelta + +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import redirect +from django.utils import timezone +from zenpy import Zenpy +from zenpy.lib.exception import APIException +from zenpy.lib.api_objects import User as ZenpyUser, Ticket as ZenpyTicket +from zenpy.lib.generator import SearchResultGenerator + +from access_controller.settings import ZENDESK_ROLES as ROLES, ACTRL_ZENDESK_SUBDOMAIN +from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus +from main.requester import TicketListRequester +from main.zendesk_admin import zenpy + + +def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None: + """ + Функция меняет роль пользователя. + + :param user_profile: Профиль пользователя + :param role: Новая роль + :param who_changes: Пользователь, меняющий роль + :return: Пользователь с обновленной ролью + """ + zendesk = zenpy + user = zendesk.get_user(user_profile.user.email) + user.custom_role_id = role + user_profile.custom_role_id = role + user_profile.save() + log(user_profile, who_changes.userprofile) + zendesk.update_user(user) + + +def make_engineer(user_profile: UserProfile, who_changes: User) -> None: + """ + Функция устанавливает пользователю роль инженера. + + :param user_profile: Профиль пользователя + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" + """ + update_role(user_profile, ROLES['engineer'], who_changes) + + +def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: + """ + Функция устанавливает пользователю роль легкого агента. + + :param user_profile: Профиль пользователя + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" + """ + tickets: SearchResultGenerator = get_tickets_list(user_profile.user.email) + ticket: ZenpyTicket + for ticket in tickets: + UnassignedTicket.objects.create( + assignee=user_profile.user, + ticket_id=ticket.id, + status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED + ) + if ticket.status == 'solved': + ticket.assignee_id = zenpy.solved_tickets_user_id + else: + ticket.assignee = None + ticket.group_id = zenpy.buffer_group_id + if tickets: + zenpy.admin.tickets.update(tickets) + attempts, success = 20, False + while not success and attempts != 0: + try: + update_role(user_profile, ROLES['light_agent'], who_changes) + success = True + except APIException as e: + attempts -= 1 + if attempts == 0: + raise e + + +def get_users_list() -> list: + """ + Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. + """ + zendesk = zenpy + + # У пользователей должна быть организация SYSTEM + org = next(zendesk.admin.search(type='organization', name='SYSTEM')) + users = zendesk.admin.organizations.users(org) + return users + + +def get_tickets_list(email): + """ + Функция возвращает список тикетов пользователя Zendesk + """ + return TicketListRequester().get_tickets_list_for_user(zenpy.get_user(email)) + + +def get_tickets_list_for_group(group_name): + """ + Функция возвращает список неназначенных, нерешённых тикетов группы Zendesk + """ + return TicketListRequester().get_tickets_list_for_group(zenpy.get_group(group_name)) + + +def update_profile(user_profile: UserProfile): + """ + Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. + + :param user_profile: Профиль пользователя + :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя + """ + user = zenpy.get_user(user_profile.user.email) + user_profile.name = user.name + user_profile.role = user.role + user_profile.custom_role_id = user.custom_role_id if user.custom_role_id else 0 + user_profile.image = user.photo['content_url'] if user.photo else None + user_profile.save() + + +def check_user_exist(email: str) -> bool: + """ + Функция проверяет, существует ли пользователь. + + :param email: Email пользователя + :return: Зарегистрирован ли пользователь в Zendesk + """ + return zenpy.check_user(email) + + +def get_user_organization(email: str) -> str: + """ + Функция возвращает организацию пользователя. + + :param email: Email пользователя + :return: Организация пользователя + """ + return zenpy.get_user_org(email) + + +def check_user_auth(email: str, password: str) -> bool: + """ + Функция проверяет, верны ли входные данные. + + :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован + """ + creds = { + 'email': email, + 'password': password, + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, + } + try: + user = Zenpy(**creds) + user.search(email, type='user') + except APIException: + return False + return True + + +def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): + """ + Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. + + :param profile: Профиль пользователя + :param zendesk_user: Данные пользователя в Zendesk + :return: Обновленный профиль пользователя + """ + profile.name = zendesk_user.name + profile.role = zendesk_user.role + profile.image = zendesk_user.photo['content_url'] if zendesk_user.photo else None + if zendesk_user.custom_role_id is not None: + profile.custom_role_id = int(zendesk_user.custom_role_id) + profile.save() + + +def count_users(users) -> tuple: + """ + Функция подсчета количества сотрудников с ролями engineer и light_agent + """ + engineers, light_agents = 0, 0 + for user in users: + if user.custom_role_id == ROLES['engineer']: + engineers += 1 + elif user.custom_role_id == ROLES['light_agent']: + light_agents += 1 + return engineers, light_agents + + +def update_users_in_model(): + """ + Обновляет пользователей в модели UserProfile по списку пользователей в организации + """ + users = get_users_list() + for user in users: + try: + profile = User.objects.get(email=user.email).userprofile + update_user_in_model(profile, user) + except ObjectDoesNotExist: + pass + return users + + +def daterange(start_date, end_date) -> list: + """ + Функция возвращает список дней с start_date по end_date, исключая правую границу. + + :param start_date: Начальная дата + :param end_date: Конечная дата + :return: Список дней, не включая конечную дату + """ + dates = [] + for n in range(int((end_date - start_date).days)): + dates.append(start_date + timedelta(n)) + return dates + + +def get_timedelta(log, time=None) -> timedelta: + """ + Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, + который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. + + :param log: Лог + :param time: Время + :return: Сколько времени прошло от начала суток до события + """ + if time is None: + time = log.change_time.time() + time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) + return time + + +def last_day_of_month(day: int) -> int: + """ + Функция возвращает последний день текущего месяца. + + :param day: Текущий день + :return: Последний день месяца + """ + next_month = day.replace(day=28) + timedelta(days=4) + return next_month - timedelta(days=next_month.day) + + +class DatabaseHandler(logging.Handler): + def __init__(self): + logging.Handler.__init__(self) + + def emit(self, record): + database = RoleChangeLogs() + users = record.msg + if users[1]: + user = users[0] + admin = users[1] + elif not users[1]: + user = users[0] + admin = users[0] + database.name = user.name + database.user = user.user + database.changed_by = admin.user + if user.custom_role_id == ROLES['engineer']: + database.old_role = ROLES['light_agent'] + elif user.custom_role_id == ROLES['light_agent']: + database.old_role = ROLES['engineer'] + database.new_role = user.custom_role_id + database.save() + + +class CsvFormatter(logging.Formatter): + def __init__(self): + logging.Formatter.__init__(self) + + def format(self, record): + users = record.msg + if users[1]: + user = users[0] + admin = users[1] + elif not users[1]: + user = users[0] + admin = users[0] + msg = '' + msg += user.name + if user.custom_role_id == ROLES['engineer']: + msg += ',engineer,' + elif user.custom_role_id == ROLES['light_agent']: + msg += ',light_agent,' + time = str(timezone.now().today()) + msg += time[:16] + msg += ',' + msg += admin.name + return msg + + +def log(user, admin=None): + """ + Осуществляет запись логов в базу данных и csv файл + :param admin: + :param user: + :return: + """ + users = [user, admin] + logger = logging.getLogger('MY_LOGGER') + if not logger.hasHandlers(): + dbhandler = DatabaseHandler() + csvformatter = CsvFormatter() + csvhandler = logging.FileHandler('logs/logs.csv', "a") + csvhandler.setFormatter(csvformatter) + logger.addHandler(dbhandler) + logger.addHandler(csvhandler) + logger.setLevel('INFO') + logger.info(users) + + +def set_session_params_for_work_page(request, count=None, is_confirm=True): + """ + Функция для страницы получения прав + Устанавливает данные сессии о успешности запроса и количестве назначенных тикетов + """ + request.session['is_confirm'] = is_confirm + request.session['count_tickets'] = count + return redirect('work', request.user.id) diff --git a/main/requester.py b/main/requester.py index d3bdd18..468abee 100644 --- a/main/requester.py +++ b/main/requester.py @@ -2,7 +2,7 @@ import requests from zenpy import TicketApi from zenpy.lib.api_objects import Ticket -from main.lib.zendesk_admin import zenpy +from main.zendesk_admin import zenpy class TicketListRequester: diff --git a/main/statistic_data.py b/main/statistic_data.py new file mode 100644 index 0000000..fa1ab24 --- /dev/null +++ b/main/statistic_data.py @@ -0,0 +1,261 @@ +from datetime import date, datetime, timedelta +from typing import Optional + +from django.contrib.auth.models import User +from django.utils import timezone + +from access_controller.settings import ONE_DAY, ZENDESK_ROLES as ROLES +from main.extra_func import last_day_of_month, get_timedelta, daterange +from main.models import RoleChangeLogs + + +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) diff --git a/main/tests.py b/main/tests.py index 6271ae8..99d58cc 100644 --- a/main/tests.py +++ b/main/tests.py @@ -8,7 +8,7 @@ from django.urls import reverse, reverse_lazy from django.utils import translation import access_controller.settings as sets -from main.lib.zendesk_admin import zenpy +from main.zendesk_admin import zenpy class RegistrationTestCase(TestCase): @@ -96,31 +96,31 @@ class MakeEngineerTestCase(TestCase): self.admin_client = Client() self.admin_client.force_login(User.objects.get(email=self.admin)) - @patch('main.lib.extra_func.zenpy') + @patch('main.extra_func.zenpy') def test_redirect(self, ZenpyMock): user = User.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.lib.extra_func.zenpy') + @patch('main.extra_func.zenpy') def test_light_agent_make_engineer(self, ZenpyMock): self.client.post(reverse_lazy('work_become_engineer')) self.assertEqual(ZenpyMock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) - @patch('main.lib.extra_func.zenpy') + @patch('main.extra_func.zenpy') def test_admin_make_engineer(self, ZenpyMock): 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']) - @patch('main.lib.extra_func.zenpy') + @patch('main.extra_func.zenpy') def test_engineer_make_engineer(self, ZenpyMock): client = Client() client.force_login(User.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']) - @patch('main.lib.extra_func.zenpy') + @patch('main.extra_func.zenpy') def test_control_page_make_one(self, ZenpyMock): self.admin_client.post( reverse_lazy('control'), @@ -131,7 +131,7 @@ class MakeEngineerTestCase(TestCase): self.assertEqual(len(call_list), 1) self.assertEqual(mock_object.custom_role_id, sets.ZENDESK_ROLES['engineer']) - @patch('main.lib.extra_func.zenpy') + @patch('main.extra_func.zenpy') def test_control_page_make_many(self, ZenpyMock): self.admin_client.post( reverse_lazy('control'), diff --git a/main/views.py b/main/views.py index 4355141..467e925 100644 --- a/main/views.py +++ b/main/views.py @@ -13,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 +from django.urls import reverse_lazy, reverse from django.views.generic import FormView from django_registration.views import RegistrationView # Django REST @@ -21,11 +21,12 @@ from rest_framework import viewsets from rest_framework.response import Response from access_controller.settings import DEFAULT_FROM_EMAIL, ZENDESK_ROLES, ZENDESK_MAX_AGENTS, ZENDESK_GROUPS -from main.lib.extra_func import check_user_exist, update_profile, get_user_organization, \ +from main.extra_func import check_user_exist, update_profile, get_user_organization, \ make_engineer, make_light_agent, get_users_list, update_users_in_model, count_users, \ - set_session_params_for_work_page, get_tickets_list_for_group -from main.lib.statistic_data import StatisticData -from main.lib.zendesk_admin import zenpy + log, set_session_params_for_work_page, get_tickets_list_for_group +from .statistic_data import StatisticData +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 .models import UserProfile diff --git a/main/zendesk_admin.py b/main/zendesk_admin.py new file mode 100644 index 0000000..2a689ce --- /dev/null +++ b/main/zendesk_admin.py @@ -0,0 +1,99 @@ +from typing import Optional, Dict +from zenpy import Zenpy +from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup +from zenpy.lib.exception import APIException +from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD, \ + ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL + + +class ZendeskAdmin: + """ + Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора. + + :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) + :type credentials: :class:`Dict[str, str]` + """ + + 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 + + def update_user(self, user: ZenpyUser) -> bool: + """ + Функция сохраняет изменение пользователя в Zendesk. + + :param user: Пользователь с изменёнными данными + """ + self.admin.users.update(user) + + def check_user(self, email: str) -> bool: + """ + Функция осуществляет проверку существования пользователя в Zendesk по email. + + :param email: Email пользователя + :return: Является ли зарегистрированным + """ + return True if self.admin.search(email, type='user') else False + + def get_user(self, email: str) -> ZenpyUser: + """ + Функция возвращает пользователя (объект) по его email. + + :param email: Email пользователя + :return: Объект пользователя, найденного в БД + """ + return self.admin.users.search(email).values[0] + + def get_group(self, name: str) -> Optional[ZenpyGroup]: + """ + Функция возвращает группу по названию + + :param name: Имя пользователя + :return: Группы пользователя (в случае отсутствия None) + """ + groups = self.admin.search(name, type='group') + for group in groups: + return group + return None + + def get_user_org(self, email: str) -> str: + """ + Функция возвращает организацию, к которой относится пользователь по его email. + + :param email: Email пользователя + :return: Организация пользователя + """ + user = self.admin.users.search(email).values[0] + return user.organization.name if user.organization else None + + def create_admin(self) -> Zenpy: + """ + Функция создает администратора, проверяя наличие вводимых данных в env. + + :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') + + if self.credentials.get('token') is None and self.credentials.get('password') is None: + raise ValueError('access_controller token or password not in env') + + admin = Zenpy(**self.credentials) + try: + admin.search(self.credentials['email'], type='user') + except APIException: + raise ValueError('invalid access_controller`s login data') + + return admin + + +zenpy = ZendeskAdmin({ + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, + 'email': ACTRL_API_EMAIL, + 'token': ACTRL_API_TOKEN, + 'password': ACTRL_API_PASSWORD, +}) From e886004069a26f9bda6666aa0bf62e28929d90f1 Mon Sep 17 00:00:00 2001 From: Iurii Tatishchev Date: Thu, 6 May 2021 09:07:21 -0700 Subject: [PATCH 11/16] Remove unnecessary files. --- main/lib/__init__.py | 0 main/lib/__pycache__/__init__.cpython-39.pyc | Bin 200 -> 0 bytes .../lib/__pycache__/extra_func.cpython-39.pyc | Bin 11882 -> 0 bytes .../__pycache__/statistic_data.cpython-39.pyc | Bin 11471 -> 0 bytes .../__pycache__/zendesk_admin.cpython-39.pyc | Bin 4804 -> 0 bytes main/lib/extra_func.py | 319 ------------------ main/lib/statistic_data.py | 261 -------------- main/lib/zendesk_admin.py | 99 ------ 8 files changed, 679 deletions(-) delete mode 100644 main/lib/__init__.py delete mode 100644 main/lib/__pycache__/__init__.cpython-39.pyc delete mode 100644 main/lib/__pycache__/extra_func.cpython-39.pyc delete mode 100644 main/lib/__pycache__/statistic_data.cpython-39.pyc delete mode 100644 main/lib/__pycache__/zendesk_admin.cpython-39.pyc delete mode 100644 main/lib/extra_func.py delete mode 100644 main/lib/statistic_data.py delete mode 100644 main/lib/zendesk_admin.py diff --git a/main/lib/__init__.py b/main/lib/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/main/lib/__pycache__/__init__.cpython-39.pyc b/main/lib/__pycache__/__init__.cpython-39.pyc deleted file mode 100644 index 70ee7f6ed0f60b941986434ab357ce214c10c586..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 200 zcmYe~<>g`k0z&ryk0@&Ee@O9 Q{FKt1R6CF}J_9iW0F?tVr2qf` diff --git a/main/lib/__pycache__/extra_func.cpython-39.pyc b/main/lib/__pycache__/extra_func.cpython-39.pyc deleted file mode 100644 index 506e57c529b5d7bf576e43a4ca5b8b21158116e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11882 zcmds7TWlLwdY%~$FQP71WLdr>*)YzPiY$5UTpY*9+VO3(>&S-XBn`1GLvcnDWnSbn zLnl_LZ5-LV$tJeZZGZw>Z(_Iml0FoQFB>IUJ3yZb6a~na0-c9_=|k(jv_K0KSfD_^ z?>{6(F;q6$Y+ovyXU=`jod5f;=VW?&;|BhA_PzJ2HEJ0D%tGs57=^v~dA~Ld!!aB) zZ>0FN@@BzGStj$Le5hcjY{}dCa3PY4NIsm87GkNGpBJn^Ah$+2Y)N&PYAt+~eGf=cDcx=UZsK&q<&)=?=S(Irm?- zoUP6SD372#EaeBChfvlcJ?RK7Xo^`&1+%t|jVI=q8A?3%DX3*v53a;bk{R|5B zi=M0S95^~T>t_7}CD$7-`3GOmc|K}m%5`!I^~`hFe5vRnA5OW&xiiT0>^t`S!Pm3y zoS!Qdla>nM9YN$oE_>4Dm<fWQx=7kM(({;I#IEmm(;Kdt?!x?H=IsQ#*YvwEv` zsd}^aW}@~+^>+2M+PjJB=Q>mU7|(0frP`b5^;zxGXk6ylJ(p3LLZbECb|ewxGR!hnW%PAmNlO2QUf4L5Sw=WH1{B|B@MfUOfDbvWy_vlDx~>w>6{bTUgoqL zgz}~7pqu^bU`TbB=bVi1$_&Y{T8}B!Ad?N8JPHIku6I&xLg_<8-G|3-N@J>A90Q-_ zGGp1y{QSJD#wI7HrktHqnQc$+dV1%!$Fq~!ZIe58Zr`@+=_jAu_0*0XJD-@^F{a$P zk~el(mCAGJ?K{$$Y}WO>ZP`-M$13xf1uHL(<#Ur`?(4qFq^HWo?C9JXELg9R-@*lY zeMpQh+w3zV_!l#KOm#nMk|Di*E?o^F$y!Jnf6UwX37+|hzELrJ(=j(26>~8Jj9S2G zsJv?pjMf%wXMw;a&|^txvrJ41V%Grb#U}L=z{0KSFNK^p0nnx28-$yP`f2SQ%_oW4 zxfR~3E+>YETefd_SRnQ}m&%YA$jI4auR<}5vE4r@QDyR%%jq*4o zCTS9cMGT@O&=S%RMFJ(2V2|EH=A@f$415Sxp3p6-9z=c>4Wp=_-WMAF8X3}X)||B# z%>}Ds9WefId?DnAX6@N9>nowz2!2t=JZY&Ner&d@Xd{g~)&cS9p6#iG(!Hnp z9Z0xzAIj_S>_63Cv6MpTuGxVr>noN>GW!SCg1r!~guS3*JK>Wd70hq)2Z5yxPNa#Y z=tB5q}HF zt7}%51_SjuC1k`Cbqe6#p>#N;AP0<~yJemecUYC&81z%->XBYL53MWmQEf)6TE|h* zl9(J&tXbtkyarazQID{>pUshpqeoskcp!+sIKFRU;`zhl2M>raPYvlMHs==IhP8D! z7L0`|a!6|3WO-@|N_|?SDZ0d(xp2lQjOYQW zSjP9=!kq5~QLijAIxt<$ufn+rbPoE3X6PzYsDsK!nkUk%936VXaLMn7@`2xG4Cy?fsgSH7(Eco6R%91IQYV-7MwjLHJvHu z<}Ld%O6(fhHnkC*J~TCxuGT>6 zF*N-EKdpCcDgO@NeF;E3^SWH*0Uz{uFtsO|);+ z_1YUy`kzCAztd@Rf-N04M?{7ORTn!*8U93E%MyaF4Ks=inQhrBmchVO?yGi;8a#v_ z+r8sReBh+%IhYl579InA2Wsjx*mpLfhM5*E2mR6!=87@ZSF`tIhwJK));d&bx~%f!$@mIq zwP?USptklQwjdXT=VnTNDG28ZP{!(RR5gr2Iu&jB3i|5CYTZAfDDirb7*VY;2FxK- zJ&!Ub5q&Fme!GlnB9F$RMqXVu{R=8OB1X1CDE@{<46NL>{U*1AmnG`!j5uJ)> zXWZ;bEeW4PMf;Zbue7IKMn}$|Q^N%R9`dUgn_Ip1_WlD}J8o|$(ceig)-JBOQ{Q}Z z-~XZ>riSiJOih`_CiXORWt!Tw(>88s?a@@_i!FQkedM8(3ELnx2b1o#l5)Q2sM1NL z8Wz!c2pI%aOdlbX%b^8pKH-~FMh+pCUzit724rTf3S3v(L#FYvQ5=NJ5jwNigpf4p zTG$hCC!8=8bENzqn5-jpI)GoZ0@Y0&b=PX=fx{cDsordrDmNR1QJI{FNXqVP9^bb6 z)zjj`|O+AleAW_0(f5!l0|XUFuN& z1PI?n2HFr{Nb^Y@NiAxr1XD&apd$f=bO>eJvC5{}<5RENi|otzTtqadj&k__2bz;8 zf^4LXEAc$BVrc2EsG!>UHQ9|cUFxh&>stj3eL!LOwW52iyNe0lYbry)Yz@Y57o;oF zxuZ(PJYRiPiVQst=W9hv9!8BKd6nqPP!91hs!4T}NgLJ%>-tf9hQ)NDgmCv2bktfh zMz3lJ-WEb)6R2G!x+z$i5iq{cF%iq4+hibevO|OkBVw?(5S@6mVmhIxj0N))Vk5Jx zx23+P0%3!C3-xBjnj)UU<+GSh_^pG>CHs5>>I`-*ICTj=5!f7hR4TN2wIo&-6&~~~ zgpBwB^Z{tnkfRaz75k+TCj!nAE$*l0bxW)ioovO5)G(7I63B7xuK2&;cDh4BS7Ra+MY|3b)h;n(C~`b!JoVeI#=1(U*@ zv2@{B^8#$)1q<^%1<-yuD2W^1SvrswkpXbF;0Mf#G9e%U>U*96Zr*2S6ghTU40C) zGAE?m&&7#c;t>d?D(8V0K;9GL)K5+tuf>3$0Vnh`f84Qq(>lFcheqoMVbK@~Ts4)u zypJqipHZ(}7EoK%j2aHYibq|Fu3=z1nKOu$W#;CvBt?~n)3Nx<7cs1?cPI#R*Sx?i zwgaS#4KnB7W4w-I#B9jIK~ueonpI%nE1Gc($_U6hvuvmVc&PAGuR`ki;6lxrc!n7< zMzD$TjZpdDzYYkn4IkBh0?L0%=K=N)=x`RuSV}a{DzSsuEl>yS1WO1IIp3r+#%SY= z8ugcA@5tseSaq|mW}#p)TJ$Ff2&!Xpyo0t;oluDJ(z4R0Y(T+?796YJDx}vo-ZBKS z1h1|oaAK5rbnTBgjx(8+Q_Cc^zc@(Obhkch}nermI?~0PpxHT9vD|M=R*Jur##&*1ZCCNVIpbe>j|!MT{eA z9G)qa6@sD&u9k_xsOQ2PcD!UH=++0Rh}#wrtYa!D4r~qyB6TqbSm zy_Ze|$IBu1KcR<4a#sxc1xU`J22sHf7)Cn|fMWBHsBY@_hfyG!87#eELVJfC`vSxn zQB0u+n-S$r)Ft8a-=VX7=NArUJIOWbbLwB zj&uMm*e{4PrV3-K=+Cs12Nn%wr8uMXL)62S5N$}DbA?!52p2c$ocC-?3wwksHr!V$ZQ%*E3*RLy zPn?j=hW&^W5{q*VIoO_Pk76d>qHWQ)bRV{`9?uw*w~i1Bvd6Ur$X!%hnTRbIS3E%u zLIrPH+u@8v1&EVSB{}KIGb^@Y5G&;!IYkd*MfWwy=^$1_o#rT9oyfwC4`L8?Ld6zC zhB5lKZ4Yd>kn{f=4IbBH)K9R<-ZUi}|7gX~q>P#KgY9(T1mnptDa;6MIvvE*^aIK` z=}!ke>GY{`Ca+tB$ZBErGDPGgST~2{Kk*YVu+Xqh=7%87Y~My2Ml0+-Cv?>Ui6U1(Cdyp& zgsf@iBK2I%Y1DPq?+NIT+n?ceqL!-fi%IsjXe%Bl(4m!pGEM}wS80WS zM`-$(Zdfyh&j8ADBI}*TH4ij?QvFP;O6>&5$?xvg8v3jCin=zH)>PLEucVJ0J$(4! z@%dip4sVRfDCP~!Okh7he&{H}`uU7BD0*R!ph~iGv}OjHreluLt!2YWkn_IkD=J8&z3Tolzje`4*#T4C2o$%?`>PbsJYbJqbtz{q;s~rlkCA z+4tnrf}VPH+0W%Yk)8Ssj4~#*9>K=BGo!e{wlbYWl2aR2S~7FFbp3irsw>3-48bUd ziBjECJ|TBfT4%#87Ja`|#nCO;qOZ=8DD|szqu4W>bZ;AUr-IV9R;xDn5;Tmz%#lsPsT6wIaiK5#37p&i8$6KQ43i7$eiI*pVBO}cI^r*T-PG{qUoTyaVH zpP_9nC_u_8N?J+wO9}*mQ||&n0i!^%RHgx%}7jf9IUpN*U6arjF(xrS>tj776- zE*fQH(JEUepIHri(J4D}Z8tKD*>YB{oyNpsuAGzWOk;8}U(PR1m8a!C+n8A_lnaYn z%3Bs^%d_%qqOo;xTX~yleA94q?&M{|om{rc+ui(0qcrs~`Zr2;=(yFu3v)aN^+hk7 zCp zh3}j=iDtJps(w&$tLH1N)0M?mGk7hWIpYPDxb<+7U8-vHjE6=GFSZ)q*I(n^cUous z8V8sB%i`vF9R7dclO45~rh59+n!)u7sLIEIlg;;;Mj z?w#&h?^5qlv3s+7r}s{=x7_=2cfI$+?phI#@q=SSt~R>2yBo#sM)w{bt@kc=Z*lAMu?uu#R%;-CJ|I&-bMEIyzjFHrBg$dsn;b zthgpE-Q@s!7kgKa75$*9h=#yBRP>r||7x*XZye&wU@k9>9y?c6)y1M)_s=z|=Z_V; zzr<)RcJH$LA_s=Cd?1Z~h>_gGl-?#9G>Y*Z@ahl7VPf52QoImFc znm<+pO8sN6HXzeilLM$X0nIn64UFJtX+aHlN3ltV==4VrYob#=N%T5sbJK1IVnWA1 z<)pEGnC2S0;1Z{nDA9JZV!w4tJ2k1k;FE3N2O92UhHPJpQ=}V#Ako91^5*SxIgb@j z@MjbF^xjD}smu4<^8=M0u2L=KW3a106tIhN-X;cqfVKjV$P!Rn@FF z&x|zpYr)RV-f~x8eF!)Uu)4Pr%@1mC+5x0p#aLG~<}u7m81;JhHua9`0X5VRg=(Qs zs21MddJr+`miryM^;#OL)E+Sj=zR#Qx~t!$(y28`v^Lt~d@Td(+yCRWh2!fu{8Ko& zM#re=v)M6mZFMZ1?T(GJ6J(b8J{$c`1Ub2%jDGVSV`Xa9FpZAm8f`0))9fRKtu%goO^#5N18WNSNy{COjWxDgLh-PR0`t z^ObtDF2<@FZqe8A%TgwEe6Mj@ZN+@mHk`tI8*Z@@^~8ej8|t%o@t^(?)ovbvFRfRP z)T&EMOP)G%>eT7e?vtmhho61s*(VQwxpt~{_|(xSA3yxevrj$s%+p7YKKYf?M~^7) zT+2W5P1S0jt2};l^3*Cvu3h^=#Ohn_9E*&hYPHbv1+KtI>v%| z0qebDb<9`C9L@j5sy~rGVMoXGT*Q!wpMyEQ*t;ffM-&I$pdBdXM&v}a0f0*>_AU?n4^8nV9%zg| zO`Gb2n9e1rJPN|v@(1^DOV>(s#d%OE_^PFLY;uH^#KlnkIgz+wFUJxG6p;01^>Umfkx! zgAc@#dQfqV0Tg8z_ZdUwCDrDdXrXh}v09`Nq*6C1ctRoVh`0!lpFi%QY=h z^|`(Ts7^+9shAeu=nA6ES+@BoPBp2-iug568$E6ahjf$)Y>EhdaTpmJbM^h=nAOJ}n|;!~8O>4@sG8%GZbE zs23xprMncyCyP$S0mB7#S%We_6+p^Z>R#_1;b2;QNIx&($rbXjfuanrabNF-+9S?5 z)fgLycCr%3_;eZP{q}bu@?noVWSqth{5OvE8!z>7#D(wcr+^?0rf}Maw)G|VZ4~w; z_?*a@v!(+TUD}&QSF$+fQ-~}=<#bCeR)gOs#J=@=g4lx^;513^u8jgLJy*c&K|+hM z3pj+7{{!Ty64o}%yw++fzx_Sp?sp#iaJ-^*CDNS%^0p+9_a$5pdxj}ODH7?+cs~rh zJ)^*jN{{#i+A;A7J8=Q0|KMGmoVER)`k0V>>aJi?Nx zo!}T5DbmI!4qqHuc9=Y*Bn`tSoa9lIPdPpY9>{!X(EKGXlA!UobwDQS#s!ek6>HTv zZv5cbE7k@30)mJIILNb>`YJ*UYoCGO#D30reZM@n@qB;B32fKeVImtiDqXScnDdOCJgiyq8pRNNXlG0M3N}stO?l0EyK-er_1nCuA;Qg z*rZJq;gO-l-mm+&2%_QNVz15d-b$%SJY#hT^RLf>vxy-pIl^KTZA6%<2i~Hu2-#9* zz$C+5(|c2hU}&H9&W8?*4QY%zfJ$%R@EJ%M69xDz2c9c$7I17E@m@(1n*szopTr?n zWCItQ0HP0ozl))Q4EZSr4MZKubCSa{?iuE3_Zjsh z@_?HI6Ah-bifM`mc8G3A5{M6mMYN0@=4j$_ZGg#xSm%#vGMU3Z?^RWp(P?HI>}<&{ z9xiII7BpCeK0S`&p}kmrOULgtLW)t1dP={jHzlT5WKp5B(5g2>8)>P~hRrVJ2Iy8# zu+mYM${^rsd*$u5wo+a*(5Y?$Uj^l@>4(TmKwX-y_tvl?Fj)UF4xf=&UlH=wthv|R zZ|isVKdijBrZCnP#ROh8k2*6?ZajKEs4SB({C)i~>@N5#_;fp+_! zQ7abnIAb>yg>4!g+#uk9RYH-L2F_(Ddzu8-W}YU>?n+L3!1&qJNXK)M0RwiB`aV?r zMz!I4@kBsvGJ0_(bwkmFcC+=S5)h2>RbjsNnpZn38y4@OwD#!P{al6JX?8OxII;vZ zO|k?RaiPtUuLCsrq9{N!ncZJH7FY}R0=-lQM>d$?zQ%QPmvP1la(FX|r+GJX9XlM? zt(7Sk1_mMB^!JSB0mndzne$5~;%g+(82*`VuHX9iji&WD!a*$VWcw$dW?tyShEp&R zEr4Z_5NiYB7V`Tj#ykd+{cuU}d4hMz>O=4P`8U4gUc_dG01BRp;$YIQ!um)mZ5%R~ z5hs*{(E$LkYdYz_9-9g<8X7wcbra(Qi2MzTNWx%zk!!+dBs z;t(ojYzV0h2lz!7YV1a0f7W%y@HPTmu{0n&0bP zL%srn!k!PJ_+_Nn*2gI1y!tZ6gwzEC4*YD#Psu88tI5us=t89;dv>A2yk|I-c+>tO z=kp%sGivq6NVQZ@&!d7Si!-Rz<$2$f?zHJaJrMuBBY0 zUQeADP99DlZ+ZTFD>(5MvU=kfx?(!?eH=bHJd;?Ak}br^JIuoWWNque^G;zzxd++c zlyY-GF$w(#+-T*d`mLC&hJQfR(2AAZq5?vNZPx}Lz;8fRx0jKV*$swZ85hU_%!N#l zb+Hu*R`3d1nqVFkZCVSt_<4ZmlN~S((*Q3?w6bi1>^^#dTn0J!Jw~F=71WvP*sSyV z6)^?bcJb3p0n;j?A2H7mt)c0c>?x9CP%>IzkXgyh-Q}$?5dvP!LU#?tQiKEu#7k7` z-Z_k>V8kWeG$0&(4QCz)%;mZ?FPs{h#H`OHjbGS-n`3Rn>hDTRiU-NKB_XW$3XAw_ zm$9OQcPz8pkXSPwkq!U`Z805-n9|2|VsC_+cBmflbIs8ya{W%`?6W}kgKsx89{uIyt1&6-_C&M|2 zU}@Ie{mHbWfrK2wBWVzgv4JB6l#a2s1&8F9S!|LaPM3zLsY5{t^L6o}>+0yy8;!#&IqK{_zW8S*Olz1C zw}pLwe1aC*2+;M;1_9Uqqi5m0SPZ5a1K0w11q&3a0Q*f6Wp->MBv5lt}E0X4!< zxHHFmlmu}SkYHRlg;O$(N)HF)B-4tAgIv7g?z0$)ey;H^_}~?ue!$byJPo0VX6N5= z__F>vF#I9Ml6Gq;mQQ*XI~ImFfQ9gTxDI~rK&CC3!@i4g?NuA!Ug~|;Dk{XoA2Hbw zfrMd^DB|S3CYpo9>;~EA4X!##vB;$V4IU}#z{U!hoG)edo_|I-q}JE?YZdL>$7DOy zA7iZCp4U=x@*_NLh?4SC98-}Vm-g|ilV)|%t5opu3^HJDyTR+}O6B!-wGq9Us8rlm z4bkHxsH1lA#I#iCoNBciI!8w%t|(=7m?!F}X1;U0I>6KCd3v5F26KvxQ;|oACr~sT zVs=6syP|sToLB-90*)e|6>uV+d^Vgjo!$A_LZOhGotd54Hi_S@v%3rV+}6U*Tp_n5 zm)kidHRek@l+EgrHzK6MEIum4w`DTkaId?7k62rC^0`j^)LgY4yfz1scnx1HW;h)c zXa{vXBDli6`V*hZ;1i!YY_A3UUQH6~ipg#DCoH<1-~P`v#8x-L=pYTN4kF2}>S{GiJU6U^zj|j( zi)AE8*lt@`=&JirRj37NUwCSoKnY0*5B&#bUgxz>eX9D}_MyLX$9C*|&{egz=6cSZ zGxwZ(&iS2luFdZ5go4jMx<9+}o0OvbjfnP71jK1vb{&JISgfEFS(X*mtjdU13!$Qx z)#QDs5H3ct(PAtcm$X($6uYur@;+Qh7Q3_E#hz?Wu{YaWOl4EWzHDEyKikigbBYzQ zqIVQ4x}atU_yF(bJ-nBvtk^?sfn`Hhd{Rj#{tgY4wCcwuDo(y!G7G*omd`n9=7+Pq zRJnmi+O*AuA3i6_)e7d5XD*!||9Or}{&d9OcjoNW<%>pkJTo>vdBK>R{=wM9kItOW z_`Sgcup8r;xY#~1HF04)?~RWcQ|HfK z7@wL9mdcUV?Vk{c)41$kV&F=aSxQ#5uuHD-Fb~hF2Vm8Q%nDiB9W5K-Q696x4^?R) zEgJ_<40chixRt>31n8)wyR0PWF3?HdjWxQ#(_{5wu7}o@JSnRWG+DME5(cb6P<_@O zSa+|Vgb^)nUpr$J^QG!drYH#=ANTj}n!D)T_HOG(jt zYSTWaB3hasy4P@>#bv*Zq0Z`xq12T}>@h||#a+nJEOtP7%;v(FiC`w`#2QrGQs8nm z<40zyv$I^J6QUQZ_(=#f9d4Ku0)9C7iC^W8ag`7ylEkFuEx$K-FPJtk5hJE5z$dzi zXRlo@T<4bIv$NKT zS##*r$x|nW-p!B+8^?|tW-iBVdni{f zIig%BV3neoFO3xPGb6PivV(mMS8n(*!^oHNj$!mc13QL6VG)*K{j9d9J;sNde55&T z&yNW1R7Gi8)}xKc-}dS~NN$tleF{l0 z2?qqur!u4)WJ~i*IwWY1ewbFa{YbT9A!gF5*pFH9RT{{z!Elf`y8;#&1^X~z%UF)o z_IH@NjS1Ecb9k>Hz6SPfwr+(!NepaVXBM;6PgF5zg(ekCgT=$uea#%UgT-O*j^ssl z4D8;PeUPXYwj{a0^5hV*`~P-`Wp+YN zgTE;C+(JYwx02!tXiB7%nxr3FYe_#nVZKI5Y3heh)WD|^o0}r{AxP=WH(}qc&P?P! z@Z~1YHs`f~GT3rn0F=29?U5;Z zSKA@EQz7~_tg`COrvqn56K#CiKKa9@oL$v9N(FAweIaWeTmt2msp_Fhe(HYK+Oa^~ zDrrr+M0_3h;+q)!$aS+&<+gZ>W;!7z_G0cZt^iumEm^FYoGFWy%p%_oD}q!DR^I^8 z39FlGT^&eH~5XP|JN~2TS z#nNb#q_ZhKefijfI?F-)G#jul5-JjBnRH|e&;$X=PnN}1vy`uybk-2$SQNg^thiI1 zOEz(4;N9=x4VxgPLzz%~z4^(4MD3(!cIZ+@sgQd4pkmpmqruOFerJw}nMDq_E* zTA>9tkFHP7YmNES4LaPU!51MuCp4?%WyE~W?z5Y$w!hBiG~%Y5YvBO#3668`E2V>) z;)HL^Ghm!4csovS|5z!h$IxRUniAC)uZbpVW8eU49Apdgnl>HL)K+NG=A)WYZbT)c zzMO!#BvbuU5bNGu@1wScgoq`bm#(*$RAJ_A@Eh z5Cj3xfLi`mr%y)$5{uJ}tf#U#H%=s^%CjJAHzc>^n@+jyN6!CK@-7gfHcvL1B{5Zli?nRGv$3#i%{hMzEu zV%e$|=ssx}KdqXD#*?UFSmm5y2+GEO2q!l26}pQ9;*}fIbV$%<%H@L4F)O}B165~1 zp!O4zNuF5*)dDd@gWMqL-nJiE;HiBUJs9G+Z2Ui?#F!S{8%f1d@l<>;nM%l~Y0Zz@ z)fuZyCm!izF-&icV92Bg{Rq0eeCeuGAy7M9BW0yve~-K$#zAS=tmKXI%pA`-wg}S- z@6tg30f|!>{DDnY-uz!hI>kS@&9}ogcN|Ey1*I}UI}r3=ljx#>j*{}MC^xXvzWvyD a0}p(kQlm{aOu>hA=~~aU3ZKgFfBp+R{3Hed diff --git a/main/lib/extra_func.py b/main/lib/extra_func.py deleted file mode 100644 index ad251cd..0000000 --- a/main/lib/extra_func.py +++ /dev/null @@ -1,319 +0,0 @@ -import logging -from datetime import timedelta - -from django.contrib.auth.models import User -from django.core.exceptions import ObjectDoesNotExist -from django.shortcuts import redirect -from django.utils import timezone -from zenpy import Zenpy -from zenpy.lib.exception import APIException -from zenpy.lib.api_objects import User as ZenpyUser, Ticket as ZenpyTicket -from zenpy.lib.generator import SearchResultGenerator - -from access_controller.settings import ZENDESK_ROLES as ROLES, ACTRL_ZENDESK_SUBDOMAIN -from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus -from main.requester import TicketListRequester -from main.lib.zendesk_admin import zenpy - - -def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None: - """ - Функция меняет роль пользователя. - - :param user_profile: Профиль пользователя - :param role: Новая роль - :param who_changes: Пользователь, меняющий роль - :return: Пользователь с обновленной ролью - """ - zendesk = zenpy - user = zendesk.get_user(user_profile.user.email) - user.custom_role_id = role - user_profile.custom_role_id = role - user_profile.save() - log(user_profile, who_changes.userprofile) - zendesk.update_user(user) - - -def make_engineer(user_profile: UserProfile, who_changes: User) -> None: - """ - Функция устанавливает пользователю роль инженера. - - :param user_profile: Профиль пользователя - :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" - """ - update_role(user_profile, ROLES['engineer'], who_changes) - - -def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: - """ - Функция устанавливает пользователю роль легкого агента. - - :param user_profile: Профиль пользователя - :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" - """ - tickets: SearchResultGenerator = get_tickets_list(user_profile.user.email) - ticket: ZenpyTicket - for ticket in tickets: - UnassignedTicket.objects.create( - assignee=user_profile.user, - ticket_id=ticket.id, - status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED - ) - if ticket.status == 'solved': - ticket.assignee_id = zenpy.solved_tickets_user_id - else: - ticket.assignee = None - ticket.group_id = zenpy.buffer_group_id - if tickets: - zenpy.admin.tickets.update(tickets) - attempts, success = 20, False - while not success and attempts != 0: - try: - update_role(user_profile, ROLES['light_agent'], who_changes) - success = True - except APIException as e: - attempts -= 1 - if attempts == 0: - raise e - - -def get_users_list() -> list: - """ - Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. - """ - zendesk = zenpy - - # У пользователей должна быть организация SYSTEM - org = next(zendesk.admin.search(type='organization', name='SYSTEM')) - users = zendesk.admin.organizations.users(org) - return users - - -def get_tickets_list(email): - """ - Функция возвращает список тикетов пользователя Zendesk - """ - return TicketListRequester().get_tickets_list_for_user(zenpy.get_user(email)) - - -def get_tickets_list_for_group(group_name): - """ - Функция возвращает список неназначенных, нерешённых тикетов группы Zendesk - """ - return TicketListRequester().get_tickets_list_for_group(zenpy.get_group(group_name)) - - -def update_profile(user_profile: UserProfile): - """ - Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. - - :param user_profile: Профиль пользователя - :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя - """ - user = zenpy.get_user(user_profile.user.email) - user_profile.name = user.name - user_profile.role = user.role - user_profile.custom_role_id = user.custom_role_id if user.custom_role_id else 0 - user_profile.image = user.photo['content_url'] if user.photo else None - user_profile.save() - - -def check_user_exist(email: str) -> bool: - """ - Функция проверяет, существует ли пользователь. - - :param email: Email пользователя - :return: Зарегистрирован ли пользователь в Zendesk - """ - return zenpy.check_user(email) - - -def get_user_organization(email: str) -> str: - """ - Функция возвращает организацию пользователя. - - :param email: Email пользователя - :return: Организация пользователя - """ - return zenpy.get_user_org(email) - - -def check_user_auth(email: str, password: str) -> bool: - """ - Функция проверяет, верны ли входные данные. - - :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован - """ - creds = { - 'email': email, - 'password': password, - 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, - } - try: - user = Zenpy(**creds) - user.search(email, type='user') - except APIException: - return False - return True - - -def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): - """ - Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. - - :param profile: Профиль пользователя - :param zendesk_user: Данные пользователя в Zendesk - :return: Обновленный профиль пользователя - """ - profile.name = zendesk_user.name - profile.role = zendesk_user.role - profile.image = zendesk_user.photo['content_url'] if zendesk_user.photo else None - if zendesk_user.custom_role_id is not None: - profile.custom_role_id = int(zendesk_user.custom_role_id) - profile.save() - - -def count_users(users) -> tuple: - """ - Функция подсчета количества сотрудников с ролями engineer и light_agent - """ - engineers, light_agents = 0, 0 - for user in users: - if user.custom_role_id == ROLES['engineer']: - engineers += 1 - elif user.custom_role_id == ROLES['light_agent']: - light_agents += 1 - return engineers, light_agents - - -def update_users_in_model(): - """ - Обновляет пользователей в модели UserProfile по списку пользователей в организации - """ - users = get_users_list() - for user in users: - try: - profile = User.objects.get(email=user.email).userprofile - update_user_in_model(profile, user) - except ObjectDoesNotExist: - pass - return users - - -def daterange(start_date, end_date) -> list: - """ - Функция возвращает список дней с start_date по end_date, исключая правую границу. - - :param start_date: Начальная дата - :param end_date: Конечная дата - :return: Список дней, не включая конечную дату - """ - dates = [] - for n in range(int((end_date - start_date).days)): - dates.append(start_date + timedelta(n)) - return dates - - -def get_timedelta(log, time=None) -> timedelta: - """ - Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, - который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. - - :param log: Лог - :param time: Время - :return: Сколько времени прошло от начала суток до события - """ - if time is None: - time = log.change_time.time() - time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) - return time - - -def last_day_of_month(day: int) -> int: - """ - Функция возвращает последний день текущего месяца. - - :param day: Текущий день - :return: Последний день месяца - """ - next_month = day.replace(day=28) + timedelta(days=4) - return next_month - timedelta(days=next_month.day) - - -class DatabaseHandler(logging.Handler): - def __init__(self): - logging.Handler.__init__(self) - - def emit(self, record): - database = RoleChangeLogs() - users = record.msg - if users[1]: - user = users[0] - admin = users[1] - elif not users[1]: - user = users[0] - admin = users[0] - database.name = user.name - database.user = user.user - database.changed_by = admin.user - if user.custom_role_id == ROLES['engineer']: - database.old_role = ROLES['light_agent'] - elif user.custom_role_id == ROLES['light_agent']: - database.old_role = ROLES['engineer'] - database.new_role = user.custom_role_id - database.save() - - -class CsvFormatter(logging.Formatter): - def __init__(self): - logging.Formatter.__init__(self) - - def format(self, record): - users = record.msg - if users[1]: - user = users[0] - admin = users[1] - elif not users[1]: - user = users[0] - admin = users[0] - msg = '' - msg += user.name - if user.custom_role_id == ROLES['engineer']: - msg += ',engineer,' - elif user.custom_role_id == ROLES['light_agent']: - msg += ',light_agent,' - time = str(timezone.now().today()) - msg += time[:16] - msg += ',' - msg += admin.name - return msg - - -def log(user, admin=None): - """ - Осуществляет запись логов в базу данных и csv файл - :param admin: - :param user: - :return: - """ - users = [user, admin] - logger = logging.getLogger('MY_LOGGER') - if not logger.hasHandlers(): - dbhandler = DatabaseHandler() - csvformatter = CsvFormatter() - csvhandler = logging.FileHandler('logs/logs.csv', "a") - csvhandler.setFormatter(csvformatter) - logger.addHandler(dbhandler) - logger.addHandler(csvhandler) - logger.setLevel('INFO') - logger.info(users) - - -def set_session_params_for_work_page(request, count=None, is_confirm=True): - """ - Функция для страницы получения прав - Устанавливает данные сессии о успешности запроса и количестве назначенных тикетов - """ - request.session['is_confirm'] = is_confirm - request.session['count_tickets'] = count - return redirect('work', request.user.id) diff --git a/main/lib/statistic_data.py b/main/lib/statistic_data.py deleted file mode 100644 index 2e1061d..0000000 --- a/main/lib/statistic_data.py +++ /dev/null @@ -1,261 +0,0 @@ -from datetime import date, datetime, timedelta -from typing import Optional - -from django.contrib.auth.models import User -from django.utils import timezone - -from access_controller.settings import ONE_DAY, ZENDESK_ROLES as ROLES -from main.lib.extra_func import last_day_of_month, get_timedelta, daterange -from main.models import RoleChangeLogs - - -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) diff --git a/main/lib/zendesk_admin.py b/main/lib/zendesk_admin.py deleted file mode 100644 index 2a689ce..0000000 --- a/main/lib/zendesk_admin.py +++ /dev/null @@ -1,99 +0,0 @@ -from typing import Optional, Dict -from zenpy import Zenpy -from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup -from zenpy.lib.exception import APIException -from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD, \ - ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL - - -class ZendeskAdmin: - """ - Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора. - - :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) - :type credentials: :class:`Dict[str, str]` - """ - - 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 - - def update_user(self, user: ZenpyUser) -> bool: - """ - Функция сохраняет изменение пользователя в Zendesk. - - :param user: Пользователь с изменёнными данными - """ - self.admin.users.update(user) - - def check_user(self, email: str) -> bool: - """ - Функция осуществляет проверку существования пользователя в Zendesk по email. - - :param email: Email пользователя - :return: Является ли зарегистрированным - """ - return True if self.admin.search(email, type='user') else False - - def get_user(self, email: str) -> ZenpyUser: - """ - Функция возвращает пользователя (объект) по его email. - - :param email: Email пользователя - :return: Объект пользователя, найденного в БД - """ - return self.admin.users.search(email).values[0] - - def get_group(self, name: str) -> Optional[ZenpyGroup]: - """ - Функция возвращает группу по названию - - :param name: Имя пользователя - :return: Группы пользователя (в случае отсутствия None) - """ - groups = self.admin.search(name, type='group') - for group in groups: - return group - return None - - def get_user_org(self, email: str) -> str: - """ - Функция возвращает организацию, к которой относится пользователь по его email. - - :param email: Email пользователя - :return: Организация пользователя - """ - user = self.admin.users.search(email).values[0] - return user.organization.name if user.organization else None - - def create_admin(self) -> Zenpy: - """ - Функция создает администратора, проверяя наличие вводимых данных в env. - - :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') - - if self.credentials.get('token') is None and self.credentials.get('password') is None: - raise ValueError('access_controller token or password not in env') - - admin = Zenpy(**self.credentials) - try: - admin.search(self.credentials['email'], type='user') - except APIException: - raise ValueError('invalid access_controller`s login data') - - return admin - - -zenpy = ZendeskAdmin({ - 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, - 'email': ACTRL_API_EMAIL, - 'token': ACTRL_API_TOKEN, - 'password': ACTRL_API_PASSWORD, -}) From 63949dd54e79c21c2d73edd9790fac18accab834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B5=D0=B2=20=D0=94=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 10 May 2021 15:05:34 +0000 Subject: [PATCH 12/16] Fix/header --- data.json | 5 +++-- main/templates/base/menu.html | 34 ++++++++++++++++++++-------------- main/views.py | 31 ++++++------------------------- 3 files changed, 29 insertions(+), 41 deletions(-) diff --git a/data.json b/data.json index a4310a4..8b2e666 100644 --- a/data.json +++ b/data.json @@ -22,8 +22,9 @@ "pk": 1, "fields": { "name": "ZendeskAdmin", - "user": 1, - "role": "admin" + "user": 3, + "role": "admin", + "user_id": 1 } }, { diff --git a/main/templates/base/menu.html b/main/templates/base/menu.html index ef5df18..17c5f81 100644 --- a/main/templates/base/menu.html +++ b/main/templates/base/menu.html @@ -3,56 +3,62 @@ -
-
- - -
+
+ {% csrf_token %} + {{ get_tickets_form.count_tickets }} + +
{% for message in messages %} diff --git a/main/tests.py b/main/tests.py index 5f71955..59df965 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,5 +1,5 @@ import random -from unittest.mock import patch +from unittest.mock import patch, Mock from django.contrib.auth.models import User from django.core import mail @@ -43,7 +43,7 @@ class RegistrationTestCase(TestCase): self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) self.assertEqual(response.status_code, 302) self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].to, [self.zendesk_admin_email]) + self.assertEqual(mail.outbox[0].to, [self.any_zendesk_user_email]) # context that the email template was rendered with email_context = response.context[0].dicts[1] @@ -262,3 +262,93 @@ class PasswordChangeTestCase(TestCase): } ) self.assertContains(resp, 'Введённый пароль слишком похож на имя пользователя', count=1, status_code=200) + + +class GetTicketsTestCase(TestCase): + """ + Класс тестов для проверки функции получения тикетов. + """ + fixtures = ['fixtures/test_make_engineer.json'] + + def setUp(self): + """ + Предустановленные значения для проведения тестов. + """ + 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.light_agent_client = Client() + self.light_agent_client.force_login(User.objects.get(email=self.light_agent)) + + @patch('main.views.zenpy.get_user') + @patch('main.extra_func.zenpy') + def test_redirect(self, ZenpyMock, GetUserMock): + """ + Функция проверки переадресации пользователя на рабочую страницу. + """ + GetUserMock.return_value = Mock() + user = User.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) + + @patch('main.views.zenpy') + @patch('main.views.get_tickets_list_for_group') + def test_take_one_ticket(self, TicketsMock, ZenpyViewsMock): + """ + Функция проверки назначения одного тикета на engineer. + """ + TicketsMock.return_value = [Mock()] + ZenpyViewsMock.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) + + @patch('main.views.get_tickets_list_for_group') + @patch('main.views.zenpy') + def test_take_many_tickets(self, ZenpyMock, TicketsMock): + """ + Функция проверки назначения нескольких тикетов на engineer. + """ + TicketsMock.return_value = [Mock()] * 3 + ZenpyMock.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 + for ticket in tickets[0][0]: + self.assertEqual(ticket.assignee, ZenpyMock.get_user.return_value) + + @patch('main.views.zenpy.get_user') + @patch('main.views.zenpy') + def test_light_agent_take_ticket(self, ZenpyMock, GetUserMock): + """ + Функция проверки попытки назначения тикета на light_agent. + """ + GetUserMock.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 + self.assertIsNone(tickets) + + @patch('main.views.zenpy') + @patch('main.views.get_tickets_list_for_group') + def test_take_zero_tickets(self, TicketsMock, ZenpyMock): + """ + Функция проверки попытки назначения нуля тикета на engineer. + """ + TicketsMock.return_value = [Mock()] * 3 + ZenpyMock.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] + self.assertListEqual(tickets, []) + + @patch('main.views.get_tickets_list_for_group') + @patch('main.views.zenpy') + def test_take_invalid_count_tickets(self, ZenpyMock, TicketsMock, ): + """ + Функция проверки попытки назначения нуля тикетов на engineer. + """ + TicketsMock.return_value = [Mock()] * 3 + ZenpyMock.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 + self.assertIsNone(tickets) diff --git a/main/views.py b/main/views.py index 22978c2..4602bcf 100644 --- a/main/views.py +++ b/main/views.py @@ -1,19 +1,18 @@ from smtplib import SMTPException -from typing import Dict, Any from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.models import User, Permission from django.contrib.auth.tokens import default_token_generator -from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.views import LoginView from django.contrib.contenttypes.models import ContentType from django.contrib.messages.views import SuccessMessageMixin from django.core.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 @@ -23,13 +22,13 @@ 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 -from main.zendesk_admin import zenpy -from main.requester import TicketListRequester -from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm + set_session_params_for_work_page, get_tickets_list_for_group +from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm, \ + WorkGetTicketsForm from main.serializers import ProfileSerializer, ZendeskUserSerializer +from main.zendesk_admin import zenpy from .models import UserProfile +from .statistic_data import StatisticData class CustomRegistrationView(RegistrationView): @@ -178,6 +177,7 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse: 'messages': messages.get_messages(request), 'licences_remaining': max(0, ZENDESK_MAX_AGENTS - len(engineers)), 'pagename': 'Управление правами', + 'get_tickets_form': WorkGetTicketsForm() } return render(request, 'pages/work.html', context) return redirect("login") @@ -211,21 +211,18 @@ def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: @login_required() def work_get_tickets(request): zenpy_user = zenpy.get_user(request.user.email) - if zenpy_user.role == 'admin' or zenpy_user.custom_role_id == ZENDESK_ROLES['engineer']: - tickets = get_tickets_list_for_group(ZENDESK_GROUPS['buffer']) - assigned_tickets = [] - count = 0 - for i in range(len(tickets)): - if i == int(request.GET.get('count_tickets')): - if assigned_tickets: - zenpy.admin.tickets.update(assigned_tickets) - return set_session_params_for_work_page(request, count) - tickets[i].assignee = zenpy_user - assigned_tickets.append(tickets[i]) - count += 1 - if assigned_tickets: - zenpy.admin.tickets.update(assigned_tickets) - return set_session_params_for_work_page(request, count) + + if request.method == 'POST': + if zenpy_user.role == 'admin' or zenpy_user.custom_role_id == ZENDESK_ROLES['engineer']: + form = WorkGetTicketsForm(request.POST) + if form.is_valid(): + tickets = get_tickets_list_for_group(ZENDESK_GROUPS['buffer']) + assigned_tickets = [] + for i in range(min(form.cleaned_data['count_tickets'], len(tickets))): + tickets[i].assignee = zenpy_user + assigned_tickets.append(tickets[i]) + zenpy.update_tickets(assigned_tickets) + return set_session_params_for_work_page(request, len(assigned_tickets)) return set_session_params_for_work_page(request, is_confirm=False) diff --git a/main/zendesk_admin.py b/main/zendesk_admin.py index 2a689ce..627d900 100644 --- a/main/zendesk_admin.py +++ b/main/zendesk_admin.py @@ -1,7 +1,9 @@ -from typing import Optional, Dict +from typing import Optional, Dict, List + from zenpy import Zenpy -from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup +from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup, Ticket as ZenpyTicket from zenpy.lib.exception import APIException + from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD, \ ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL @@ -28,6 +30,15 @@ class ZendeskAdmin: """ self.admin.users.update(user) + def update_tickets(self, tickets: List[ZenpyTicket]): + """ + Функция сохраняет изменение тикетов в Zendesk. + + :param tickets: Тикеты с изменёнными данными + """ + if tickets: + self.admin.tickets.update(tickets) + def check_user(self, email: str) -> bool: """ Функция осуществляет проверку существования пользователя в Zendesk по email. From 74f8b1ce1fbad9c334e2a276b2d69b816869dcaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=BE=D0=BA=D1=83=D1=80=D0=BE=D0=B2=20=D0=98=D0=B4?= =?UTF-8?q?=D0=B0=D1=80?= Date: Thu, 13 May 2021 04:57:51 +0000 Subject: [PATCH 16/16] Add profile tests --- fixtures/profile.json | 59 ++++++++++++++++++++++++++++++++++++++++++ main/tests.py | 60 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 fixtures/profile.json diff --git a/fixtures/profile.json b/fixtures/profile.json new file mode 100644 index 0000000..8ce02db --- /dev/null +++ b/fixtures/profile.json @@ -0,0 +1,59 @@ +[ + { + "model": "auth.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$216000$gHBBCr1jBELf$ZkEDW3IEd8Wij7u8vkv+0Eze32CS01bcaYWhcD9OIC4=", + "last_login": null, + "is_superuser": true, + "username": "idar.sokurov.05@mail.ru", + "first_name": "", + "last_name": "", + "email": "idar.sokurov.05@mail.ru", + "is_staff": true, + "is_active": true, + "date_joined": "2021-03-10T16:38:56.303Z", + "groups": [], + "user_permissions": [ + 33 + ] + } + }, + { + "model": "main.userprofile", + "pk": 1, + "fields": { + "name": "ZendeskAdmin", + "user": 1, + "role": "admin" + } + }, + { + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$216000$5qLJgrm2Quq9$KDBNNymVZXkUx0HKBPFst2m83kLe0egPBnkW7KnkORU=", + "last_login": null, + "is_superuser": false, + "username": "krav-88@mail.ru", + "first_name": "", + "last_name": "", + "email": "krav-88@mail.ru", + "is_staff": false, + "is_active": true, + "date_joined": "2021-03-10T16:38:56.303Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "main.userprofile", + "pk": 2, + "fields": { + "name": "UserForAccessTest", + "user": 2, + "role": "agent", + "custom_role_id": "360005209000" + } + } +] diff --git a/main/tests.py b/main/tests.py index 59df965..b086488 100644 --- a/main/tests.py +++ b/main/tests.py @@ -352,3 +352,63 @@ class GetTicketsTestCase(TestCase): self.client.post(reverse('work_get_tickets'), data={'count_tickets': 'asd'}) tickets = ZenpyMock.update_tickets.call_args self.assertIsNone(tickets) + + +class ProfileTestCase(TestCase): + """ + Класс тестов для проверки синхронизации профиля пользователя. + """ + fixtures = ['fixtures/profile.json'] + + def setUp(self): + """ + Предустановленные значения для проведения тестов. + """ + 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.admin_client = Client() + self.admin_client.force_login(User.objects.get(email=self.zendesk_admin_email)) + + def test_correct_username(self): + """ + Функция проверки синхронизации имени пользователя. + """ + 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): + """ + Функция проверки синхронизации почты пользователя. + """ + 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): + """ + Функция проверки синхронизации роли пользователя. + """ + 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): + """ + Функция проверки синхронизации рабочей роли пользователя. + """ + resp = self.client.get(reverse('profile')) + user = zenpy.get_user(self.zendesk_agent_email) + self.assertEqual(resp.context['profile'].custom_role_id, user.custom_role_id if user.custom_role_id else 0) + resp = self.admin_client.get(reverse('profile')) + 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): + """ + Функция проверки синхронизации изображения пользователя. + """ + resp = self.client.get(reverse('profile')) + user = zenpy.get_user(self.zendesk_agent_email) + self.assertEqual(resp.context['profile'].image, user.photo['content_url'] if user.photo else None)