diff --git a/access_controller/settings.py b/access_controller/settings.py index a26931d..36cc858 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -136,7 +136,6 @@ ACCOUNT_ACTIVATION_DAYS = 7 LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' - # Название_приложения.Название_файла.Название_класса_обработчика AUTHENTICATION_BACKENDS = [ 'access_controller.auth.EmailAuthBackend', @@ -192,3 +191,5 @@ REST_FRAMEWORK = { 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' ] } + +ONE_DAY = 12 # Количество часов в 1 рабочем дне diff --git a/access_controller/urls.py b/access_controller/urls.py index 92edfe1..e174717 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -16,8 +16,10 @@ Including another URLconf from django.contrib import admin from django.contrib.auth import views as auth_views from django.urls import path, include -from main.views import work_page, work_hand_over, work_become_engineer, AdminPageView + from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView +from main.views import work_page, work_hand_over, work_become_engineer, \ + AdminPageView, statistic_page from main.urls import router @@ -26,7 +28,7 @@ urlpatterns = [ path('', main_page, name='index'), path('accounts/profile/', profile_page, name='profile'), path('accounts/register/', CustomRegistrationView.as_view(), name='registration'), - path('accounts/login/', CustomLoginView.as_view(extra_context={}), name='login', ), # TODO add extra context + path('accounts/login/', CustomLoginView.as_view(), name='login'), path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('django_registration.backends.one_step.urls')), path('work/', work_page, name="work"), @@ -34,8 +36,9 @@ urlpatterns = [ path('work/become_engineer/', work_become_engineer, name="work_become_engineer"), path('accounts/', include('django_registration.backends.activation.urls')), path('accounts/login/', include('django.contrib.auth.urls')), - path('control/', AdminPageView.as_view(), name='control') -] + path('control/', AdminPageView.as_view(), name='control'), + path('statistic/', statistic_page, name='statistic') + ] urlpatterns += [ path( diff --git a/main/extra_func.py b/main/extra_func.py index 8ad1b80..58e53a0 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,14 +1,15 @@ import os +from datetime import timedelta, datetime, date from django.contrib.auth.models import User from zenpy import Zenpy from zenpy.lib.exception import APIException -from main.models import UserProfile +from main.models import UserProfile, RoleChangeLogs from django.core.exceptions import ObjectDoesNotExist -from access_controller.settings import ZENDESK_ROLES +from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY class ZendeskAdmin: @@ -75,7 +76,7 @@ class ZendeskAdmin: user = self.admin.users.search(email).values[0] return user.photo['content_url'] if user.photo else None - def get_user(self, email: str) -> str: + def get_user(self, email: str): """ Функция **get_user** возвращает пользователя (объект) по его email @@ -89,7 +90,7 @@ class ZendeskAdmin: Функция **get_user_org** возвращает организацию, к которой относится пользователь по его email """ user = self.admin.users.search(email).values[0] - return user.organization.name + return user.organization.name if user.organization else None def create_admin(self) -> Zenpy: """ @@ -103,7 +104,7 @@ class ZendeskAdmin: if self.email is None: raise ValueError('access_controller email not in env') - self.credentials['email'] = os.getenv('ACCESS_CONTROLLER_API_EMAIL') + self.credentials['email'] = self.email if self.token: self.credentials['token'] = self.token @@ -132,14 +133,14 @@ def make_engineer(user_profile: UserProfile) -> UserProfile: """ Функция **make_engineer** устанавливапет пользователю роль инженера. """ - update_role(user_profile, ZENDESK_ROLES['engineer']) + update_role(user_profile, ROLES['engineer']) def make_light_agent(user_profile: UserProfile) -> UserProfile: """ Функция **make_light_agent** устанавливапет пользователю роль легкого агента. """ - update_role(user_profile, ZENDESK_ROLES['light_agent']) + update_role(user_profile, ROLES['light_agent']) def get_users_list() -> list: @@ -161,6 +162,7 @@ def update_profile(user_profile: UserProfile) -> UserProfile: user = ZendeskAdmin().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() @@ -214,9 +216,9 @@ def count_users(users) -> tuple: """ engineers, light_agents = 0, 0 for user in users: - if user.custom_role_id == ZENDESK_ROLES['engineer']: + if user.custom_role_id == ROLES['engineer']: engineers += 1 - elif user.custom_role_id == ZENDESK_ROLES['light_agent']: + elif user.custom_role_id == ROLES['light_agent']: light_agents += 1 return engineers, light_agents @@ -233,3 +235,211 @@ def update_users_in_model(): except ObjectDoesNotExist: pass return users + + +def daterange(start_date, end_date) -> list: + """ + Возвращает список дней с start_date по end_date исключая правую границу + """ + 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), если введён + """ + 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): + """ + Возвращает последний день любого месяца + """ + next_month = day.replace(day=28) + timedelta(days=4) + return next_month - timedelta(days=next_month.day) + + +class StatisticData: + 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._set_data() + if stat is None: + self._set_statistic() + else: + self.statistic = stat + + def get_statistic(self): + """ + Вернуть словарь 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): + """ + Были ли ошибки при создании статистики + """ + return not self.errors and self.statistic + + def set_interval(self, interval): + """ + Устанавливает интервал работы + """ + if interval not in ['months', 'days']: + self.errors += ['Интервал работы должен быть в днях или месяцах'] + return False + self.interval = interval + return True + + def set_display(self, display_format): + """ + Устанавливает формат отображения + """ + if display_format not in ['days', 'hours']: + self.errors += ['Формат отображения должен быть в часах или днях'] + return False + self.display = display_format + return True + + def get_data(self): + """ + Вернуть данные + data - массив объектов RoleChangeLogs, является списком логов пользователя + data может быть пустым списком + """ + if self.is_valid_data(): + return self.data + else: + return None + + def is_valid_data(self): + """ + Были ли ошибки при получении логов + """ + return not self.errors + + def _use_display(self, stat): + """ + Приводит данные к формату отображения + """ + 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): + """ + Объединяет ключи и значения в соответствии с интервалом работы + """ + 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): + """ + Проверка на правильность введенного времени + """ + if self.end_date < self.start_date or self.end_date > datetime.now().date(): + return False + return True + + def _set_data(self): + """ + Получение логов в диапазоне дат start_date-end_date для пользователя с почтой email + """ + 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 _set_statistic(self): + """ + Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд + """ + 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.fill_daterange(self.start_date, first_log.change_time.date()) + self.statistic[first_log.change_time.date()] += get_timedelta(first_log).total_seconds() + + if last_log.new_role == ROLES['engineer']: + self.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date + timedelta(days=1)) + self.statistic[last_log.change_time.date()] += (timedelta(days=1) - get_timedelta(last_log)).total_seconds() + if self.end_date == datetime.now().date(): + self.statistic[self.end_date] = get_timedelta(None, datetime.now().time()).total_seconds() + + for log_index in range(len(self.data) - 1): + if self.data[log_index].new_role == ROLES['engineer']: + current_log, next_log = self.data[log_index], self.data[log_index + 1] + if current_log.change_time.date() != next_log.change_time.date(): + self.statistic[current_log.change_time.date()] += ( + timedelta(days=1) - get_timedelta(current_log)).total_seconds() + self.statistic[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() + self.fill_daterange(current_log.change_time.date() + timedelta(days=1), next_log.change_time.date()) + else: + elapsed_time = next_log.change_time - current_log.change_time + self.statistic[current_log.change_time.date()] += elapsed_time.total_seconds() + + def fill_daterange(self, first, last, val=24 * 3600): + """ + Заполение диапазона дат значением val + по умолчанию val = кол-во секунд в 1 дне + """ + for day in daterange(first, last): + self.statistic[day] = val + + def clear_statistic(self): + """ + Обнуление всех дней + """ + self.statistic.clear() + self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) diff --git a/main/forms.py b/main/forms.py index 32ed00b..a8c2ec1 100644 --- a/main/forms.py +++ b/main/forms.py @@ -2,6 +2,7 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm from django_registration.forms import RegistrationFormUniqueEmail +from access_controller.settings import ZENDESK_ROLES from main.models import UserProfile @@ -14,7 +15,6 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): :param visible_fields.email: Поле для ввода email, зарегистирированного на Zendesk :type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail` """ - def __init__(self, *args, **kwargs) -> RegistrationFormUniqueEmail: super().__init__(*args, **kwargs) for visible in self.visible_fields(): @@ -23,7 +23,7 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): visible.field.widget.attrs['class'] += 'form-control' else: visible.field.widget.attrs['class'] = 'form-control' - if visible.html_name !='email': + if visible.html_name != 'email': visible.field.required = False class Meta(RegistrationFormUniqueEmail.Meta): @@ -42,7 +42,8 @@ class AdminPageUsers(forms.Form): queryset=UserProfile.objects.filter(role='agent'), widget=forms.CheckboxSelectMultiple( attrs={ - 'class': 'form-check-input' + 'class': 'form-check-input', + } ), label='' @@ -65,3 +66,31 @@ class CustomAuthenticationForm(AuthenticationForm): , 'inactive': "Аккаунт не активен.", } + + +class StatisticForm(forms.Form): + email = forms.EmailField( + label='Электроная почта', + ) + interval = forms.CharField( # TODO: Переделать под html страницу + label='Интервал работы', + ) + display_format = forms.CharField( # TODO: Переделать под html страницу + label='Формат отображения', + ) + range_start = forms.DateField( # TODO: Переделать под html страницу + label='Начало диапазона', + widget=forms.DateInput( + attrs={ + 'type': 'date', + } + ), + ) + range_end = forms.DateField( # TODO: Переделать под html страницу + label='Конец диапазона', + widget=forms.DateInput( + attrs={ + 'type': 'date', + } + ), + ) diff --git a/main/migrations/0009_models_help_text.py b/main/migrations/0009_models_help_text.py new file mode 100644 index 0000000..4bc87e1 --- /dev/null +++ b/main/migrations/0009_models_help_text.py @@ -0,0 +1,61 @@ +# Generated by Django 3.1.6 on 2021-03-11 08:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('main', '0008_auto_20210303_2305'), + ] + + operations = [ + migrations.AlterField( + model_name='rolechangelogs', + name='change_time', + field=models.DateTimeField(help_text='Дата и время изменения роли'), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='changed_by', + field=models.ForeignKey(help_text='Кем была изменена роль', on_delete=django.db.models.deletion.CASCADE, related_name='changed_by', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='name', + field=models.TextField(help_text='Имя пользователя'), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='new_role', + field=models.TextField(help_text='Присвоенная роль'), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='user', + field=models.ForeignKey(help_text='Пользователь, которому присвоили другую роль', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='userprofile', + name='image', + field=models.URLField(blank=True, help_text='Аватарка', null=True), + ), + migrations.AlterField( + model_name='userprofile', + name='name', + field=models.CharField(default='None', help_text='Имя пользователя на нашем сайте', max_length=100), + ), + migrations.AlterField( + model_name='userprofile', + name='role', + field=models.CharField(default='None', help_text='Код роли пользователя', max_length=100), + ), + migrations.AlterField( + model_name='userprofile', + name='user', + field=models.OneToOneField(help_text='Пользователь', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/main/migrations/0010_userprofile_meta.py b/main/migrations/0010_userprofile_meta.py new file mode 100644 index 0000000..28fa435 --- /dev/null +++ b/main/migrations/0010_userprofile_meta.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.6 on 2021-03-11 08:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0009_models_help_text'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userprofile', + options={'permissions': (('has_control_access', 'Can view admin page'),)}, + ), + ] diff --git a/main/migrations/0011_auto_20210311_1734.py b/main/migrations/0011_auto_20210311_1734.py new file mode 100644 index 0000000..c228bfc --- /dev/null +++ b/main/migrations/0011_auto_20210311_1734.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.6 on 2021-03-11 14:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0010_userprofile_meta'), + ] + + operations = [ + migrations.AddField( + model_name='rolechangelogs', + name='old_role', + field=models.IntegerField(default=0, help_text='Старая роль'), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='new_role', + field=models.IntegerField(default=0, help_text='Присвоенная роль'), + ), + migrations.AlterField( + model_name='userprofile', + name='role', + field=models.IntegerField(default=0, help_text='Код роли пользователя'), + ), + ] diff --git a/main/migrations/0012_auto_20210312_1225.py b/main/migrations/0012_auto_20210312_1225.py new file mode 100644 index 0000000..6d33580 --- /dev/null +++ b/main/migrations/0012_auto_20210312_1225.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.6 on 2021-03-12 09:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0011_auto_20210311_1734'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='custom_role_id', + field=models.IntegerField(default=0, help_text='Код роли пользователя'), + ), + migrations.AlterField( + model_name='userprofile', + name='role', + field=models.CharField(default='None', help_text='Глобальное имя роли пользователя', max_length=100), + ), + ] diff --git a/main/models.py b/main/models.py index f8f8385..112e29e 100644 --- a/main/models.py +++ b/main/models.py @@ -7,8 +7,14 @@ from django.dispatch import receiver class UserProfile(models.Model): """Модель профиля пользователя""" + class Meta: + permissions = ( + ('has_control_access', 'Can view admin page'), + ) + user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') - role = models.CharField(default='None', max_length=100, help_text='Код роли пользователя') + role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя') + custom_role_id = models.IntegerField(default=0, help_text='Код роли пользователя') image = models.URLField(null=True, blank=True, help_text='Аватарка') name = models.CharField(default='None', max_length=100, help_text='Имя пользователя на нашем сайте') @@ -26,8 +32,11 @@ def save_user_profile(sender, instance, **kwargs): class RoleChangeLogs(models.Model): """Модель для логирования изменений ролей пользователя""" - user = models.ForeignKey(to=User, on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль') + user = models.ForeignKey(to=User, on_delete=models.CASCADE, + help_text='Пользователь, которому присвоили другую роль') name = models.TextField(help_text='Имя пользователя') - new_role = models.TextField(help_text='Присвоенная роль') + old_role = models.IntegerField(default=0, help_text='Старая роль') + new_role = models.IntegerField(default=0, help_text='Присвоенная роль') change_time = models.DateTimeField(help_text='Дата и время изменения роли') - changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') + changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', + help_text='Кем была изменена роль') diff --git a/main/templates/base/menu.html b/main/templates/base/menu.html index f64ceb3..92aaec1 100644 --- a/main/templates/base/menu.html +++ b/main/templates/base/menu.html @@ -3,16 +3,18 @@ -