From 298baa31981bbec9369a3880347fc8650f73485b Mon Sep 17 00:00:00 2001 From: Dmitriy Andreev Date: Wed, 10 Feb 2021 21:18:38 +0300 Subject: [PATCH 001/195] Added site logo --- access_controller/settings.py | 5 +++-- main/templates/base/base.html | 1 + main/templates/base/menu.html | 5 +++-- staticfiles/main/img/logo.png | Bin 0 -> 53835 bytes 4 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 staticfiles/main/img/logo.png diff --git a/access_controller/settings.py b/access_controller/settings.py index dc44189..caf4521 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -9,7 +9,7 @@ https://docs.djangoproject.com/en/3.1/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/3.1/ref/settings/ """ - +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -121,8 +121,9 @@ USE_TZ = True # https://docs.djangoproject.com/en/3.1/howto/static-files/ STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'static') STATICFILES_DIRS = [ - 'static' + os.path.join(BASE_DIR, 'staticfiles'), ] MEDIA_ROOT = BASE_DIR / 'media' diff --git a/main/templates/base/base.html b/main/templates/base/base.html index e41acf5..071c914 100644 --- a/main/templates/base/base.html +++ b/main/templates/base/base.html @@ -1,5 +1,6 @@ +{% load static %} diff --git a/main/templates/base/menu.html b/main/templates/base/menu.html index ca31bd0..4aabc46 100644 --- a/main/templates/base/menu.html +++ b/main/templates/base/menu.html @@ -1,10 +1,11 @@ +{% load static %}
-
diff --git a/main/views.py b/main/views.py index 8dbfe9d..d9f8fd9 100644 --- a/main/views.py +++ b/main/views.py @@ -31,7 +31,6 @@ class CustomRegistrationView(RegistrationView): password=form.data['password1'] ) profile = UserProfile( - image='None.png', user=user, role=0, ) @@ -65,15 +64,13 @@ def profile_page(request): :return: объект ответа сервера с HTML-кодом внутри """ if request.user.is_authenticated: - # UP = UserProfile.objects.get(user=request.user) - UP = UserProfile.objects.get(user=request.user) - # else: # TODO: Убрать после появления регистрации и авторизации, добавить login_required() - # UP = UserProfile.objects.get(user=1) + user_profile = request.user.userprofile + context = { - 'name': set_and_get_name(UP), - 'email': set_and_get_email(UP), - 'role': set_and_get_role(UP), - 'image_name': load_and_get_image(UP), + 'name': set_and_get_name(user_profile), + 'email': set_and_get_email(user_profile), + 'role': set_and_get_role(user_profile), + 'image_name': load_and_get_image(user_profile), 'pagename': 'Страница профиля' } return render(request, 'pages/profile.html', context) From 7e779733c235dbda77cb6f29b6e691227a2ee5cf Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 11 Feb 2021 21:04:24 +0300 Subject: [PATCH 008/195] Mode imgs from `staticfiles` to `static` folder --- {staticfiles => static}/main/img/logo.png | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename {staticfiles => static}/main/img/logo.png (100%) diff --git a/staticfiles/main/img/logo.png b/static/main/img/logo.png similarity index 100% rename from staticfiles/main/img/logo.png rename to static/main/img/logo.png From d91f5b8ba832288220d9a823053cb1c2f716f4b6 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Thu, 11 Feb 2021 21:26:02 +0300 Subject: [PATCH 009/195] Add media to staticfiles, update `profile.html` --- access_controller/settings.py | 2 +- access_controller/urls.py | 3 -- main/templates/pages/profile.html | 51 +++++++++++++++++-------------- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index 6d913ab..eecfa19 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -119,6 +119,7 @@ STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'staticroot') STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'static'), + os.path.join(BASE_DIR, 'media'), ] MEDIA_ROOT = BASE_DIR / 'media' @@ -126,4 +127,3 @@ MEDIA_URL = '/media/' LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' - diff --git a/access_controller/urls.py b/access_controller/urls.py index 6504a02..b2603b2 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -31,6 +31,3 @@ urlpatterns = [ path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('django_registration.backends.one_step.urls')), ] - -if DEBUG: - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/main/templates/pages/profile.html b/main/templates/pages/profile.html index 0b855b4..2b917eb 100644 --- a/main/templates/pages/profile.html +++ b/main/templates/pages/profile.html @@ -10,37 +10,42 @@ {% block extra_css %} - + + + + {% endblock %} {% block content %} -
-
-
-
- {% if image_name %} - Аватар - {% else %} - Нет изображения - {% endif %} -
-
-
-
-

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

-

Электронная почта {{email}}

-

Текущая роль {{role}}

-
- -
-
+
+
+
+
+ {% if image_name %} + Аватар + {% else %} + Нет изображения + {% endif %}
+
+
Имя пользователя {{name}}
+
+
Электронная почта {{email}}
+
+
Текущая роль {{role}}
+
+
+
+
+ +
+
{% endblock %} From c216b44b2ae7d1723588c682f44671b890fe64a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 16 Feb 2021 12:28:08 +0300 Subject: [PATCH 010/195] Make models documentation --- docs/source/code.rst | 11 ++++++++--- docs/source/index.rst | 1 + main/extra_func.py | 19 ++++++++++++++----- main/models.py | 12 ++++++++++++ requirements.txt | 2 +- 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/source/code.rst b/docs/source/code.rst index 1c8af9f..3c660f7 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -1,6 +1,11 @@ -***** -TODOs -***** + +Models +------ + +.. automodule:: main.models + :members: + + Extra Functions --------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index 778a091..2fcbe88 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,6 +15,7 @@ Welcome to ZenDesk Access Controller's documentation! todo.rst + Indices and tables ================== diff --git a/main/extra_func.py b/main/extra_func.py index 305e897..3f679c8 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -6,7 +6,7 @@ from zenpy.lib.exception import APIException from main.models import UserProfile -# Дополнительные функции + def set_and_get_name(user_profile: UserProfile): """ Функция устанавливает поле :class:`username` текущим именем в Zendesk @@ -22,10 +22,13 @@ def set_and_get_name(user_profile: UserProfile): return user_profile.user.username -def set_and_get_email(user_profile: UserProfile): # TODO: Переделать с получением данных через API +def set_and_get_email(user_profile: UserProfile): """ Функция устанавливает поле :class:`user.email` текущей почтой в Zendesk + .. TODO:: + Переделать с получением данных через API + :param UP: Объект профиля пользователя :type UP: :class:`main.models.UserProfile` :return: Почта пользователя @@ -34,10 +37,13 @@ def set_and_get_email(user_profile: UserProfile): # TODO: Переделать return user_profile.user.email -def set_and_get_role(user_profile: UserProfile): # TODO: Переделать с получением данных через API +def set_and_get_role(user_profile: UserProfile): """ Функция устанавливает поле :class:`role` текущей ролью в Zendesk + .. TODO:: + Переделать с получением данных через API + :param UP: Объект профиля пользователя :type UP: :class:`main.models.UserProfile` :return: Роль пользователя @@ -46,10 +52,13 @@ def set_and_get_role(user_profile: UserProfile): # TODO: Переделать return user_profile.role -def load_and_get_image(user_profile: UserProfile): # TODO: Переделать с получением изображения через API +def load_and_get_image(user_profile: UserProfile): """ Функция загружает и устанавливает изображение в поле :class:`image` + .. TODO:: + Переделать с получением изображения через API + :param UP: Объект профиля пользователя :type UP: :class:`main.models.UserProfile` :return: Название изображения @@ -88,7 +97,7 @@ def check_user_auth(email: str, password: str) -> bool: :param password: Пароль пользователя :type password: :class:`str` :return: True, если входные данные верны, иначе False - :raise :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован + :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован :rtype: :class:`bool` """ try: diff --git a/main/models.py b/main/models.py index 96d04db..53b404d 100644 --- a/main/models.py +++ b/main/models.py @@ -5,6 +5,18 @@ from django.db import models class UserProfile(models.Model): + """ + Модель профиля пользователя + + :param user: OneToOneField к модели :class:`django.contrib.auth.models.User` + :param role: Код роли пользователя + :type role: :class:`integer` + :param image: Аватарка + :type image: :class:`img` + :param name: Имя пользователя на нашем сайте + :type name: :class:`str` + """ + user = models.OneToOneField(to=User, on_delete=models.CASCADE) role = models.IntegerField() image = models.ImageField(upload_to='user_avatars') diff --git a/requirements.txt b/requirements.txt index e40b267..b2e4c64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ Django==3.1.6 Pillow==8.1.0 zenpy~=2.0.24 -django-registration==3.1.1 +django_registration==3.1.1 # Documentation From 6edb0d2c17bcd7a899e9649c74489826533cb024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 16 Feb 2021 15:41:55 +0300 Subject: [PATCH 011/195] Add new context --- docs/source/code.rst | 11 ++++++----- docs/source/index.rst | 13 +++++++++++-- docs/source/todo.rst | 1 + main/extra_func.py | 8 ++++---- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/source/code.rst b/docs/source/code.rst index 3c660f7..2d0af4e 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -1,21 +1,22 @@ - +****** Models ------- +****** .. automodule:: main.models :members: - +*************** Extra Functions ---------------- +*************** .. automodule:: main.extra_func :members: +***** Views ------ +***** .. automodule:: main.views :members: diff --git a/docs/source/index.rst b/docs/source/index.rst index 2fcbe88..4cbdce9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,13 +6,22 @@ Welcome to ZenDesk Access Controller's documentation! ===================================================== +Структура документации: +----------------------- + +Документация пользователя +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Документация разработчика +~~~~~~~~~~~~~~~~~~~~~~~~~ + .. toctree:: :maxdepth: 2 :caption: Contents: - code.rst - todo.rst + code + todo diff --git a/docs/source/todo.rst b/docs/source/todo.rst index d6ad446..64946f9 100644 --- a/docs/source/todo.rst +++ b/docs/source/todo.rst @@ -3,3 +3,4 @@ TODOs ***** .. todolist:: + diff --git a/main/extra_func.py b/main/extra_func.py index 3f679c8..f44ff2a 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -12,7 +12,7 @@ def set_and_get_name(user_profile: UserProfile): Функция устанавливает поле :class:`username` текущим именем в Zendesk .. TODO:: - Переделать с получением данных через API + Переделать функцию set_and_get_name с получением данных через API :param UP: Объект профиля пользователя :type UP: :class:`main.models.UserProfile` @@ -27,7 +27,7 @@ def set_and_get_email(user_profile: UserProfile): Функция устанавливает поле :class:`user.email` текущей почтой в Zendesk .. TODO:: - Переделать с получением данных через API + Переделать функцию set_and_get_email с получением данных через API :param UP: Объект профиля пользователя :type UP: :class:`main.models.UserProfile` @@ -42,7 +42,7 @@ def set_and_get_role(user_profile: UserProfile): Функция устанавливает поле :class:`role` текущей ролью в Zendesk .. TODO:: - Переделать с получением данных через API + Переделать функцию set_and_get_role с получением данных через API :param UP: Объект профиля пользователя :type UP: :class:`main.models.UserProfile` @@ -57,7 +57,7 @@ def load_and_get_image(user_profile: UserProfile): Функция загружает и устанавливает изображение в поле :class:`image` .. TODO:: - Переделать с получением изображения через API + Переделать функцию load_and_get_image с получением изображения через API :param UP: Объект профиля пользователя :type UP: :class:`main.models.UserProfile` From 21ab4bf6cca684306810d638f26922f7f759d57c Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Tue, 16 Feb 2021 22:32:29 +0300 Subject: [PATCH 012/195] Complete registration, small update profile.html --- main/extra_func.py | 120 +++++++++++++++--------------- main/models.py | 19 ++++- main/templates/pages/profile.html | 8 +- main/views.py | 28 +++---- 4 files changed, 90 insertions(+), 85 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 305e897..73c533b 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -7,55 +7,66 @@ from main.models import UserProfile # Дополнительные функции -def set_and_get_name(user_profile: UserProfile): +class ZendeskAdmin: + # Класс существует, чтобы в каждой фунциии отдельно не проверять аккаунт администратора + credentials = { + 'subdomain': 'ngenix1612197338' + } + email = os.getenv('ACCESS_CONTROLLER_API_EMAIL') + token = os.getenv('ACCESS_CONTROLLER_API_TOKEN') + password = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') + + def __init__(self): + self.create_admin() + + def check_user(self, email: str) -> bool: + return True if self.admin.search(email, type='user') else False + + def get_user_name(self, email: str) -> str: + user = self.admin.users.search(email).values[0] + return user.name + + def get_user_role(self, email: str) -> str: + user = self.admin.users.search(email).values[0] + return user.role + + def get_user_id(self, email: str) -> str: + user = self.admin.users.search(email).values[0] + return user.id + + def get_user_image(self, email: str) -> str: + user = self.admin.users.search(email).values[0] + return user.photo['content_url'] if user.photo else None + + def create_admin(self) -> None: + if self.email is None: + raise ValueError('access_controller email not in env') + self.credentials['email'] = os.getenv('ACCESS_CONTROLLER_API_EMAIL') + + if self.token: + self.credentials['token'] = self.token + elif self.password: + self.credentials['password'] = self.password + else: + raise ValueError('access_controller token or password not in env') + self.admin = Zenpy(**self.credentials) + try: + self.admin.search(self.email, type='user') + except APIException: + raise ValueError('invalid access_controller`s login data') + + +def update_profile(user_profile: UserProfile): """ - Функция устанавливает поле :class:`username` текущим именем в Zendesk + Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk - .. TODO:: - Переделать с получением данных через API - - :param UP: Объект профиля пользователя - :type UP: :class:`main.models.UserProfile` - :return: Имя пользователя - :rtype: :class:`str` + :param user_profile: Объект профиля пользователя + :type user_profile: :class:`main.models.UserProfile` """ - return user_profile.user.username - - -def set_and_get_email(user_profile: UserProfile): # TODO: Переделать с получением данных через API - """ - Функция устанавливает поле :class:`user.email` текущей почтой в Zendesk - - :param UP: Объект профиля пользователя - :type UP: :class:`main.models.UserProfile` - :return: Почта пользователя - :rtype: :class:`str` - """ - return user_profile.user.email - - -def set_and_get_role(user_profile: UserProfile): # TODO: Переделать с получением данных через API - """ - Функция устанавливает поле :class:`role` текущей ролью в Zendesk - - :param UP: Объект профиля пользователя - :type UP: :class:`main.models.UserProfile` - :return: Роль пользователя - :rtype: :class:`str` - """ - return user_profile.role - - -def load_and_get_image(user_profile: UserProfile): # TODO: Переделать с получением изображения через API - """ - Функция загружает и устанавливает изображение в поле :class:`image` - - :param UP: Объект профиля пользователя - :type UP: :class:`main.models.UserProfile` - :return: Название изображения - :rtype: :class:`str` - """ - return user_profile.image.name + user_profile.name = ZendeskAdmin().get_user_name(user_profile.user.email) + user_profile.role = ZendeskAdmin().get_user_role(user_profile.user.email) + user_profile.image = ZendeskAdmin().get_user_image(user_profile.user.email) + user_profile.save() def check_user_exist(email: str) -> bool: @@ -67,16 +78,7 @@ def check_user_exist(email: str) -> bool: :return: True, если существует, иначе False :rtype: :class:`bool` """ - admin_creds = { - 'email': os.environ.get('Admin_email'), - 'subdomain': 'ngenix1612197338', - 'token': os.environ.get('Oauth_token'), - } - admin = Zenpy(**admin_creds) - zenpy_user = admin.search(email, type='user') - if zenpy_user: - return True - return False + return ZendeskAdmin().check_user(email) def check_user_auth(email: str, password: str) -> bool: @@ -91,12 +93,12 @@ def check_user_auth(email: str, password: str) -> bool: :raise :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован :rtype: :class:`bool` """ - try: - creds = { + creds = { 'email': email, - 'subdomain': 'ngenix1612197338', 'password': password, + 'subdomain': 'ngenix1612197338', } + try: user = Zenpy(**creds) user.search(email, type='user') except APIException: diff --git a/main/models.py b/main/models.py index 96d04db..58ae492 100644 --- a/main/models.py +++ b/main/models.py @@ -1,11 +1,22 @@ -import os - from django.contrib.auth.models import User from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver class UserProfile(models.Model): user = models.OneToOneField(to=User, on_delete=models.CASCADE) - role = models.IntegerField() - image = models.ImageField(upload_to='user_avatars') + role = models.CharField(default='None', max_length=100) + image = models.URLField(null=True, blank=True) name = models.CharField(default='None', max_length=100) + + +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + if created: + UserProfile.objects.create(user=instance) + + +@receiver(post_save, sender=User) +def save_user_profile(sender, instance, **kwargs): + instance.userprofile.save() diff --git a/main/templates/pages/profile.html b/main/templates/pages/profile.html index 2b917eb..bfd8cd7 100644 --- a/main/templates/pages/profile.html +++ b/main/templates/pages/profile.html @@ -14,8 +14,8 @@ .img{ width:auto; height:auto; - max-width:300px!important; - max-height:500px!important; + max-width:100px!important; + max-height:100px!important; } @@ -28,8 +28,8 @@
- {% if image_name %} - Аватар + {% if image_url %} + Аватар {% else %} Нет изображения {% endif %} diff --git a/main/views.py b/main/views.py index d9f8fd9..797ea8b 100644 --- a/main/views.py +++ b/main/views.py @@ -1,8 +1,7 @@ from django.shortcuts import render from django.urls import reverse_lazy -from main.extra_func import set_and_get_name, set_and_get_email, load_and_get_image, set_and_get_role, check_user_exist, \ - check_user_auth +from main.extra_func import check_user_exist, check_user_auth, update_profile from main.models import UserProfile from django.contrib.auth.models import User @@ -24,21 +23,15 @@ class CustomRegistrationView(RegistrationView): is_allowed = True def register(self, form): + self.is_allowed = True if check_user_exist(form.data['email']) and check_user_auth(form.data['email'], form.data['password_zen']): user = User.objects.create_user( username=form.data['username'], email=form.data['email'], password=form.data['password1'] ) - profile = UserProfile( - user=user, - role=0, - ) - set_and_get_name(profile) - set_and_get_email(profile) - set_and_get_role(profile) - load_and_get_image(profile) - profile.save() + profile = user.userprofile + update_profile(profile) else: self.is_allowed = False @@ -58,19 +51,18 @@ def profile_page(request): """ Отображение страницы профиля - :param request: объект с деталями запроса :type request: :class:`django.http.HttpResponse` :return: объект ответа сервера с HTML-кодом внутри """ - if request.user.is_authenticated: - user_profile = request.user.userprofile + user_profile = request.user.userprofile + update_profile(user_profile) context = { - 'name': set_and_get_name(user_profile), - 'email': set_and_get_email(user_profile), - 'role': set_and_get_role(user_profile), - 'image_name': load_and_get_image(user_profile), + 'email': user_profile.user.email, + 'name': user_profile.name, + 'role': user_profile.role, + 'image_url': user_profile.image, 'pagename': 'Страница профиля' } return render(request, 'pages/profile.html', context) From 023eb69c0a7f2958ca94979208b98c7cbbb7f9df Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Tue, 16 Feb 2021 22:33:54 +0300 Subject: [PATCH 013/195] add models migration --- main/migrations/0003_auto_20210216_2222.py | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 main/migrations/0003_auto_20210216_2222.py diff --git a/main/migrations/0003_auto_20210216_2222.py b/main/migrations/0003_auto_20210216_2222.py new file mode 100644 index 0000000..33076ac --- /dev/null +++ b/main/migrations/0003_auto_20210216_2222.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.6 on 2021-02-16 19:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0002_userprofile_name'), + ] + + operations = [ + migrations.AlterField( + model_name='userprofile', + name='image', + field=models.URLField(blank=True, null=True), + ), + migrations.AlterField( + model_name='userprofile', + name='role', + field=models.CharField(default='None', max_length=100), + ), + ] From 7d240651f638db48fa7a17d377fd5e8a338d2561 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 17 Feb 2021 15:47:45 +0300 Subject: [PATCH 014/195] mvp ready --- main/templates/pages/work.html | 74 ++++++++++++++++++++ static/main/css/work.css | 121 +++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 main/templates/pages/work.html create mode 100644 static/main/css/work.css diff --git a/main/templates/pages/work.html b/main/templates/pages/work.html new file mode 100644 index 0000000..bd760cb --- /dev/null +++ b/main/templates/pages/work.html @@ -0,0 +1,74 @@ +{% extends 'base/base.html' %} + +{% load static %} + +{% block title %}{{ pagename }}{% endblock %} + +{% block heading %}Управление{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+

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

+
+ +
+
+
Список сотрудников с правами инженера
+ + + + + + + + + + + + + + + + + + + + + + +
IDemailExpiration DateName(link to profile)
1big_boss123@example.ru19:30 18.02.21Иван Иванов
2gachi_cool456@example.ru21:00 18.02.21Пётр Петров
+
+
+
+
+
+
+
инженеров:
+
+
+ 13 +
+
+
+
легких агентов:
+
+
+ 22 +
+ +
+
+
+
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/static/main/css/work.css b/static/main/css/work.css new file mode 100644 index 0000000..751eed4 --- /dev/null +++ b/static/main/css/work.css @@ -0,0 +1,121 @@ +/* .all { + display: flex; + justify-content: start; +} */ + +/* .menu { + position: absolute; + top: 0; + left: 0; + display: inline-flex; + width: 150px; + height: 100vh; + background: #45729C; +} */ + +.page-title { + margin: auto; + display: block; + text-align: center; +} + +.page-description { + display: block; + margin: auto; + font-size: 1rem; + text-align: center; +} + +.new-section { + margin-top: 50px; +} + +.table-title { + text-align: center; + margin: auto; + font-size: 1.2rem; + font-weight: bolder; +} + +.container { + width: calc(100% - 150px); + /* margin-left: calc(150px); + margin-right: calc((100vw - 150px) / 2); */ +} + +.light-table { + font-weight: bold; + margin: auto; + margin-top: 10px; + font-size: 1.2rem; + width: 100%; +} + +.light-table th { + background: #515A63; + color: white; + padding: 10px; +} + +.light-table td { + padding: 5px; + padding-left: 10px; +} + +.light-table tr:nth-child(odd) { + background: #93A2AF; +} + +.info-row { + margin: 5px auto; + height: 53px; +} + +.info-target { + width: 200px; + display: inline-block; + font-size: 1.2rem; +} + +.info-quantity { + max-width: 200px; + display: inline-block; +} + + +.light-green { + background: #83DC87; +} + +.light-yellow { + background: #F3EB3C; +} + +.info-quantity-value { + font-weight: bold; +} + + +.status-circle-small { + margin: auto; + display: inline-block; + border-radius: 4px; + width: 8px; + height: 8px; +} + +.request-acess-button, +.hand-over-acess-button { + font-size: 1.2rem; + width: 100%; +} + +.default-button { + border: none; + display: block; + margin: 5px auto; + text-align: center; + padding: 10px; + background: #3B91D4; + color: white; +} \ No newline at end of file From 7e8bcb96e75502f85215612f840ee74dc5419dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D0=BB=D0=B0=D0=BA=D0=BE=D0=B2=20=D0=AE=D1=80?= =?UTF-8?q?=D0=B8=D0=B9?= Date: Wed, 17 Feb 2021 20:30:57 +0300 Subject: [PATCH 015/195] Added a model for logging user role changes --- main/migrations/0004_rolechangelogs.py | 27 ++++++++++++++++++++++++++ main/models.py | 22 +++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 main/migrations/0004_rolechangelogs.py diff --git a/main/migrations/0004_rolechangelogs.py b/main/migrations/0004_rolechangelogs.py new file mode 100644 index 0000000..c3d6328 --- /dev/null +++ b/main/migrations/0004_rolechangelogs.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.6 on 2021-02-17 17:25 + +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', '0003_auto_20210216_2222'), + ] + + operations = [ + migrations.CreateModel( + name='RoleChangeLogs', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField()), + ('new_role', models.TextField()), + ('change_time', models.DateTimeField()), + ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changed_by', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/main/models.py b/main/models.py index 58ae492..753d6c8 100644 --- a/main/models.py +++ b/main/models.py @@ -20,3 +20,25 @@ def create_user_profile(sender, instance, created, **kwargs): @receiver(post_save, sender=User) def save_user_profile(sender, instance, **kwargs): instance.userprofile.save() + + +class RoleChangeLogs(models.Model): + """ + Модель для логирования изменений ролей пользователя + + :param user: Пользователь, которому присвоили другую роль, + ForeignKey к модели :class:`django.contrib.auth.models.User` + :param name: Имя пользователя + :type name: :class:`str` + :param new_role: Присвоенная роль + :type new_role: :class:`str` + :param change_time: Дата изменения роли` + :type change_time: :class:`datetime.datetime` + :param changed_by: Кем была изменена роль, + ForeignKey к модели :class:`django.contrib.auth.models.User` + """ + user = models.ForeignKey(to=User, on_delete=models.CASCADE) + name = models.TextField() + new_role = models.TextField() + change_time = models.DateTimeField() + changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by') From bf69c870cdd88d2da042fe68d49f1cfceababcb2 Mon Sep 17 00:00:00 2001 From: Andrey Kovalev Date: Wed, 17 Feb 2021 20:43:57 +0300 Subject: [PATCH 016/195] Add rights management backend --- access_controller/urls.py | 5 +++- main/templates/pages/work.html | 31 ++++++++++++++++++++ main/views.py | 53 +++++++++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 main/templates/pages/work.html diff --git a/access_controller/urls.py b/access_controller/urls.py index b2603b2..bf79ff6 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -20,7 +20,7 @@ from django.urls import path, include from access_controller import settings from access_controller.settings import DEBUG -from main.views import main_page, profile_page, CustomRegistrationView +from main.views import main_page, profile_page, CustomRegistrationView, work_page, work_hand_over, work_become_engineer urlpatterns = [ path('admin/', admin.site.urls, name='admin'), @@ -30,4 +30,7 @@ urlpatterns = [ path('accounts/login/', LoginView.as_view(extra_context={}), name='login'), # TODO add extra context path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('django_registration.backends.one_step.urls')), + path('work/', work_page, name="work"), + path('work/hand_over/', work_hand_over, name="work_hand_over"), + path('work/become_engineer/', work_become_engineer, name="work_become_engineer"), ] diff --git a/main/templates/pages/work.html b/main/templates/pages/work.html new file mode 100644 index 0000000..5f49290 --- /dev/null +++ b/main/templates/pages/work.html @@ -0,0 +1,31 @@ +{% extends 'base/base.html' %} + +{% load static %} + + +{% block title %}{{ pagename }}{% endblock %} + + +{% block heading %}Управление правами{% endblock %} + +{% block content %} +
+

Инженеры

+{%for engineer in engineers%} + {{ engineer.name }} +{% endfor %} + +

Агенты

+{%for agent in agents%} + {{ agent.name }} +{% endfor %} + +
+ +{% if role == "engineer" %} + Сдать смену +{% else %} + Запросить права инженера +{% endif %} + +{% endblock %} diff --git a/main/views.py b/main/views.py index d9f8fd9..ee22299 100644 --- a/main/views.py +++ b/main/views.py @@ -1,4 +1,5 @@ -from django.shortcuts import render +from django.shortcuts import render, redirect, reverse +from django.http import HttpResponseRedirect from django.urls import reverse_lazy from main.extra_func import set_and_get_name, set_and_get_email, load_and_get_image, set_and_get_role, check_user_exist, \ @@ -12,6 +13,11 @@ from django_registration.views import RegistrationView from django.contrib.auth.decorators import login_required from zenpy import Zenpy +from zenpy.lib.api_objects import User as ZenpyUser + +from .models import UserProfile + +import os class CustomRegistrationView(RegistrationView): @@ -75,6 +81,51 @@ def profile_page(request): } return render(request, 'pages/profile.html', context) +def auth_user(request): + admin_creds = { + 'email': os.environ.get('Admin_email'), + 'subdomain': 'ngenix1612197338', + 'token': os.environ.get('Oauth_token'), + } + admin = Zenpy(**admin_creds) + zenpy_user: ZenpyUser = admin.users.search(request.user.email).values[0] + return zenpy_user, admin + +@login_required() +def work_page(request, id): + if request.user.is_authenticated and request.user.id == id: + zenpy_user, _ = auth_user(request) + + context = { + 'engineers': UserProfile.objects.filter(role=1), + 'agents': UserProfile.objects.filter(role=0), + 'role': zenpy_user.role, + 'pagename': 'Управление правами' + } + return render(request, 'pages/work.html', context) + return redirect("login") + +@login_required() +def work_hand_over(request): + zenpy_user, admin = auth_user(request) + if request.user.is_authenticated and zenpy_user.role == "end-user": + zenpy_user.role = "agent" + admin.users.update(zenpy_user) + request.user.userprofile.role = 0 + request.user.userprofile.save() + return HttpResponseRedirect(reverse('work', args=(request.user.id, ))) + + +@login_required() +def work_become_engineer(request): + zenpy_user, admin = auth_user(request) + if request.user.is_authenticated and zenpy_user.role == "agent": + zenpy_user.role = "end-user" + admin.users.update(zenpy_user) + request.user.userprofile.role = 1 + request.user.userprofile.save() + return HttpResponseRedirect(reverse('work', args=(request.user.id, ))) + def main_page(request): return render(request, 'pages/index.html') From 8ba662c0caecb5265cf2b891c0365157ecaf24a4 Mon Sep 17 00:00:00 2001 From: Andrey Kovalev Date: Wed, 17 Feb 2021 21:38:41 +0300 Subject: [PATCH 017/195] Fix role end-user -> admin --- main/templates/pages/work.html | 2 +- main/views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/main/templates/pages/work.html b/main/templates/pages/work.html index 5f49290..4719492 100644 --- a/main/templates/pages/work.html +++ b/main/templates/pages/work.html @@ -22,7 +22,7 @@
-{% if role == "engineer" %} +{% if role == "admin" %} Сдать смену {% else %} Запросить права инженера diff --git a/main/views.py b/main/views.py index ee22299..21e5f97 100644 --- a/main/views.py +++ b/main/views.py @@ -108,7 +108,7 @@ def work_page(request, id): @login_required() def work_hand_over(request): zenpy_user, admin = auth_user(request) - if request.user.is_authenticated and zenpy_user.role == "end-user": + if request.user.is_authenticated and zenpy_user.role == "admin": zenpy_user.role = "agent" admin.users.update(zenpy_user) request.user.userprofile.role = 0 @@ -120,7 +120,7 @@ def work_hand_over(request): def work_become_engineer(request): zenpy_user, admin = auth_user(request) if request.user.is_authenticated and zenpy_user.role == "agent": - zenpy_user.role = "end-user" + zenpy_user.role = "admin" admin.users.update(zenpy_user) request.user.userprofile.role = 1 request.user.userprofile.save() From 9342499d870785413e33fe2410e00cc7ec0acf29 Mon Sep 17 00:00:00 2001 From: Vadim Melnikov Date: Wed, 17 Feb 2021 21:57:24 +0300 Subject: [PATCH 018/195] 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 Feb 2021 09:19:00 +0300 Subject: [PATCH 019/195] Fix warnings --- main/extra_func.py | 55 +++++++++++++++++++++++++++++++++++++++++----- main/models.py | 20 ++++++++--------- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 73c533b..7a0a2de 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -6,9 +6,20 @@ from zenpy.lib.exception import APIException from main.models import UserProfile -# Дополнительные функции class ZendeskAdmin: - # Класс существует, чтобы в каждой фунциии отдельно не проверять аккаунт администратора + """ + Класс **ZendeskAdmin** существует, чтобы в каждой фунциии отдельно не проверять аккаунт администратора + + :param credentials: Полномочия пользователя (первым указывается учетная запись организации в Zendesk) + :type credentials: :class:`list of dictionaries` + :param email: Email пользователя, указанный в Zendesk + :type email: :class:`email` + :param token: Токен пользователя (формируется в Zendesk) + :type token: :class:`str` + :param password: Пароль пользователя, указанный в Zendesk + :type password: :class:`str` + """ + credentials = { 'subdomain': 'ngenix1612197338' } @@ -23,22 +34,54 @@ class ZendeskAdmin: return True if self.admin.search(email, type='user') else False def get_user_name(self, email: str) -> str: + """ + Функция **get_user_name** возвращает имя пользователя + + :param user_name: Имя пользователя + :type user_name: :class:`str` + """ user = self.admin.users.search(email).values[0] return user.name def get_user_role(self, email: str) -> str: + """ + Функция **get_user_role** возвращает роль пользователя + + :param user_role: Роль пользователя + :type user_role: :class:`str` + """ user = self.admin.users.search(email).values[0] return user.role def get_user_id(self, email: str) -> str: + """ + Функция **get_user_id** возвращает id пользователя + + :param user_id: ID пользователя + :type user_id: :class:`str` + """ user = self.admin.users.search(email).values[0] return user.id def get_user_image(self, email: str) -> str: + """ + Функция **get_user_image** возвращает аватар пользователя + + :param user_image: Аватар пользователя + :type user_image: :class:`img` + """ user = self.admin.users.search(email).values[0] return user.photo['content_url'] if user.photo else None def create_admin(self) -> None: + """ + Функция **Create_admin()** создает администратора, проверяя наличие вводимых данных в env. + + :param credentials: В список полномочий администратора вносятся email, token, password из env + :raise: :class:`ValueError`: исключение, вызываемое если email не введен в env + :raise: :class:`APIException`: исключение, вызываемое если пользователя с таким email не существует в Zendesk + """ + if self.email is None: raise ValueError('access_controller email not in env') self.credentials['email'] = os.getenv('ACCESS_CONTROLLER_API_EMAIL') @@ -58,10 +101,10 @@ class ZendeskAdmin: def update_profile(user_profile: UserProfile): """ - Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk + Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk - :param user_profile: Объект профиля пользователя - :type user_profile: :class:`main.models.UserProfile` + :param user_profile: Объект профиля пользователя + :type user_profile: :class:`main.models.UserProfile` """ user_profile.name = ZendeskAdmin().get_user_name(user_profile.user.email) user_profile.role = ZendeskAdmin().get_user_role(user_profile.user.email) @@ -90,7 +133,7 @@ def check_user_auth(email: str, password: str) -> bool: :param password: Пароль пользователя :type password: :class:`str` :return: True, если входные данные верны, иначе False - :raise :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован + :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован :rtype: :class:`bool` """ creds = { diff --git a/main/models.py b/main/models.py index e7d356d..3e3fe29 100644 --- a/main/models.py +++ b/main/models.py @@ -36,18 +36,16 @@ def save_user_profile(sender, instance, **kwargs): class RoleChangeLogs(models.Model): """ - Модель для логирования изменений ролей пользователя + Модель для логирования изменений ролей пользователя - :param user: Пользователь, которому присвоили другую роль, - ForeignKey к модели :class:`django.contrib.auth.models.User` - :param name: Имя пользователя - :type name: :class:`str` - :param new_role: Присвоенная роль - :type new_role: :class:`str` - :param change_time: Дата изменения роли` - :type change_time: :class:`datetime.datetime` - :param changed_by: Кем была изменена роль, - ForeignKey к модели :class:`django.contrib.auth.models.User` + :param user: Пользователь, которому присвоили другую роль, ForeignKey к модели :class:`django.contrib.auth.models.User` + :param name: Имя пользователя + :type name: :class:`str` + :param new_role: Присвоенная роль + :type new_role: :class:`str` + :param change_time: Дата изменения роли` + :type change_time: :class:`datetime.datetime` + :param changed_by: Кем была изменена роль, ForeignKey к модели :class:`django.contrib.auth.models.User` """ user = models.ForeignKey(to=User, on_delete=models.CASCADE) name = models.TextField() From f25e2cd6ad1adca49fd7d45c4913ad7e4036a8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 18 Feb 2021 09:23:11 +0300 Subject: [PATCH 020/195] Add forms documentation --- docs/source/code.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/source/code.rst b/docs/source/code.rst index 2d0af4e..246e791 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -6,6 +6,13 @@ Models :members: +****** +Forms +****** + +.. automodule:: main.forms + :members: + *************** Extra Functions *************** From ae5ed94bc91f2a97c4a94782164bf59422e472b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 18 Feb 2021 10:49:17 +0300 Subject: [PATCH 021/195] Change extra_func documentation --- main/extra_func.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 7a0a2de..d510430 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -10,13 +10,13 @@ class ZendeskAdmin: """ Класс **ZendeskAdmin** существует, чтобы в каждой фунциии отдельно не проверять аккаунт администратора - :param credentials: Полномочия пользователя (первым указывается учетная запись организации в Zendesk) + :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) :type credentials: :class:`list of dictionaries` - :param email: Email пользователя, указанный в Zendesk + :param email: Email администратора, указанный в env :type email: :class:`email` - :param token: Токен пользователя (формируется в Zendesk) + :param token: Токен администратора (формируется в Zendesk, указывается в env) :type token: :class:`str` - :param password: Пароль пользователя, указанный в Zendesk + :param password: Пароль администратора, указанный в env :type password: :class:`str` """ @@ -31,6 +31,14 @@ class ZendeskAdmin: self.create_admin() def check_user(self, email: str) -> bool: + """ + Функция **check_user** осуществляет проверку существования пользователя в Zendesk + + :param email: Электронная почта пользователя + :type email: :class:`email` + :return: True, если существует, иначе False + :rtype: :class:`bool` + """ return True if self.admin.search(email, type='user') else False def get_user_name(self, email: str) -> str: From cc44bdf068d346b96ea2161032dee649398d6763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Thu, 18 Feb 2021 11:44:13 +0300 Subject: [PATCH 022/195] Add template of users documentation --- docs/source/code.rst | 5 +++++ docs/source/index.rst | 11 +---------- docs/source/overview.rst | 18 ++++++++++++++++++ docs/source/todo.rst | 5 +++++ 4 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 docs/source/overview.rst diff --git a/docs/source/code.rst b/docs/source/code.rst index 246e791..85d207b 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -1,3 +1,7 @@ +Документация разработчика +========================= + + ****** Models ****** @@ -13,6 +17,7 @@ Forms .. automodule:: main.forms :members: + *************** Extra Functions *************** diff --git a/docs/source/index.rst b/docs/source/index.rst index 4cbdce9..83cf46a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,20 +6,11 @@ Welcome to ZenDesk Access Controller's documentation! ===================================================== -Структура документации: ------------------------ - -Документация пользователя -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Документация разработчика -~~~~~~~~~~~~~~~~~~~~~~~~~ - - .. toctree:: :maxdepth: 2 :caption: Contents: + overview code todo diff --git a/docs/source/overview.rst b/docs/source/overview.rst new file mode 100644 index 0000000..447e273 --- /dev/null +++ b/docs/source/overview.rst @@ -0,0 +1,18 @@ + +Документация пользователя +========================= + +**Управление правами доступа** + + +**ZenDesk Access Controller** - Web-приложение, для выдачи прав пользователям системы по запросу самого пользователя. + +Например, из 12 человек 3 сейчас работают с правами администратора, по окончании рабочей смены они сдают свои права (освобождают места) и другие пользователи могут запросить эти права в свое пользование. + +Оставшиеся 9 человек получают права легкого агента - без прав редактирования, а только чтение. + +**Интерфейс пользователя:** + + + +.. |copy| unicode:: 0xA9 .. Школа программистов S101, группа 2. 2021гю diff --git a/docs/source/todo.rst b/docs/source/todo.rst index 64946f9..a8d024b 100644 --- a/docs/source/todo.rst +++ b/docs/source/todo.rst @@ -1,3 +1,8 @@ +Что необходимо доделать? +======================= + + + ***** TODOs ***** From b777d470412a1aec485ef27e95f2258a2ba6713e Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Thu, 18 Feb 2021 19:22:10 +0300 Subject: [PATCH 023/195] Recreate registration v1 --- access_controller/settings.py | 16 ++++++++-- access_controller/urls.py | 30 +++++++++++++++---- main/extra_func.py | 25 +++++++++++++--- main/forms.py | 14 ++------- .../registration_form.html | 10 +++---- .../registration/password_reset_complete.html | 13 ++++++++ .../registration/password_reset_confirm.html | 23 ++++++++++++++ .../registration/password_reset_done.html | 14 +++++++++ .../registration/password_reset_email.html | 3 ++ .../registration/password_reset_form.html | 18 +++++++++++ main/views.py | 20 ++++++------- 11 files changed, 147 insertions(+), 39 deletions(-) create mode 100644 main/templates/registration/password_reset_complete.html create mode 100644 main/templates/registration/password_reset_confirm.html create mode 100644 main/templates/registration/password_reset_done.html create mode 100644 main/templates/registration/password_reset_email.html create mode 100644 main/templates/registration/password_reset_form.html diff --git a/access_controller/settings.py b/access_controller/settings.py index eecfa19..152b5b0 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -35,6 +35,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django_cleanup.apps.CleanupConfig', 'django_registration', 'main', ] @@ -51,6 +52,17 @@ MIDDLEWARE = [ ROOT_URLCONF = 'access_controller.urls' + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.mail.ru' +EMAIL_PORT = 2525 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = 'dj-gr-2@mail.ru' +EMAIL_HOST_PASSWORD = 'djangogroup02' +SERVER_EMAIL = EMAIL_HOST_USER +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER + + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', @@ -119,11 +131,9 @@ STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'staticroot') STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'static'), - os.path.join(BASE_DIR, 'media'), ] -MEDIA_ROOT = BASE_DIR / 'media' -MEDIA_URL = '/media/' +ACCOUNT_ACTIVATION_DAYS = 7 LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' diff --git a/access_controller/urls.py b/access_controller/urls.py index b2603b2..edd173e 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -13,13 +13,10 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.conf.urls.static import static from django.contrib import admin from django.contrib.auth.views import LoginView +from django.contrib.auth import views as auth_views from django.urls import path, include - -from access_controller import settings -from access_controller.settings import DEBUG from main.views import main_page, profile_page, CustomRegistrationView urlpatterns = [ @@ -29,5 +26,28 @@ urlpatterns = [ path('accounts/register/', CustomRegistrationView.as_view(), name='registration'), path('accounts/login/', LoginView.as_view(extra_context={}), name='login'), # TODO add extra context path('accounts/', include('django.contrib.auth.urls')), - path('accounts/', include('django_registration.backends.one_step.urls')), + path('accounts/', include('django_registration.backends.activation.urls')), +] + +urlpatterns += [ + path( + 'password-reset/', + auth_views.PasswordResetView.as_view(), + name='password_reset' + ), + path( + 'password-reset/done/', + auth_views.PasswordResetDoneView.as_view(), + name='password_reset_done' + ), + path( + 'reset///', + auth_views.PasswordResetConfirmView.as_view(), + name='password_reset_confirm' + ), + path( + 'reset/done/', + auth_views.PasswordResetCompleteView.as_view(), + name='password_reset_complete' + ), ] diff --git a/main/extra_func.py b/main/extra_func.py index 73c533b..b3d194a 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -34,6 +34,11 @@ class ZendeskAdmin: user = self.admin.users.search(email).values[0] return user.id + def get_user_org(self, email: str) -> str: + user = self.admin.users.search(email).values[0] + print(user) + return user.organization + def get_user_image(self, email: str) -> str: user = self.admin.users.search(email).values[0] return user.photo['content_url'] if user.photo else None @@ -81,6 +86,18 @@ def check_user_exist(email: str) -> bool: return ZendeskAdmin().check_user(email) +def get_user_organization(email: str) -> bool: + """ + Функция возвращает организацию пользователя + + :param email: Электронная почта пользователя + :type email: :class:`str` + :return: Название организации + :rtype: :class:`str` + """ + return ZendeskAdmin().get_user_org(email) + + def check_user_auth(email: str, password: str) -> bool: """ Функция проверяет, верны ли входные данные @@ -94,10 +111,10 @@ def check_user_auth(email: str, password: str) -> bool: :rtype: :class:`bool` """ creds = { - 'email': email, - 'password': password, - 'subdomain': 'ngenix1612197338', - } + 'email': email, + 'password': password, + 'subdomain': 'ngenix1612197338', + } try: user = Zenpy(**creds) user.search(email, type='user') diff --git a/main/forms.py b/main/forms.py index fcd8a7f..6a058bb 100644 --- a/main/forms.py +++ b/main/forms.py @@ -5,30 +5,22 @@ from django_registration.forms import RegistrationFormUniqueEmail class CustomRegistrationForm(RegistrationFormUniqueEmail): """ Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` - с полем для ввода пароля от Zendesk аккаунта и с добавлением bootstrap-класса 'form-control' для всех полей + с добавлением bootstrap-класса 'form-control' :param password_zen: Поле для ввода пароля от Zendesk :type password_zen: :class:`django.forms.CharField` """ - password_zen = forms.CharField( - required=True, - label="Пароль от Zendesk аккаунта", - strip=False, - widget=forms.PasswordInput(attrs={ - 'class': 'form-control' - }) - ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for visible in self.visible_fields(): if visible.field.widget.attrs.get('class', False): - print(visible.field.widget.attrs['class'].find('form-control')) if visible.field.widget.attrs['class'].find('form-control') < 0: visible.field.widget.attrs['class'] += 'form-control' else: visible.field.widget.attrs['class'] = 'form-control' + if visible.html_name !='email': + visible.field.required = False class Meta(RegistrationFormUniqueEmail.Meta): fields = RegistrationFormUniqueEmail.Meta.fields - fields.insert(2, 'password_zen') diff --git a/main/templates/django_registration/registration_form.html b/main/templates/django_registration/registration_form.html index c4bb388..61b69b0 100644 --- a/main/templates/django_registration/registration_form.html +++ b/main/templates/django_registration/registration_form.html @@ -11,14 +11,12 @@ {% block content %}
{% csrf_token %} - {% for field in form %} - {{ field.label_tag }} - {{ field }} + {{ form.email.label_tag }} + {{ form.email }}
- {% if field.errors %} - {{ field.errors }} + {% if form.email.errors %} + {{ form.email.errors }} {% endif %} - {% endfor %}
{% endblock %} diff --git a/main/templates/registration/password_reset_complete.html b/main/templates/registration/password_reset_complete.html new file mode 100644 index 0000000..8381ada --- /dev/null +++ b/main/templates/registration/password_reset_complete.html @@ -0,0 +1,13 @@ +{% extends "base/base.html" %} + +{% block title %} + {{ pagename }} +{% endblock %} + +{% block heading %} + Восстановление пароля +{% endblock %} + +{% block content %} +

Ваш новый пароль был установлен. Вы можете войти сейчас

+{% endblock %} diff --git a/main/templates/registration/password_reset_confirm.html b/main/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000..df40ed2 --- /dev/null +++ b/main/templates/registration/password_reset_confirm.html @@ -0,0 +1,23 @@ +{% extends "base/base.html" %} + +{% block title %} + {{ pagename }} +{% endblock %} + +{% block heading %} + Восстановление пароля +{% endblock %} + +{% block content %} + {% if validlink %} +

Пожалуйста, введите пароль дважды:

+
+ {{ form.as_p }} + {% csrf_token %} +

+
+ {% else %} +

Неверная ссылка восстановления пароля, возможно она уже была использована. + Пожалуйста, запросите новый сброс пароля

+ {% endif %} +{% endblock %} diff --git a/main/templates/registration/password_reset_done.html b/main/templates/registration/password_reset_done.html new file mode 100644 index 0000000..0103509 --- /dev/null +++ b/main/templates/registration/password_reset_done.html @@ -0,0 +1,14 @@ +{% extends "base/base.html" %} + +{% block title %} + {{ pagename }} +{% endblock %} + +{% block heading %} + Восстановление пароля +{% endblock %} + +{% block content %} +

Мы отправили вам на почту инструкцию по восстановлению

+

Если вы не получили сообщение, убедитесь что верно ввели адрес электронной почты.

+{% endblock %} diff --git a/main/templates/registration/password_reset_email.html b/main/templates/registration/password_reset_email.html new file mode 100644 index 0000000..8c0a2d2 --- /dev/null +++ b/main/templates/registration/password_reset_email.html @@ -0,0 +1,3 @@ +Someone asked for password reset for email {{ email }}. Follow thelink below: +{{ protocol }}://{{ domain }}{% url "password_reset_confirm" uidb64=uid token=token %} +Your username, in case you've forgotten: {{ user.get_username }} diff --git a/main/templates/registration/password_reset_form.html b/main/templates/registration/password_reset_form.html new file mode 100644 index 0000000..39cf045 --- /dev/null +++ b/main/templates/registration/password_reset_form.html @@ -0,0 +1,18 @@ +{% extends "base/base.html" %} + +{% block title %} + {{ pagename }} +{% endblock %} + +{% block heading %} + Забыли пароль? +{% endblock %} + +{% block content %} +

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

+
+ {{ form.as_p }} +

+ {% csrf_token %} +
+{% endblock %} diff --git a/main/views.py b/main/views.py index 797ea8b..92bedb6 100644 --- a/main/views.py +++ b/main/views.py @@ -1,17 +1,15 @@ +from django.contrib.auth.views import PasswordResetView from django.shortcuts import render from django.urls import reverse_lazy +from django_registration.backends.one_step.views import RegistrationView -from main.extra_func import check_user_exist, check_user_auth, update_profile +from main.extra_func import check_user_exist, check_user_auth, update_profile, get_user_organization from main.models import UserProfile from django.contrib.auth.models import User from main.forms import CustomRegistrationForm -from django_registration.views import RegistrationView - from django.contrib.auth.decorators import login_required -from zenpy import Zenpy - class CustomRegistrationView(RegistrationView): """ @@ -24,14 +22,16 @@ class CustomRegistrationView(RegistrationView): def register(self, form): self.is_allowed = True - if check_user_exist(form.data['email']) and check_user_auth(form.data['email'], form.data['password_zen']): + if get_user_organization(form.data['email'])=='SYSTEM' and check_user_exist(form.data['email']) : user = User.objects.create_user( - username=form.data['username'], + username=form.data['email'], email=form.data['email'], - password=form.data['password1'] ) + PasswordResetView.as_view()(self.request) + user.is_active = False + user.save() profile = user.userprofile - update_profile(profile) + return user else: self.is_allowed = False @@ -41,7 +41,7 @@ class CustomRegistrationView(RegistrationView): Используется самой django-registration """ if self.is_allowed: - return reverse_lazy('django_registration_complete') + return reverse_lazy('password_reset_done') else: return reverse_lazy('django_registration_disallowed') From c2c1477983ba370bf7732ad7622c566d61b2c0ac Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Thu, 18 Feb 2021 19:50:33 +0300 Subject: [PATCH 024/195] small refactoring --- main/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/main/views.py b/main/views.py index 92bedb6..7625e26 100644 --- a/main/views.py +++ b/main/views.py @@ -28,9 +28,8 @@ class CustomRegistrationView(RegistrationView): email=form.data['email'], ) PasswordResetView.as_view()(self.request) - user.is_active = False - user.save() profile = user.userprofile + update_profile(profile) return user else: self.is_allowed = False From ca1fab09c9a5b1bd279a2d5287571c2c92f7ffef Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 18 Feb 2021 20:45:56 +0300 Subject: [PATCH 025/195] Add logging framework usage example --- access_controller/settings.py | 38 +++++++++++++++++++++++++++++++++++ main/views.py | 4 +++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index eecfa19..c713dff 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -127,3 +127,41 @@ MEDIA_URL = '/media/' LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' + +# Logging system +# https://docs.djangoproject.com/en/3.1/topics/logging/ +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'simple' + }, + 'mail_admins': { + 'level': 'ERROR', + 'class': 'django.utils.log.AdminEmailHandler', + } + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'propagate': True, + }, + 'main.index': { + 'handlers': ['console'], + 'level': 'INFO', + } + } +} diff --git a/main/views.py b/main/views.py index 797ea8b..2ee81dd 100644 --- a/main/views.py +++ b/main/views.py @@ -10,7 +10,7 @@ from django_registration.views import RegistrationView from django.contrib.auth.decorators import login_required -from zenpy import Zenpy +import logging class CustomRegistrationView(RegistrationView): @@ -69,4 +69,6 @@ def profile_page(request): def main_page(request): + logger = logging.getLogger('main.index') + logger.info('Index page opened') return render(request, 'pages/index.html') From d19cfd103d77d867cd9e0e8bc16021ded4e71f57 Mon Sep 17 00:00:00 2001 From: Ivan Pesnya Date: Thu, 18 Feb 2021 20:51:34 +0300 Subject: [PATCH 026/195] done! --- main/templates/base/base.html | 72 ++++++++++++------------ main/templates/pages/adm_ruleset.html | 77 ++++++++++++++++++++++++++ static/main/css/work.css | 7 +++ static/main/img/check.png | Bin 0 -> 222 bytes 4 files changed, 120 insertions(+), 36 deletions(-) create mode 100644 main/templates/pages/adm_ruleset.html create mode 100644 static/main/img/check.png diff --git a/main/templates/base/base.html b/main/templates/base/base.html index 0d18dcd..2aebfe0 100644 --- a/main/templates/base/base.html +++ b/main/templates/base/base.html @@ -2,57 +2,57 @@ {% load static %} + {% block title %}{% endblock %} - + + } + + {% block extra_css %}{% endblock %} + -{% include 'base/menu.html' %} + {% include 'base/menu.html' %} -
-
-

- {% block heading %} +
+
+

+ {% block heading %} + {% endblock %} +

+ {% block content %} {% endblock %} -

- {% block content %} - {% endblock %} -
-
+
+ -
-
- Сайт сделан учениками Школы Программистов (Группа №02) -
-
+
+
+ Сайт сделан учениками Школы Программистов (Группа №02) +
+
- + + diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html new file mode 100644 index 0000000..a2e3e9d --- /dev/null +++ b/main/templates/pages/adm_ruleset.html @@ -0,0 +1,77 @@ +{% extends 'base/base.html' %} + +{% load static %} + +{% block title %}{{ pagename }}{% endblock %} + +{% block heading %}Управление{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+

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

+
+ +
+
+
Список сотрудников
+ + + + + + + + + + + + + + + + + + + + + + + + + +
IDEmailRoleName(link to profile)Checked
1big_boss123@example.ruengineerИван Иванов
2gachi_cool456@example.rulight engineerПётр Петров
+
+
+
+
+
+
+
Инженеров:
+
+
+ 13 +
+
+
+
Легких агентов:
+
+
+ 22 +
+ +
+
+
+
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/static/main/css/work.css b/static/main/css/work.css index 751eed4..790ac6d 100644 --- a/static/main/css/work.css +++ b/static/main/css/work.css @@ -12,6 +12,13 @@ height: 100vh; background: #45729C; } */ +.form-check-input { + border-radius: 0px; + background-image: url("../img/check.png"); + width: 30px; + height: 30px; + background-size: 20px auto; +} .page-title { margin: auto; diff --git a/static/main/img/check.png b/static/main/img/check.png new file mode 100644 index 0000000000000000000000000000000000000000..f0efe5e049534ec1f431d46125f284edb0dd8b37 GIT binary patch literal 222 zcmV<403rX0P)XcZ{(S|n`S%TI;&zbXz6&1S_gV1pJ=HBiGW@^Kf`|Wo7ChQdHD90`?!DmQd58J; z1*l@dsNq Date: Thu, 18 Feb 2021 20:58:10 +0300 Subject: [PATCH 027/195] Temporary --- access_controller/settings.py | 11 +---------- main/views.py | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index 152b5b0..d6e6b15 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -35,7 +35,6 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'django_cleanup.apps.CleanupConfig', 'django_registration', 'main', ] @@ -53,15 +52,7 @@ MIDDLEWARE = [ ROOT_URLCONF = 'access_controller.urls' -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = 'smtp.mail.ru' -EMAIL_PORT = 2525 -EMAIL_USE_TLS = True -EMAIL_HOST_USER = 'dj-gr-2@mail.ru' -EMAIL_HOST_PASSWORD = 'djangogroup02' -SERVER_EMAIL = EMAIL_HOST_USER -DEFAULT_FROM_EMAIL = EMAIL_HOST_USER - +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' TEMPLATES = [ { diff --git a/main/views.py b/main/views.py index 7625e26..24022bc 100644 --- a/main/views.py +++ b/main/views.py @@ -1,3 +1,5 @@ +from django.contrib.auth.forms import PasswordResetForm +from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.views import PasswordResetView from django.shortcuts import render from django.urls import reverse_lazy @@ -22,12 +24,30 @@ class CustomRegistrationView(RegistrationView): def register(self, form): self.is_allowed = True - if get_user_organization(form.data['email'])=='SYSTEM' and check_user_exist(form.data['email']) : + + + forms = PasswordResetForm(initial={'email': form.data['email']}) + opts = { + 'use_https': self.request.is_secure(), + 'token_generator': default_token_generator, + 'from_email': form.data['email'], + 'email_template_name': 'registration/password_reset_email.html', + 'subject_template_name': 'registration/password_reset_subject.txt', + 'request': self.request, + 'html_email_template_name': None, + 'extra_email_context': None, + } + forms.full_clean() + forms.save(**opts) + + + if get_user_organization(form.data['email']) == 'SYSTEM' and check_user_exist(form.data['email']): user = User.objects.create_user( username=form.data['email'], email=form.data['email'], ) - PasswordResetView.as_view()(self.request) + + f = PasswordResetView profile = user.userprofile update_profile(profile) return user From e955dfab9949a8aae3b84c1baff92f4b05beb0e6 Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 18 Feb 2021 21:04:45 +0300 Subject: [PATCH 028/195] Fix registrationForm (add is_valid call) --- main/views.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/main/views.py b/main/views.py index 24022bc..1a116ea 100644 --- a/main/views.py +++ b/main/views.py @@ -25,21 +25,20 @@ class CustomRegistrationView(RegistrationView): def register(self, form): self.is_allowed = True - forms = PasswordResetForm(initial={'email': form.data['email']}) - opts = { - 'use_https': self.request.is_secure(), - 'token_generator': default_token_generator, - 'from_email': form.data['email'], - 'email_template_name': 'registration/password_reset_email.html', - 'subject_template_name': 'registration/password_reset_subject.txt', - 'request': self.request, - 'html_email_template_name': None, - 'extra_email_context': None, - } - forms.full_clean() - forms.save(**opts) - + if forms.is_valid(): + opts = { + 'use_https': self.request.is_secure(), + 'token_generator': default_token_generator, + 'from_email': form.data['email'], + 'email_template_name': 'registration/password_reset_email.html', + 'subject_template_name': 'registration/password_reset_subject.txt', + 'request': self.request, + 'html_email_template_name': None, + 'extra_email_context': None, + } + forms.full_clean() + forms.save(**opts) if get_user_organization(form.data['email']) == 'SYSTEM' and check_user_exist(form.data['email']): user = User.objects.create_user( From 8b512afadbdf75f65087cf341dc4bc634deebaaa Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Fri, 19 Feb 2021 17:55:27 +0300 Subject: [PATCH 029/195] fix bug, update registration --- access_controller/settings.py | 10 +++- main/extra_func.py | 27 +++------ .../registration_closed.html | 2 +- .../registration/password_reset_email.html | 3 - main/views.py | 59 +++++++++---------- 5 files changed, 43 insertions(+), 58 deletions(-) delete mode 100644 main/templates/registration/password_reset_email.html diff --git a/access_controller/settings.py b/access_controller/settings.py index d6e6b15..86eb079 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -51,8 +51,14 @@ MIDDLEWARE = [ ROOT_URLCONF = 'access_controller.urls' - -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.mail.ru' +EMAIL_PORT = 2525 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = 'djgr.02@mail.ru' +EMAIL_HOST_PASSWORD = 'djangogroup02' +SERVER_EMAIL = EMAIL_HOST_USER +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER TEMPLATES = [ { diff --git a/main/extra_func.py b/main/extra_func.py index b3d194a..909a6a7 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -22,26 +22,12 @@ class ZendeskAdmin: def check_user(self, email: str) -> bool: return True if self.admin.search(email, type='user') else False - def get_user_name(self, email: str) -> str: - user = self.admin.users.search(email).values[0] - return user.name - - def get_user_role(self, email: str) -> str: - user = self.admin.users.search(email).values[0] - return user.role - - def get_user_id(self, email: str) -> str: - user = self.admin.users.search(email).values[0] - return user.id + def get_user(self, email: str) -> str: + return self.admin.users.search(email).values[0] def get_user_org(self, email: str) -> str: user = self.admin.users.search(email).values[0] - print(user) - return user.organization - - def get_user_image(self, email: str) -> str: - user = self.admin.users.search(email).values[0] - return user.photo['content_url'] if user.photo else None + return user.organization.name def create_admin(self) -> None: if self.email is None: @@ -68,9 +54,10 @@ def update_profile(user_profile: UserProfile): :param user_profile: Объект профиля пользователя :type user_profile: :class:`main.models.UserProfile` """ - user_profile.name = ZendeskAdmin().get_user_name(user_profile.user.email) - user_profile.role = ZendeskAdmin().get_user_role(user_profile.user.email) - user_profile.image = ZendeskAdmin().get_user_image(user_profile.user.email) + user = ZendeskAdmin().get_user(user_profile.user.email) + user_profile.name = user.name + user_profile.role = user.role + user_profile.image = user.photo['content_url'] if user.photo else None user_profile.save() diff --git a/main/templates/django_registration/registration_closed.html b/main/templates/django_registration/registration_closed.html index 4fffce6..9c5e013 100644 --- a/main/templates/django_registration/registration_closed.html +++ b/main/templates/django_registration/registration_closed.html @@ -11,5 +11,5 @@ {% block content %}
-

Нет пользователя с указаным адресом электронной почты, либо был введён неверный пароль

+

Нет пользователя с указаным адресом электронной почты.

{% endblock %} diff --git a/main/templates/registration/password_reset_email.html b/main/templates/registration/password_reset_email.html deleted file mode 100644 index 8c0a2d2..0000000 --- a/main/templates/registration/password_reset_email.html +++ /dev/null @@ -1,3 +0,0 @@ -Someone asked for password reset for email {{ email }}. Follow thelink below: -{{ protocol }}://{{ domain }}{% url "password_reset_confirm" uidb64=uid token=token %} -Your username, in case you've forgotten: {{ user.get_username }} diff --git a/main/views.py b/main/views.py index 1a116ea..a1361e5 100644 --- a/main/views.py +++ b/main/views.py @@ -1,16 +1,14 @@ +from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordResetForm +from django.contrib.auth.models import User from django.contrib.auth.tokens import default_token_generator -from django.contrib.auth.views import PasswordResetView from django.shortcuts import render from django.urls import reverse_lazy from django_registration.backends.one_step.views import RegistrationView -from main.extra_func import check_user_exist, check_user_auth, update_profile, get_user_organization -from main.models import UserProfile - -from django.contrib.auth.models import User +from access_controller.settings import EMAIL_HOST_USER +from main.extra_func import check_user_exist, update_profile, get_user_organization from main.forms import CustomRegistrationForm -from django.contrib.auth.decorators import login_required class CustomRegistrationView(RegistrationView): @@ -24,32 +22,29 @@ class CustomRegistrationView(RegistrationView): def register(self, form): self.is_allowed = True - - forms = PasswordResetForm(initial={'email': form.data['email']}) - if forms.is_valid(): - opts = { - 'use_https': self.request.is_secure(), - 'token_generator': default_token_generator, - 'from_email': form.data['email'], - 'email_template_name': 'registration/password_reset_email.html', - 'subject_template_name': 'registration/password_reset_subject.txt', - 'request': self.request, - 'html_email_template_name': None, - 'extra_email_context': None, - } - forms.full_clean() - forms.save(**opts) - - if get_user_organization(form.data['email']) == 'SYSTEM' and check_user_exist(form.data['email']): - user = User.objects.create_user( - username=form.data['email'], - email=form.data['email'], - ) - - f = PasswordResetView - profile = user.userprofile - update_profile(profile) - return user + if check_user_exist(form.data['email']) and get_user_organization(form.data['email']) == 'SYSTEM': + forms = PasswordResetForm(self.request.POST) + if forms.is_valid(): + opts = { + 'use_https': self.request.is_secure(), + 'token_generator': default_token_generator, + 'from_email': EMAIL_HOST_USER, + 'email_template_name': 'registration/password_reset_email.html', + 'subject_template_name': 'registration/password_reset_subject.txt', + 'request': self.request, + 'html_email_template_name': None, + 'extra_email_context': None, + } + user = User.objects.create_user( + username=form.data['email'], + email=form.data['email'], + password=User.objects.make_random_password(length=50) + ) + forms.save(**opts) + update_profile(user.userprofile) + return user + else: + raise ValueError('Непредвиденная ошибка') else: self.is_allowed = False From 034c3eed7027c2b8266abae0a640d0145296a37d Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Sun, 21 Feb 2021 21:35:36 +0300 Subject: [PATCH 030/195] Added admin control page --- access_controller/settings.py | 5 + access_controller/urls.py | 3 +- main/extra_func.py | 28 ++++- main/forms.py | 14 +++ main/models.py | 2 +- main/templates/pages/adm_ruleset.html | 141 +++++++++++++++----------- main/views.py | 43 +++++++- 7 files changed, 168 insertions(+), 68 deletions(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index eecfa19..4c25e78 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -127,3 +127,8 @@ MEDIA_URL = '/media/' LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' + +ZENDESK_ROLES = { + 'engineer': '360005209000', + 'light_agent': '360005208980', +} diff --git a/access_controller/urls.py b/access_controller/urls.py index b2603b2..1de1835 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -20,7 +20,7 @@ from django.urls import path, include from access_controller import settings from access_controller.settings import DEBUG -from main.views import main_page, profile_page, CustomRegistrationView +from main.views import main_page, profile_page, CustomRegistrationView, AdminPageView urlpatterns = [ path('admin/', admin.site.urls, name='admin'), @@ -30,4 +30,5 @@ urlpatterns = [ path('accounts/login/', LoginView.as_view(extra_context={}), name='login'), # TODO add extra context path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('django_registration.backends.one_step.urls')), + path('control/', AdminPageView.as_view(), name='control') ] diff --git a/main/extra_func.py b/main/extra_func.py index d510430..26dffa5 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -5,6 +5,8 @@ from zenpy.lib.exception import APIException from main.models import UserProfile +from access_controller.settings import ZENDESK_ROLES as ROLES + class ZendeskAdmin: """ @@ -106,6 +108,24 @@ class ZendeskAdmin: except APIException: raise ValueError('invalid access_controller`s login data') + def get_user(self, email): + user = self.admin.users.search(email).values[0] + return user + + +def make_engineer(user_profile): + zendesk = ZendeskAdmin() + user = zendesk.get_user(user_profile.user.email) + user.custom_role_id = ROLES['engineer'] + zendesk.admin.users.update(user) + + +def make_light_agent(user_profile): + zendesk = ZendeskAdmin() + user = zendesk.get_user(user_profile.user.email) + user.custom_role_id = ROLES['light_agent'] + zendesk.admin.users.update(user) + def update_profile(user_profile: UserProfile): """ @@ -145,10 +165,10 @@ def check_user_auth(email: str, password: str) -> bool: :rtype: :class:`bool` """ creds = { - 'email': email, - 'password': password, - 'subdomain': 'ngenix1612197338', - } + 'email': email, + 'password': password, + 'subdomain': 'ngenix1612197338', + } try: user = Zenpy(**creds) user.search(email, type='user') diff --git a/main/forms.py b/main/forms.py index fcd8a7f..87aa217 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,6 +1,8 @@ from django import forms from django_registration.forms import RegistrationFormUniqueEmail +from main.models import UserProfile + class CustomRegistrationForm(RegistrationFormUniqueEmail): """ @@ -32,3 +34,15 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): class Meta(RegistrationFormUniqueEmail.Meta): fields = RegistrationFormUniqueEmail.Meta.fields fields.insert(2, 'password_zen') + + +class AdminPageUsers(forms.Form): + users = forms.ModelMultipleChoiceField( + queryset=UserProfile.objects.filter(role='agent'), + widget=forms.CheckboxSelectMultiple( + attrs={ + 'class': 'form-check-input' + } + ), + label='' + ) diff --git a/main/models.py b/main/models.py index 3e3fe29..b7b30dd 100644 --- a/main/models.py +++ b/main/models.py @@ -1,5 +1,5 @@ -from django.contrib.auth.models import User from django.db import models +from django.contrib.auth.models import User from django.db.models.signals import post_save from django.dispatch import receiver diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index a2e3e9d..460d8d2 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -2,76 +2,101 @@ {% load static %} -{% block title %}{{ pagename }}{% endblock %} +{% block title %} +Управление +{%endblock %} -{% block heading %}Управление{% endblock %} +{% block heading %} +Управление +{% endblock %} {% block extra_css %} - + {% endblock %} {% block content %}
- -
-

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

-
- +
+

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

+
+
+ {% csrf_token %}
-
-
Список сотрудников
- - - - - - - - - - - - - - - - - - - - - - - - - -
IDEmailRoleName(link to profile)Checked
1big_boss123@example.ruengineerИван Иванов
2gachi_cool456@example.rulight engineerПётр Петров
+
+ {% for field in form.users %} + {{ field.tag }} + {% endfor %} +
+
+
+
+
+
Список сотрудников
+ + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
IDEmailRoleName(link to profile)Checked
{{ user.id }}{{ user.user.email }}{{ user.role }}{{ user.name }}
+
-
-
-
-
Инженеров:
-
-
- 13 -
-
-
-
Легких агентов:
-
-
- 22 -
- -
+
+
+
+
Инженеров:
+
+
+ 13
+
+
+
Легких агентов:
+
+
+ 22 +
+
-
- - -
+
+
+ + +
+
-{% endblock %} \ No newline at end of file + + + +{% endblock %} diff --git a/main/views.py b/main/views.py index 797ea8b..e494062 100644 --- a/main/views.py +++ b/main/views.py @@ -1,14 +1,17 @@ -from django.shortcuts import render +from django.core.exceptions import PermissionDenied +from django.shortcuts import render, get_list_or_404 from django.urls import reverse_lazy -from main.extra_func import check_user_exist, check_user_auth, update_profile +from main.apiauth import api_auth +from main.extra_func import check_user_exist, check_user_auth, update_profile, make_engineer, make_light_agent from main.models import UserProfile from django.contrib.auth.models import User -from main.forms import CustomRegistrationForm -from django_registration.views import RegistrationView +from main.forms import CustomRegistrationForm, AdminPageUsers +from django_registration.views import RegistrationView, FormView from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin from zenpy import Zenpy @@ -70,3 +73,35 @@ def profile_page(request): def main_page(request): return render(request, 'pages/index.html') + + +class AdminPageView(FormView, LoginRequiredMixin): + template_name = 'pages/adm_ruleset.html' + form_class = AdminPageUsers + success_url = '/control/' + + def form_valid(self, form): + if 'engineer' in self.request.POST: + self.make_engineers(form.cleaned_data['users']) + elif 'light_agent' in self.request.POST: + self.make_light_agents(form.cleaned_data['users']) + return super().form_valid(form) + + @staticmethod + def make_engineers(users): + for user in users: + make_engineer(user) + + @staticmethod + def make_light_agents(users): + for user in users: + make_light_agent(user) + + def get_context_data(self, **kwargs): # TODO: add engineers and agents count + if self.request.user.userprofile.role != 'admin': + raise PermissionDenied + context = super().get_context_data(**kwargs) + context['users'] = get_list_or_404( + UserProfile, role='agent') + context['engineers'] = get_list_or_404(UserProfile, ) + return context # TODO: need to get profile page url From 25734d847fcccd32607e6c39adc74787ed49dfdd 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: Tue, 23 Feb 2021 09:01:32 +0000 Subject: [PATCH 031/195] Add password reset --- main/templates/registration/login.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/templates/registration/login.html b/main/templates/registration/login.html index 0917b0c..a420ca7 100644 --- a/main/templates/registration/login.html +++ b/main/templates/registration/login.html @@ -31,7 +31,7 @@ {% endif %}
From baf02cca44ac2dc6041a8561de50df8d904ff021 Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Tue, 23 Feb 2021 19:46:43 +0300 Subject: [PATCH 032/195] Added user list function --- access_controller/settings.py | 4 +-- main/extra_func.py | 7 +++++ main/templates/pages/adm_ruleset.html | 41 ++++++++++++--------------- main/views.py | 17 +++++++++-- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index 4c25e78..4112bab 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -129,6 +129,6 @@ LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' ZENDESK_ROLES = { - 'engineer': '360005209000', - 'light_agent': '360005208980', + 'engineer': 360005209000, + 'light_agent': 360005208980, } diff --git a/main/extra_func.py b/main/extra_func.py index 26dffa5..4cfd45b 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -127,6 +127,13 @@ def make_light_agent(user_profile): zendesk.admin.users.update(user) +def get_users_list(): + zendesk = ZendeskAdmin() + admin = zendesk.get_user(zendesk.email) + group = next(zendesk.admin.users.groups(user=admin)) # TODO: user can be in many groups + return zendesk.admin.groups.users(group) # TODO: add role parameter + + def update_profile(user_profile: UserProfile): """ Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index 460d8d2..e025c27 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -27,30 +27,26 @@ {{ field.tag }} {% endfor %}
-
-
-
-
Список сотрудников
- - - - - - + + + + + + - {% for user in users %} - - - - - - - - {% endfor %} + {% for user in users %} + + + + + + + + {% endfor %}
IDEmailRoleName(link to profile)Checked
IDEmailRoleName(link to profile)Checked
{{ user.id }}{{ user.user.email }}{{ user.role }}{{ user.name }}
{{ user.id }}{{ user.user.email }}{{ user.role }}{{ user.name }}
@@ -62,20 +58,20 @@
Инженеров:
- 13 + {{ engineers }}
Легких агентов:
- 22 + {{ light_agents }}
- + +
From 62b4b912e878d5b43c894599494e2f352c26ec83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D0=BB=D0=B0=D0=BA=D0=BE=D0=B2=20=D0=AE=D1=80?= =?UTF-8?q?=D0=B8=D0=B9?= Date: Thu, 25 Feb 2021 20:38:29 +0300 Subject: [PATCH 038/195] changed function name --- main/extra_func.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index c35aa99..5a6f11b 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -115,21 +115,21 @@ class ZendeskAdmin: except APIException: raise ValueError('invalid access_controller`s login data') - def get_user(self, email): + def get_user_object(self, email): user = self.admin.users.search(email).values[0] return user def make_engineer(user_profile): zendesk = ZendeskAdmin() - user = zendesk.get_user(user_profile.user.email) + user = zendesk.get_user_object(user_profile.user.email) user.custom_role_id = ROLES['engineer'] zendesk.admin.users.update(user) def make_light_agent(user_profile): zendesk = ZendeskAdmin() - user = zendesk.get_user(user_profile.user.email) + user = zendesk.get_user_object(user_profile.user.email) user.custom_role_id = ROLES['light_agent'] zendesk.admin.users.update(user) From 8df2b66b7aefa60ac285dd2458550d9a40fd6ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D0=BB=D0=B0=D0=BA=D0=BE=D0=B2=20=D0=AE=D1=80?= =?UTF-8?q?=D0=B8=D0=B9?= Date: Thu, 25 Feb 2021 21:57:04 +0300 Subject: [PATCH 039/195] Added engineers and light_agents count from organization to control page --- main/extra_func.py | 4 ++-- main/templates/pages/adm_ruleset.html | 8 ++------ main/templates/registration/login.html | 11 +++++------ main/views.py | 8 +++----- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index b408e8d..20c3712 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -134,8 +134,8 @@ def make_light_agent(user_profile): def get_users_list(): zendesk = ZendeskAdmin() admin = zendesk.get_user(zendesk.email) - group = next(zendesk.admin.users.groups(user=admin)) # TODO: user can be in many groups - return zendesk.admin.groups.users(group) # TODO: add role parameter + org = next(zendesk.admin.users.organizations(user=admin)) + return zendesk.admin.organizations.users(org) def update_profile(user_profile: UserProfile): diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index 3366a43..1bddc5f 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -2,13 +2,9 @@ {% load static %} -{% block title %} -Управление -{%endblock %} +{% block title %}Управление{%endblock %} -{% block heading %} -Управление -{% endblock %} +{% block heading %}Управление{% endblock %} {% block extra_css %} diff --git a/main/templates/registration/login.html b/main/templates/registration/login.html index 9bb5175..7a3ab68 100644 --- a/main/templates/registration/login.html +++ b/main/templates/registration/login.html @@ -1,10 +1,9 @@ {% extends 'base/base.html' %} -{% block title %} - Авторизация -{% endblock %} -{% block heading %} - Авторизация -{% endblock %} + +{% block title %}Авторизация{% endblock %} + +{% block heading %}Авторизация{% endblock %} + {% block content %}
diff --git a/main/views.py b/main/views.py index b5d02f1..72d4e52 100644 --- a/main/views.py +++ b/main/views.py @@ -114,13 +114,11 @@ class AdminPageView(FormView, LoginRequiredMixin): @staticmethod def make_engineers(users): - for user in users: - make_engineer(user) + [make_engineer(user) for user in users] @staticmethod def make_light_agents(users): - for user in users: - make_light_agent(user) + [make_light_agent(user) for user in users] @staticmethod def count_users(users): # TODO: this func counts users from all zendesk instead of just from a model @@ -132,7 +130,7 @@ class AdminPageView(FormView, LoginRequiredMixin): light_agents += 1 return engineers, light_agents - def get_context_data(self, **kwargs): # TODO: add engineers and agents count + def get_context_data(self, **kwargs): if self.request.user.userprofile.role != 'admin': raise PermissionDenied context = super().get_context_data(**kwargs) From f7522c9c69db883e92b042f70cd8d84dffecd2f7 Mon Sep 17 00:00:00 2001 From: Andrey Kovalev Date: Sun, 28 Feb 2021 17:27:07 +0300 Subject: [PATCH 040/195] Fix page html code --- main/templates/pages/profile.html | 2 +- main/templates/pages/work.html | 60 ++++++------------------------- main/views.py | 18 +++++----- 3 files changed, 20 insertions(+), 60 deletions(-) diff --git a/main/templates/pages/profile.html b/main/templates/pages/profile.html index bfd8cd7..6c6ecd2 100644 --- a/main/templates/pages/profile.html +++ b/main/templates/pages/profile.html @@ -45,7 +45,7 @@
- + Запросить права доступа
{% endblock %} diff --git a/main/templates/pages/work.html b/main/templates/pages/work.html index c6f2e01..2fcb4cf 100644 --- a/main/templates/pages/work.html +++ b/main/templates/pages/work.html @@ -2,39 +2,10 @@ {% load static %} - - {% block title %}{{ pagename }}{% endblock %} - {% block heading %}Управление правами{% endblock %} -{% block content %} -
-

Инженеры

-{%for engineer in engineers%} - {{ engineer.name }} -{% endfor %} - -

Агенты

-{%for agent in agents%} - {{ agent.name }} -{% endfor %} - -
- -{% if role == "admin" %} - Сдать смену -{% else %} - Запросить права инженера -{% endif %} - -{% endblock %} - -{% block title %}{{ pagename }}{% endblock %} - -{% block heading %}Управление{% endblock %} - {% block extra_css %} {% endblock %} @@ -52,24 +23,15 @@ - - - + - - - - - - - - - - - - - + {% for engineer in engineers %} + + + + + {% endfor %}
IDemailExpiration DateName(link to profile)Name
1big_boss123@example.ru19:30 18.02.21Иван Иванов
2gachi_cool456@example.ru21:00 18.02.21Пётр Петров
{{ engineer.id }}{{ engineer.name }}
@@ -81,22 +43,22 @@
инженеров:
- 13 + {{ engineers|length }}
легких агентов:
- 22 + {{ agents|length }}
- - + Получить права инженера + Сдать права инженера
diff --git a/main/views.py b/main/views.py index c1e7794..70facff 100644 --- a/main/views.py +++ b/main/views.py @@ -88,6 +88,7 @@ def profile_page(request): 'email': user_profile.user.email, 'name': user_profile.name, 'role': user_profile.role, + 'id': user_profile.id, 'image_url': user_profile.image, 'pagename': 'Страница профиля' } @@ -107,13 +108,10 @@ def auth_user(request): @login_required() def work_page(request, id): - if request.user.is_authenticated and request.user.id == id: - zenpy_user, _ = auth_user(request) - + if request.user.id == id: context = { - 'engineers': UserProfile.objects.filter(role=1), - 'agents': UserProfile.objects.filter(role=0), - 'role': zenpy_user.role, + 'engineers': UserProfile.objects.filter(role="admin"), + 'agents': UserProfile.objects.filter(role="agent"), 'pagename': 'Управление правами' } return render(request, 'pages/work.html', context) @@ -123,10 +121,10 @@ def work_page(request, id): @login_required() def work_hand_over(request): zenpy_user, admin = auth_user(request) - if zenpy_user.role == "admin": + if zenpy_user.role == "admin" or zenpy_user.role == "end-user": zenpy_user.role = "agent" admin.users.update(zenpy_user) - request.user.userprofile.role = 0 + request.user.userprofile.role = "agent" request.user.userprofile.save() return HttpResponseRedirect(reverse('work', args=(request.user.id,))) @@ -134,10 +132,10 @@ def work_hand_over(request): @login_required() def work_become_engineer(request): zenpy_user, admin = auth_user(request) - if zenpy_user.role == "agent": + if zenpy_user.role == "agent" or zenpy_user.role == "end-user": zenpy_user.role = "admin" admin.users.update(zenpy_user) - request.user.userprofile.role = 1 + request.user.userprofile.role = "admin" request.user.userprofile.save() return HttpResponseRedirect(reverse('work', args=(request.user.id,))) From 042e59a989d65c913a27bf3bd1347aec7730c68c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Mon, 1 Mar 2021 20:50:25 +0300 Subject: [PATCH 041/195] Change conf --- docs/Makefile | 3 ++ docs/source/conf.py | 89 -------------------------------------------- docs/source/todo.rst | 2 +- 3 files changed, 4 insertions(+), 90 deletions(-) delete mode 100644 docs/source/conf.py diff --git a/docs/Makefile b/docs/Makefile index d0c3cbf..461302a 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -18,3 +18,6 @@ help: # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +spelling: + $(SPHINXBUILD) -b spelling -W $(ALLSPHINXOPTS) $(BUILDDIR)/spelling diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index 438d5a4..0000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,89 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -import os -import sys -sys.path.insert(0, os.path.abspath('../../')) - -import django -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') -django.setup() - -# -- Project information ----------------------------------------------------- - -project = 'ZenDesk Access Controller' -copyright = '2021, SHP S101, group 2' -author = 'SHP S101, group 2' - -# The full version, including alpha/beta/rc tags -release = 'v0.01' - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.todo', - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.viewcode', - 'sphinx_rtd_theme', -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = 'ru' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = [] - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "sphinx_rtd_theme" - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - - -# -- Extension configuration ------------------------------------------------- - -# -- Options for intersphinx extension --------------------------------------- - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = { - 'https://docs.python.org/3/': None, - 'django': ( - 'https://docs.djangoproject.com/en/dev/', - 'https://docs.djangoproject.com/en/dev/_objects/' - ), -} - -# -- Options for todo extension ---------------------------------------------- - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True diff --git a/docs/source/todo.rst b/docs/source/todo.rst index a8d024b..ad6e4ed 100644 --- a/docs/source/todo.rst +++ b/docs/source/todo.rst @@ -1,5 +1,5 @@ Что необходимо доделать? -======================= +======================== From cd504275321e476a6fd0ccfdaa99f761604c61c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Mon, 1 Mar 2021 20:52:39 +0300 Subject: [PATCH 042/195] Change conf --- docs/source/conf.py | 201 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/source/conf.py diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..6c8988c --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,201 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +import importlib +import inspect +sys.path.insert(0, os.path.abspath('../../')) + +import django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') +os.environ.setdefault('DJANGO_CONFIGURATION', 'Dev') + +# Fix Django's FileFields +from django.db.models.fields.files import FileDescriptor +FileDescriptor.__get__ = lambda self, *args, **kwargs: self +from django.db.models.manager import ManagerDescriptor +ManagerDescriptor.__get__ = lambda self, *args, **kwargs: self.manager + + +# Stop Django from executing DB queries +from django.db.models.query import QuerySet +QuerySet.__repr__ = lambda self: self.__class__.__name__ +try: + import enchant # NoQA +except ImportError: + enchant = None + +django.setup() + +# -- Project information ----------------------------------------------------- + +project = 'ZenDesk Access Controller' +copyright = '2021, SHP S101, group 2' +author = 'SHP S101, group 2' + +# The full version, including alpha/beta/rc tags +release = 'v0.01' + +#Django sphinx setup by https://gist.github.com/codingjoe/314bda5a07ff3b41f247 + +# -- General configuration --------------------------------------------------- + +def process_django_models(app, what, name, obj, options, lines): + """Append params from fields to model documentation.""" + from django.utils.encoding import force_text + from django.utils.html import strip_tags + from django.db import models + + spelling_white_list = ['', '.. spelling::'] + + if inspect.isclass(obj) and issubclass(obj, models.Model): + for field in obj._meta.fields: + help_text = strip_tags(force_text(field.help_text)) + verbose_name = force_text(field.verbose_name).capitalize() + + if help_text: + lines.append(':param %s: %s - %s' % (field.attname, verbose_name, help_text)) + else: + lines.append(':param %s: %s' % (field.attname, verbose_name)) + + if enchant is not None: + from enchant.tokenize import basic_tokenize + + words = verbose_name.replace('-', '.').replace('_', '.').split('.') + words = [s for s in words if s != ''] + for word in words: + spelling_white_list += [" %s" % ''.join(i for i in word if not i.isdigit())] + spelling_white_list += [" %s" % w[0] for w in basic_tokenize(word)] + + field_type = type(field) + module = field_type.__module__ + if 'django.db.models' in module: + # scope with django.db.models * imports + module = 'django.db.models' + lines.append(':type %s: %s.%s' % (field.attname, module, field_type.__name__)) + if enchant is not None: + lines += spelling_white_list + return lines + + +def process_modules(app, what, name, obj, options, lines): + """Add module names to spelling white list.""" + if what != 'module': + return lines + from enchant.tokenize import basic_tokenize + + spelling_white_list = ['', '.. spelling::'] + words = name.replace('-', '.').replace('_', '.').split('.') + words = [s for s in words if s != ''] + for word in words: + spelling_white_list += [" %s" % ''.join(i for i in word if not i.isdigit())] + spelling_white_list += [" %s" % w[0] for w in basic_tokenize(word)] + lines += spelling_white_list + return lines + + +def skip_queryset(app, what, name, obj, skip, options): + """Skip queryset subclasses to avoid database queries.""" + from django.db import models + if isinstance(obj, (models.QuerySet, models.manager.BaseManager)) or name.endswith('objects'): + return True + return skip + + +def setup(app): + # Register the docstring processor with sphinx + app.connect('autodoc-process-docstring', process_django_models) + app.connect('autodoc-skip-member', skip_queryset) + if enchant is not None: + app.connect('autodoc-process-docstring', process_modules) + + + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.todo', + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', + 'sphinx_rtd_theme', + 'sphinx.ext.graphviz', + 'sphinx.ext.napoleon', + 'sphinx.ext.inheritance_diagram', + +] + +if enchant is not None: + extensions.append('sphinxcontrib.spelling') + + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'ru' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = { + 'https://docs.python.org/3/': None, + 'django': ( + 'https://docs.djangoproject.com/en/dev/', + 'https://docs.djangoproject.com/en/dev/_objects/' + ), + 'djangoextensions': ('https://django-extensions.readthedocs.org/en/latest/', None), + 'geoposition': ('https://django-geoposition.readthedocs.org/en/latest/', None), + 'braces': ('https://django-braces.readthedocs.org/en/latest/', None), + 'select2': ('https://django-select2.readthedocs.org/en/latest/', None), + 'celery': ('https://celery.readthedocs.org/en/latest/', None), +} + +autodoc_default_flags = ['members'] + +# spell checking +spelling_lang = 'en_US' +spelling_word_list_filename = 'spelling_wordlist.txt' +spelling_show_suggestions = True +spelling_ignore_pypi_package_names = True + +# -- Options for todo extension ---------------------------------------------- + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True From a64e641a33e0d5eb623e15ad227098432ed8c2e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Mon, 1 Mar 2021 21:18:27 +0300 Subject: [PATCH 043/195] Change conf using github snippet --- docs/source/conf.py | 1 + main/models.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/source/conf.py b/docs/source/conf.py index 6c8988c..b70857e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -85,6 +85,7 @@ def process_django_models(app, what, name, obj, options, lines): lines.append(':type %s: %s.%s' % (field.attname, module, field_type.__name__)) if enchant is not None: lines += spelling_white_list + print('ok') return lines diff --git a/main/models.py b/main/models.py index b7b30dd..2cf76ac 100644 --- a/main/models.py +++ b/main/models.py @@ -15,6 +15,7 @@ class UserProfile(models.Model): :type image: :class:`img` :param name: Имя пользователя на нашем сайте :type name: :class:`str` + """ user = models.OneToOneField(to=User, on_delete=models.CASCADE) From f9e6b79f0124755a90bb7b48f9f5e5104c260d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 2 Mar 2021 09:52:18 +0300 Subject: [PATCH 044/195] Change models documentation using snippets --- main/models.py | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/main/models.py b/main/models.py index 2cf76ac..8665570 100644 --- a/main/models.py +++ b/main/models.py @@ -8,20 +8,12 @@ class UserProfile(models.Model): """ Модель профиля пользователя - :param user: OneToOneField к модели :class:`django.contrib.auth.models.User` - :param role: Код роли пользователя - :type role: :class:`integer` - :param image: Аватарка - :type image: :class:`img` - :param name: Имя пользователя на нашем сайте - :type name: :class:`str` - """ - user = models.OneToOneField(to=User, on_delete=models.CASCADE) - role = models.CharField(default='None', max_length=100) - image = models.URLField(null=True, blank=True) - name = models.CharField(default='None', max_length=100) + user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') + role = models.CharField(default='None', max_length=100, help_text='Код роли пользователя') + image = models.URLField(null=True, blank=True, help_text='Аватарка') + name = models.CharField(default='None', max_length=100, help_text='Имя пользователя на нашем сайте') @receiver(post_save, sender=User) @@ -39,17 +31,9 @@ class RoleChangeLogs(models.Model): """ Модель для логирования изменений ролей пользователя - :param user: Пользователь, которому присвоили другую роль, ForeignKey к модели :class:`django.contrib.auth.models.User` - :param name: Имя пользователя - :type name: :class:`str` - :param new_role: Присвоенная роль - :type new_role: :class:`str` - :param change_time: Дата изменения роли` - :type change_time: :class:`datetime.datetime` - :param changed_by: Кем была изменена роль, ForeignKey к модели :class:`django.contrib.auth.models.User` """ - user = models.ForeignKey(to=User, on_delete=models.CASCADE) - name = models.TextField() - new_role = models.TextField() - change_time = models.DateTimeField() - changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by') + user = models.ForeignKey(to=User, on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль') + name = models.TextField(help_text='Имя пользователя') + new_role = models.TextField(help_text='Присвоенная роль') + change_time = models.DateTimeField(help_text='Дата и время изменения роли') + changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') From 4a9b3d71a42678327d8a8861f3376d281f0d7f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 2 Mar 2021 12:30:56 +0300 Subject: [PATCH 045/195] Add typehints --- docs/source/conf.py | 6 ++++++ main/extra_func.py | 31 ++++++++++--------------------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index b70857e..fdcc4e4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -134,6 +134,7 @@ extensions = [ 'sphinx.ext.graphviz', 'sphinx.ext.napoleon', 'sphinx.ext.inheritance_diagram', + 'sphinx_autodoc_typehints' ] @@ -200,3 +201,8 @@ spelling_ignore_pypi_package_names = True # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True + +set_type_checking_flag = True +typehints_fully_qualified = True +always_document_param_types = True +typehints_document_rtype = True diff --git a/main/extra_func.py b/main/extra_func.py index 20c3712..2d780b8 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -20,65 +20,54 @@ class ZendeskAdmin: :type token: :class:`str` :param password: Пароль администратора, указанный в env :type password: :class:`str` + """ - credentials = { + credentials:dict = { 'subdomain': 'ngenix1612197338' } - email = os.getenv('ACCESS_CONTROLLER_API_EMAIL') - token = os.getenv('ACCESS_CONTROLLER_API_TOKEN') - password = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') + email: str = os.getenv('ACCESS_CONTROLLER_API_EMAIL') + token: str = os.getenv('ACCESS_CONTROLLER_API_TOKEN') + password: str = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') def __init__(self): self.create_admin() def check_user(self, email: str) -> bool: """ - Функция **check_user** осуществляет проверку существования пользователя в Zendesk + Функция **check_user** осуществляет проверку существования пользователя в Zendesk по email - :param email: Электронная почта пользователя - :type email: :class:`email` - :return: True, если существует, иначе False - :rtype: :class:`bool` """ return True if self.admin.search(email, type='user') else False def get_user_name(self, email: str) -> str: """ - Функция **get_user_name** возвращает имя пользователя + Функция **get_user_name** возвращает имя пользователя по его email - :param user_name: Имя пользователя - :type user_name: :class:`str` """ user = self.admin.users.search(email).values[0] return user.name def get_user_role(self, email: str) -> str: """ - Функция **get_user_role** возвращает роль пользователя + Функция **get_user_role** возвращает роль пользователя по его email - :param user_role: Роль пользователя - :type user_role: :class:`str` """ user = self.admin.users.search(email).values[0] return user.role def get_user_id(self, email: str) -> str: """ - Функция **get_user_id** возвращает id пользователя + Функция **get_user_id** возвращает id пользователя по его email - :param user_id: ID пользователя - :type user_id: :class:`str` """ user = self.admin.users.search(email).values[0] return user.id def get_user_image(self, email: str) -> str: """ - Функция **get_user_image** возвращает аватар пользователя + Функция **get_user_image** возвращает аватар пользователя по его email - :param user_image: Аватар пользователя - :type user_image: :class:`img` """ user = self.admin.users.search(email).values[0] return user.photo['content_url'] if user.photo else None From edbe0a66af24762b70678d175f30b90f20dc35d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 2 Mar 2021 15:10:28 +0300 Subject: [PATCH 046/195] Change extra_func documentation --- main/extra_func.py | 65 +++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 2d780b8..9e104be 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -13,9 +13,9 @@ class ZendeskAdmin: Класс **ZendeskAdmin** существует, чтобы в каждой фунциии отдельно не проверять аккаунт администратора :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) - :type credentials: :class:`list of dictionaries` + :type credentials: :class:`dict` :param email: Email администратора, указанный в env - :type email: :class:`email` + :type email: :class:`str` :param token: Токен администратора (формируется в Zendesk, указывается в env) :type token: :class:`str` :param password: Пароль администратора, указанный в env @@ -23,7 +23,7 @@ class ZendeskAdmin: """ - credentials:dict = { + credentials: dict = { 'subdomain': 'ngenix1612197338' } email: str = os.getenv('ACCESS_CONTROLLER_API_EMAIL') @@ -36,14 +36,12 @@ class ZendeskAdmin: def check_user(self, email: str) -> bool: """ Функция **check_user** осуществляет проверку существования пользователя в Zendesk по email - """ return True if self.admin.search(email, type='user') else False def get_user_name(self, email: str) -> str: """ Функция **get_user_name** возвращает имя пользователя по его email - """ user = self.admin.users.search(email).values[0] return user.name @@ -51,7 +49,6 @@ class ZendeskAdmin: def get_user_role(self, email: str) -> str: """ Функция **get_user_role** возвращает роль пользователя по его email - """ user = self.admin.users.search(email).values[0] return user.role @@ -59,7 +56,6 @@ class ZendeskAdmin: def get_user_id(self, email: str) -> str: """ Функция **get_user_id** возвращает id пользователя по его email - """ user = self.admin.users.search(email).values[0] return user.id @@ -67,23 +63,29 @@ class ZendeskAdmin: def get_user_image(self, email: str) -> str: """ Функция **get_user_image** возвращает аватар пользователя по его email - """ 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: + """ + Функция **get_user** возвращает пользователя (объект) по его email + """ return self.admin.users.search(email).values[0] def get_user_org(self, email: str) -> str: + """ + Функция **get_user_org** возвращает организацию, к которой относится пользователь по его email + """ user = self.admin.users.search(email).values[0] return user.organization.name - def create_admin(self) -> None: + def create_admin(self) -> Zenpy: """ Функция **Create_admin()** создает администратора, проверяя наличие вводимых данных в env. :param credentials: В список полномочий администратора вносятся email, token, password из env + :type credentials: :class:`dict` :raise: :class:`ValueError`: исключение, вызываемое если email не введен в env :raise: :class:`APIException`: исключение, вызываемое если пользователя с таким email не существует в Zendesk """ @@ -105,34 +107,43 @@ class ZendeskAdmin: raise ValueError('invalid access_controller`s login data') -def update_role(user_profile, role): +def update_role(user_profile: UserProfile, role: str) -> UserProfile: + """ + Функция **update_role** меняет роль пользователя. + """ zendesk = ZendeskAdmin() user = zendesk.get_user(user_profile.user.email) user.custom_role_id = role zendesk.admin.users.update(user) -def make_engineer(user_profile): +def make_engineer(user_profile: UserProfile) -> UserProfile: + """ + Функция **make_engineer** устанавливапет пользователю роль инженера. + """ update_role(user_profile, ROLES['engineer']) -def make_light_agent(user_profile): +def make_light_agent(user_profile: UserProfile) -> UserProfile: + """ + Функция **make_light_agent** устанавливапет пользователю роль легкого агента. + """ update_role(user_profile, ROLES['light_agent']) -def get_users_list(): +def get_users_list() -> list: + """ + Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации. + """ zendesk = ZendeskAdmin() admin = zendesk.get_user(zendesk.email) org = next(zendesk.admin.users.organizations(user=admin)) return zendesk.admin.organizations.users(org) -def update_profile(user_profile: UserProfile): +def update_profile(user_profile: UserProfile) -> UserProfile: """ - Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk - - :param user_profile: Объект профиля пользователя - :type user_profile: :class:`main.models.UserProfile` + Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk """ user = ZendeskAdmin().get_user(user_profile.user.email) user_profile.name = user.name @@ -143,12 +154,7 @@ def update_profile(user_profile: UserProfile): def check_user_exist(email: str) -> bool: """ - Функция проверяет, существует ли пользователь - - :param email: Электронная почта пользователя - :type email: :class:`str` - :return: True, если существует, иначе False - :rtype: :class:`bool` + Функция проверяет, существует ли пользователь """ return ZendeskAdmin().check_user(email) @@ -156,11 +162,6 @@ def check_user_exist(email: str) -> bool: def get_user_organization(email: str) -> str: """ Функция возвращает организацию пользователя - - :param email: Электронная почта пользователя - :type email: :class:`str` - :return: Название организации - :rtype: :class:`str` """ return ZendeskAdmin().get_user_org(email) @@ -169,13 +170,7 @@ def check_user_auth(email: str, password: str) -> bool: """ Функция проверяет, верны ли входные данные - :param email: Электроная почта пользователя - :type email: :class:`str` - :param password: Пароль пользователя - :type password: :class:`str` - :return: True, если входные данные верны, иначе False :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован - :rtype: :class:`bool` """ creds = { 'email': email, From 525a5a61d226356187505ad459c39c2fede3b67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Tue, 2 Mar 2021 16:52:20 +0300 Subject: [PATCH 047/195] Change extra_func documentation --- main/extra_func.py | 2 +- main/forms.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 9e104be..b07f242 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -78,7 +78,7 @@ class ZendeskAdmin: Функция **get_user_org** возвращает организацию, к которой относится пользователь по его email """ user = self.admin.users.search(email).values[0] - return user.organization.name + return user.organization def create_admin(self) -> Zenpy: """ diff --git a/main/forms.py b/main/forms.py index d80932c..176302e 100644 --- a/main/forms.py +++ b/main/forms.py @@ -6,11 +6,11 @@ from main.models import UserProfile class CustomRegistrationForm(RegistrationFormUniqueEmail): """ - Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` - с добавлением bootstrap-класса 'form-control' + Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` + с добавлением bootstrap-класса 'form-control' - :param password_zen: Поле для ввода пароля от Zendesk - :type password_zen: :class:`django.forms.CharField` + :param password_zen: Поле для ввода пароля от Zendesk + :type password_zen: :class:`django.forms.CharField` """ def __init__(self, *args, **kwargs): @@ -29,6 +29,13 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): class AdminPageUsers(forms.Form): + """ + Форма для установки статуса + + :param password_zen: Поле для ввода пароля от Zendesk + :type password_zen: :class:`django.forms.CharField` + """ + users = forms.ModelMultipleChoiceField( queryset=UserProfile.objects.filter(role='agent'), widget=forms.CheckboxSelectMultiple( From f50ce49d7766cd08432c7f97a6c37aec3f63242d Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Tue, 2 Mar 2021 21:44:13 +0300 Subject: [PATCH 048/195] update login with email --- access_controller/auth.py | 19 +++++++++++++++++++ access_controller/settings.py | 6 ++++++ access_controller/urls.py | 5 +++-- main/extra_func.py | 15 +++++++++------ main/forms.py | 23 +++++++++++++++++++---- main/views.py | 18 ++++++++++++------ 6 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 access_controller/auth.py diff --git a/access_controller/auth.py b/access_controller/auth.py new file mode 100644 index 0000000..be707e1 --- /dev/null +++ b/access_controller/auth.py @@ -0,0 +1,19 @@ +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.models import User + + +class EmailAuthBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + try: + user = User.objects.get(email=username) + if user.check_password(password): + return user + return None + except User.DoesNotExist: + return None + + def get_user(self, user_id): + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None diff --git a/access_controller/settings.py b/access_controller/settings.py index 480cfbd..8b4ec96 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -135,6 +135,12 @@ ACCOUNT_ACTIVATION_DAYS = 7 LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' + +# Название_приложения.Название_файла.Название_класса_обработчика +AUTHENTICATION_BACKENDS = [ + 'access_controller.auth.EmailAuthBackend', +] + # Logging system # https://docs.djangoproject.com/en/3.1/topics/logging/ LOGGING = { diff --git a/access_controller/urls.py b/access_controller/urls.py index 45781bf..b91e747 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -14,17 +14,18 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin +from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.views import LoginView from django.contrib.auth import views as auth_views from django.urls import path, include -from main.views import main_page, profile_page, CustomRegistrationView +from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView urlpatterns = [ path('admin/', admin.site.urls, name='admin'), path('', main_page, name='index'), path('accounts/profile/', profile_page, name='profile'), path('accounts/register/', CustomRegistrationView.as_view(), name='registration'), - path('accounts/login/', LoginView.as_view(extra_context={}), name='login'), # TODO add extra context + path('accounts/login/', CustomLoginView.as_view(extra_context={}), name='login',), # TODO add extra context path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('django_registration.backends.activation.urls')), path('accounts/login/', include('django.contrib.auth.urls')), diff --git a/main/extra_func.py b/main/extra_func.py index 9e0a564..3ce296a 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -26,6 +26,12 @@ class ZendeskAdmin: email = os.getenv('ACCESS_CONTROLLER_API_EMAIL') token = os.getenv('ACCESS_CONTROLLER_API_TOKEN') password = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') + _instance=None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance def __init__(self): self.create_admin() @@ -73,10 +79,7 @@ class ZendeskAdmin: def get_user_image(self, email: str) -> str: """ - Функция **get_user_image** возвращает аватар пользователя - - :param user_image: Аватар пользователя - :type user_image: :class:`img` + Функция **get_user_image** возвращает url-ссылку на аватар пользователя """ user = self.admin.users.search(email).values[0] return user.photo['content_url'] if user.photo else None @@ -86,7 +89,7 @@ class ZendeskAdmin: def get_user_org(self, email: str) -> str: user = self.admin.users.search(email).values[0] - return user.organization.name + return user.organization.name def create_admin(self) -> None: """ @@ -140,7 +143,7 @@ def check_user_exist(email: str) -> bool: return ZendeskAdmin().check_user(email) -def get_user_organization(email: str) -> bool: +def get_user_organization(email: str) -> str: """ Функция возвращает организацию пользователя diff --git a/main/forms.py b/main/forms.py index 6a058bb..e33e1ae 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.auth.forms import AuthenticationForm from django_registration.forms import RegistrationFormUniqueEmail @@ -6,11 +7,7 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): """ Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` с добавлением bootstrap-класса 'form-control' - - :param password_zen: Поле для ввода пароля от Zendesk - :type password_zen: :class:`django.forms.CharField` """ - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for visible in self.visible_fields(): @@ -24,3 +21,21 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): class Meta(RegistrationFormUniqueEmail.Meta): fields = RegistrationFormUniqueEmail.Meta.fields + + +class CustomAuthenticationForm(AuthenticationForm): + """ + Форма для авторизации :class:`django.contrib.auth.forms.AuthenticationForm` + с изменением поля username на email + """ + username = forms.CharField( + label="Электронная почта", + widget=forms.EmailInput(), + ) + error_messages = { + 'invalid_login': + "Пожалуйста, введите правильные электронную почту и пароль. Оба поля " + "могут быть чувствительны к регистру." + , + 'inactive': "Аккаунт не активен.", + } diff --git a/main/views.py b/main/views.py index a73017f..8fb892a 100644 --- a/main/views.py +++ b/main/views.py @@ -1,18 +1,17 @@ +import logging + from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.models import User from django.contrib.auth.tokens import default_token_generator +from django.contrib.auth.views import LoginView from django.shortcuts import render from django.urls import reverse_lazy -from django_registration.backends.one_step.views import RegistrationView +from django_registration.views import RegistrationView from access_controller.settings import EMAIL_HOST_USER from main.extra_func import check_user_exist, update_profile, get_user_organization -from main.forms import CustomRegistrationForm -from django_registration.views import RegistrationView -from django.contrib.auth.decorators import login_required -import logging - +from main.forms import CustomRegistrationForm, CustomAuthenticationForm class CustomRegistrationView(RegistrationView): @@ -89,3 +88,10 @@ def main_page(request): logger = logging.getLogger('main.index') logger.info('Index page opened') return render(request, 'pages/index.html') + + +class CustomLoginView(LoginView): + """ + Отображение страницы авторизации пользователя + """ + form_class = CustomAuthenticationForm From 6cdd787cc8fcf4f8240fc3efdeeeb631d678ac32 Mon Sep 17 00:00:00 2001 From: Artyom Kravchenko Date: Wed, 3 Mar 2021 13:46:44 +0300 Subject: [PATCH 049/195] add typehints to requirements --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b2e4c64..4161b6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,4 @@ django_registration==3.1.1 # Documentation Sphinx==3.4.3 sphinx-rtd-theme==0.5.1 - +sphinx-autodoc-typehints==1.11.1 From 0cda9da3116820266e9f3113875cf0c87fb2ee24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 3 Mar 2021 20:59:53 +0300 Subject: [PATCH 050/195] Add views and forms documentation --- main/forms.py | 15 ++++++++------- main/views.py | 35 +++++++++++++++++++++++++---------- requirements.txt | 1 + 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/main/forms.py b/main/forms.py index 176302e..63a3556 100644 --- a/main/forms.py +++ b/main/forms.py @@ -7,13 +7,14 @@ from main.models import UserProfile class CustomRegistrationForm(RegistrationFormUniqueEmail): """ Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` - с добавлением bootstrap-класса 'form-control' - :param password_zen: Поле для ввода пароля от Zendesk - :type password_zen: :class:`django.forms.CharField` + с добавлением bootstrap-класса "form-control" + + :param visible_fields.email: Поле для ввода email, зарегистирированного на Zendesk + :type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail` """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> RegistrationFormUniqueEmail: super().__init__(*args, **kwargs) for visible in self.visible_fields(): if visible.field.widget.attrs.get('class', False): @@ -30,10 +31,10 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): class AdminPageUsers(forms.Form): """ - Форма для установки статуса + Форма для установки статусов engineer или light_agent пользователям - :param password_zen: Поле для ввода пароля от Zendesk - :type password_zen: :class:`django.forms.CharField` + :param users: Поле для установки статуса + :type users: :class:`ModelMultipleChoiceField` """ users = forms.ModelMultipleChoiceField( diff --git a/main/views.py b/main/views.py index 72d4e52..c7c1849 100644 --- a/main/views.py +++ b/main/views.py @@ -27,13 +27,17 @@ from access_controller.settings import ZENDESK_ROLES class CustomRegistrationView(RegistrationView): """ Отображение и логика работы страницы регистрации пользователя + + 1. Ввод email пользователя, указанный на Zendesk + 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk и относится к определенной организации, происходит сброс ссылки с установлением пароля на указанный email + 3. Создается пользователь class User, а также его профиль """ form_class = CustomRegistrationForm template_name = 'django_registration/registration_form.html' success_url = reverse_lazy('django_registration_complete') is_allowed = True - def register(self, form): + def register(self, form: CustomRegistrationForm) -> User: self.is_allowed = True if check_user_exist(form.data['email']) and get_user_organization(form.data['email']) == 'SYSTEM': forms = PasswordResetForm(self.request.POST) @@ -61,7 +65,7 @@ class CustomRegistrationView(RegistrationView): else: self.is_allowed = False - def get_success_url(self, user=None): + def get_success_url(self, user: User = None) -> success_url: """ Возвращает url-адрес страницы, куда нужно перейти после успешной/неуспешной регистрации Используется самой django-registration @@ -73,13 +77,9 @@ class CustomRegistrationView(RegistrationView): @login_required() -def profile_page(request): +def profile_page(request: UserProfile) -> UserProfile: """ Отображение страницы профиля - - :param request: объект с деталями запроса - :type request: :class:`django.http.HttpResponse` - :return: объект ответа сервера с HTML-кодом внутри """ user_profile = request.user.userprofile update_profile(user_profile) @@ -95,17 +95,26 @@ def profile_page(request): def main_page(request): + """ + Отображение логгирования на главной странице + """ logger = logging.getLogger('main.index') logger.info('Index page opened') return render(request, 'pages/index.html') class AdminPageView(FormView, LoginRequiredMixin): + """ + Class AdminPageView - логика работы страницы администратора + """ template_name = 'pages/adm_ruleset.html' form_class = AdminPageUsers success_url = '/control/' - def form_valid(self, form): + def form_valid(self, form: AdminPageUsers) -> AdminPageUsers: + """ + Функция установки ролей пользователям + """ if 'engineer' in self.request.POST: self.make_engineers(form.cleaned_data['users']) elif 'light_agent' in self.request.POST: @@ -121,7 +130,10 @@ class AdminPageView(FormView, LoginRequiredMixin): [make_light_agent(user) for user in users] @staticmethod - def count_users(users): # TODO: this func counts users from all zendesk instead of just from a model + def count_users(users: User) -> int: #.. todolist:: :this func counts users from all zendesk instead of just from a model: + """ + Функция подсчета количества сотрудников с ролями engineer и light_a + """ engineers, light_agents = 0, 0 for user in users: if user.custom_role_id == ZENDESK_ROLES['engineer']: @@ -130,7 +142,10 @@ class AdminPageView(FormView, LoginRequiredMixin): light_agents += 1 return engineers, light_agents - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> list: + """ + Функция формирования контента страницы администратора (с проверкой прав доступа) + """ if self.request.user.userprofile.role != 'admin': raise PermissionDenied context = super().get_context_data(**kwargs) diff --git a/requirements.txt b/requirements.txt index b2e4c64..826cc73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ django_registration==3.1.1 # Documentation Sphinx==3.4.3 sphinx-rtd-theme==0.5.1 +sphinx-autodoc-typehints==1.11.1 From cd47331ae7496b7a3b02cc1d1ba18c4bf05848ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Wed, 3 Mar 2021 21:05:31 +0300 Subject: [PATCH 051/195] After mergin develop --- main/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/forms.py b/main/forms.py index a9624dd..32ed00b 100644 --- a/main/forms.py +++ b/main/forms.py @@ -51,8 +51,8 @@ class AdminPageUsers(forms.Form): class CustomAuthenticationForm(AuthenticationForm): """ - Форма для авторизации :class:`django.contrib.auth.forms.AuthenticationForm` - с изменением поля username на email + Форма для авторизации :class:`django.contrib.auth.forms.AuthenticationForm` + с изменением поля username на email """ username = forms.CharField( label="Электронная почта", From fd1e048ebe67bbab7ba42cc599bb175bf5995e49 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Thu, 4 Mar 2021 01:25:56 +0300 Subject: [PATCH 052/195] statistic backend first version --- access_controller/urls.py | 10 ++-- main/forms.py | 28 ++++++++- main/migrations/0005_auto_20210304_0119.py | 67 ++++++++++++++++++++++ main/models.py | 1 + main/templates/pages/stat.html | 26 +++++++++ main/views.py | 56 +++++++++++++++++- 6 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 main/migrations/0005_auto_20210304_0119.py create mode 100644 main/templates/pages/stat.html diff --git a/access_controller/urls.py b/access_controller/urls.py index 45d815b..6554616 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -14,15 +14,12 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.contrib.auth.forms import AuthenticationForm -from django.contrib.auth.views import LoginView from django.contrib.auth import views as auth_views from django.urls import path, include -from access_controller import settings -from access_controller.settings import DEBUG -from main.views import main_page, profile_page, CustomRegistrationView, 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 urlpatterns = [ path('admin/', admin.site.urls, name='admin'), @@ -37,7 +34,8 @@ 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 += [ diff --git a/main/forms.py b/main/forms.py index e69de1d..ff6a9d3 100644 --- a/main/forms.py +++ b/main/forms.py @@ -10,6 +10,7 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` с добавлением bootstrap-класса 'form-control' """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for visible in self.visible_fields(): @@ -18,7 +19,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): @@ -57,3 +58,28 @@ class CustomAuthenticationForm(AuthenticationForm): , 'inactive': "Аккаунт не активен.", } + + +class StatisticForm(forms.Form): + email = forms.EmailField( + label='Электроная почта', + ) + inter = forms.CharField( # TODO: Переделать под html страницу + label='Выбор интервала', + ) + dio_start = forms.DateField( # TODO: Переделать под html страницу + label='Начало диапазона', + widget=forms.DateInput( + attrs={ + 'type': 'date', + } + ), + ) + dio_end = forms.DateField( # TODO: Переделать под html страницу + label='Конец диапазона', + widget=forms.DateInput( + attrs={ + 'type': 'date', + } + ), + ) diff --git a/main/migrations/0005_auto_20210304_0119.py b/main/migrations/0005_auto_20210304_0119.py new file mode 100644 index 0000000..361ec25 --- /dev/null +++ b/main/migrations/0005_auto_20210304_0119.py @@ -0,0 +1,67 @@ +# Generated by Django 3.1.6 on 2021-03-03 22:19 + +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', '0004_rolechangelogs'), + ] + + operations = [ + migrations.AddField( + model_name='rolechangelogs', + name='old_role', + field=models.TextField(default='agent', help_text='Старая роль'), + preserve_default=False, + ), + 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/models.py b/main/models.py index 8665570..13bd55e 100644 --- a/main/models.py +++ b/main/models.py @@ -34,6 +34,7 @@ class RoleChangeLogs(models.Model): """ user = models.ForeignKey(to=User, on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль') name = models.TextField(help_text='Имя пользователя') + old_role = models.TextField(help_text='Старая роль') new_role = models.TextField(help_text='Присвоенная роль') change_time = models.DateTimeField(help_text='Дата и время изменения роли') changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') diff --git a/main/templates/pages/stat.html b/main/templates/pages/stat.html new file mode 100644 index 0000000..1ac47d7 --- /dev/null +++ b/main/templates/pages/stat.html @@ -0,0 +1,26 @@ +{% extends 'base/base.html' %} + +{% load static %} + +{% block title %}{{ pagename }}{% endblock %} + +{% block heading %}Статистика{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} + {% for field in form %} + {{field.label}} + {{field}} +
+ {% endfor %} + +
+
+{% endblock %} + diff --git a/main/views.py b/main/views.py index b765bd0..532aac0 100644 --- a/main/views.py +++ b/main/views.py @@ -9,7 +9,7 @@ from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.views import LoginView from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect -from django.shortcuts import get_list_or_404, redirect, reverse, render +from django.shortcuts import get_list_or_404, redirect, reverse, render, get_object_or_404 from django.urls import reverse_lazy from django.views.generic import FormView from django_registration.views import RegistrationView @@ -19,8 +19,8 @@ from zenpy.lib.api_objects import User as ZenpyUser from access_controller.settings import EMAIL_HOST_USER, ZENDESK_ROLES from main.extra_func import check_user_exist, update_profile, get_user_organization, make_engineer, make_light_agent, \ get_users_list -from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm -from .models import UserProfile +from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm +from .models import UserProfile, RoleChangeLogs class CustomRegistrationView(RegistrationView): @@ -190,3 +190,53 @@ class CustomLoginView(LoginView): Отображение страницы авторизации пользователя """ form_class = CustomAuthenticationForm + + +@login_required() +def statistic_page(request): + if not request.user.is_superuser: + return redirect('index') + context = { + 'pagename': 'страница статистики', + } + if request.method == "POST": + form = StatisticForm(request.POST) + if form.is_valid(): + start_date = form.cleaned_data['dio_start'] + end_date = form.cleaned_data['dio_end'] + try: + data = RoleChangeLogs.objects.filter( + change_time__range=[start_date, end_date], + user=User.objects.get(email=form.cleaned_data['email']), + ) + except User.DoesNotExist: + data = [] + context['errors'] = 'Пользователь не найден' + if not data: + context['errors'] = 'Не найдено изменений роли за этот промежуток времени' + else: + sep = form.cleaned_data['inter'] + stat = {} + if sep == 'days': + for day in daterange(start_date, end_date+timedelta(days=1)): + stat[day] = 0 + if data[0].old_role == 'engineer': + stat[start_date] += data[0].change_time.time().hour + if data[-1].old_role == 'engineer': + stat[end_date] += 24-data[-1].change_time.time().hour + else: + context['errors'] = form.errors + + if request.method == 'GET': + form = StatisticForm() + + context['form'] = form + return render(request, 'pages/stat.html', context) + + +from datetime import timedelta, date + + +def daterange(start_date, end_date): + for n in range(int((end_date - start_date).days)): + yield start_date + timedelta(n) From d46b90010de3cd8d62bb5b6ad896b9f2a301ef9b Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Thu, 4 Mar 2021 18:22:49 +0300 Subject: [PATCH 053/195] Added permissions system to control page --- main/migrations/0005_auto_20210302_2255.py | 17 +++++++++++++ main/migrations/0006_delete_userprofile.py | 16 ++++++++++++ main/migrations/0007_userprofile.py | 29 ++++++++++++++++++++++ main/migrations/0008_auto_20210303_2305.py | 17 +++++++++++++ main/templates/base/menu.html | 3 +++ main/templates/pages/adm_ruleset.html | 23 ++++++++--------- main/views.py | 26 ++++++++++++++----- static/main/js/control.js | 9 +++++++ 8 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 main/migrations/0005_auto_20210302_2255.py create mode 100644 main/migrations/0006_delete_userprofile.py create mode 100644 main/migrations/0007_userprofile.py create mode 100644 main/migrations/0008_auto_20210303_2305.py create mode 100644 static/main/js/control.js diff --git a/main/migrations/0005_auto_20210302_2255.py b/main/migrations/0005_auto_20210302_2255.py new file mode 100644 index 0000000..dff2dc2 --- /dev/null +++ b/main/migrations/0005_auto_20210302_2255.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.6 on 2021-03-02 19:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0004_rolechangelogs'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userprofile', + options={'permissions': [('admin', 'Have access to control page')]}, + ), + ] diff --git a/main/migrations/0006_delete_userprofile.py b/main/migrations/0006_delete_userprofile.py new file mode 100644 index 0000000..23adaab --- /dev/null +++ b/main/migrations/0006_delete_userprofile.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1.6 on 2021-03-03 19:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0005_auto_20210302_2255'), + ] + + operations = [ + migrations.DeleteModel( + name='UserProfile', + ), + ] diff --git a/main/migrations/0007_userprofile.py b/main/migrations/0007_userprofile.py new file mode 100644 index 0000000..2b05dd7 --- /dev/null +++ b/main/migrations/0007_userprofile.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.6 on 2021-03-03 19:35 + +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', '0006_delete_userprofile'), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(default='None', max_length=100)), + ('image', models.URLField(blank=True, null=True)), + ('name', models.CharField(default='None', max_length=100)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'permissions': [('control_access', 'User has access to control page')], + }, + ), + ] diff --git a/main/migrations/0008_auto_20210303_2305.py b/main/migrations/0008_auto_20210303_2305.py new file mode 100644 index 0000000..8082682 --- /dev/null +++ b/main/migrations/0008_auto_20210303_2305.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.6 on 2021-03-03 20:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0007_userprofile'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userprofile', + options={}, + ), + ] diff --git a/main/templates/base/menu.html b/main/templates/base/menu.html index 8448b0c..f64ceb3 100644 --- a/main/templates/base/menu.html +++ b/main/templates/base/menu.html @@ -11,6 +11,9 @@ {% if request.user.is_authenticated %}
Профиль + {% if perms.main.has_control_access %} + Управление + {% endif %} Выйти
{% else %} diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index 1bddc5f..387cd73 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -2,7 +2,7 @@ {% load static %} -{% block title %}Управление{%endblock %} +{% block title %}Управление{% endblock %} {% block heading %}Управление{% endblock %} @@ -16,19 +16,24 @@

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

+ {% block form %}
{% csrf_token %}
+ {% block hidden_form %}
{% for field in form.users %} {{ field.tag }} {% endfor %}
+ {% endblock %}
Список сотрудников
+ + {% block table %} @@ -52,10 +57,12 @@
+ {% endblock%}
+ {% block count %}
@@ -91,19 +98,11 @@
+ {% endblock %} + {% endblock %}
- + {% endblock %} diff --git a/main/views.py b/main/views.py index 72d4e52..517ea84 100644 --- a/main/views.py +++ b/main/views.py @@ -1,22 +1,20 @@ -from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordResetForm -from django.contrib.auth.models import User from django.contrib.auth.tokens import default_token_generator +from django.contrib.contenttypes.models import ContentType from django.shortcuts import render, get_list_or_404 from django.urls import reverse_lazy from django.views.generic import FormView -from django_registration.backends.one_step.views import RegistrationView from access_controller.settings import EMAIL_HOST_USER from main.extra_func import check_user_exist, update_profile, get_user_organization, \ make_engineer, make_light_agent, get_users_list -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Permission from main.models import UserProfile from main.forms import CustomRegistrationForm, AdminPageUsers from django_registration.views import RegistrationView from django.contrib.auth.decorators import login_required -from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.core.exceptions import PermissionDenied import logging @@ -55,12 +53,27 @@ class CustomRegistrationView(RegistrationView): ) forms.save(**opts) update_profile(user.userprofile) + self.set_permission(user) return user else: raise ValueError('Непредвиденная ошибка') else: self.is_allowed = False + @staticmethod + def set_permission(user) -> None: + """ + Дает разрешение на просмотр страница администратора, если пользователь имеет роль admin + """ + + content_type = ContentType.objects.get_for_model(UserProfile) + permission, created = Permission.objects.get_or_create( + codename='has_control_access', + content_type=content_type, + ) + if user.userprofile.role == 'admin': + user.user_permissions.add(permission) + def get_success_url(self, user=None): """ Возвращает url-адрес страницы, куда нужно перейти после успешной/неуспешной регистрации @@ -100,7 +113,8 @@ def main_page(request): return render(request, 'pages/index.html') -class AdminPageView(FormView, LoginRequiredMixin): +class AdminPageView(FormView, LoginRequiredMixin, PermissionRequiredMixin): + permission_required = 'main.has_control_access' template_name = 'pages/adm_ruleset.html' form_class = AdminPageUsers success_url = '/control/' diff --git a/static/main/js/control.js b/static/main/js/control.js new file mode 100644 index 0000000..1fd4f9c --- /dev/null +++ b/static/main/js/control.js @@ -0,0 +1,9 @@ +"use strict"; +let checkboxes = document.getElementsByName("users"); +let fields = document.querySelectorAll(".checkbox_field"); +if (checkboxes.length == fields.length) { + for (let i = 0; i < fields.length; ++i) { + let el = checkboxes[i].cloneNode(true); + fields[i].appendChild(el); + } +} From 525bb256974383a40981d20813d63551ca2649a1 Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Thu, 4 Mar 2021 19:06:45 +0300 Subject: [PATCH 054/195] Merge branch 'develop' into feature/adm_ruleset/backend --- main/views.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/main/views.py b/main/views.py index 979aa6b..5c5ed12 100644 --- a/main/views.py +++ b/main/views.py @@ -1,15 +1,15 @@ -import os - -from django.contrib.auth.forms import PasswordResetForm import logging import os -from django.contrib.auth.decorators import login_required 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.shortcuts import render, get_list_or_404 -from django.urls import reverse_lazy +from django.http import HttpResponseRedirect +from django.shortcuts import render, get_list_or_404, redirect +from django.urls import reverse_lazy, reverse from django.views.generic import FormView +from zenpy import Zenpy from access_controller.settings import EMAIL_HOST_USER from main.extra_func import check_user_exist, update_profile, get_user_organization, \ @@ -17,13 +17,14 @@ from main.extra_func import check_user_exist, update_profile, get_user_organizat from django.contrib.auth.models import User, Permission from main.models import UserProfile -from main.forms import CustomRegistrationForm, AdminPageUsers +from main.forms import CustomRegistrationForm, AdminPageUsers, CustomAuthenticationForm from django_registration.views import RegistrationView from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.core.exceptions import PermissionDenied -from access_controller.settings ZENDESK_ROLES +from access_controller.settings import ZENDESK_ROLES +from zenpy.lib.api_objects import User as ZenpyUser class CustomRegistrationView(RegistrationView): From 656081295ee723b9e734e187129f0337e4bbefcc Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 4 Mar 2021 20:01:10 +0300 Subject: [PATCH 055/195] Profile view page refactoring --- main/templates/pages/profile.html | 27 ++++++++++++--------------- main/views.py | 14 +++++--------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/main/templates/pages/profile.html b/main/templates/pages/profile.html index 6c6ecd2..a0f21f9 100644 --- a/main/templates/pages/profile.html +++ b/main/templates/pages/profile.html @@ -10,17 +10,14 @@ {% block extra_css %} - + {% endblock %} {% block content %} @@ -28,24 +25,24 @@
- {% if image_url %} - Аватар - {% else %} - Нет изображения - {% endif %} + Нет изображения
-
Имя пользователя {{name}}
+
Имя пользователя {{ profile.name }}

-
Электронная почта {{email}}
+
Электронная почта {{ profile.user.email }}

-
Текущая роль {{role}}
+
Текущая роль {{ profile.role }}
{% endblock %} diff --git a/main/views.py b/main/views.py index 5c5ed12..b5d953a 100644 --- a/main/views.py +++ b/main/views.py @@ -5,7 +5,8 @@ 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.http import HttpResponseRedirect +from django.core.handlers.wsgi import WSGIRequest +from django.http import HttpResponseRedirect, HttpResponse from django.shortcuts import render, get_list_or_404, redirect from django.urls import reverse_lazy, reverse from django.views.generic import FormView @@ -95,19 +96,14 @@ class CustomRegistrationView(RegistrationView): @login_required() -def profile_page(request: UserProfile) -> UserProfile: +def profile_page(request: WSGIRequest) -> HttpResponse: """ Отображение страницы профиля """ - user_profile = request.user.userprofile + user_profile: UserProfile = request.user.userprofile update_profile(user_profile) - context = { - 'email': user_profile.user.email, - 'name': user_profile.name, - 'role': user_profile.role, - 'id': user_profile.id, - 'image_url': user_profile.image, + 'profile': user_profile, 'pagename': 'Страница профиля' } return render(request, 'pages/profile.html', context) From 9ddf1db2a3c4d6e658bc88e84adc63b75eb8c257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D0=BB=D0=B0=D0=BA=D0=BE=D0=B2=20=D0=AE=D1=80?= =?UTF-8?q?=D0=B8=D0=B9?= Date: Thu, 4 Mar 2021 20:45:55 +0300 Subject: [PATCH 056/195] Fixed bug with permissions --- access_controller/urls.py | 6 +----- main/views.py | 24 +++++++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/access_controller/urls.py b/access_controller/urls.py index 45d815b..3595e4f 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -14,13 +14,9 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.contrib.auth.forms import AuthenticationForm -from django.contrib.auth.views import LoginView from django.contrib.auth import views as auth_views from django.urls import path, include -from access_controller import settings -from access_controller.settings import DEBUG -from main.views import main_page, profile_page, CustomRegistrationView, work_page, work_hand_over, work_become_engineer, AdminPageView +from main.views import work_page, work_hand_over, work_become_engineer, AdminPageView from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView diff --git a/main/views.py b/main/views.py index 5c5ed12..7486d69 100644 --- a/main/views.py +++ b/main/views.py @@ -27,6 +27,13 @@ from access_controller.settings import ZENDESK_ROLES from zenpy.lib.api_objects import User as ZenpyUser +content_type_temp = ContentType.objects.get_for_model(UserProfile) +permission_temp, created = Permission.objects.get_or_create( + codename='has_control_access', + content_type=content_type_temp, +) + + class CustomRegistrationView(RegistrationView): """ Отображение и логика работы страницы регистрации пользователя @@ -74,13 +81,12 @@ class CustomRegistrationView(RegistrationView): """ Дает разрешение на просмотр страница администратора, если пользователь имеет роль admin """ - - content_type = ContentType.objects.get_for_model(UserProfile) - permission, created = Permission.objects.get_or_create( - codename='has_control_access', - content_type=content_type, - ) if user.userprofile.role == 'admin': + content_type = ContentType.objects.get_for_model(UserProfile) + permission = Permission.objects.get( + codename='has_control_access', + content_type=content_type, + ) user.user_permissions.add(permission) def get_success_url(self, user: User = None) -> success_url: @@ -167,7 +173,7 @@ def main_page(request): return render(request, 'pages/index.html') -class AdminPageView(FormView, LoginRequiredMixin, PermissionRequiredMixin): +class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): permission_required = 'main.has_control_access' template_name = 'pages/adm_ruleset.html' form_class = AdminPageUsers @@ -192,7 +198,7 @@ class AdminPageView(FormView, LoginRequiredMixin, PermissionRequiredMixin): [make_light_agent(user) for user in users] @staticmethod - def count_users(users: User) -> int: #.. todolist:: :this func counts users from all zendesk instead of just from a model: + def count_users(users) -> tuple: #.. todolist:: :this func counts users from all zendesk instead of just from a model: """ Функция подсчета количества сотрудников с ролями engineer и light_a """ @@ -204,7 +210,7 @@ class AdminPageView(FormView, LoginRequiredMixin, PermissionRequiredMixin): light_agents += 1 return engineers, light_agents - def get_context_data(self, **kwargs) -> list: + def get_context_data(self, **kwargs) -> dict: """ Функция формирования контента страницы администратора (с проверкой прав доступа) """ From 1d7ff524b0bd65dcf82ff0ae18da011f978cc323 Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 4 Mar 2021 20:54:45 +0300 Subject: [PATCH 057/195] Documentation fix Add enchant, remove intersphinx useless links, remove napoleon, fix todos --- docs/source/conf.py | 30 +++++++++++++----------------- main/extra_func.py | 5 ++++- main/models.py | 10 ++-------- main/views.py | 5 ++++- requirements.txt | 4 ++-- 5 files changed, 25 insertions(+), 29 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index fdcc4e4..c9efe47 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,21 +14,26 @@ import os import sys import importlib import inspect + sys.path.insert(0, os.path.abspath('../../')) import django + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') os.environ.setdefault('DJANGO_CONFIGURATION', 'Dev') # Fix Django's FileFields from django.db.models.fields.files import FileDescriptor -FileDescriptor.__get__ = lambda self, *args, **kwargs: self -from django.db.models.manager import ManagerDescriptor -ManagerDescriptor.__get__ = lambda self, *args, **kwargs: self.manager +FileDescriptor.__get__ = lambda self, *args, **kwargs: self + +from django.db.models.manager import ManagerDescriptor + +ManagerDescriptor.__get__ = lambda self, *args, **kwargs: self.manager # Stop Django from executing DB queries from django.db.models.query import QuerySet + QuerySet.__repr__ = lambda self: self.__class__.__name__ try: import enchant # NoQA @@ -46,7 +51,8 @@ author = 'SHP S101, group 2' # The full version, including alpha/beta/rc tags release = 'v0.01' -#Django sphinx setup by https://gist.github.com/codingjoe/314bda5a07ff3b41f247 + +# Django sphinx setup by https://gist.github.com/codingjoe/314bda5a07ff3b41f247 # -- General configuration --------------------------------------------------- @@ -121,7 +127,6 @@ def setup(app): app.connect('autodoc-process-docstring', process_modules) - # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. @@ -132,7 +137,6 @@ extensions = [ 'sphinx.ext.viewcode', 'sphinx_rtd_theme', 'sphinx.ext.graphviz', - 'sphinx.ext.napoleon', 'sphinx.ext.inheritance_diagram', 'sphinx_autodoc_typehints' @@ -141,7 +145,6 @@ extensions = [ if enchant is not None: extensions.append('sphinxcontrib.spelling') - # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -157,7 +160,6 @@ language = 'ru' # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] - # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for @@ -170,7 +172,6 @@ html_theme = "sphinx_rtd_theme" # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] - # -- Extension configuration ------------------------------------------------- # -- Options for intersphinx extension --------------------------------------- @@ -179,14 +180,9 @@ html_static_path = ['_static'] intersphinx_mapping = { 'https://docs.python.org/3/': None, 'django': ( - 'https://docs.djangoproject.com/en/dev/', - 'https://docs.djangoproject.com/en/dev/_objects/' - ), - 'djangoextensions': ('https://django-extensions.readthedocs.org/en/latest/', None), - 'geoposition': ('https://django-geoposition.readthedocs.org/en/latest/', None), - 'braces': ('https://django-braces.readthedocs.org/en/latest/', None), - 'select2': ('https://django-select2.readthedocs.org/en/latest/', None), - 'celery': ('https://celery.readthedocs.org/en/latest/', None), + 'https://docs.djangoproject.com/en/dev/', + 'https://docs.djangoproject.com/en/dev/_objects/' + ), } autodoc_default_flags = ['members'] diff --git a/main/extra_func.py b/main/extra_func.py index 6227bf1..691bd37 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -74,7 +74,10 @@ class ZendeskAdmin: def get_user(self, email: str) -> str: """ - Функция **get_user** возвращает пользователя (объект) по его email + Функция **get_user** возвращает пользователя (объект) по его email + + :param email: email пользователя + :return: email пользователя, найденного в БД """ return self.admin.users.search(email).values[0] diff --git a/main/models.py b/main/models.py index 8665570..f8f8385 100644 --- a/main/models.py +++ b/main/models.py @@ -5,10 +5,7 @@ from django.dispatch import receiver class UserProfile(models.Model): - """ - Модель профиля пользователя - - """ + """Модель профиля пользователя""" user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') role = models.CharField(default='None', max_length=100, help_text='Код роли пользователя') @@ -28,10 +25,7 @@ def save_user_profile(sender, instance, **kwargs): class RoleChangeLogs(models.Model): - """ - Модель для логирования изменений ролей пользователя - - """ + """Модель для логирования изменений ролей пользователя""" user = models.ForeignKey(to=User, on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль') name = models.TextField(help_text='Имя пользователя') new_role = models.TextField(help_text='Присвоенная роль') diff --git a/main/views.py b/main/views.py index b5d953a..0e6aa41 100644 --- a/main/views.py +++ b/main/views.py @@ -188,9 +188,12 @@ class AdminPageView(FormView, LoginRequiredMixin, PermissionRequiredMixin): [make_light_agent(user) for user in users] @staticmethod - def count_users(users: User) -> int: #.. todolist:: :this func counts users from all zendesk instead of just from a model: + def count_users(users: User) -> int: """ Функция подсчета количества сотрудников с ролями engineer и light_a + + .. todo:: + this func counts users from all zendesk instead of just from a model: """ engineers, light_agents = 0, 0 for user in users: diff --git a/requirements.txt b/requirements.txt index 826cc73..7a4f941 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,9 +4,9 @@ Pillow==8.1.0 zenpy~=2.0.24 django_registration==3.1.1 - # Documentation Sphinx==3.4.3 sphinx-rtd-theme==0.5.1 sphinx-autodoc-typehints==1.11.1 - +pyenchant==3.2.0 +sphinxcontrib-spelling==7.1.0 From 5909750fcc0e71f88ed388b8ecab9a4016566e41 Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 4 Mar 2021 20:56:38 +0300 Subject: [PATCH 058/195] Remove todolist commant --- main/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/views.py b/main/views.py index 5343906..ca8c9c2 100644 --- a/main/views.py +++ b/main/views.py @@ -194,7 +194,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): [make_light_agent(user) for user in users] @staticmethod - def count_users(users) -> tuple: #.. todolist:: :this func counts users from all zendesk instead of just from a model: + def count_users(users) -> tuple: """ Функция подсчета количества сотрудников с ролями engineer и light_a From 8566e2b5b644fe012cd21d2207050f57e3ae9680 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Thu, 4 Mar 2021 23:13:10 +0300 Subject: [PATCH 059/195] Updated version with days iteration --- main/extra_func.py | 15 +++++++ main/templates/pages/stat.html | 20 +++++---- main/views.py | 79 ++++++++++++++++++++++++---------- 3 files changed, 84 insertions(+), 30 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 6227bf1..8929d3e 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,4 +1,5 @@ import os +from datetime import timedelta, datetime from zenpy import Zenpy from zenpy.lib.exception import APIException @@ -188,3 +189,17 @@ def check_user_auth(email: str, password: str) -> bool: except APIException: return False return True + + +def daterange(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 = log.change_time.time() + time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) + print(time) + return time diff --git a/main/templates/pages/stat.html b/main/templates/pages/stat.html index 1ac47d7..ea5e793 100644 --- a/main/templates/pages/stat.html +++ b/main/templates/pages/stat.html @@ -12,15 +12,19 @@ {% block content %}
-
- {% csrf_token %} - {% for field in form %} - {{field.label}} - {{field}} -
+ + {% csrf_token %} + {% for field in form %} + {{field.label}} + {{field}} +
+ {% endfor %} + +
+ {% for key,val in stats_logs.items %} +

{{key}} {{val}}

+
{% endfor %} - -
{% endblock %} diff --git a/main/views.py b/main/views.py index 532aac0..a489b01 100644 --- a/main/views.py +++ b/main/views.py @@ -9,7 +9,7 @@ from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.views import LoginView from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect -from django.shortcuts import get_list_or_404, redirect, reverse, render, get_object_or_404 +from django.shortcuts import get_list_or_404, redirect, reverse, render from django.urls import reverse_lazy from django.views.generic import FormView from django_registration.views import RegistrationView @@ -18,7 +18,7 @@ from zenpy.lib.api_objects import User as ZenpyUser from access_controller.settings import EMAIL_HOST_USER, ZENDESK_ROLES from main.extra_func import check_user_exist, update_profile, get_user_organization, make_engineer, make_light_agent, \ - get_users_list + get_users_list, daterange, get_timedelta from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm from .models import UserProfile, RoleChangeLogs @@ -196,34 +196,74 @@ class CustomLoginView(LoginView): def statistic_page(request): if not request.user.is_superuser: return redirect('index') + context = { 'pagename': 'страница статистики', } + if request.method == "POST": form = StatisticForm(request.POST) + if form.is_valid(): start_date = form.cleaned_data['dio_start'] end_date = form.cleaned_data['dio_end'] + try: data = RoleChangeLogs.objects.filter( change_time__range=[start_date, end_date], user=User.objects.get(email=form.cleaned_data['email']), - ) + ).order_by('change_time') except User.DoesNotExist: - data = [] context['errors'] = 'Пользователь не найден' - if not data: - context['errors'] = 'Не найдено изменений роли за этот промежуток времени' - else: - sep = form.cleaned_data['inter'] - stat = {} - if sep == 'days': - for day in daterange(start_date, end_date+timedelta(days=1)): - stat[day] = 0 - if data[0].old_role == 'engineer': - stat[start_date] += data[0].change_time.time().hour - if data[-1].old_role == 'engineer': - stat[end_date] += 24-data[-1].change_time.time().hour + context['form'] = StatisticForm() + return render(request, 'pages/stat.html', context) + sep = form.cleaned_data['inter'] # Разрез + stat = {} + + # Этот кусок кода будет заполнять массив stat, в котором ключ - дата, значение кол-во часов + # Далее будет заполнение контекса в зависимости от выбранного интервала и отображения + # (пока есть только отображение в часах, разрез в днях) + + # Обнуление всех дней + for day in daterange(start_date, end_date + timedelta(days=1)): + stat[day] = 0 + + # Проеврка крайних случаев + # Если окажется, что инженер работал ещё до начала диапазона + if data[0].old_role == 'engineer': + for day in daterange(start_date, data[0].change_time.date()): + stat[day] = 24 + stat[data[0].change_time.date()] += data[0].change_time.time().hour + # Если окажется, что инженер закончил работать после диапазона + if data[len(data) - 1].new_role == 'engineer': + for day in daterange(data[len(data) - 1].change_time.date() + timedelta(days=1), + end_date + timedelta(days=1)): + stat[day] = 24 + stat[data[len(data) - 1].change_time.date()] += int( + (timedelta(days=1) - get_timedelta(data[len(data) - 1])).total_seconds() // 3600) + + # Цикл по логам + for log_index in range(len(data) - 1): + if data[log_index].new_role == 'engineer': + log1, log2 = data[log_index], data[log_index + 1] + + # Если сессия закончилась НЕ в тот же день, что и началась + if log1.change_time.date() != log2.change_time.date(): + stat[log1.change_time.date()] += int( + (timedelta(days=1) - get_timedelta(log1)).total_seconds() // 3600) + stat[log2.change_time.date()] += log2.change_time.time().hour + + # Если проработал несколько дней подряд, то заполнить эти дни по 24 часа + for day in daterange(log1.change_time.date() + timedelta(days=1), log2.change_time.date()): + stat[day] = 24 + + # Если сессия закончилась в тот же день, что и началась + else: + times = log2.change_time - log1.change_time + stat[log1.change_time.date()] += times.seconds // 3600 + + if sep == 'days': + context['stats_logs'] = stat else: context['errors'] = form.errors @@ -234,9 +274,4 @@ def statistic_page(request): return render(request, 'pages/stat.html', context) -from datetime import timedelta, date - - -def daterange(start_date, end_date): - for n in range(int((end_date - start_date).days)): - yield start_date + timedelta(n) +from datetime import timedelta From 21b29fdc1104f5e917dd3a1c0fb7ea37f964738d Mon Sep 17 00:00:00 2001 From: Vadim Melnikov Date: Fri, 5 Mar 2021 19:07:13 +0300 Subject: [PATCH 060/195] Added statistic layout --- layouts/statistic/statistic.png | Bin 0 -> 66179 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 layouts/statistic/statistic.png diff --git a/layouts/statistic/statistic.png b/layouts/statistic/statistic.png new file mode 100644 index 0000000000000000000000000000000000000000..931b7e129bb333f0dfc8cb8fcf9828dc40abc8af GIT binary patch literal 66179 zcmd?QcT`hb+boiQ9@k~3ACtljzpI>OHUBRdGr#C>jir#1B_K)~j+ z9nU^|;rB2mdLL-(@8PeZaMXPbk0&FX3Ov4{R^Po zzeg_)2;KZWipl)nIpuTVNp_R&jF&BfeTVU&xOf%u7VuVoYu!N9>h@6>AbfSmG_2Lic-NF6`od z&ZaH_&vvaX_c9utzlq-=tWM(O2H_R^vDVf*DM<_16Aycz>+(y~D6?pS(BCh|dJbY&# zSxE{}2$^g7^zOO_hbuXpH8bzz=oMX3VPOuU_7f(sI9wQm?{WBh5zvbrLO;)J9`cG) z0F-=kN4};EGAoMaucGF#7XN8ELBY)>;@WTnsrF0&evtE|xqm6~uhl}ZJRp{S-J0CK z;im22a_6;p)}`aYik|oLAV}2jiI0c@XYpr6-(B0hzU74{%{{xdRpj^@hJ`=O>DFw` z0~+N#h%SwHlNuJLxPhM}HtEX7$s@kY&m=qw51g)m&HS4W_4`2BAJ-zN9udl8Dnut- zHzu_xkQzaGsl&{Po@;ng{G!S=l&3KlXsC>cRa^N|Fm!-?qWMi5uNVB!;YJ+=1-`C= zhx@kgF}>qlO9;$M;c#1zZf=o_Z1B+90DF4~+ zCdrToa}X5`GE>U&_UMz@5y9+SP#TYmjPUJ^o_{8h%7}m(51T`0|HJ3A;vnq#!ma&| zlrtLtXlrN^NN>kTmXU8!#QBDQevJAP)bYTl;5M8!q07NHJkq~JC$04UKK9PJZJ*`( zl0L5{Vn_UD7gq~z5GF?t^8!hVR>V$b!0j2c|GK~T?mszmQtnK)XnntVBmI7>sx^z5 zlIBt}=|zJqQ&)!LQJCRWKv&=VN#osVB{Bak6n_(o{~X2%azFXFfVcQ_e6=(@7F8cT zNYr(Xh@>`qEXU8NVJEY>3L1~UBmbjSe}Cxl01SK-%-w3$7tPvKt*3z8R8s6+{A#O# z6LekHZ|1M1rk2DgX7ZMt99UqF|H$>`iFP{tge(b$dzRwArDI+P!D=RXlq6@vWmXUz zK37MPV^e=zL#D}$GPfvY(onOsp1l8uBtjm5uLeW|MhUZWL5cq!-bnYw$oKZp^8+|v zE2ci0BbK9c0-pJgiXc$6%$Yx3a*+QvAxjrP!+*k_#N!a)`((1Z$Lcb4*1np7qkpen zc@^(HB8A-bw_E#`sAghF?Bwq`Tjn_b6Frh2w&`?pVUqmg73p>V-@)23DuYt$H} z2f;Hea}CzNtl_UOLR!8ZnHjNi&sROn=Yxl1>iaZE(m7`wLwZXnURlF#eAulIm6+@z zGb&^pO%BW%$(^^hUgZD^(k(7E(mCafv!>!`fvXpL#?B~Sr89@{rhp4j#4pS;$rUDq zoLk6*tEStet^8`B_7Tp#No9Z+;R7MO?Sd*)*1ZY^#9m^7?fLwI>-_Zz1~a zqHg1;lmH@+F^|C(ru~~k@xneGnfm>A_GFgA`PiGw&qRYR2jSNPd*_;Z8fD5W?mK(+ zO6=Mapy9k4Py2os>K`JK5&MrHdhiFR<3q8S@jFb;J*nf`6%9E`y)p&<*HS z&Pe23*3$?+;G!~EMypG8$%DRy$$J*vcl`ZL45M=UJxkLkOb9pU?Fe8@>I@^t(l8b( z8d%#QI@!gRDh1w2TxxQGZ6_X8P6`6zj}IgC-^+E{3H_9fKbax3Gl()eDw=%qs?na` z{|QY#`94Io1d=>}uB*gS%Qq$oUp;>RYkG^Q^y6ql{tKgB+z3uDJM^|mkio=zZJ#`J zEJXKrjC#)Ml<>;FU_kQ=s6?IG07D!Tqex!EN7$~5{+`9?66oyXw)Ew~P_i4F4R-ta z_6CHjW>&)a9j3&{{{fQEmFKlD&&*7DSTgH%4dvh+XdT0&$}sH6T99W@*~xzev@7cW ziFfNs=Y9tjpkI&vy9LRTZw(_Du8(&9=Fh3M;{Rp={eO?N<9|x!t@B#}wr`yyA^0Kb z2D~pTrCfz8RvVM=R+?Ob@@qq;q=iB6^NbfBh!y0gxt>MVEnV@ex@J~jl`yEPR`b5~ z5eO7j_1V=S9R#}hd1J|eo&O`v?Om#i&x|(Yb#g&izq_IudhM3q17XQiBA|2TJfev} zAJ2=8=(e#UT_!USC-5AOj2}&7KHC_xUSJ>(I%2+=*_{IMIsL=}1=mm~$U;SPcN%*{ z5sBv9qwWQT_S%q)(>er;-hO$j{Z<}>Q=&64W|gxn=$!m!)^`)+8aX2|Q|z1lmmvHn zA28pdGxtI7mD`G;^z1zT#ear43;a z2;~4?33aIQC4}?I6e*IpPgkQKIO_*(|I|Q1J0w+1@7#WR6Z50OA zP`d-#vOP^EC?`AoEGM(%4DH_1dB0es`Qd9Z^2?Jhh|3nLgtwksK#-CB@fz81L2ShC zgP_kx3WW~6k3ZJt=99MQ_n?bgsKzo}!nzH8CSMk>B>HT2;#C9FfDtl9vqS2A7s2 zWU9iYanwn_E>F=6V~InjVzVN`CN12esaZ4Q8RbDo{?_-H0;jEd`Juf%N}>ovh-KaU z0IXZyFrb(2vqj&bYy-LSeHkSU`TN!P$Ay0G_Rw3*4Q;XtyylQ~##jy8v)`!S_=rNP zyH7>izQ_i$O9CPK9U`v zaH^)!c3C$w`ud0cdI?U@{Lub)=8gg>P)q?eyuT;3)y-zggicg)vn?_J9M#R~AH0K| zC|86t)h|#ec-Kf7Pgp%6w;L4dW}DCf$SF0H>oyai zfxpq4i4UjftJQa2@cXrWE*(?1!-)0G#x4;u$3|TCLw^|2+9Z$k!egu6SL&K!Shs}V z*N-wKjO0~)-wnouc65qFUAHj^6k{1RC&v6(%pS>6ubmw-_V}=(Ifp_+hP=4}WX@c9 zz+K3<1v`hoY!o7E$}}z1(QEq{;=pGgz~6U#Hr>IjwJ}&t_mC8#U9oD9{A;tK^6Sdg z!_oZXHj~U7kL$e7Q1y`Y-AjI}z4pkV-pGx4{g7Sf7rd;DY}RC|7Qa_>I}ATcqGC#&abC}$bw@Hy_N)*SDM}%-Ksxl@WXrDt&DhGh2#J-BI~fbNBF$)6G4Et+_-smq#X|iPOu;v~#t@X#16v;z)#A_teMT-7 zN<k)@H z(y4%~N9o*RREs)8+^eU6+_7-$pZt%rgspr(a&>#rYC3~^gC314MJMxM$zapP%nk$8 z*OTMnS4&lip`5le84~bqy&hT8g@N!6NfOVQ8Q-4CjpENAeim}V!h>sPg{Ap^$T+rP zG{K!}_ZZhLxui>3$-9O<@%5*pXw9KN6xcbwA5pMXkj({vrfC)?|ZZYq~rS&9t91)!C0R$Yn_ zH|bvM`YTrALWxhd%iU!|X2%utM6T<_WD`K6VNadWLs7k3tqT2o<-O?(l4$1#vdpcE zQg-h=Ozz!n@SMakCzL>pxg8SdZxwtpPQh&NS&X#zvBcu=Z$&2;R78s-E z3RX5u=uLhP-qd3Re78N!rQuDEds;bRrmvF18VZ>DYD;j+j$*7|F!e2lq~wP1R|>W1 zBRjgitj^lK-CJWD>^9hy5s)d#`MCp})P!dnz2QS|taDrPrz}-Bf~uz;#LBBsV#mVc z$@|mdW}d5X{rc(HYjfpjNRC9w>X7v?DI<^wh6@L1Q36d7P52wObCMKTCmf3LZkaqp z-pZyJ%0X5ZpCGIV^KmQa#n{`%7WA3224te^b)wN{T zNY@;c&Y_aA++EXv{boE!y)E;7an_#VyvOjCbFWWBo=75w$YOnc)7k!*XevHiM&y{V z^y$9al#tubnTlVXhYi=xOjacye!)&Y>!%Z~B4PKy3cuiNFel?7e>nVd3LAUQ``G3+gRn0!N%+KYp23}jLZ1YTE@h+^Y9uiNr zi`!RHT^E)jk!F=MJnQuiCH24&zkt1Q^hk95;yagIH5eT_A97axLbKV0j{_qC5-A^G z&zt*qAuLRb>`Mh1T^+$z%b18p9bHL2ZX~iGiVOK}-JEYEChM^lFHmPWBt$H-)>PzL zix!gn@*7a{kwv3;q!c7|c5dJ~Ou!Dxa@E>UlW6|SHseeQ^>3DUKC`Tss#fM)UIQ}D zPIxT}&namDJa9Zs@=zK zF3%GBOZS~r_4qNwV_Kr6q1!=;t|@uMy>>3eTB5ILfN1Zoa^a!#tqeuBVe#yUPrU>Evn$~VPN-*}0~*3O+53qpvkio= zSjV)`m8alTJpA#~;CWLM-zOIu;}!{hT3Pzw@ZJCzzJC?_wUX)s1VHKZ=WJ99pKXPoOtT}mPpwz^9_jd?mz3*K)gztP^XtY|d`S() zzqsgg{JsLctox78_Ya58Z8YO~4BQO#wfYrW=DoPum$xS+F`Jj(}?IaX@RJWV28X(d!gZJUyzAOC#{#a6F@zZ!7VyWVQ z^iX(ecoOuPU+Fmi6R*#uPnPuD*W;8RIG=lfD;Q?F7eP8+S!}>kV+JPwRB$79N{PoL zamhK-v$R7v5y=S3DA_wD-zjCHg6NdeD&CnL%5k#8Ij;f{c%Jh%KbTKHb7W7@*tNm}{geXIQj4G#VgQFMvqxX_%@YpWBkD`lC1 zr`10<3$G5Oi3}Gyo-GaVj8pRpK>+E>b3W%N=m ztbQaSX`t|)dS6IOjT|}UfRgRC8QH18G6~ra=Te(^*4O6!Uq?!X{iB89Q;{3dp8L7o$z6{S5w?2J2Y;VaM z7qljkWNhqYg+Nb7p;EbW;o+BWv^49a9-F&XRXw z)rd4nBX1^PBw5Hm=HtT7dZkaOc0GN&x30P!J+v@ig+?5Fb-d3gk*AW?r|a%3z&A;L zt-jGEXtzHs6?SXb3qO=ALQpXsNR!~F#@%G|0ndsHcz#?;gUj|WEjue9%Ft;dMv8wR zGwt<~dRyR$@E;{5c-GU5(jj>;!5%nEhXl_A?>qxd`K;MkpXz1&dy_bmwr2_smxxX2 zpBMYf{d#~SeX6|W-jgEtq5I=u6z6qHk+_Q5>-FzN?$4DouZU;&mX@E4gVjzyGb#;C z7A=k#;W{DM5+ZDeK0EgF&A3`~Dt40joc~E`R z(%Gh`Y@9qDlrhkj4_;c#Qaj|7!+mYJHqZsNb@&NCO(gwFB`8s zG_Sn`mu(J`ZFagfG*A!a=l7SgPQi~@qnQ3rKB7mamQPutjeQke+fKjj*z}8jv1#s5 zl@OIKL6rL(PR~22tNgg#C#NPxoegzL%KMrC^5ad8y|A13Wz+9=7T$+y2F+xAj86+u z#(F-R25GP5B^Pb9?;q_9KRoxIQAcL!Q1)@jS7f^D2`DVX@3CJBg*d~)Nsqvb!;_)5 z&63SJLd3FN9U|N-TYDmh5wPXybNU$tJFia z_?J4v!}~a7TY=^z`kSL!ex#y7n7sXLkV$o}8=zqlp0I7pbk{U#AeSj>1+vC=Wy=RO zE2q;W)gRm2OJW)hXq9MmKGjrc=bO|=7`?z7_v#WWp$*9Ayl1eIvh3#p{X@+WBcr+BAo@CGI?UF=~-$ecaIrou zQ*P&x^`*L!@0KF zYSTs#vn>;#e#aT#SNn<~@OmHb0bL-Z_x8N~I0S`X^cmD#8a0(F9_7N-D0Fmu5ze7U24DLv%ahk@&*yDS1+BEGfkIjfw%$H7f~nU#3P(P)`< z1O4!$!$`;8n3<%q@zdX&^V^?|>y_4NkC4E2kplb!Mb(+NPEZ$mFSN6mf0U7Z=`qb?|1i#9338ol*mdP(a$)!lOni%Giq~94wK9TTO5SV5ei&>zd0aUX+E70@#Wy=77 zBZl7!6JAj1&#}jzKB-*XQ8B~auC#>G5r*yXD;Fd|h1FX_PLRm>) zO#o2A((Nr!b>h0Ngf|(ypq5uZKY5vGIFVFINivy_cLQ7H2PXC31XFSwh7@W-uRQi1 z#mo>pT$SGR^%|(>E#XqG2Xeb)K9&~Pqf`z{d}!b9iT`7See7pO_@wnU4eQS7b-68m zS6j35rJ9S~a;jDjl6Y@N-!D}armZabTH(rcq8r=Pt`UO$hrm-v!~J6sF5auRZN(W; zKi4oAal_ApGRvo} zn-!qJ7db)(Uq|0!&hRa>LQT_8%{(n>{#M|qP?#WRKbKi- zw@yt9=Uw(rnMjjg>zKayI0~u|5m>iA?=>VHzB*M^1PE7}84KBc^DG;>SXD@!y1ub+ zR5@Mc5ksU%DEEdB@r0i{Iy0H7R6Ed>#N4N+Q4Ufe3n zosDg(*9C34C;v@s&#hUup6(OkBP$iAHHomsc|S;s6su=opp?4yF<~Jdy4$A8LWp5o!}c~p$WFI1y@r@Y;pH3g6K9L)oE$-}^1@%sedWAHIpTcPRKdnd z-3slsdP~@v%t%%*C;3>cS`Tk_#q&6`HsvTIGY;X4oO7{tYR^Om<=ePxQ1cap2`fwB zJw^JlLb=k0RvmV5yQKk&azk;sfh=C&rPkvMh>~OU>)u&bF zS{3=uyApsqkQRo=#R9#hWNwt+D6xkc(BFubt3#i7H3jzeyvg30?=s%|%stQrrN`}_ z18+Z9m_VsGl|SQ)8B|@4-Jv^T?3V8&>MxhpPMDI!FS{>)8B+;zh;>kK|A;eDfBqg^ z+9gx4hi&-5zlE|E5uK`CC%G`}CuK{OBoUXU*tgZ8oo&8d=aOvl!(kl-QCe4bPlgaF zOy<_8y^Z|4TC+(P8iPi7<5wo8BFjoC9mS4B-Qs$&2r8Ts+HAy5wOh8Vi@d^_eB7Yr zvD^V|@l|{_2hDcbG`=ARW7?6$hxoL?UkDF)%&rbMnAQKhGyTgYD0&; zoSE%scy|WJ194z~JH7f}`zM(BT^%xk1pETc>x{hqP4adWnzK+sDHwqwhGxh)W!!of z#w@N9O`h`9m}WxH>#o?PcsR}M1==zPftLD(!qQh`4|#t_>gV+>CTz**J_>I3L80ML zhi^9YQO_(sJjm=y zj3edh6=bZd&Q1aqJRpj{BhXb}kLn%YPOhc3ld=OdFKWt*|7uSDvZPv~r1(~4bvn;# zrM6jo#MIGYBmN5YN(vco7KvL<%DJ+Gj`bZ>oVzA4C>=9gPR$5%#=2P<8+lz%?t7HX zuOl@Nhexb)Eo|dic2As(4~)AzKj$u>O0no($&3`9VP7bGevv3R@n=%nvc|h^Uzst- zlTV)-M*IIv^?QEPSZL~oH99?PUaE0rLVS{y{~BQGoo48lb46uKj8bJmvF~;^7wA2o zGZcwYsm@a_7RMnV@onbbZU<=rZ9{fgQ{e$ctFx2}mJxqmbPqa@?9-9{nx)U{u5@^= z1Hy@4(k+o0@4ZJkk9yaX}e7E zUx{nY^P180_K|e@`|exuD@KWdw)IVKx>68{1AH$TZ)0Wf;G@7K8x?6@CX9vEaIkZV z6`}0_IEw^0n{`paeflB*3R&U}N~DD6P`qO+DaYQHVgvnbi<{Ex#rq@#mGU<^Difkws){F$UChPDFvJSBad^C?F zc2?bYp#)TU4-&n~2+R28?(7{&8XFSHX&Ou&jOuPYbZBK=Aosw3F&Ik6EXP<$)dSrH zTWa_)BHLO-&w{PnbgDoxd8Sq65mhzIKn!{wM-v@8t0Aw|)l7Hws=aSCqh~gr*}9+G zTMFBh=@;8RANRN0@&u`SmkD_$+Fz9LBi$@`K0L$Mm<80ID~tPwVH+nmdu3@nwW`*& zwq_sUE=uyq`{I6Z>`q_HlekkRKJ*lWN|v`_m{+|t8mPlVFacB+13GAzkXY_@c^_=`Lu8@deox-#-xv<>p7BkgN`LV1-shlC1$#SU=PW4>!d{v{R1J0UUn?nn z!{eq#r6lDaQ;oZMO2hN2{rf`#Iqn)wf+wBwCWU-Lx8%S$VZ8|v)Y_12>>lo6dopK1 zA+|rj?J<#ZSHD1Ycj7+up*4yAM@i$wsNyukqa`tdfMH`hm!!_6&b;ItTF0S=Th~3D zCY&vln)0No=kQ0`xmzyHM9IUVTi7Hp#og?P+hD55m`d{zK95N=s0}~?rK?kKown9o za=qGCBDAZtyymKl2RJ-2p3$mNak)33g7LF^%FV48Q}muQG$SQZP&aR3`^ekQwNaRT zCHSbxT*X|HV{Icoz-^+D8Y0MJ!swU>@+hB``xHnoDG=zH4nSLXxgqm#!&v5HCs@m6}DNLSi6tG|=ds)zkBjkTlpwFmo~=H*nO`$`gTVo?gRtKEPa zvDtuu>gp)d$sRwe2ULC;(_pdcqHl)xh>$!x}e+^om0Ty_x>rKWct=|W3H5`BpZIN723}T z785r^-5)23QgCDGGUV@j`qVPA4IUDKrYPZ4c;F1*RAO2QGjJDv>d?`9wqEWbWTdyv ziRQWr8AK?re0x?!vWLss#hFY|4Wvu3eDjz?GyS!3b!V*fP#|!U6d(fKmyP(q*`dEU z5MG|vOxE>m1zLGcUMU*Voc& z4*)Z*HTMLh-U+ETVjV3NQUMAK4C_6q`S&@bfu^H0W8&<~VCz+&TTJUSzGGiDRWoWp zM}A{GejtnTSp^P%2`IhS1t3RNd@x780%ho1>C_wfOt~FbH-5YyVY}a(Cm8x-j(u$s z_aa)03@H~@swJJ5#iB3NEH@ztJ|JmI+b-N0cVBc{v)IQ06k?sj)>4seFq`A^@TWTF z%3{>BaieghgBZJ7%b*o+*?zP!+ux2G?v?4>3n=9-odRXX+;H-hL3aZ-ZgG&D7;<7r z!EVZ_+<@T$g*<;|Zz{6fzB5)z2xDft7ChFUtSY*eD~&x}GYnn!r1LZ<&c%OLx$u*4DgTZ)4V&vW?ylTq*e=@9fnOLa;f_aDlrXd-e~Sf2DUcwFoxba;RSFHkCR%?t3&f?W7;t9I#75~7)|q+s~rx@M;LsF=6l zMA3oqE;Tw_L!`9a75^0RxP`t85g*|9OEy74ZROVVrakPu@3EzR!_u4um1$0(mV5{U z$%soeY2X$a1sZkc5;trIZ@b+|6lL0gU8|8wtkOe8O|~BlSGNw9yyRwS z3*KoaE(6uTJN_2p=M?IvEY*nob8{<^tLAu2qh9|m(DvK_>cOc$EkmZ?R_g6RCeuC2L!+Pm3d>Wd&!({ahLHCSu2ezg7RURaKv&W4&CCCgMt4~_~ zc_71E@YDB5bFp1Jykl}^SfL1TQ`r|6713oCaSGz0+*tptgGaAyyIWxozDhG(CG2P^ z(ID6V3~nosDMZ^VxJv{!YBy?zfr~{dkExa(7kbmOt8T!|79+V8DY5~NZ2I9hgcth= zbgfFua!);^6%EACgisC(h1opL+ibN!X5SsQvy1J@5r2p_pogULlc4!yb>*6F5kQ%; zJc&MK{rJgZgku&Ax;`-*w{kqyHTrW?o{Yh&`@Uvg+Em*+g!`pC!_W7f>G*=~JCvOb zftcAnlN-G^EXA~-yCQ%q4>v!Z77kT7R<{S|soniVj}T6q zkH_5HjwCueeESt!9dt;*_XnM=@es4yD}9dKYFWt#G%j&|o?+g2prq;ufr zneL~AOGQ$M_N4RHk8KiHz{U2>Mi#a!9qBr_ebbF~cxHRHTMg1K*@*vZ<78UAjY_|$ zafaM@XMcsv9Lc`?i>%4j>ABb)L|8@i(^!!pne~bbhD~_~crT>&2nIkFd^xAOl9qBy zVH`E$Oisic21-_>ryB=5SHB52mRib~93k&CTeU9BakO$hLcBlr_=1il;vgIRX@@VB%9P+|Ix1y>}oF3*AlGnDVRSQ z3Tm@;FsDZncH37LT18 zs?M>8NO2Xnm-<KCD@7@D+1+(Tzv55cR&omtu^}_fZtB2MaxU51&wQ zN*kw^3H9Y%(JaZM3zN&rzFxD8HQI4?V#%oh_NOP3EtxHx2?uwTs|en}0jrd2J7vjN z5Q`y#VRSKJJj_dayy%X4+&wk`3b~pyaI2~k{oG4IK6<6!ZZv4yfTo*1z>Qx|K#|!+ z8-qSQ5Whz6H0c-d-HrV+=r7W0ZmNlAnrI0$d}e_OCls<)Jf)@ax%mmJf)ZeSliM3; z?b$a+t~isM)~q6wP$U^IIv;Tr`Ukf8hE|d^yY_2(NtXilnjoy#7TdV2o4h3w^=#Q# zsO&AARYzf5BSI$<+isSpu)E*(VYFov+3!S7L+SW}$PUvdep=wW&FfL5U#p@LuQl*2 zZaVmD_UXXp>Xs#oLZn&H!2OuMAHlOIdv~p%DR`kVbo-KQx(>ACg&SMSK<&baLf|LM z56)+Vx{7{f5iyG&35A&CpV-qk>8V)}8@7Y?VQwunpKwi5=4!J}*0Ka>vL2i?3p5QPfv%agrS z+4n|n*SHkd2;7mWy=ed+_T(15qQd9YL@DRfkqYczUfqEA((wo0+x-_) zYrd!Ql8n$#)2C`55kN-*cy7js#LN^u5&r!5U!YSrH}P&CfLmRSAdDFaHZ>FIapv#esI27{$`3?gp)t{S6~*N*EeAvIVsJzR*no zHu7Fr%Joztw_AQ@hY3NIx0sCR?ggDAJ!hVn609wKjYwT%2!THT{1bq}#_Wy1l77uR z@U5lM+{Vht{9v67X!`IX^$$Yc-P8BC_7XuL{ff`Sn_$hWz|3W9L$E~&heBL|rImow zNXiF5m=}|`g4|ZW*;o}WYpnmp`6ii>k2k@Boo_x6-VMMq_x{FBUPCOrTxp00Zd7To zMzwujCz6MNs!qHb;f^$>^TN|Dpy?u~kjak`{8L&+>njP`tNb0^3L{avytxrCa#5n$ zY$PZ~Rvfey^clo$_28!$n5^AP52sz6d0v=f8ALSFYF{TS?AZ$Hw2oIjtEz_nHMWTt zyOyWRyK@vk;c=1sc7R^!Zn9_diOF0zVDiuKf9HL@|0!_x|F~O)|Cdo&@&3U!Kt`HW z7ib#OCqYAj3?j13W=jlaQ~p5b4!C}dZTcqmXQoD{u8F!3XsG>Q=Wkj1UwozV|L+(? zr7tVvH!-quWcic0u;eUEw-pF<{VUrCAn(1@&Y0M@ttevNrv{tAb-OzM+1GpM8$jHq z)q~puSl_0t~W5p^ctir z>rZ4YzIGwo6((A>s-Z8D&?t>G4}>{2tkv=`*2~#@J)dl!rz`u03qWP*KY#DQTv80AOg566Njjf`%lJp@r*bgGf@=_Fg`4&PxAUEal$--SawGlw<@1 zJV}qpin_FkycCNBZlt`I`WF-I7+Zfjl$#(Kp#hq7${QEB9dL`JwxI(D>D>0&y77h1 zldO105D0+z=>mcTieeEy7}tq2-1UPmc&1}jgX;N~>5I5D9JTG~FwmnaiEV_}J_Fc~ znEFk;??q#fdYlg7Z$vUTJALtnZhB93(e3h;d{Us{i6J1v+S>T{F&-gp`@Fo{CGtZx zr3t)p$;DX{KqQy=l_ej>*->XufX^&i!~?M`=sRv0wPkp(mT!A`ZHnW?HBlvZ+b}K= zff@zWbUcIOIYN1CR2@AAF{GUZ-S}dMHCbD$vcuBXo?io+iQufp(1^_l$AF1os^Eg! zhE(z!Oo#!_zH3)_ff3eMvo-;-S{@p%zrnEKi4$hr`Wm9|#;-o0#~@Ye4g~<*e~SpK zA9# z+^iOPrVa}zH zKH2a7HW0BqQ11QS#3g<#lHVcD)vm`+zn+LNsGQ_!_A;!;jr(n7a6Ei&s?UgkvYL*J z?$F8WPvV6WX{<5!q&A91iwX)ixH)E3!iZ;+fW~zHg7My-h{P`&`kPH7;HIPcVv`}$ zdxS7nX%SXznh2di(;SS#^TGt-UB^pgEe#^Ba2Rh4{R1`BhX8QvqR%F{Gzhphm#Q)J z&9tscgLraT$gOy+F?Wr&sS7agWI5*%!HvZEpha|Z87#nHqO)RaNUNPD|Q3cGeKM~7M3L1w~2CnTG|TK zeaZKrcU%$8gzj_l6e>J93AVv`;3cY6pnVJs>$RVlSuS>~OZ-y6+1VLw@A=}zb z_%p~&O!%y^dXivFe;-O8t}!REDcb0rF;hR%ca;}Ko-az{F;a9}oTXBB2ERO`e2Hbu z7&g|(Oo@Zn_|N{O=606%WnxG9KGr{209_w@wUy)9TI)^4Gh+w#-mSY)ZadH1L`ZJx z$HnAi_~23}IivipzrC$_j{yO4{=aUzbn3QswTe5+J>R`}(}+x&a#Vx6_$52CEy*2c zOFj^y*+yIL73vtWQnoJ*FjCmlbAdSQD8XR9zLd*@ob=7HqIc6twGRwfhU;*O3N6Fs z-H)G4)cWlYRvGJ_+jqZ#Ay|;Q>31e)c>XAORAcpgE>i`_t&%jq?F z^QU0>G>j2;Rv<;{xFThwV@O@;7O3Eu-B#+^eNK~GXYy3wMWZ7btA)e@uslM8x`H#Y z&w6BDj=eW@dr<77Mqu@w21-03>KEE1A4Sb^Q?>TbKQ47uW8Q&ZOPP;iQ7JTKDAJiX zFdtsEL3*S`&L*xkY(OiA#6YJ8oVG@0ujM0Bi;f-IEoGMttJn}E1mZ`ZT`)agB~@{y zv5{P})>pJyw#<*OHtp)Dd4Gr=*a#GldwYa`+_0WlM3Xa!p#EnAIpJ<1%*TPrE{3ZR zkBE!9%~%K057po;1QaacNJ+}6+!LIm97XuOOUygB=T-Lns-RjhyENN_6m$KnQV7Lw z3Q2|GGpwoMb8_4)IFbH@*}gbn|CM&5t5C4C%AA@#6zd+g;)XbN=Hk_Dm8AN0!7L;6 zF%}8Hr@oGh993zc?ZY-F?;YDAA%2j3T&Uf&Bf4UtzD(g}CTni+30G2&9`OW(&K_VQ z>gIbdd>`7xGM)>PUfObRV+qb)EbPgg4EV~&?TyTEUIeP}0S$48tsm)gDrr|R_OY~3)`bRYNSUbje7z!sMgON2>O8GNmFs%O&V zK;9MIuf1lEdPPFgaBCBFo}rJw<@c7sp5J5-`wb>1e~L;xiPE3k0=j>MZF5e8NPeki z!Yj7k-qght=%0QiQ?QeMJS}rwsv25_9zJaq^tvU{19jU4qx} zaF)MKwb0vjup9H)rCov=nU8MmI3rQkX8u))NLez!So zWpOT4c9IU*G0130;E^QW132;8)(w0uD#ozjl{|hl-FwREF-}t$y5eRJQs5GJXl&ZG z^G5q?j2*G14^+^YueAMo3fy2Vz$P z>1dX`<}q}qYaMlfVmveQN)xPp8yVVXoK@+gsOX6tEyOozY8=Ux$y(UW! zz7iGB8nX%So+$s!eFWFnLSox?bf-2*sY4mUV)Zs?j|zMXyeRX+;eR#itG+>;H+!6$ zVF=rO+`p*)j@>%5=56T2x1H|*grAAmHB3=@$#G>%fBoPk^gH$?$6&y%?m{`~Cx*NB z#Kqfokt{T-9D75?g&ay#b_Xw(^bwi@f<0EuQ)`OCo2$*8Aym~J{Hf2^JSk=FGFblX z1cC9?U7F@@pwzI5%bZ=TAUQp-e=M$>1G_p2r+->4TFHC8w{^aGpwQ9x)r0=|0e1fD z=5US7hTsH;xX#j%(5jT)9w*AQ#fWjvP=1>6ltI|MSPwTqm#Et>-8|5&2Orlf-*{=d zXs(L#(5kp6fBX6MLEFxiIESeXblLHgQRdB85>GWB1KbSEdCB9A#wMRJ(YbH0v!0d1 zyKHbTG^$4W!`(6y*|~jE__e+q(K&8vTydtwMXGjCz^oU>{XTq6W{fD9%Q3#;xTB^g zJHmEHjW{GNZb285Ch-tRA1Tp~O7}@cV}>KC=Uvt_CInRx3P10sc#nN1mX+-j`oKPR z-;xm8Du9rPBJ~Os>=M!$W&M;lgzd=zd*Z5dh&T$la_13nl zQPfKfx9!JoYphU$!44+GA522MIu`?B|$6PSYj2G`XJp!ycj zGuI>K^OkRIGh5Zp>pFs;d=-yDQ|njA02_&$Y;4_R|895S(*x3C6(aasq15?&>KoEwTyB-A*Z%P?9}#EtW@4&tGPrhG@H}9An<~Xo!4Qk#hPt= z8~Uy}D-F6?_6k&fR66jOxGY$CQVqC}`?TJ4WO*iZxNX^RXsv7hdj@-2^>7Gv)TGcKh9gf)X7%0&ne$Y;hjP4lireziId%?7e9;+wa%^tHZbBPgS+0 zQM70kRbNHTbTYK2s;NRyGcnXm5bal6Gu0L~rln?~WPQ)@WJN~mpP23jqeL9?xOWtZC#*@98Y^Ri>pA4fetcU&t{dI9&A89he zbUF| zP_Zg&O+$Z~VT;m>FT$8VDwEY)$(^Ms&2t^-+`omj4P-Q^zs#c}>%q)K>hzF8zj@9r z@37g?7aE%m?}N%s)hX|z^^f~=J+E@v@{T)1S&vVu#OG`3m}AXe%Vl1HKCFKd?Ky-> zS5O3fyb#^jqRgaLdP^JbjT!W_b-e1ofekS(1q*3i;rrio5#>7?D;43N``?6IhtV6P z$sr@dU6Vc&o%OzaW-LtjqOWk$xg6FhsJ2p41jiLL>vX)YH8CC~SHr2_%cRbb zLL_p42MBBxyhm^p?|o%9HmxvuyQ5fNwzZ4v0)Sl{lZ+ zcOWGIJwH8&85S-#;_A=AjWyYz(H3gfW|eDUTQEMULd|5hY}EiUbxczDi9e~purJ~! zLCtIT^;-%Tm$u2y;ooj~pDq{r#3Pno2q&H%bpS8rG-OOw>BPFd+268k&4Gu^evUr^2op{j~Kd8XXLl`)6k0 zovXR}d1xoiG|B=a)L_AV7ec?QB}9bp7>x1d7rCrfMVRJmiCy4}tz5ZpsZ@^oeiIVe zuQR_c1i%>=_xy6y8}`;g%ooU8g1&tnRNad^?Ljx17sui-B5DzPL&RA$$oxIEsAX%8 zZlhF5&S{z69*)?2T_!`^3MtgK1W$g1kFT^a^4*)ha&0S{jG`t%py%eScC28M=_Ce4ESn$Vj`<|IOe`zpQgO?dO+8q)M5R|4V?c& zE#`F{`Skev%%pTJQ@OQmOE}X;b@LFx+TtkJN1VX{$l{x2kv^La>HYa?s2XE>Fbb2S z7`8q`!P{cSw|GHVA0E!HqP_Vw{93EU;VY za^RM!s-p&LKVde0k>a-0wxxsuCALc}{v!1r7yM`k*fNvt}#}5`L1{^itWwhTDMr>xfe?0J9 z?%u)1E~##roZTHS9+q7Cn(jUK^4Ny&r;aA?SSti+|FD!!40|^g0eyHOUmTpV^1BLT zIwAvOPR$zBp5&zm} zxSBUo;5~}+3a;9U-ZvQY2ub?f)w3Cy()8M?oSsqV9iH&LEHxp<-P_S)QK(^BeREPM zKck*K)sQ(!|GiTq#`iV;#?T(txTy8V#(NSF7T%G9Ft+&(8Pz5a&5w8L{lY4sQ)(rZ zn1`$zwuH5J;9dF{cz+-RH4PY8i=fm1a#U!(1;+@yr1DN~{@zR#d(JQTk@qVR6WaH? zwxOz?Ovy4>-PIhlCK*_jiylx_XDAia;u*b(ZzA@pQy+Bbi!lA&dskiF{sbIg?1}(E z73{Y;0!5U4=-aiX=6+~u^`U{sNBa}|^)V9BH7h|zqY5;@IbA(qQy_?5&VMQi+NcJ= zsSkS(^%I53$f}2nb99a0Zo}MG!F_P$&Gp1FTjVSqj7R?}3>z*nL_2CyQ0$aK-HQZR ze^f@YQga1ZK{d_TRKV03?lRezBjsVuSZh=)!v_+Uetxy!iwGqb&EdQIon)$7=LU}& z>gyY1ziCR1cck3h<@`5W7LL2)bRYH+*QW%UY6Zv_H1ZBi0^Tio?koTYj2Wh zPVLa$b7FBn22kH=Sa7|+ZKz`a71iCU;?**AHv_--O-MFPhcf0r0!j6<6+6?p8L_=l zBwa6*BdV!spCc;z&DLx7pE(%Xnu`nK$o)%-uwsb8Vpp^FEs9d`PUzM4Mk*x4+!S9j zx|8OiaIH-tc{J}SxxU#&iPjqWA#lPc>jt?;+PiZf?;=<}`a5W`MM23>Z6-Ue!)THk zWvFy1$W%)RC`38{4j*%oXd7`=u!_7#Ox-PCIYOI=47u)~+4bR6LyFUV>72OS9n$%T!0m-^+rw+8tD3J$Fufb%Y_k+tKXyB{FfEgBgzux5n(e6 zI7M13N4H1{9bZ(OejC~G*+^g>aDeen-RpEq;UACRdOYee4iehmex-l>R*d9Vu?cIh zh6Y6~$0rFoiM(wKK`r|kLwb0y!+mNJzZL}KuC`_B6K2=A-uC!=W7>uZ4dh!t`8Tm{H8Yfape}ZXKEwenEljr4?|9H90tU>+5YwB&4j{w zUhbz=6;JZ>s*n~|Sr?qol$@|kuRf@R=i z6@48U%Q~YB^^4V;%|~6fRxd>Y!BnXtw88;vFWz@V=IPnvRrjU~{3U&Mut&^IHIhY? zR*DJWAX}f5p-Ie-#XkM+NNUw%hkCGj{aCq#lyG9dSt#k{xNSm@4FB;tc1zyPs*ADn zA?2{H#gh%f6WzMfurf7u@Fd7aTu9>kjpXl@Ux73qny-<7(QT%*u>Pd z1Sq7sQ2k_lk`KCQJy(t8xHxBh@!8#biB^Lj8|+3I@O!Ml2D*!;F!_tK;t}0LGyF!% zWhsOkXrHlbuida7W4i3Q^4DFUVd-}NEJ>)$Xgxbbd7=l~(fC?YygKfeR6!0`dQAfbFY zXzWXHv(ULCv^`1LsNWj65`Z(u;Q@)QD-ox6$})b(F+IxoI?`M-lbNhsu&CN8!hXae zrsw>zJov=;F8j7IMIC{K%p&IJlyWc%ocHMG)FHW%#l63!` zjcJ)6Il9$!HWMr@_sCdWt{Sv0f!rHQ>&`4Pg8%t;T6uF+Io}S%=cNzwC0IW0Hz)sL z<&v>Y#5J;)EG6ql`u3#c=DHrMQjQj1Kz*?y*&p2n${&YbK^bRN;cs7$H30%(ob+3_ zW}b0SQGjmfP($sFXM%ZA-v}bsnnKK^b~TaqpF}N6lYKml7dM<*f?H$j;c$Rq5yG23m|>xkq=KkK=_}J&1SfK^5v8veGaittr5C z=AP`2cx0Dd0p(bGg~T1{HIDF%PNO&B8SB8x9#{cZ_8MnpXIue|_M_)571j35jQcO( zSjxl|J5xOW@bK6>ONq}qv=z1TcR*^MxH8g2sKhPl{jEJzu6q33gG#;Pg{N;Yh>e@a zi2k97JpyVvjocRDpKy@1KcWUrD@t_=5;vlcyO$N(;T~x33xC>yGvN7$IxQYDSQ|}4 zWWv>IKpa8-o0w8dqTFLh7zg*VJsn}bNPnP8C!?V)J{m9Ow4TbbPB7YQlC(I z71dPUj|o;}-c&i$k2DG<@Hns=Ac2>K)VQ9@mHj^cD@Z$P5hzbgt*?qca?8OKq!QBB;?ahM2XG1me>OxNO z%4@hXptrDq-m(=T+QP3|HyzQrP*d^4x3vzO6hyw%i?C3lD!$oF!v#MP-cSch7vw1l z4jPcKg*4(KX_)VQgadU7I}E2z5xfV(m+dX)^JJ#wW^6vz?>=l6Po^qic4QAl+Km_! z#cE}BCQ~a-Cu2TTE9#3Q78O&T$I)|wf0yQKR!*$Fj;2$ndvf66pG~J95Md;9al5%9 zt15z?(PHXHPuH*%r(Al8IS39HqYihGJoVhUi67ckNNcA$Yo-ryF-AWi4x)11Vj6+U zlMuqlt9a(Btlal2ppVsK15h)Cw7y(g>TBKZ`}xB>#_F{S-wO$-7*(GFUMOMI(#R4K z{z#uHxEJwri>G00QXvw9e;`bMsCBWyVj9ZvkFbb8>9K{4EHrU4ZWB4C@QG%#KWdz| z@(~hEd8Z-djrqB!R*qj0+5@8kz3F5{RUL)}(QzIy(It+F5PwVrlydO#dyCIjco9;D z_^G7g;iczadHw69IN z4I^L9I|6#8%uOJ2@Y!q+0y-zyQs6*76R@zqDZWik39j`&+=-|p^ zYxyJSim*@VK(htW>2m)N!^`!eEj^Q;n>af+O#lN2FC z_>+x7T8(_dZ>gr*3w{0%IZ^~n1F_+Vx)wkxN{>3%2npp;qE`Y2bXlhu^kchLp)F24 zK!-fn;DCl*!mV*wO+!&?nzdAe;;Kii4$|E6N#R_+THc*&ptM<1OT4X}>vgC{i$L9J zQ>8`o?Qxy`DfWQlGEf*YY?$7(UsQD+3{ecs3SP7&4RY~wKfs`7lC}Vt(KSs4M80bD zL^=Jip@I!v0UiRKHvZvt$1SZH`NGs6=*pcq-hJ?62K9hU+XVGo22r{bp5ZeUo! zVuh58lP=P@?Z#6blZE$YZIS~c_twLyW5G{~YW-HB%Zp6}AKa3Rgpcw*+lz>P&@nB+ z{Sy!lM;5{=hDmUV%7(hFl~$FNn`fHj40m6p+ocP`_Dcemdg67}W&5F)mE3?S@~xfj zFneBXHzsj$)%r}egl;aD=pK2X(B-W7v+dX$m%`~o8#D3`OjH^!lx6QdA~sfPWG^zU z=b+B#2|$(fEd{pG|D#|k6sT5M^w#`^pG_Pi5 z*;;Qb(mrafvEFfw@*o@dj9DwHr5>&%Nu3)=D^1a zU=RX^CBs~va28TRzOF+rCWptY*?Xn{?B_$) zb_1rMCh~Xw7Mbj0fY=%LcF(CBdn6}x>DiiaAmKtVQB93WSJBd=POl2NClfS3uqaf8 zO9#2O_^KemF-j<1Nbu|NQ*nmC_PMr83FZH+Mqx#$?hFqP@BGDYCMhKP=Y3QvTN$J+ zyP|M4bi`UiL2{&1dfs_HF8fQdYp8oIgBmL+e{9er8&m)n*K#J==OLz*(!RykEO@~s zK=|imtBTNm=W>C`6<+`V`=}MO_S_|A?PKGKs_%3W1pF5A%a3mvkdo{vuM+Q-lF3n9|%JYv)8PjWHm>u|RE@|ZAN#+Jmk!AY6VFSy(cY-G2YJG#RU zH!zaD&bOh&-5-Edl>=4)?ziY!`P2~9O+XH+!prSL{(Rrhr*hv`xf7IuN! zMf_YsSLzReFV{yo+ypGp-bl0wb_Bzl3WmXDmAA&q4!S+IQL?asXKay))5R+S!>rve z?{2=sbYE|Nf&%o*TMSpgtMOBAselP}|GWXzr}2s*yyL1P8ua{p8*Q#ZX*!P~P?MAu z3P=6QY?hCYT<2*N`j=ZC8pvy?M7u1j9&{WG z<%eXP$_e##>8#Y;y(AWSU`eV6&qll4L|1;({M@4VXKqeH3y~yLSG#J(s}TAMRNR-6?ZmAhQwPvOQ@x zBJ9}3~M-*|(v0*Dks6OB>*FfNZ{?A|_oNx_%)Y4>nW`=+D%`gVUwajp< z$b^wO;J61&|HQ0;=jpb&SwSj0*sYOwVnywE=PuRdrKrLZV4rZojQ)6P(6irFuAiY4 zxD%GIsJzCHfyh5)_6qp>tyh0td`)QtmcVkwp2&lAJ9cxxWWTq-daj)LxDK>3?H1l1 zS<5JuPC^-{+xSLIviyml^_@05rqiT%S+7n6vw2%py=KN?x{m1HsJU^&bZlE`a<911 zF1;Ybk6VSBCq29=+nT+twtiHMRZrCN%B-JWU6V;b69Z1Z zF_W`ej468| zP~o#pEN}!7Oy#f8-2J%41DsFW4old!Mdk`dEusXyAMj}I>oEok*p^i!k*RI8d!4z3 zQI-$NmvBa7b82(>dB4!0S&1dY{ngf}tPpss6e+#3HioAfq}%!i!j=X*q7k6Lnl%gS z&%nG7S*BJljuDcsjH0Mb$2HcoRf5^=L!)uYJ4_hvu_#{yI)0?YlPP|%N4{Sp^8V>tPQ`=vqrm*QLaK@)a(8UB z-ANQ?Q?(#v>TbW%BYE_Uk_&}S6Ih1!Ak{GjwlI~X) z3n#5wojZ;N;{|K6dE=)9x`8`5?hM`wk3VE2pm?nG{DJk?VIo#14z~Mc*oIU!AOGbx zV)ZmSO4lo-BgOJR1)YL{H_~<{U=4Bt7v)Xkai3c;vh{CGOFWE>5`{gDBq#Vw zZQp|VP-~fcLqJ@<&J?CuYHC>SN512p*38ol%B87qO>G>hQ|_lv1(Ir6F!FnN@{w9A zEtK1iN>#52B*1;rHM!)KhV1}%N_O15MOx6iqfw`$=0A;%bS1`N4X4<|p!S(V8sOUO zaSGt=s~|;z@)hKu2}bOAMYF1qYdK~+)1Bl+RJ-W<(uy7SwC(+L)mmqkF(@g8kurYj z>#<)+F(t_AF#zd2e6J|mKjmiNljb|nreoJNnW;Xc>62Rh5eaMelSgc?ZG1RO|LLdEPATLuO;n{I^TkOQA8mU2Ecz(OC4*jiZQhi2{%0@rt+O6%h{2 zo`fXw+i~xm;3^0yK(sGL@^$omQNQ-0LQCw0%MN)PsVzpP@)@N zx(Ll&gJ^;WK-_BYl)|msP=M|HfSyAtEYO88uA9-oTt3%?G(TtBH;V6AYC^3wE0v6Q z2$VbnD)3|3r8l-CI*>}0OU@e9Nve~kQg3C^2isC`0?e&&rfisGqSQ4I7eWn++Vjpn zs$GH6(av!mwam_E`;WY6!q_Qwj~RFnz@>aok-*zL($UaOxh z4>*MCsTPyn=AEL$46{Yk+<&q6BGS)6@^a3Vf10!w z1H=NBIw#2b|5|fA+%>uX% zVL}~S_Eu=b(NQWj@=Km)qLKUdcYWE!;v#kOZc3?Q&UCfk967P|COvhA&57+2+BdIA zo17EtNYZozTT|7K$>|BR=6Vn=-dDESfSj?CGNv)j->Y?r(B7Oog!R4fQZX3{RQ=b0 zB|k?UjBf851^)@TqXACnCQdvw zqhr0qf0e+EXt%0uf`7;q1o~>$vlC_z)NBoDf-mXk5oXm#RFSXPooD+Rxk}=;>{a=G zZZ_-H1w_TfUg+sIhQqx?5tDjw*CFbcrI*Klu&MVod|H>=&vDC-npUTld4GB0wttu7 zQ6JP5{?2B-K;mJzJ27`Q9+6p!+s)GnM)&fB`&5@4#e_xPi(8)mZ%Z(avjm4@mGdv~ zXxfCXarD?8sudf=b>?f6^_+}!TH#~~%iRf-W1eP>nju&Pq^y1fNQ6yeuHZ6*(dw@1 z_O9;w7PJSyT_DhAl31gEEBxo5NMbOqr^69c78F*5JMqiGqfiGFJ+yDTWyO7;uD*ZY zdT*@UHb)nIRwKErC@1c9xnjCaZ3$XxD0Cp9Wu=n0qgP$a|Gn{uZy=h^MrW zTH_v9woaBRl8drv7fl-pCUrj2iobUcg823S)uwpxY_u;M~8w$yl?SR{1aq zqn~gDz_6n~1tKV6i?2)&@}I4pOSkt=$|f?hgL+zK(|RmBP%6CwCt6FR5|(^H8u|F+ zH$B$o(%va~dZ=lsuMecrFUz7;lVQt(uFTJ~1V>b}+s{%(AzK-3Ri4ZgOJ?F#7}OOx zdX`KL^x!V{PDR?G|0cD?X$abl#n+{+DEbPQ^4)GDt}r!v?VujtCu)W?!wh8fkV%w+ zrqtsvj`N-OUJC5Kjy7wf0w#i%;#5wW-ip-iWh}dBSXLU-zIhSxzs+247d#;~j4|r) z;oI{8_zTeu3U576JG@bMN|k@x)Jwr_A#Vx$UaV+D(hi5q#;&dK0XuPWG`_LI+L`R? z&`eo&$>tTS5L?c5cI%xGJ&ix0Z3&Q6oZM)+z3kjzO&BMucGjBJQJ4Y{1VzC!nYVsK@8(jA`fBbMV19t0d$&4%Rrp?%4>8|O9N ziaitbu!$+jf^T!Y#JST=hdl2xQgL`{AuWnVs-OzmN~YSZ)MZy>KdJv=*__yk={ zQtl`dl&znD#aFZ_ogF;?ZcLwp$}L9RoyuDC`yE?QXOtBy#E?7EDLpM?S(d#%4s@(* z(M<3#XpH#|+`xx!bh_RY zAwH{Hu%4WDh*uapRDL0p>Zr9$3mtKnTbuqQ(O(*pz7#n1@R8m8=EpR3vX6pQ;^CPu zOtoSE#w=&S?xg)yOwzG^M_B0+FKb9&*3v*IXw;`du;#dZX7KJP&P+i8i5fcLUbIHU zZDuC8Pqdnh-qV6D;-D>DI{aeo9#^VG3srZ<YLFC^m?>uzC^>W=MX&W)Bpr&g*dn-<~?G<9NI1(8m&di@f}Si=3pm0MQaq zX0YI*wc&)DE`#t1W*>AaC%1);oOESx#lo-pGpK? zITja~H+!T;&smB-vMHMs0!*n>VML(w-~(lw#du2OQWbBGY?6k(hwfOXRGv4E*kqTP zwi~i~FTv&KJO4TEcq5z7v-Bo^y9+eM!8ea?z({G(+LTWy-4F!}EK1Aq+so4BQd^JT z&1I@Tk<)!oo)Hi66y{@m!P*ZnzcZK*^La`Gn{$m-nLV7bZQSfu)+97(!ial7lDI#M z8A)iKZH{o8#C!8a4M=nJw;0MQl8``t>!1J6ctsfOLcjLG4Pv1syzfRc1BWh4xCNBa zs<6j?Woz?%1?T&55)bzib9z%QgtP6FXt@agdvGBwwqWWXhh($iEL?!M9Nv^ZxXiwt44?F-tS&v*kFE646!S{e-<@#=t+bk9)* zpr@EX8@-{0Ut5hw=Up%}(FN$n-B3L5WO$(-U<5w{^5?CEonlxo4K8b~6^36aGv9KH zSgq>N&FKT`6~dJ}8ufSlEh;>oG*Q24DQq$JM8b<_=__kzA?28W9XY$oYt9PrattsM z_zKlUBa)NG<_qV&um|LGa_YT95WeBivH0d$O$UIY1<}GHI9_o?$`$*m*B}ET?$E3h zv!-(fUssvs3u)*cqhf;y!{m?(g~ z#RC#;KlH@KGx!#uA3MbA$R2%kSIIlZQKl?-EimLBo~oVkdRnPWoez50pf^q@``Qmv zV~g#7CD0S--u1Y}0^D@&Zph)wNU-Ea`V}Iw>k>R0?rfRjSYO@sl-`}s=Th@j5j}`8i?_^$L)R{=my8L-|KqZqI-Jx@Mb4EI>1@q}r@u*~rs6D44n)bGN zko*O#em*k(_9gF_tiiII8tX-_@*?D45N|&5a>g}b>3DFZ*KUqNWIh?(l^ zq>(#14maPX(UKthNcG*UBiCkb)E{b>I{xdT1_LXfA!zNH(msYn?=y&Xb@C}wiCF}_ ztu!{)vG*!GdaKj`M}$*jI`ZVi`m(27hu$IKES`w$7(_iRPLwcuFJGLi?UxIUjC?Q~@^O7|`mE0cTZB zWnga_YU=H@-Xn?F_x7BM<&Us)-v;UZY~NmV%l-;f>R|6uhK(k2{88o9WlPo~Dif*& z*@%~xf)iO0*5^K32-ftDS(~X-@vi++w3mHn4zT`8H%t1RQM*3=Ve&>F!qHe2S#ac^ zkr`N0|Bng&{eVCAgzUkcntO~I+~NOR9@9p;{@L`XNMs$rvZ>CED|l~cp3~~4sOOUw zps)U`ZXs1cj;^MO0sV~yAy)xpC%3*Cd%?A))E0@EZt}KzO+Rj;qfENplxG z{)|IU#YsA~!!leT+j!pOwVUq9w0iUIAZsrRlvU+ASlhlg!7w>&vhX6pAUFV+#AmTJn{->I@NOIzRIkM)xkLnH zlE*`BA^c{GeTnC)gjtV5SFrVko~$IfM|hu@_Ee3aiIUkWHhrNo?O;EUZtr$?z|ncW zS8W>>t>ZD8mWS7TS?|-)6D1^7^Zs?lMt{_igG-4&wh>_e&k*bpK=ET1;eYCCV0vX0 z(N6j4JSfLX)a0FcpPnC&=znA_d8WP);M=zYa%c&vvg5|$JEssuqyGtQ)R zq2q>Uv$?ZFtw=1e{rf;)Rlrs4TUg#-*HzZb6_`J$WgpH+79Ndvhl@^FNk12fcaHn` zH@S|(A?xGINcv%G@0+}hrbhliPLk82F;aH?(Bg!_drHuiveT8_jt{_urB#G?=go9xskNgbW!-N6%_!|Y8vwa*09b3B(hf^b5gFX z&%q0W?^A9;_6Ny!kNJqkWUiM)49#?eM}+i?K9}X0t-(UO!eV#;nJKxPDd;VQ?Bo+& zk|GAB8B>^5JznPsr>K#}DE7B#sTI|O{VUH~ZMJ%napxckr?W%4dU#E z?JJS6*v}2t(2crFgU*Gsu^4y-1sJnFi2GP+Nt`G)ENG77K|s;1@3tsy>uYE?3iDkgsa7E;XB^@*G#?S zax69Q1{2pB=%6zizqPbzeVGfz=WIs#@(*l}OJdwz^Ja=|zf5&$3j6+|sH|Ge#qCTK zU+LN{NnU%i7Zm?YSRv1phD-P`xjqE-5i!B}vfvD9q7f&DdLd(tiOv1Orcn|4z@S&w zvuAet2C|9~{Uw3AA9Z{tGF&4fM_#{Pnz?v~!XVQ+RNc|fezk@b(8^#Jac4|XhRlH^ z$2?mg=-2vJT?ifB6bhbZ!tVVwpd{-14CtD@KT~fd+Eg>-r=R-v^yrbxM>_AJ_i%av zkasCuf`$edg6DTP@?WTBMXAnEo~m zHhC&`dTH>|VCMXGaQK+RriXH; zzcpd3Gs34Kx2)(=clRX`*(H9tOnU8w$kGeCSa1&iMJ+LTm0>DS%yPguyoZQ`Cz~SI zNaDdYoaTUdJvp;w4h5I8*5U&iQM$8D3Rl)@I_!fR&)^TVP^csKSfX!|OFn^khuf!M z?N19{A9C(Gf5)Lnhn*I`{30(Ju8^l7=FJjYip$N<@q82G)e(jbYYV$2mP7#h)x^!O zUw~iHkHdA%QB7U~nVpE=EE@U@w`BMktA#gG+1iSgV z3pH^B8W_mFdpKmB?@kxcw#)KHlfLx`|KL4c0eB>Lpf*e`)AdrU4>sEJ*?N2qi@#I?b<{zHRD3m78@Rj+6B7JZ)MA*gM_|I{rMU~EwA z7a+N;Sp;=yi`J8kIeXD@|D7&pFLHf-e&G+>a|*UOy z7n3Qw<1G0h<@MjGw~+9^%Fy9)iM)zvKG>z=GNvU-5$w zwh^@cKmB(mtuj*oxtp3m>r{53Y6VK(mJFoET_iNJkpHv?C zsNJu==Lz;$Z!;6&x}6Nfu^@m_cmg0s_6cERsj|<_)aJ-P?Rg@>-wuWc`ItxC{&XFs z$F8V4@hBdk0HOg3K>H5`(3$0(T>;P^Rkqx><7XM`eZijPjOTYVloh#}q|p9qoXba~YzjFSI@b*N>Y42mmnE0Qevlv1=bg&Ob9aVDD)S%;Xx9 z*v`&uWfC@rQV`6B+JU<#lW0;0K)<`BD+`BNq`hT3O;*YlJyN!uE6({xAWjd+Mw0SR z{Oc^h4x9kkfp@%Lxo($4ljeJ`7R+H3<YmNpWzFgfR%;HAr_xCV5A;s{V6 zd1M2$^lx`|1n=2{9qUGi_%X1%sJ-b4slR@n#YHz7m9x?s-d6yZXKwKW?|uw;cl^Hr zm;?ZT!P!Kbs*?y;Y-oBb%k1Us0j{%Y;}tbnK|+4TCh#p*am+0+ z!{I_OO_cV4HSUJbVIm=aYtXi8mMbG847KGR-B2fChLc40UPg@-Dxuw9{2_Sircq#k z;At;#|K<<@3=m`>#*=`0*#ZzV9XV*lpXc_SCx(3!Qi+y z-#(uZV)VON-RW@T+HL7xJb^J3Sy>Z-T?c<5uqPpP*d;KsmwO&Niy*2r{gk+3UX z>g1Bw(_-CnSq6I1-c3%+vQ~kAQ;8NpTUnOKa6pkKaIZz;aXw$BvWA1m*M~;-baEh? zVcrZ1TDB-&nCag}>?3%r*Y;IMB5r|wKlR{(YI<_UR+_S0h0MwtjMqbVcEBf#kT?NE z8l<-R!6j;h$M(1rn%%~(#TJuXFc+hYQX4YX;1p71Y0HsBk#z3~_vPB3|M)zF!s*H9 zfODG5Zn#n2J!QBJY}Tuq< zUGA{m?b*jU3zjD9eH1~{&b%@_AIO+m!)msXKE3}!o!NcT{S*UlUB+$A$+?+HFCZmW z@=%AmB1CMxcjSc-9UF&tbtCuoi#ymuVRjWe%-Tf$RN!K9Kx&)-ZjH4!8;;yCBZ%wQ zg?P7I)ma=JUJY_QJGG4Q(sbYWp=Mi*!N_>Z2Y#{LIGcYIdL^=3`|-2hO~kb;-OyO( z^!icb5kpp5ywk8BX}^kc>!^7{AHfB?ouwg??|)k>X5N{mw=wu%A1ui+5OV)}bIZMs z7g-ip!hnNX5D|~q{ykS}g}Rnz=_ks;mM9 zW7p%HC{v;CMJL(sE7(hZ8G*DEgu0@rd*I~zSoh>aejzw~gkK-ktpjB@G!%^z_bhLz{bm|^Y!yLkV zFLt+)s$sSM@0Ge9!}lY1_L>xRwe8FUy%Kn_I>52Nul9d)0LEfTooI7oNl<>51EG84 z{_TA&;do4HJkBNY@3IEM1}}7-t@plNHFsefUD`R{ITczdA%8PFG|VyIN|3+~S}8E~ z81+&=Kg^XLKmVw_2rL7|&W?&aky_j9Ym<80mhvQCG2Ib4ak;KuB?bkj+*;ZTHp?|$ zZ>=(3=(u|3IWf|1p-BLdm?aWcu&%W7d`W!JKq3LIaU{{BzJ-RpMnK*ODRl@lnclH+ zQDNnz=06vUg+mkP*uACDxe6aa#I8CZYS@3Ah@#LwzN_S&<4XuyQDM$!J=<>-uXiC? z`+MGW5KrdKn-5ebYrl^^K&&|FWNP_Ugr9wy@C$?=$7T@SL6-W$JvMmbT$a(z6%pIzsw2L5jG6y&JP^u4~H729Ch0=BIF#Oj9Uiobh$6-sA>v!3=PLCz!5iT>;KmigVHl z93pK116D*6#E0zM>`z_scl4SRNX%7av`k$RiL&Uj)(_~qhN*wGr`ck254uHZq`Vv=eY7zc6yB3} z!dOe`TKS+~FC&22lW+bkos80Vch#;>gv{R1sUs-T6HQ zBJfR{L*|=B5Y`Np_(7FMRPu=M>b_H+Zo53DH@s2uW=`U*<0v_o5@s$pCVqoImgrlg z@wT2lQByGZiJ7_2En%}c_3W+OnFM6QPhs(<^ahWI+tMPif<@DsRYiA)w)_RtA-gA| zyoF=1A3^|0Ag`qg{;X-76>2_w3n!oF zZ}4!r2XAM0vN7Dw@%)QC3DNwBxe(~_)QX9^3W)_~pd9m0JzyJqZOPUC=}m`hoA3!n~!s|r0Z(nvo1 zBB+O;87z42cst^4CJM$hS%Jo*_%I0`l? z=_4Zt+3YoL1*eoJnh>)psvrF@y(7}NoOh0NXFqbYRNPOkMhp-;=s#lTjXT=8?CmjN z6v~8gvgRwFWfk-M)0W`X#LtFh_(a~MD$43WKM)ziPZ0fyR!!3`p+k;18oyVFT1gab zNHTF1-i{!f9ff}@okAJc-mt@{CoRlF~*2@(Bz{{pzc3GF1o;H zg>m42uAf_spA%hyrja2XOQs1HT1BuEgNCnl9Bu(8RS6Ik2sP&ZBO&WbInB*vxxYN} z^A}zR0K1?dB_h7Wu8fXsv8_&;Fy}TvT!aqk9Q0fwjV0XoS29mN4=L}Qca!ZS=itp0~iLrr3~EPAu!bmECogLADcppm$B7nXq9J08AE8 zOjYj%Q*W*^>Q}M9-Be9vb8b$ywy8Fb4j-^I-pdT^smE+Ip~Scy&0NYK2o4XOVs1T_ z+?`rHwrZQ*jND%S4-8}my}I~p5h9ujkMhb&x2p3UAiP1GsXMp z&LhfCtWOGbVN#9W*WJ3%SEq%D`B%_lD^ifW&16TiWp$S)vXl7KGUg8RM9|qS-rK#^ zQ+x{MV|OY#cZKv7Mv8@blwnKB4blDy$q=DpSKG#pabQmR-{zd@?$1$qHtvD31IK7A zdL*-2bU(ghy&QkptolW^n$>#8v0r%J z=ZU&6r`mj*nBG z1xgoMcWmDwq3i!5?>(cM+TMQAAgH(j0hQiVq$pjgbVWd=$)<)by@y^x2`GqC6_8#; zq$o{#4X6l_8j93FBE19%9ReXaEAIXN-!tyG_rpEoo^i(=cYdN|Wvw;q^DEDsW*=BE z94efT^6-dJCZ)aZy=Kx*51uZ%L{>`{mU;BEj0vj!P$5lA;UVrT_1A7m4@N_y^wfbL z@~0gnd;KN()*!o3c!-mp7IDJK+?*I7CDDyFwLO#l8y#@BVYd1s-(VraslW;6jOF`2 z8p~bH#oVV1T>?%coA~COm5bSV71pbgD+jJtBo3sBrKFJBVf{E=1ioV0nD_)|4FYA1 z?2{);4W?&(V&QdCbh_O_4-ol;wXdZV=22Hr#O$Iqxs-*2G8J$J;Yf$7v1_Oi-Sl-~ zta53@by(p`%R_gO;ROf@2KW0V@s`24V!#u8eJT-n?w#2rkq*A#qn|~L@CUrAs4sP}8Td^^ z?ch!AMDD1a+`{v{x!3Mn#Cvd}RE5UFN*ZUYA2y?_NKom=4a8FrP2rGd3O z-+tE2=I-`(xLN9LdzxRYuk#5d;;dN z3fax>1P)nY696(2t2ZbTAsN}GK?TlYNRBvLpN~AZV!t|3JLqQD3*;_ zH9qTl75HOvhKoAT%~LyJ-k6!iZeX7&9UH?yv2`s^Ld+8L72rRq++TsK$%XF*xkHhQ z;D7JK{+N8Y!JqQvKb!VHz4GT_fWJm0|L16d)F|@V4&?Iw7S-23Km33DEBoti0+E4# z@fnY%unzfs_y_7ZWIboT9RFJ(#mao||9@IH|8I7RAN^KVZ(*lLq+jO^{;ht$5e#7| zKP@2m$PdJ66}~nln(^2B^6=et>>+tm|F6_rY@n};uNS;BZ~upr`_qRbyzhk2JOe&r zv;NO$1b+OVJF=tMTUCL!T3_oL?AKv$*l3@ak{cZO3{bS+{P(~qxF|m4Ev9_nRpU4O zJhtj=d46f}orrnGKWXU^{HqkSeipF&2J$Qbl+EkW6!P;kvMhU5YdEais!(D2cEQz> z}98zJ0YJmwC&yZIbLL8 zf|ZpVB_(N^1*^4LBXE(39c)otCk_(fob| zg`=Vy-hVsM+Wzw$cf~J=4>4L>3Z_Qn-LrdO%?5{c$ir@gaTcZ3Zp)sAt$oL@Eu=UN z@|jdxHa_^%RuOqcKwTYtDBlCB(me34(*b?bZNflw++!F`uWCn)gIgCa3Eg@J-0I`? z%RBO)_T|q8v97)Li{SM`AI?QC;svH_4DS``q!>No{M>#Icoo+n|A!&eS266v9o^u+A z)w#7w%%Zo$*5+FqaP{!0(=I}&eCYB?b-+X11_yQ>=NTwaI-DGFrs$#oq?dPFSikT`HzJruX&)q@PnJ(drI?J zR+?jUb3SLhw9mnLOgPEZDsr4{8JEXsNY$2q7~Y7qE4jm7-tE)k+w`Tp66M2v?}1&3DJzBTok5+4g9|8?o9DTjT}9V zrhODwNOq-63Z3QOT2v-GS0jxoRK>ry=XV?sOL1X4pJGyq;+W$@pjr5S$26xx%h0Gfgv8{Q*i^NOP-(3S* z8!Tr(-e*lw2in<)^LNeeK&|5epB9T)&i2HtF?Cp+-FCMV0tZIU1ia4XnF{=Ii9*V! zMh@*=Uf7RM$_oj7(ASiCb=Np2N_V*@uuw^hzsyN*=aeIN#kgEj37SRnWbsnf-_Q!L z_74dkWRabt78hi9s=2oBaN26?#WLFAfT|=Mv2bJFn9SbhmRyjA z?Z5^0TzGUO^QZcE-TlJN>bcp6`)gty-g3pwPaYxYXS2CmW1j^m@+2WRnq+E49B{}% zM%%2sVD~Wjfi62IMV>vGJamtNhj}bAY+-ixCH-^m5$(cEV<~0VhJrq4I%C`Rd8axJ zm3p93B?bn46{JwC{Q>?d7oKVJQ-}}As01Gz#3m)iZRHbp2fV{957E>P|?8Zpu>#fZ?<8j19n`r5n8*57%%8@MUc+!b^9k5UI7;(2m){%1$AT2u z&nIH-aY}-PjbksRg+@9W`FHvV~^>9WfpkX47E(io?Rqn0B0ysB=^`*xBMd z(-b}s8`_b2Fr)p_GfL-Io|?dJpkhma!+`G_YRo+0-e8FxhHZ?#ChVZyPGE0hEQ=U< zes4Ee@o1pPTgS36e=2rqYBf-{m79HRyXIs)lz5y*Y|p}mtv$!%L#iy`GRxOwT1C1R z`YFq{Sa*ogjdb*vuHh0Ec1$Ns4ZO{t{3SbAc!lAq?tn~|i~2&LBS*`%qf;aV`u^st zxHU0d6T`RcdQVH{27gE?AS=c<0sz;i6m_8+bwp%Cw)1bJvOsXo!-p;dW=qo8v#<|U znU9j{<=*Ihe0a&gI#c(jx&IY;umC3EP&l(cPw8>W-b}22v1SsD+b{wA6g7dOpNw7r0Qbd&AH?T zVmBCZt<#jKk(qn73d(KN;`g#Vq*A1&j!T*7ENjlf&7M_hP)}ec(p8A-4}v)&`BHv) zl`!qixK>P7dYE)dQQO@GfVB;+1JKfkxBO~!+q2iecfm>_X7zN^wS)Vs-V_(>5T9zT zSMpo!zpXC$uMFg-m{i__&Doydf}o1={D-f>kKWO4_F*%D$(xYrE^pg$ysL#wJmJ#R zg=MPb!LTBQ?F~~%_kduTli7jF7!B##@_D#E1uwgEmkfi^9owH-lNV}SS-YOF+2y7K zZ5!?EE^Ws9(^ilJv0XjLR6O5s>o^)c>|U7Z$iX9x8sa020s6vvphdHMs6~^<;N%L3 z+}DN1pLuiDKd43d+qOP&VbG8T@PS8QpLQ#Iaf5Nr8|y3^NKCbq?*nvf%21CY-m}iB z$NpgBv6(os!qf@6-xNVPz0mt^2jkaT1UYUCYAZ@b+|8?L&$NZa*{=r4jVp_keSGtJ zone4|QxXMNL`kS@N{#PpOv;zR9H+mCaFE)kgAkPNM-A7|m_R{TD3VJHaTazknHJGA z600FOAoFKxGquflUq0t&A*=pkx|%$>RhY(%A#kiCf}eeus)8q~*f~w!RT{fb*i~EJ zUwPyZ(ZGiNZHj#OL_$rVZZWFOi}KEs9Sul-xs&Z(sp12;Pof$=_uw&CuECj1_8&!pOjbr%!o{OLS26n}6}g0H~~cs5hlc?sk9oW06?y^c~4O zZuVKPf_rJ?2JlBF8w-ZuzC29gju?6EdZ}ZoK?tIHjftsy)Yo2W^SIv=za?kYUgLm$ z)qxlo0AhyGw6z2rp=*2-@Aet;vHR(H?{GG{f_Z-x!dCHPn%fJL>_wj?k?w6(pE%kb z&}pC+9Zsze`tG%-@^pdIVlUynFJC2lNSUTpp~tp}n7aWM--OVNszCDr57d!_lHWa%eKXGaP=;mJqW6P`tCl>H^0RUK}Iq;7oLJH>yIt{$lxUkpYx+!*$t7dQHH#&29rUWNsC@J{SA<5APcc z0;{Ao*^WNSx+Hoo$nj!ejn48VudS9#Z`a(*l^O@Y?>QSepPWe1m}>T zO)X#B?(rfFAP@JqUv71EjMu zUj@iM=wrv)pYAkm#qF4JOyOLb49ta=?`=sW1YEAM1?TY6%Kjj)c`a)8z>sbLh68~8 zY-lUwz?^RGSm_s9y9}HAQ+vSd!XYY!%`8vB1?&sE6xAGF9xc3h{jpG>ePYhZO%LtA zu@{V`ZuK5YFxH5Ov{ixo(uJRJECO2!)=bcBCgyT;3mtALxTQBHMF^E?kMwQ_w&hQ) zFHI%lT}pcL(8iuxj6`4)Oc$eLH}i8X_Z8R&v<(H=3yg4AHEHOvYtb(ih6UT+zMRW7 zjk%hM(e;>U5L%_B*rxh*r|_xa2K>Bgu-|-Cs4?l7z6a>~;6D4@(8fZi$07s!8@KIn z;!04EXY84ag^-5$wbQBDQ~HVWMMfs&yY%HPg#}^S%%xq*XLud@McLjh>tzbGQKj}>U+r6bruNlbG_b<% zg)roEs#w-C8cUQWOgK%$MT}xPBtCvOUAff2XY<*`p)gZJ3N=(@+Hf3=kc}^sz&&JV z5n$hn|1=exI6>M^hb+TLWh%k0KSCCy)$J-e9UI+HOnymgxW z+fJY-SJh~KU9rov?;dTH(ETat{lH4ERaB}>I%LL}}^_MFdJ1}RDPb#TvYY;t8*E%$DmhV)K z${HI>PS>>!-CuP$&_!}vw)#9QWf?W4ZE7biPM&^0p4m|HE))-k97?7ziy$>RLTaS$ z!s#p6xQnl+!9q0xwENQxycNICV^Pf_>T0zjsDPyo&0~e}H-7>3aR->NGo7WbPVkI% zk2*Ul&S&v$-M%Iy?m%amTo4u@<@c$`N-e#MW_byQ@!bv@IO10JmWOeU!rThdU0C=p5v=DcN$ zss6Y%u=%>OaQ<$obb$TAUDq+wzg6wBHkF<#;+3H5+5;Rmr%Cy}Jes&}?bZ;dc+JMc zZs(7{a(UYT_eCQu1n$ziI!vkUU#)WXZ8@Q*3NxE)rPWn`zXJQB>HZM_$k@@y9Jp8Y zmhV)!`1kURm(U4|`tMEa*vueUg*PtcbFb-&W@es-iloc8UH9cy0ky(W4n*fFIK^nO zv@s{SfLpH1?Igeh7up&nHFf~#?)mowsrmN&4;xdB=5IZ72blwBsKSK z?i~#ba9LZlb*XC=x186L{IH@Oz-XA$ZhPGYnc$G*D$lQwe5Fa=2H|n!b zUC*=gFd1;4NbodakGcd}twJIGpU$XDZOg~Z zWHY9R73^-dY%aED=JCzid_KJF7SP-(0kB)CQx7=?3^~enhJ)BY%8Ysyy*(Ot)&4lA z>zkDsW6HB5`PPNU?N2|j50|2TG0sTNdw$dZkikskdADF+>iG%@E6a-9brDGpbIUO} z_`}2|5bzq+oqTG%s^DiEG(ACkIf-)ebMT#5?yqFHLFDsfs4|s#qry>?i+vZkh2yXi zWgL;Y3FUB-t+!KlJ7{U~wRxr-U@M$%2FxG+sjq$BNpMvs&SbpY8taMs9I!uFDl`7r zSvEIR7FBy|v?ougUaFO#VeT|LS%^sZL%OYUQg~&nCuNq)K8eaYfIbsx@vtHu1Azz= z(z4x0G9zaUGm-py;K?l_KzeGVhCf=5e_<|sekwNjb6(nq)#|+=B_Y?BUnD#v#v#l< zj9Yz*s`DP_vlNSQ>;>I4t4{n-=V*2}#MpYe(-eqI$>rjE zjsY@u?@a^iR(pLIq${Jn@b&J`z2$ygEm(tXp2k%NjnmW9~+-Xok)t4PKn*ghmK`4mie!Z7`H znvm4!#C)#P%Sk6&!oS@e^Rgr$TRDt4(F+3!X0^6KCjnfFX}Yj_>Yp%x|6kKn9F%HJ z{BQ)J`Zb~1(u}P@2|b_X%@4~*}ra$ zIHrRH&hRA{_yFryE{3`Jqx_i+!usZ>iy(WX3&Ajs;SgY+dr9m{I+DW*QpzQaRqj(} zdzNIH8OCY1uFkf@tO)JCZNjAFeW+et846V8%f6YN_NgiAv7$~KxMo>Lbv*WyN=Xf zDdNVb)=Znh$obVs!nTr7ibEU=e+)Ki<|-3LQtpd5e*bta)9s zFCAt!Ep0<<^QS)lpA;!2D}LU>_mouy?w_8VhPYKD*Rfi~I@srz*4_{^;f>)TJ>?gO zB;xYytbyMp9WBR$gTX`2(@*W(CnSzN6vhA>Xf~qatHGlE*y#u1aO6-i4B8KYQ~>7< zVqP66_-X`JVD?x<>o;p5o=By-@De`D#&BT1v-x}#B=GppTkoG%TC}2vaoO%Bl~y{Q zGpj{-`1pB);d|*-R-5m@I+pEss}DkcPkvgg{Kn^zop;zWY=H9JD5#5HSxVCOm|<+4U^VxE$k#379xJTWM@;+^n?=5Ai%Z93o-ZMoR=F z9S?PHRI*7H3xp|M+b+QdLd7aDY(4$lS(r@8;u4f!RmQzA4afj4eywN!@pN8eSkU_g z%spR6$Y#~N>9vFnXjgxxWb)c5)cI_1qvC)gA+qG)DWuG_11{Au3@$7g2d-3-?#Sf_ zx@O>TuAT!(NQwW(0T#nQQF)~MHNO&r z#Q@#GuloQyk>Z9q93)t^qV2FpC@k=eCRMQPP=&`RU*31!S^j6!2a7)R(sHNECrfq1 z;Hp>M{u7XW;3eHALR`-@?1nj%excUa56D-hR~=5Czyp+;ht)_h1)V?Z0GC&7j5EE+ zTAhR!gpQ3;{0}PH{hh^>#s<=Uu1imka)JA;{WGzGp1Pj<-_Zph0XtTQ1^s?hFXglWL`_rLykyc$emOY2l@B;C8`!ox8X$q*CsGH%TJ0cY)sPvm z4FGl?KC+DKDz`he(&N^Fx;Lj@7$f%QADLtUUwY$SkRoYena=Ib6IDtOT;y{Ur@Xi8 z5B&Wc$#RQ^kG7?+K1i;#>!PRIW$y(qTbxW>G{$5qR?p>GS?2)DO5CN0)_wpJ{Or>< zP7c1OT+k?bFRl+7>i{g@3@`Hx%Qe{L;1`>DFj*1~sR?K`A|7< zi}QWJNAa&3nRWw|n$c~g+QyA%q%Jv3#mA=oz=r_!w7NfE&uw3^3+Pe83MNoEO| zRGBS;hA8KAZ2B53@$vsHZBF@vUri9Yj`FlmoCY5O11Ya6B{a_if5?Is=<{x zh#||R?=~Po`UH4{=Pi(9Z;zRg(|w#|lA^f;4I?#=QW*c#$vU=&+?4H=8#UFpd2L?p z6oWDp>&;FzD03(EU_SeXyWf`+Tp-s0{dy{J!(Mp+kO2}i{*d|E9V_k4hC3DkVJCxw zVdILR)K@_D$zB0TI@pVR(#=yc@l`C+ws?v_s=1{JkVRl0VAdE(3gf8gJ-9=;dfB_W z(PehsevZ(*wtr1awTo31HtGBISOp}0zDwGrx<#|%f@ONy=jTz)zK#MwFRU$qg5EDm z9iD4>4a&HEL$uBs)uPnww`sNu=p5`5(Y`Z5NjqMo9qYy-S@Z%hnt)9*A^wGhi{K>-+;88-T4ah|Vtys7W?-H7UyXoPZqN z?>k&d~`cj-ReAkURh3B9v69mvVH--EVD> zgA6(&3olC+kb4eUV6no3#Jo~d8U0Er)YkXHqsk}7be9*EP_Ca2+D&!IEJIF1fS6uH zKwKEgSvJZT;4_XO_U2DELoPyNN>%tdG zGUpZJ5k)|^|Blxd&h9g*^eMFYO(q^_3xx1frW-;M0uF+_Tft|)s?c#fli^$pLMTIq~+VZb$d84)yi*5z-mt%$33nO^)6i+ zx>SuJtchW?J6y)g+ZU^di=P-V>{YkdalU_D068!s@EBUl+FNQ9>FmXTWh=#{r3f>7 z^GhgM1eVgsL{U@49rAFCT5>*fm$(+s-NESU7y!`PDnvknC+rmacP=n=cW_Kpmg8Go z$BJ?aOFW{YGdl%zW#R4%59A+oHbsawY7Kx#32%p|@ zgPH4c_oQ?l$Bk>%#-a@UA+s5cB|Plt;(!g6WR=9BHsj-L`=TZu^L)`b`_f3&@)zVz zuKVgtpR;85Y>354Gt_M-gY~p|xObHjWS})}D$Dj4aGE1vO|t=1u~yU6+Ovb!*3s-9 z@7m)KHTeBF+nh1&{m^n|*^JsD4@`9mz826)2Fws!c|k^?R?2nYVfUz$|0r zXRtEG9bj+86I;tQ)6xA=y*_Vd;^P>&Q5UT?_?1+ZY zo)3(;q`O_2(l2-sVP-WpL7CR=P!ZTn?7>L+yo}L?f9`8lnFfT{$%a?v^86{FcsGl5 z-;I@sm*nA*xAJ{r-mFL=zeH?(nWUnByMXl^3*sAn5;HrHqGn)DD`dz;{QL;mCR|(Q z;9~0};KsO|9)J$Qyiyg1BA5}62YU6!Q{ELmc^8&{{=d8cf7vl=R1Fw7bqrFDy%xMs zj$au+HkTZC_KK{f0+3&C47XeCi z@7aaB`s}c=-w`87Al&QPmSw+80|Ttao;N{Tw!_#Xz$&3FAl^+uoC}mS+ArS_+E0zA z8RStqI&N|*X1%Mywj_9v9Tt#UQ9j}OX*Bj3E+|JVRYO!Xe_g^i?*2Ajekrxk#I5AT zWRI0tnB9Im7xFZAL7XyGeIu?=k!1QoJDhM0p?49~yOf)IdgnU^r*a#}Hv_fAftupf zapQta%|NVG7rF;&*nC2EM1?+II49Y=+3m~7u3>X8FK%%4d`@({e(~pQ$lp#&;c*L> zShPSe{1{&GdIBEfzimpi@CI+Fgx9+7_d3WTl0?#OCLWbr*V_K5LBX01S?f1P=&%lL zD8qOMwxv%C_4=1vOhpyY*#}Zphg~L}_X%I0pTHXdB+#NC)zS5Y$D3}yH?2at#II{a zy=PBP(OC(YF}P%BZ{j>9bZz2=SZ(9S0IO4-EtJR9Uz6BmY{N!@#+>z;_vl%LDV@o( zF%~BZPngrLnK>92#5l)$m{-VELWXO)Xf)ddPR%EYI|ZSf(-l0S77~NrsRp_)zxOr? zCUMt;1=--W^R|j?>>T*|3e?_2jOid_Ti}R&dAguEGfG3aP}@6u8cy1Nb@Z?)J3*%n z7)%5l)`V_3Nk!Q(#C#^q>lg?6FIgz%TD3MY^vd$Lwnw{>*@Y*z0fEOk}IizTViu4`%vda^`U3z zyobVbWKkT;GKOt#F8RFg-da2c8|D=MjujV&7bA*1*a@J1^Db%k04`K~ z>7jiSHSl{buzstiPE^Qpdnq2l5#uX%2#rUkLW1$=%Bg7AHi7TIH~hyIdNuHK)DO)+ zohX!*NEVCH@Rq43{i43c`=$Rm&WXEO1#qj01P`%}5;UXXUyVzUEqqt%&{NK9wt8xR@=wj4~8naKFdWCI9o> z`bL<<;aC+Kt-O9}8)pCQPolqSrZW(BQ37EX<2R@Mtaw!s4*KI}9r}+qy!Kz64f`e~ zQQuYDPlW^Fk(Gx0)n{HXYwKNNyBZ!_c;rI$MncyUE)k!lk5wpi+Uh(i*Y1<&socv2 zC;bBCf?MH_HaAMsdn&O(rz@bz z)$4kgPW>cz?NJyI`>NY;eZyJ4pkyi_)0{x5_%D_qllVWe1mbC{XB->t#+IwJBTN4D_ZHu@x391e^Pz7xVsd>Jz?r7{S3 zugRq@`^u0u+b~Vwu!nC7*-jaL(kGiTz%l-@sIW|nQ8Os}olNCKt88~!LiO`n?*ME< zE~iycsM>%J;-4=V@}T;veVg$|`|*CE3l0sC1NVW?LGHq;NQ>r`v)W_9gxp=rzt=eetUnRSEk2vj-Rbeoo*u~+jeZ+~NO%B` zVqPezC*gB61s5-YMC)nwXj|0OTgfKD+=Ts8HUhZ=BaV)`u&QY(^Oon>WkM_wifd} zP^z|6T>?i_*KQ)Pv5UO#7M74>Ltf#?7+5TZIx-)yF7DEIge!R5;;xE&YFZ@z{wmd1 zzv$Kb?+!p#fI;@7_TS@B1sZ66@W+~m{#4%q74~sM`w0|!J!!(f)ih1t>ehqbniG{9 zCu`?d1H22;4u}bM#uaf5eI`}lB%nStl}{CeHv<&}qr=Tw7pfq+;U5-19mvsGSb z|0Z%4hKEN%L@l&WR*?1TAP)hc1s)=Q^{nS+y_=T{KAb7Fo74Esp+Y8I2`96&YZ1g* zs~XAJki7St%sA`ee=J#P%AnMrc`p!qnLX@x0fxn`{B+tIhaY)YhQ~DyW_q|$ z$lJDbX^Zt;SS780?^=w(%o@rPM9ctFxV)}6RX2q2ENSy%goR<;&nGwmwRfzb2%p!x zi4y3l1x1i}R{4Vd_^B*=(rps0A0W-}rM;8VeF?2%M)3UnaeuX>BUJLt1F&fK#ObMI zsjw7PDBlZju@#;Xw5$&w`(0X*HGdGItdvF{%7Y6AT+oGS+UVWEi+IyZV{orHWS9CO zAP4Z1O#wu__YNGO3Zyc0UM?$iw+>t`r!Q}pociLZKF!fE&2z~4NM!|?H*2_uhtAgn z&LA<7Gyj#Z-(!Q_R`$D3reViHUD(>A8GtP&W@i2uqQ)OJd_edd5GM=j`D zf0m?LFGXGV&yDGr;d-YZH)r*zK{e>lizi6+<&DrcIF0~C*dWzxTM z(^wam>1!>}G1&sYc`VW{5UwHgZ1%EsxPSQf1#5~_eNKh&S`U-rS!Jd0daWMB`prHdGWht%8smKj zs^(1U_>_V#ax^N16Mw{66(ON?&Pa!-jGdPGl|Qz#V-DH&{OKTwy4Zwqe8RPS#yPh` zIHNGa?+4vcKD=EdQ@Ttncy5QzMK1f@d{Cng6lN*Nk_;Cxw+2H{vVj|$&X8%@2H3A^ z+__c9sT~P6`3vn|f(sAMgFeomh?eymrb;i0SV^7(8CVCv8YYJYvDdD3GJIyf;P+f2 zLThX5TV&{kq&#HSV{WIr@JJp3?}m?dbGbEZU`Z3=K8TI~4mfElA+QcD*i z+$9(iu1h|7CL^R=81#%2o)wp8(ZNrzVF$H4k<8~*lD0)J@Q3Y0w0a;T0U_(<%i7@OY|9@{;h={$t$!ODT&OU|ReUgg;8 z!OeEVe*s>x#(naw-{KcbiH}f<)$Y4g5rH!?9qEY7I`Czx|AzJd@QmBxMn*StC42Y z3-VY-P6}^{SN)zAnp4XFjICrXEX}h8wbaSy}#Q;_SViJqOS~P_L;a{{92KP-td?3{KaCOt4FqA4OB-ch;)~o1DAzy*uF&M;I z0HOLid{vGnpb+7?^U^=wiDO|v_g1OMh4*&Rd9xqoD!Ld>6&eT16yEu+(8hc-dAS@y ztl(9fGmeu;tIejIwY^i}7GVqp*3a1Oqf@=VfGFqnZJ9?GfV-Ejzx5A_EhzY~r(1Dj z_mGEYE6A{F@0w~^^Yki(_t0yr_F+4z2N>3v22*0QoUWJP%MShdK!CFfevOfPbw;Q- z(iE-(M~@7dRO*H!e^vhynzBIRA2egzzqf^>3yi0$(KXw$x_7wSogBI*zjs+VgX&D8 zI>4Qu?VSk+monlunFkQ>ewc&;90Rw3~Ef$p_qbeJV~DKiZeNs|TpY)-Xx5V}q`l zi%!s(EJRFb<5!G(iyK*uSytxy0CKw^Q2Pi-#o1knRcfmlHy275al!60zgV7sOa;ni z>g2uFX$^`nV-{nryyFi$oUm2Tq?F1A)JS1zmX_nqkldML-rZb}Hcb>Q>5g^$WNaG$T z6!E$TkVH$tE%s7f+;d$HIkyuVX~p0D7(kiHdQpI4KMZ>sU%C5PTK)Xn@<@NFHsWR? zJeYDVD3|;u{#{|un;Jvs0W*x!uC`xI%jqEB79WU90JO!(Xv!?6EEz~ETy6>i7Wv%t zxWox2gvcu^y|Ig52w}BOMHRUp487Zq0DPTNB7@Ejhx}b=v1}i=7K>N(aSa0hz|;i4 zlHedCiMhVkvVSY48ch!JbQ7nxsX$`6CGk*j{0kV)PI22cXfM?M1R6mf<-0TC;jM{h=dpR}wbDxTU+EN0KW$Rs-hGbV0CX z3-zb$SRBeK{c(_=;#FPS1U0M@p6Nl9TY~ZD~5hv99AAaotDM90*)=}ZZ22!G)7-0TD3rT-( zkJ$pheOWqt4Wya5E@*N#7?W>EHAjyLG&wO4DOM3h3#bXBoSE8vNprEr3&VE!0_naK zc#LkH6e#hFf)E84MG&v6hvsn086YVXqvq%2$<_GPkm21Iaweig$u9k zCk#9>ux^2hh}bC^KVG3uz)TYLeY9ZK^2G)+ga0C6Z5ax2C9z&Xu@(={-SIM;@>U&W*>oQA1A+rON4vxZz%!!1iQ5pjR~g0j3?-3 z`)9j^tMq2}WHba2G<+1|*B+7~097~{-FdQ7q0nM;hkv3T zVs~bRTT*}OI?(}tYe5=65h*(+g=}UWm>XlzO>Zq)UC(rllz2eoUe*d+fv8&>O>WPW zgnEyMe6MpEO$LW4k*wQ<2zw|L>Mq-vWCiAK_SnP(HtzkelT%0j(c*)XzXhu%RrG3~ zYi;f_C4A}q3|o%3pVlV9`dqi-!9DwH;}Gybjs$O}vSmSQP1Zd-=v_Mv{5Y<9)S92Z z!gBb$!QEy@-J}C7w*z<}LwVptr23E4?m$~4Z8u%_H#bbu0eKOK!GrKcPQWup)Wp8{ znNLCqlv{%YUjrFYeA(j_4e}HbX{;7)L(i!|X1b$~{a+ z>G#p)e8SD#-G9~G$OF^6Wd9NYSk^`}&8q$M$sO$Q6Qu5Kwjn^yJPUoAN#A9kT4K{K8wQ_9T&S5a2o@5*b01 zDa+Y$=_%H5xDq)@(SGlxbPtfeT0#51>__-|qr%~GLL&b>eaK})av&4jb*~d3bw5*w zOL}uNs$5LW9r?lpklfT1;@T3!9|*sus#4B0mCG`=opsvnzs=lc`oJykeTNR#3Ggge zK$MnCKb`XbIP29?t{Ksi^K6y#q|5XeXhD3;Mk+NgE9N4{0{Qjan*_ovXkYSDC1USQ zC;@V_cZUpnC=Oy5fm>$|>$)2M4zeZ2gvM_H_W`ps&9efE#Zgk@F(S z)0pzCg#8bmiVg)HG=HxeO$5dJ&{#EQ=1>3J*ss}HhuGcPnv&v#Opr%=Q?>8x4P`Bn z{lrRqx_9Ose*$xC8%;6Q`v&Jvr{ttUWmz;xj(y{Y&hJ(H>CdyW(J(pk`@oIMPpSdk zjd71{!b_}Uzgz?ada{0&q&(1xX-e7#6uT56-~jp8F<`>SVDqO>AM!eT!`h(2_rZ zY(mj#V&KI|dXN(T79|;F$d3-Q3e&(+3Qp_vFWtJT_M3tXe|2JaC<-6iSGTF9hO`AP z)%abs7C8q$i%BX`IGqoQI6z-@2&-if>(w)EWQe8g2iA(>B!F0PdI;$a(6i<@liXDD zy9vSPz@32bY)!6eYGB)J?+xuiTJIAIr}=vQn?W~a(Pb)99>W%E+A(sEB3KROn@ zODV9b(^qaD_ra63KOQl_85g{>rpb{y%fjV?7cGS~cNbP<1;`pUA^b=H?-Oh63XK2334a-nslzuF6VTDC zs2Kr-o=LnuCu;e`=mqb1m^b*x{{cqJD}m|P9ZA*{@Mnq%o}bSGO80#?6zbN`N9-y9 zHoZ`8yVAU-wBzHU9fF3}UB3!n(&uk~05c=#>0NJ}!rs@<3K~)u%!5#E1n(K^1m9>4 zTp*=(6sYMEZezxTG01TqvU9b-QbIi8%|45HqX|{n7>=#t2kxQ2>q+bltAh}7zQl|# zVx+}@`(hg~OyM`>1|AA>s9d$=-^m1oTznR9FZ0oN0j%(jSu=%6jYXt4KlEUXuBjw*7rOz(5W65KYqyMFJYRyjj`_5W1G!kW&@-(XQwgvAh3t${F;2 zMmJw6ABkBKn%KqGd~o0BfYK#WM0^rTBxMFS4suKIm{eKS21Gu-0rGsxJjiFH#8P-< z$1LXo*gMrHnHFjsS)P;CR>+z8M#AxOB3xw6WAf8l9H@?!$Q)vz@tzZ-FT|kz@_D~) z5If6v@Kd(Y{DUV6$fUfS1rIhOLO(grLNOF$iLcW@mN#uDx@3*XN`{F(=s!jaMIupZUMq#qoy5D?el0yUn!6K3@65xFUMe(}RyNbX$5oi=+ zFLQ|kP2oZP-wOg2P8C@y)6`P1Whc22s$-2Ka(nuB?6*@ zwZY9HUf2I5;NNM#k)}UhC{!$gJ(!MNOL{L|Ebw3wV54Q|7i)3xOIC`vCjl{>n9JDT z9r8ios7R>u#%?)rE`TeJWky)yZ^dE3&v{#`XBPX5C(`%^hCQyL0!e+V*44cSeMdo- z4LS3e1UHjqB$M@oXLTs=tJfgqw0Ei(oYPDD8BTMWGK6GiD1eQxaxRi*GNuWxF?e{w z*+meX@3W+%Mp^2mAy+7w#RWJKZXP)&7SWE&+7!`!KcQLP$;WK;iB1<>`04Dw5xSvc|~>g>c$Q zsy}M;+xfn4tK^9`NsmnA{+7wA*ElxHBTKCje^v3?qy{}&J8BFpwggZBHZtu3-RVEm zykVH_mUUb!-nz{+_M`LEd^A%BLeXiI(^#0JG^l+t?s%lW7a0n8bo^H&vJ<^3#Wjpu znY6+v>$IxZy8$D!>i`W%$rbPfOCu?7QVl{W5YkTYzjOdWpbbc9#D|SH9dRD%QOK)< zX5PQ+OIy5QE1!o1p!q`6m$+nI#&RaH)f#aL&nud^8RrA@s(qYcW;^w*y_(Lw<#<+l z83VVC7prq-oaToBbLsmF$BwMI6Ta-cB*n~h0gdc-i*-q2Hl3m)*s`sbYkGw`GQVnY zJy}q2)mg#Q$f}LlLeQ*LvcCVLy)Tbub8Xj8QKc=dsyWzOzm8@~)z}H`uCleJnoG?? zsEX7STAQM1Yge`As?vn0iWq_@B5f5#lOU3mlprEh2_kY{d!O^|^T)T&S-8U%H*S^u{k|7aRE5Bzy=ojsU zELjsvv-+iWacam;KoFwotkjsLI8ELcEx&-zP5|2ba*&tzTW6NIn%#(t+)z~5G#6qH z#yZMaEaz3cm1J4O>_Od+sIdNw35grYs_-Ve{caD&$!Lw zc}3^$du)GS*D=D@=?F-V$|8`K>Z|hBEk@=`H(N3VbDGf&)B!e~@1J~8aPSD)cy*g51{liyE1woY+w`kKYz0p9~p znc?sQ8rkV4o^@bE?-{vX3ps*#IN^sJ5kN2OA{FE+YjPQE*)ICbsI0HIKjq>N4Z*q_ zCnnStWPCVVxm=>`p@Z^y#*125{7zuARR;`0NL!o6w}$yh>yw+BPSzPL>!}g|ahf!r z0eFkg21Zp4)N7zN zaC@hX^^e4fpRg{H8@-*T4%!SKF`Qtn@9m)dfz~1)KogJyd;l5_>yyt1%Dqwi3K_T) z(^2c&Epn$4^#K4S4rJ*Y0xB&;n7k8%Cx3XkE8^aykX8tu2gG&f6c7B>&S$_79oMO1 zfSh+~5^}?7qCpom*MeYXGCE~}=&8~YU`azBS6bHToW41e#07X!Uz7!gouq1VWC~%F zrzaJ<)m2NkrK{cu`Qfk93#0(+x8vhyV3z5un=|?`s$fY38IWgy0r37A^D98E`;lqV zvumOIM}W_R>6gJp0q{K(3-jMKJX&?yM&GR5%TdpZ{M{#8cB)PS2++xJY}d%g(%74$ zQ$91a-J>5IG|1Z@oL4&v?ppx~1qkGj8S<`b641SmlyIuu7zCK3<07I-j}ek^Wp46^ zSHS(*_A_|`2aOwvw6*I zj|nn5bVF8iO+dj4R9=5P3Hh3)DslhCTe0fc0a`3>ZyXIl!u7J{z#Cr1K1&OrZ0M)7tvA`+=7JP zA~JJt2ex1lw$#cN+mhwBS{5omPo~ZcpaI75rFOMKQqx!sso2rhp!3)2lo~@U8b@f* zH>1V&X2n;ONCsSmqx3pdq?98)OEv3}#BB4{DOWcBO#Y&I}uZm@2k zC%$HLFycq|Q{n)ef6J_Y$rv_Y7WbU|LA#L?+O9&8tH}=-o9fjLQL*T$F=LqZRFGL{ zFWtIQ#hM>lC3aRg93VdiqIQ2{7-@&ZjGvekTFhw-Yx5q`0X;mQAd$eN_-6C7c^&T8 zc8phwO};pHvJ{O+bhvDKQqh3XCuH*{atueH~LjyejxUTajgwcIlu z9UwSM`h${BQ=Bf zV1JXtECzq+PC#_TAW99@f`*4%kZwZ+`C;H1oos``UWWBZBJn$*K9Rxy{ObY>pkQr#SaqUwMp+}q*JvpB#3qn4gSg!E+7*JxcHVc&{P~~8GrW?u+CDI>@1eu=|t0bX2}-g$ux-EOo~{_l=BJ^ z!UV?2lpU9rCgKKg7_20!PYrj{(x8p20D9k5rwq#1+CqVnnxzR7mBsOFjkkf>*kAp9o^lfqs zzxa7o$^VHB$=e8dztSKn(gcI5h%SQN7XjuLWh#-AS(Zvv}PG{@FSoLyuD^_N_hYA11RY)&cI(Xb$`K6|M;h# z-Nk=O*FF5FO5IO?Y1|#iWWRr>ucvpl=yY88=d_<|MsSq_d-rYVd{RGZm!Mx$;47G3 zWk1uuXD;#@&*8j(Uz&5yGXQW144uAPZp1#|jScM7%;sZ>Zawh_eQ0*r2!B?2fnI@9qZAK5SNA z{(uue$>{LD$M{u<|K+q)(FC!pZhGh@*Eimut$(hC!9BfqRm#9Ep7MF@pxnB**AMVw z>ge=plnWZ|+Vpv=FL>#NC9(52#c{s|*kEVy;EPrsm%)HnFSPr0nT|Yeu7ll}_aYyw zcST1e^9x2^{I&;)*GomqYPvk!STi{$YG?_Y`#@mrhwZ=>PA{{gVLlFHONE)cV$O*aWc=ao*Yj+4!R_ zZQ(Tu)iRd&??wiImZjS8jSb42Kmc`K!GE|$ycZLW2&co^l0`)OO*n7|Wuy3NDLe!EMg2seI7i#0$^%}kuWCzfKg^pXmUP2G1ZM)} zK$WQB?AO_eTT@M}J=R~9RAV45?Wm1d0mGMA&ZQf?>5`KPv$*qZ*RuCzkUtsT*4!u# zH*x!F{aGnD1u>X@UiXs!z}ERIXZC`=?Wdmc9BpX4E;^Z=#X|Bpt<9_`7UpL6*b`@` zQ#D@Yxd0HZb4XUvN%VROZh!;qIC73PC)g^fG3%mZo;Z8j^JkJZqrEqWSe#fsXz#k3u;D_$au%zl-!Ks{n&+7m!Q#w4qH|?CYAbFac{(M!Ys`^IqMRa~4ZO4IB8+i3 zk2f4|4(i$&q5`FHX7vLQKwNoEOB6)#8=4-f)THo=b|n0$A3*NQ216EVLs>m6(vMK` z<_4BS9$*nD7~9~ip0!cc@O96X%@8bd1?imKEC^?Cr`yl>k$vVl&FiVfl!J2OV~nc} zJts+2O5Jf#STz9x;ELVoO~(znuvcc2qxM6 z!LwR?L^3hc=vcQkRwgnfJ}sxH%1+P3?NU#IrI=He%7uk6N3f%Eva{Q`ovH0O2Nw2# zlI$x*ks{W@1z+=YHET0qtvT6($Z>;x458S7mC!_3sNdQziqD3lz5V>dk5;AWGL2Zk4}L|N4-WT9=b+BoqMoY>o8^|CQr;iEW$@(AvM|Aq7QL&-i>HR z3o$xSp+2?u(o|Or-S|jEmh}4rKBS|CbhPeU1I zognXj(qP#bVcv7R-Kl3CG1dE=Q{%N9u)?<{hMQan&+)=uJ%y+%4>sy%8$!lD&)37| zf$g_@j0^f>XdK09yJ+wRmA3R_Iku=utg|-(^a<1StBHMYYQNyw%2Y{vT`lOR^jq0r zrc+z@a~wYG6x2&kt=&`TTYj}VNHhsNX&hq!j7N;N@kY4_!}2VTxpv~k#>S4q?3Z|- z^V|vq92&f8-Slp2V2=BRsj!r&irwGa-5{;r{4qNVi_v%$&4SA#vV!m+sxyyi93Ac} zf(hFfO8bIFKj38%j*lH}E&4?!Z1fc@(f*mDV;M1UMlpE}e_>`Ebu8;4*5fqzNu?j} zkf~;fI-Wgw!Q=#t0wlwJqZU%3)=CqB`}jaRr(ozXQ+viMY9(WX4!!&t({I?P>trA4 z4&L|%TfP$qkD+OG4zI4=o{Is$dfUdBWv&?8F9Bl~z?`-kngzr0BoJS0o~q}0IoW+F zzW6JzZ!uzd{c+hs6o=xKqvZzbsL)2i^Ta(P*`slY>c1~tDc{3 zde!DiX?FHy1MSNG*KBSV&0MwYl`XMgnkT&Ct^4p;k>=!(7h0f*p!;e?^o4cQdEbL$ z)q&BEMe@U3&kVdU0-?#h{PkKd74AE1L)!h_6M=2xB;=Fubyu`#w!t3Wb^QiqD3qV5 zsu|xkrghfWC7PHqz06=Z9Fb$sO$8c&Ri*7oKB3ObnDc`yryOd#%u_Gu*0R>F1xQ@s z=NKhixL2k${}uj6#*`VMp-nlApM4%jF|p6R(`E~V*WBx>ir&VW*Y%dT-4Ito>gr8J z&`&s`dTnUGfX-jMsQ<2s-SH?r+C>PYB~hEB1(zeoly@+@<>YYf5<1r$Km#xI&CYc$ z-R=iv2Y+#)H)xLi8ZFK0*_yx*F|11y#F=l4Yl3(apif~#u2-RFC~l&-0Fr-o$KHIP!720o~;TX@Q8&kUcD)jKLmsV4;Gz9lR1 zxNLFvin)Nyjmh}sz~7SgIiHmsO38E|<`$s$HjbZP*JY4($#*_Jen z`Bb;J#K?pXhEI&EgOxc@wm}$$yFwsAyAxx9grGpssQrUu@!f+mql99&8**-<)&01t z#<$*SnlzSEmR}z^EvX-*SL>W~*%DiS79BlSFZh+L3FmxW=PtQe(>{5Do6@!m6Su zUIaqqzEpBeJFLN>x?;a3>U>Ihq!9JaBQwd4sQ{lPzWnmlcQR}fh$_Ds>In2YOOlAL zkzOXWSO}n8Fz9L`l$8HLdDm(csyI7>zff@MB=N0ZTrca;35)>9S5T~Tc79<0p_`ty z_&v4ljyY%DkEQQX%WVi%!b_f*GBcxu-a)kSVit>YlhSC}K8UE&o=Z!I6Z%N;M7d zH?Kj(evwi}w)2R=hqWH!3UXPL8;pd;@z3$KRIL+2ga#xEWih!NjNW}*sB!Z9)Kddu zk5pBIod)IXSNC-0_ql}xx$0yGq0d#Q3}%dOp`6 z*e}Sz+{HCGnxiopQekF|W^V!>1(mouzZwe+4j~6OXt812lvFG_kQVAW2e3 zAySXa-4`B|&6HZr`w`vLv&eq@G0Liuj>;%g#ks%gtgwyxa7Wdo>4 zR)ykep%G-Bb_fFPTex*=`ARfQp)sccJjOafShHjP#vh4M&(lVl8{LZkH1c}}b;knqoNyhjPyN+J0+Z!lXVA@ z?z;vguP%TFu&9eogjTK(HrhL5`IX+K#^>Im z`w$03{R@HY5Us8F^bO*ECZ7GP23lQCb)+@O1}0ZRAWweNyRe9vLvUPe@Z!^t%gW}m z5_T03?(f&QI}wPZND}*{v0PVG@gmIqF=4}80nu$?Dg$aEXi*?q(Ri7rzx=$W8HoNjt zSQey%LqF9Q$%n{30M_0)&pR8>n1~Ydx-gZ{58lUESf}N{ylV~ubHUhx@lW%zP5rOn z8#&I;_kh}Zm~z_fq?C0F8YcRaV&f+}-TDC@P&ZACNFJ=Kwtog2V;A01>($18Bd=aP z)8V}ov-6v5Zu>6*{WGzrmGG7w-T270*GE(V$#WE_x4w}h-I zmT@)=)RER4Tu4u<#)1hc-8g>pLcOQMU|2%nc2x>Uvq*#VL;UrOinJ>DP7aLzCt|P~ z;XepR5wot?xnNejaFl;p*}sdWya%*&|NOmwjqLo__`tU2z#r>BK}A0A-UF+2;ZtSF zop6=VKdDY2or2=n?@MS#8%!m%t8lxt5?vb=)@KI{gmcY>LFwDHtzkS}2BZ@+X;k=3 z_Vb_5{U1d){?+dP8=&j|{W$)9;P#vR&tT!-XSjVL|C6xY--f+?!v7s;{kI+d-?GC$ z^NnA=UvL3|cFb+h2$<`S2Xc?;cTU>}0j&DjKavBQu=0PzwVN-t$NFurGk?wO`Ik`n n-*;~Nl>T?!IlU^x9DK*2pIi3V{%+&Dy}wJAwii(6yzl-Sgc Date: Sat, 6 Mar 2021 01:36:19 +0300 Subject: [PATCH 061/195] Fixed info bug on page --- main/templates/pages/work.html | 4 ++-- main/views.py | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/main/templates/pages/work.html b/main/templates/pages/work.html index 2fcb4cf..a588ef5 100644 --- a/main/templates/pages/work.html +++ b/main/templates/pages/work.html @@ -22,13 +22,13 @@
Список сотрудников с правами инженера
- + {% for engineer in engineers %} - + {% endfor %} diff --git a/main/views.py b/main/views.py index ca8c9c2..0008255 100644 --- a/main/views.py +++ b/main/views.py @@ -128,10 +128,20 @@ def auth_user(request): @login_required() def work_page(request, id): + users = get_users_list() if request.user.id == id: + engineers = [] + light_agents = [] + for user in users: + + if user.custom_role_id == ZENDESK_ROLES['engineer']: + engineers.append((user)) + elif user.custom_role_id == ZENDESK_ROLES['light_agent']: + light_agents.append(user) + context = { - 'engineers': UserProfile.objects.filter(role="admin"), - 'agents': UserProfile.objects.filter(role="agent"), + 'engineers': engineers, + 'agents': light_agents, 'pagename': 'Управление правами' } return render(request, 'pages/work.html', context) From 8488ea88c29d980a2c706f27aa394ac6b1294b3c Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Sun, 7 Mar 2021 21:30:02 +0300 Subject: [PATCH 062/195] Added Django REST but have some bugs --- access_controller/settings.py | 9 ++++ access_controller/urls.py | 12 +++-- main/extra_func.py | 45 ++++++++++++++++--- main/serializers.py | 17 ++++++++ main/templates/base/base.html | 1 + main/templates/pages/adm_ruleset.html | 18 +++++--- main/urls.py | 6 +++ main/views.py | 43 ++++++++++-------- requirements.txt | 2 + static/main/js/control.js | 63 ++++++++++++++++++++++++--- 10 files changed, 176 insertions(+), 40 deletions(-) create mode 100644 main/serializers.py create mode 100644 main/urls.py diff --git a/access_controller/settings.py b/access_controller/settings.py index 96703b6..a26931d 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -36,6 +36,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django_registration', + 'rest_framework', 'main', ] @@ -183,3 +184,11 @@ ZENDESK_ROLES = { 'engineer': 360005209000, 'light_agent': 360005208980, } + +REST_FRAMEWORK = { + # Use Django's standard `django.contrib.auth` permissions, + # or allow read-only access for unauthenticated users. + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + ] +} diff --git a/access_controller/urls.py b/access_controller/urls.py index 3595e4f..92edfe1 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -17,15 +17,16 @@ 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.urls import router + urlpatterns = [ path('admin/', admin.site.urls, name='admin'), path('', main_page, name='index'), path('accounts/profile/', profile_page, name='profile'), path('accounts/register/', CustomRegistrationView.as_view(), name='registration'), - path('accounts/login/', CustomLoginView.as_view(extra_context={}), name='login',), # TODO add extra context + path('accounts/login/', CustomLoginView.as_view(extra_context={}), name='login', ), # TODO add extra context path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('django_registration.backends.one_step.urls')), path('work/', work_page, name="work"), @@ -34,7 +35,7 @@ urlpatterns = [ path('accounts/', include('django_registration.backends.activation.urls')), path('accounts/login/', include('django.contrib.auth.urls')), path('control/', AdminPageView.as_view(), name='control') - ] +] urlpatterns += [ path( @@ -58,3 +59,8 @@ urlpatterns += [ name='password_reset_complete' ), ] + +# Django REST +urlpatterns += [ + path('api/', include(router.urls)) +] diff --git a/main/extra_func.py b/main/extra_func.py index 691bd37..5cfb400 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -3,9 +3,9 @@ import os from zenpy import Zenpy from zenpy.lib.exception import APIException -from main.models import UserProfile +from main.models import UserProfile, User -from access_controller.settings import ZENDESK_ROLES as ROLES +from access_controller.settings import ZENDESK_ROLES as ROLES, ZENDESK_ROLES class ZendeskAdmin: @@ -28,7 +28,7 @@ class ZendeskAdmin: email: str = os.getenv('ACCESS_CONTROLLER_API_EMAIL') token: str = os.getenv('ACCESS_CONTROLLER_API_TOKEN') password: str = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') - _instance=None + _instance = None def __new__(cls, *args, **kwargs): if cls._instance is None: @@ -144,8 +144,9 @@ def get_users_list() -> list: Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации. """ zendesk = ZendeskAdmin() - admin = zendesk.get_user(zendesk.email) - org = next(zendesk.admin.users.organizations(user=admin)) + + # У пользователей должна быть организация SYSTEM + org = next(zendesk.admin.search(type='organization', name='SYSTEM')) return zendesk.admin.organizations.users(org) @@ -191,3 +192,37 @@ def check_user_auth(email: str, password: str) -> bool: except APIException: return False return True + + +def update_user_in_model(profile, zendesk_user): + profile.name = zendesk_user.name + profile.role = zendesk_user.role + profile.image = zendesk_user.photo['content_url'] if zendesk_user.photo else None + profile.save() + + +def count_users(users) -> tuple: + """ + Функция подсчета количества сотрудников с ролями engineer и light_a + + .. todo:: + this func counts users from all zendesk instead of just from a model: + """ + engineers, light_agents = 0, 0 + for user in users: + if user.custom_role_id == ZENDESK_ROLES['engineer']: + engineers += 1 + elif user.custom_role_id == ZENDESK_ROLES['light_agent']: + light_agents += 1 + return engineers, light_agents + + +def update_users_in_model(): + """ + Обновляет пользователей в модели UserProfile по списку пользователей в организации + """ + users = get_users_list() + for user in users: + profile = User.objects.get(email=user.email).userprofile + update_user_in_model(profile, user) + return users diff --git a/main/serializers.py b/main/serializers.py new file mode 100644 index 0000000..26d08c2 --- /dev/null +++ b/main/serializers.py @@ -0,0 +1,17 @@ +from django.contrib.auth.models import User +from rest_framework import serializers +from main.models import UserProfile + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = User + fields = ['email'] + + +class ProfileSerializer(serializers.HyperlinkedModelSerializer): + user = UserSerializer() + + class Meta: + model = UserProfile + fields = ['user', 'role', 'name'] diff --git a/main/templates/base/base.html b/main/templates/base/base.html index 2aebfe0..166195d 100644 --- a/main/templates/base/base.html +++ b/main/templates/base/base.html @@ -27,6 +27,7 @@ {% block extra_css %}{% endblock %} + {% block extra_scripts %}{% endblock %} diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index 387cd73..dd9d614 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -10,6 +10,13 @@ {% endblock %} +{% block extra_scripts %} + + + + +{% endblock%} + {% block content %}
@@ -37,25 +44,22 @@
IDEmail Name
{{ engineer.id }}{{ engineer.email }} {{ engineer.name }}
- + - - + {% for user in users %} - + - {% endfor %} -
IDName Email RoleName(link to profile) Checked
{{ user.id }}{{ user.name }} {{ user.user.email }} {{ user.role }}{{ user.name }}
{% endblock%} @@ -103,6 +107,6 @@ {% endblock %} - + {% endblock %} diff --git a/main/urls.py b/main/urls.py new file mode 100644 index 0000000..fffe11d --- /dev/null +++ b/main/urls.py @@ -0,0 +1,6 @@ +from rest_framework.routers import DefaultRouter +from main.views import UsersViewSet + + +router = DefaultRouter() +router.register(r'users', UsersViewSet) diff --git a/main/views.py b/main/views.py index ca8c9c2..36f82da 100644 --- a/main/views.py +++ b/main/views.py @@ -14,7 +14,7 @@ from zenpy import Zenpy from access_controller.settings import EMAIL_HOST_USER from main.extra_func import check_user_exist, update_profile, get_user_organization, \ - make_engineer, make_light_agent, get_users_list + make_engineer, make_light_agent, get_users_list, update_users_in_model, count_users from django.contrib.auth.models import User, Permission from main.models import UserProfile @@ -27,6 +27,11 @@ from django.core.exceptions import PermissionDenied from access_controller.settings import ZENDESK_ROLES from zenpy.lib.api_objects import User as ZenpyUser +# Django REST +from rest_framework import viewsets, status +from main.serializers import ProfileSerializer +from rest_framework.response import Response +from rest_framework.decorators import action content_type_temp = ContentType.objects.get_for_model(UserProfile) permission_temp, created = Permission.objects.get_or_create( @@ -193,22 +198,6 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): def make_light_agents(users): [make_light_agent(user) for user in users] - @staticmethod - def count_users(users) -> tuple: - """ - Функция подсчета количества сотрудников с ролями engineer и light_a - - .. todo:: - this func counts users from all zendesk instead of just from a model: - """ - engineers, light_agents = 0, 0 - for user in users: - if user.custom_role_id == ZENDESK_ROLES['engineer']: - engineers += 1 - elif user.custom_role_id == ZENDESK_ROLES['light_agent']: - light_agents += 1 - return engineers, light_agents - def get_context_data(self, **kwargs) -> dict: """ Функция формирования контента страницы администратора (с проверкой прав доступа) @@ -216,9 +205,10 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): if self.request.user.userprofile.role != 'admin': raise PermissionDenied context = super().get_context_data(**kwargs) - context['users'] = get_list_or_404( + users = get_list_or_404( UserProfile, role='agent') - context['engineers'], context['light_agents'] = self.count_users(get_users_list()) + context['users'] = users + context['engineers'], context['light_agents'] = count_users(users) return context # TODO: need to get profile page url @@ -227,3 +217,18 @@ class CustomLoginView(LoginView): Отображение страницы авторизации пользователя """ form_class = CustomAuthenticationForm + + +class UsersViewSet(viewsets.ReadOnlyModelViewSet): + """ + Класс для получения пользователей с помощью api + """ + queryset = UserProfile.objects.filter(role='agent') + serializer_class = ProfileSerializer + + def list(self, request, *args, **kwargs): + users = update_users_in_model() + profiles = UserProfile.objects.filter(role='agent') + count = count_users(users) + serializer = self.get_serializer(data=profiles, many=True) + return Response(serializer.data + {'engineers': count[0], 'light_agents': count[1]}) diff --git a/requirements.txt b/requirements.txt index 7a4f941..b32a382 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,8 @@ Django==3.1.6 Pillow==8.1.0 zenpy~=2.0.24 django_registration==3.1.1 +djangorestframework==3.12.2 + # Documentation Sphinx==3.4.3 diff --git a/static/main/js/control.js b/static/main/js/control.js index 1fd4f9c..6404741 100644 --- a/static/main/js/control.js +++ b/static/main/js/control.js @@ -1,9 +1,60 @@ "use strict"; -let checkboxes = document.getElementsByName("users"); -let fields = document.querySelectorAll(".checkbox_field"); -if (checkboxes.length == fields.length) { - for (let i = 0; i < fields.length; ++i) { - let el = checkboxes[i].cloneNode(true); - fields[i].appendChild(el); + +function move_checkboxes() { + let checkboxes = document.getElementsByName("users"); + let fields = document.querySelectorAll(".checkbox_field"); + if (checkboxes.length == fields.length) { + for (let i = 0; i < fields.length; ++i) { + let el = checkboxes[i].cloneNode(true); + fields[i].appendChild(el); + } } } + +class TableRow extends React.Component { + render() { + return ( + + +
{this.props.user.name} + + {this.props.user.user.email} + {this.props.user.role} + + + ); + } +} + +class TableBody extends React.Component { + constructor(props) { + super(props); + this.state = { users: [] }; + } + + get_users() { + axios.get("/api/users").then((response) => { + this.setState({ users: response.data }); + }); + } + + componentDidMount() { + this.interval = setInterval(() => { + this.get_users(); + move_checkboxes(); + }, 1000); + } + + componentWillUnmount() { + clearInterval(this.interval); + } + + render() { + return this.state.users.map((user, key) => ( + + )); + } +} + +move_checkboxes(); +ReactDOM.render(, document.getElementById("table")); From 7ee8cc96af1f5b8e15b3eca57fcdc15fb21d707f Mon Sep 17 00:00:00 2001 From: Artyom Kravchenko Date: Sun, 7 Mar 2021 23:08:56 +0300 Subject: [PATCH 063/195] fixed get and end engineer role fuctions --- main/views.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/main/views.py b/main/views.py index 0008255..3ec1319 100644 --- a/main/views.py +++ b/main/views.py @@ -135,7 +135,7 @@ def work_page(request, id): for user in users: if user.custom_role_id == ZENDESK_ROLES['engineer']: - engineers.append((user)) + engineers.append(user) elif user.custom_role_id == ZENDESK_ROLES['light_agent']: light_agents.append(user) @@ -151,8 +151,9 @@ def work_page(request, id): @login_required() def work_hand_over(request): zenpy_user, admin = auth_user(request) - if zenpy_user.role == "admin" or zenpy_user.role == "end-user": - zenpy_user.role = "agent" + + if zenpy_user.custom_role_id == ZENDESK_ROLES['engineer']: + zenpy_user.custom_role_id = ZENDESK_ROLES['light_agent'] admin.users.update(zenpy_user) request.user.userprofile.role = "agent" request.user.userprofile.save() @@ -162,10 +163,10 @@ def work_hand_over(request): @login_required() def work_become_engineer(request): zenpy_user, admin = auth_user(request) - if zenpy_user.role == "agent" or zenpy_user.role == "end-user": - zenpy_user.role = "admin" + if zenpy_user.custom_role_id == ZENDESK_ROLES['light_agent']: + zenpy_user.custom_role_id = ZENDESK_ROLES['engineer'] admin.users.update(zenpy_user) - request.user.userprofile.role = "admin" + request.user.userprofile.role = "agent" request.user.userprofile.save() return HttpResponseRedirect(reverse('work', args=(request.user.id,))) From b01f5fb236f5e822607cc31208a32ef39da3afc7 Mon Sep 17 00:00:00 2001 From: Artyom Kravchenko Date: Mon, 8 Mar 2021 02:28:00 +0300 Subject: [PATCH 064/195] add button from menu for engineer rules request --- main/templates/base/menu.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main/templates/base/menu.html b/main/templates/base/menu.html index f64ceb3..a389341 100644 --- a/main/templates/base/menu.html +++ b/main/templates/base/menu.html @@ -13,6 +13,8 @@ Профиль {% if perms.main.has_control_access %} Управление + {% else %} + Запрос прав {% endif %} Выйти From 49046022d77457c0cb8281883970795bacfd7d0a Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Mon, 8 Mar 2021 17:50:47 +0300 Subject: [PATCH 065/195] small refactor: change variable`s names, add a bit documentation --- main/extra_func.py | 17 +++++---- main/forms.py | 6 +-- main/views.py | 94 ++++++++++++++++++++++++---------------------- 3 files changed, 61 insertions(+), 56 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 623be63..8d5abfd 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,12 +1,11 @@ import os -from datetime import timedelta, datetime +from datetime import timedelta from zenpy import Zenpy from zenpy.lib.exception import APIException -from main.models import UserProfile - from access_controller.settings import ZENDESK_ROLES as ROLES +from main.models import UserProfile class ZendeskAdmin: @@ -29,7 +28,7 @@ class ZendeskAdmin: email: str = os.getenv('ACCESS_CONTROLLER_API_EMAIL') token: str = os.getenv('ACCESS_CONTROLLER_API_TOKEN') password: str = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') - _instance=None + _instance = None def __new__(cls, *args, **kwargs): if cls._instance is None: @@ -194,15 +193,17 @@ def check_user_auth(email: str, password: str) -> bool: return True -def daterange(start_date, end_date): +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)) + dates.append(start_date + timedelta(n)) return dates -def get_timedelta(log): +def get_timedelta(log) -> timedelta: + # Возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, + # который находится в log(RoleChangeLogs) time = log.change_time.time() time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) - print(time) return time diff --git a/main/forms.py b/main/forms.py index efafa01..7046b35 100644 --- a/main/forms.py +++ b/main/forms.py @@ -70,10 +70,10 @@ class StatisticForm(forms.Form): email = forms.EmailField( label='Электроная почта', ) - inter = forms.CharField( # TODO: Переделать под html страницу + interval = forms.CharField( # TODO: Переделать под html страницу label='Выбор интервала', ) - dio_start = forms.DateField( # TODO: Переделать под html страницу + range_start = forms.DateField( # TODO: Переделать под html страницу label='Начало диапазона', widget=forms.DateInput( attrs={ @@ -81,7 +81,7 @@ class StatisticForm(forms.Form): } ), ) - dio_end = forms.DateField( # TODO: Переделать под html страницу + range_end = forms.DateField( # TODO: Переделать под html страницу label='Конец диапазона', widget=forms.DateInput( attrs={ diff --git a/main/views.py b/main/views.py index 3f2df8f..79c14bc 100644 --- a/main/views.py +++ b/main/views.py @@ -1,30 +1,22 @@ import logging import os +from datetime import timedelta -from django.contrib.auth.tokens import default_token_generator +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.views import LoginView from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponseRedirect, HttpResponse from django.shortcuts import render, get_list_or_404, redirect from django.urls import reverse_lazy, reverse from django.views.generic import FormView -from zenpy import Zenpy - -from access_controller.settings import EMAIL_HOST_USER -from main.extra_func import check_user_exist, update_profile, get_user_organization, \ - make_engineer, make_light_agent, get_users_list - -from django.contrib.auth.models import User, Permission -from main.models import UserProfile -from main.forms import CustomRegistrationForm, AdminPageUsers, CustomAuthenticationForm from django_registration.views import RegistrationView -from django.contrib.auth.decorators import login_required -from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin -from django.core.exceptions import PermissionDenied - -from access_controller.settings import ZENDESK_ROLES +from zenpy import Zenpy from zenpy.lib.api_objects import User as ZenpyUser from access_controller.settings import EMAIL_HOST_USER, ZENDESK_ROLES @@ -247,8 +239,8 @@ def statistic_page(request): form = StatisticForm(request.POST) if form.is_valid(): - start_date = form.cleaned_data['dio_start'] - end_date = form.cleaned_data['dio_end'] + start_date = form.cleaned_data['range_start'] + end_date = form.cleaned_data['range_end'] try: data = RoleChangeLogs.objects.filter( @@ -259,53 +251,68 @@ def statistic_page(request): context['errors'] = 'Пользователь не найден' context['form'] = StatisticForm() return render(request, 'pages/stat.html', context) - sep = form.cleaned_data['inter'] # Разрез + + interval = form.cleaned_data['interval'] # интервал TODO: переделать под html-страницу + show = form.cleaned_data['display_format'] # формат отображения TODO: переделать под html-страницу stat = {} - # Этот кусок кода будет заполнять массив stat, в котором ключ - дата, значение кол-во часов - # Далее будет заполнение контекса в зависимости от выбранного интервала и отображения - # (пока есть только отображение в часах, разрез в днях) + # Этот кусок кода будет заполнять массив stat, в котором ключ - дата, значение - кол-во секунд + # Далее будет заполнение контекса в зависимости от выбранного интервала(дни/месяцы) и формата отобржаения(дни/часы) + # (пока есть только отображение в часах, интервал в днях) # Обнуление всех дней for day in daterange(start_date, end_date + timedelta(days=1)): stat[day] = 0 + first_log = data[0] + last_log = data[len(data) - 1] # Проеврка крайних случаев - # Если окажется, что инженер работал ещё до начала диапазона - if data[0].old_role == 'engineer': - for day in daterange(start_date, data[0].change_time.date()): - stat[day] = 24 - stat[data[0].change_time.date()] += data[0].change_time.time().hour - # Если окажется, что инженер закончил работать после диапазона - if data[len(data) - 1].new_role == 'engineer': - for day in daterange(data[len(data) - 1].change_time.date() + timedelta(days=1), + # Если окажется, что инженер работал ещё до начала диапазона(мы видим, что он был инженером до диапазона) + if first_log.old_role == 'engineer': + for day in daterange(start_date, first_log.change_time.date()): + stat[day] = 24 * 3600 + stat[first_log.change_time.date()] += get_timedelta(first_log.change_time.time()).total_seconds() + + # Если окажется, что инженер закончил работать после диапазона(мы видим, что он был инженером и после диапазона) + if last_log.new_role == 'engineer': + for day in daterange(last_log.change_time.date() + timedelta(days=1), end_date + timedelta(days=1)): - stat[day] = 24 - stat[data[len(data) - 1].change_time.date()] += int( - (timedelta(days=1) - get_timedelta(data[len(data) - 1])).total_seconds() // 3600) + stat[day] = 24 * 3600 + stat[last_log.change_time.date()] += (timedelta(days=1) - get_timedelta(last_log)).total_seconds() # Цикл по логам for log_index in range(len(data) - 1): if data[log_index].new_role == 'engineer': - log1, log2 = data[log_index], data[log_index + 1] + current_log, next_log = data[log_index], data[log_index + 1] # Если сессия закончилась НЕ в тот же день, что и началась - if log1.change_time.date() != log2.change_time.date(): - stat[log1.change_time.date()] += int( - (timedelta(days=1) - get_timedelta(log1)).total_seconds() // 3600) - stat[log2.change_time.date()] += log2.change_time.time().hour + if current_log.change_time.date() != next_log.change_time.date(): + stat[current_log.change_time.date()] += ( + timedelta(days=1) - get_timedelta(current_log)).total_seconds() + stat[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() # Если проработал несколько дней подряд, то заполнить эти дни по 24 часа - for day in daterange(log1.change_time.date() + timedelta(days=1), log2.change_time.date()): - stat[day] = 24 + for day in daterange(current_log.change_time.date() + timedelta(days=1), + next_log.change_time.date()): + stat[day] = 24 * 3600 # Если сессия закончилась в тот же день, что и началась else: - times = log2.change_time - log1.change_time - stat[log1.change_time.date()] += times.seconds // 3600 + elapsed_time = next_log.change_time - current_log.change_time + stat[current_log.change_time.date()] += elapsed_time.total_seconds() - if sep == 'days': + # Переделываем наши значения под формат отображения + for key, item in stat.items(): + if show == 'hours': + stat[key] = item / 3600 + elif show == 'days': + stat[key] = item / 86400 + + # Переделываем наши значения под формат отображения + if interval == 'days': context['stats_logs'] = stat + elif interval == 'months': + pass else: context['errors'] = form.errors @@ -314,6 +321,3 @@ def statistic_page(request): context['form'] = form return render(request, 'pages/stat.html', context) - - -from datetime import timedelta From b96deab2cb4a51e88f2f38b817aa21c888a77b43 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Mon, 8 Mar 2021 21:07:09 +0300 Subject: [PATCH 066/195] Update statistic backend --- main/extra_func.py | 62 ++++++++++++++-- main/forms.py | 5 +- main/templates/pages/stat.html | 13 +++- main/views.py | 127 ++++++++++++--------------------- 4 files changed, 117 insertions(+), 90 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 8d5abfd..03fd61a 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,5 +1,5 @@ import os -from datetime import timedelta +from datetime import timedelta, datetime from zenpy import Zenpy from zenpy.lib.exception import APIException @@ -194,7 +194,9 @@ def check_user_auth(email: str, password: str) -> bool: def daterange(start_date, end_date) -> list: - # Возвращает список дней с start_date по end_date исключая правую границу + """ + Возвращает список дней с start_date по end_date исключая правую границу + """ dates = [] for n in range(int((end_date - start_date).days)): dates.append(start_date + timedelta(n)) @@ -202,8 +204,60 @@ def daterange(start_date, end_date) -> list: def get_timedelta(log) -> timedelta: - # Возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, - # который находится в log(RoleChangeLogs) + """ + Возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, + который находится в log (объект класса RoleChangeLogs) + """ 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) + + +def get_statistic_from_data(data, start_date, end_date): + """ + Функция возвращает словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд + data - массив объектов RoleChangeLogs, является списком логов пользователя + """ + if not data: + return None + # Обнуление всех дней + stat = {} + for day in daterange(start_date, end_date + timedelta(days=1)): + stat[day] = 0 + first_log, last_log = data[0], data[len(data) - 1] + + # Если инженер работал ещё до начала диапазона + if int(first_log.old_role) == ROLES['engineer']: + for day in daterange(start_date, first_log.change_time.date()): + stat[day] = 24 * 3600 + stat[first_log.change_time.date()] += get_timedelta(first_log).total_seconds() + + # Если инженер закончил работать после диапазона + if int(last_log.new_role) == ROLES['engineer']: + for day in daterange(last_log.change_time.date() + timedelta(days=1), end_date + timedelta(days=1)): + stat[day] = 24 * 3600 + stat[last_log.change_time.date()] += (timedelta(days=1)-get_timedelta(last_log)).total_seconds() + # Цикл по логам + for log_index in range(len(data) - 1): + if int(data[log_index].new_role) == ROLES['engineer']: + current_log, next_log = data[log_index], data[log_index + 1] + # Если сессия закончилась НЕ в тот же день, что и началась + if current_log.change_time.date() != next_log.change_time.date(): + stat[current_log.change_time.date()] += (timedelta(days=1) - get_timedelta(current_log)).total_seconds() + stat[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() + # Если проработал несколько дней подряд, то заполнить эти дни по 24 часа + for day in daterange(current_log.change_time.date() + timedelta(days=1),next_log.change_time.date()): + stat[day] = 24 * 3600 + # Если сессия закончилась в тот же день, что и началась + else: + elapsed_time = next_log.change_time - current_log.change_time + stat[current_log.change_time.date()] += elapsed_time.total_seconds() + return stat diff --git a/main/forms.py b/main/forms.py index 7046b35..e98b7e1 100644 --- a/main/forms.py +++ b/main/forms.py @@ -71,7 +71,10 @@ class StatisticForm(forms.Form): label='Электроная почта', ) interval = forms.CharField( # TODO: Переделать под html страницу - label='Выбор интервала', + label='Интервал работы', + ) + display_format = forms.CharField( # TODO: Переделать под html страницу + label='Формат отображения', ) range_start = forms.DateField( # TODO: Переделать под html страницу label='Начало диапазона', diff --git a/main/templates/pages/stat.html b/main/templates/pages/stat.html index ea5e793..2409e0f 100644 --- a/main/templates/pages/stat.html +++ b/main/templates/pages/stat.html @@ -4,7 +4,7 @@ {% block title %}{{ pagename }}{% endblock %} -{% block heading %}Статистика{% endblock %} +{% block heading %} Пример страницы статистики(палками не бейти плиз){% endblock %} {% block extra_css %} @@ -21,10 +21,17 @@ {% endfor %} - {% for key,val in stats_logs.items %} -

{{key}} {{val}}

+ {% for key,val in log_stats.items %} +

{{key}} | {{val}}


{% endfor %} + +
    + {% for error in errors %} +
  • {{error}}
  • + {% endfor %} +
+ {% endblock %} diff --git a/main/views.py b/main/views.py index 79c14bc..f7a6967 100644 --- a/main/views.py +++ b/main/views.py @@ -1,6 +1,6 @@ import logging import os -from datetime import timedelta +from datetime import timedelta, date, datetime from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordResetForm @@ -21,7 +21,7 @@ from zenpy.lib.api_objects import User as ZenpyUser from access_controller.settings import EMAIL_HOST_USER, ZENDESK_ROLES from main.extra_func import check_user_exist, update_profile, get_user_organization, make_engineer, make_light_agent, \ - get_users_list, daterange, get_timedelta + get_users_list, last_day_of_month, get_statistic_from_data from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm from .models import UserProfile, RoleChangeLogs @@ -230,94 +230,57 @@ class CustomLoginView(LoginView): def statistic_page(request): if not request.user.is_superuser: return redirect('index') - context = { 'pagename': 'страница статистики', + 'errors': [], } - if request.method == "POST": form = StatisticForm(request.POST) - - if form.is_valid(): - start_date = form.cleaned_data['range_start'] - end_date = form.cleaned_data['range_end'] - - try: - data = RoleChangeLogs.objects.filter( - change_time__range=[start_date, end_date], - user=User.objects.get(email=form.cleaned_data['email']), - ).order_by('change_time') - except User.DoesNotExist: - context['errors'] = 'Пользователь не найден' - context['form'] = StatisticForm() - return render(request, 'pages/stat.html', context) - - interval = form.cleaned_data['interval'] # интервал TODO: переделать под html-страницу - show = form.cleaned_data['display_format'] # формат отображения TODO: переделать под html-страницу - stat = {} - - # Этот кусок кода будет заполнять массив stat, в котором ключ - дата, значение - кол-во секунд - # Далее будет заполнение контекса в зависимости от выбранного интервала(дни/месяцы) и формата отобржаения(дни/часы) - # (пока есть только отображение в часах, интервал в днях) - - # Обнуление всех дней - for day in daterange(start_date, end_date + timedelta(days=1)): - stat[day] = 0 - - first_log = data[0] - last_log = data[len(data) - 1] - # Проеврка крайних случаев - # Если окажется, что инженер работал ещё до начала диапазона(мы видим, что он был инженером до диапазона) - if first_log.old_role == 'engineer': - for day in daterange(start_date, first_log.change_time.date()): - stat[day] = 24 * 3600 - stat[first_log.change_time.date()] += get_timedelta(first_log.change_time.time()).total_seconds() - - # Если окажется, что инженер закончил работать после диапазона(мы видим, что он был инженером и после диапазона) - if last_log.new_role == 'engineer': - for day in daterange(last_log.change_time.date() + timedelta(days=1), - end_date + timedelta(days=1)): - stat[day] = 24 * 3600 - stat[last_log.change_time.date()] += (timedelta(days=1) - get_timedelta(last_log)).total_seconds() - - # Цикл по логам - for log_index in range(len(data) - 1): - if data[log_index].new_role == 'engineer': - current_log, next_log = data[log_index], data[log_index + 1] - - # Если сессия закончилась НЕ в тот же день, что и началась - if current_log.change_time.date() != next_log.change_time.date(): - stat[current_log.change_time.date()] += ( - timedelta(days=1) - get_timedelta(current_log)).total_seconds() - stat[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() - - # Если проработал несколько дней подряд, то заполнить эти дни по 24 часа - for day in daterange(current_log.change_time.date() + timedelta(days=1), - next_log.change_time.date()): - stat[day] = 24 * 3600 - - # Если сессия закончилась в тот же день, что и началась - else: - elapsed_time = next_log.change_time - current_log.change_time - stat[current_log.change_time.date()] += elapsed_time.total_seconds() - - # Переделываем наши значения под формат отображения - for key, item in stat.items(): - if show == 'hours': - stat[key] = item / 3600 - elif show == 'days': - stat[key] = item / 86400 - - # Переделываем наши значения под формат отображения - if interval == 'days': - context['stats_logs'] = stat - elif interval == 'months': - pass + if not form.is_valid(): + context['errors'] += form.errors else: - context['errors'] = form.errors + start_date, end_date, data = form.cleaned_data['range_start'], form.cleaned_data['range_end'], list() + if end_date < start_date or end_date > datetime.now().date(): + context['errors'] += ['Конец диапазона должен быть больше начала диапазона и меньше текущего времени'] + else: + try: + data = RoleChangeLogs.objects.filter( + change_time__range=[start_date, end_date + timedelta(days=1)], + user=User.objects.get(email=form.cleaned_data['email']), + ).order_by('change_time') + except User.DoesNotExist: + context['errors'] = ['Пользователь не найден'] + stats = get_statistic_from_data(data, start_date, end_date) + if stats is None: + context['errors'] += ['Не найдено изменений роли в указаном диапазоне'] + interval = form.cleaned_data['interval'] # интервал работы TODO: переделать под html-страницу + show = form.cleaned_data['display_format'] # формат отображения + if not (show in ['hours', 'days']): # Работа с форматом отображения + context['errors'] += ['Формат отображения должен быть в часах или днях'] + elif stats: + for key, item in stats.items(): + if show == 'hours': + stats[key] = item / 3600 + elif show == 'days': + stats[key] = item / 86400 + + if not (interval in ['days', 'months']): # Работа с интервалом работы + context['errors'] += ['Интервал работы должен быть в днях или месяцах'] + elif interval == 'months' and stats: # Переделываем ключи под формат в прототипе(начало_месяца-конец_месяца) + new_stats = {} + for key, value in stats.items(): + current_month_start, current_month_end = max(start_date, + date(year=key.year, month=key.month, day=1)), min( + 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_stats.get(index): + new_stats[index] += value + else: + new_stats[index] = value + stats = new_stats + context['log_stats'] = stats if not context['errors'] else None if request.method == 'GET': form = StatisticForm() - context['form'] = form return render(request, 'pages/stat.html', context) From ce55ec61e66406b581ff0c2e5be2d7d3f2ce3e70 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Mon, 8 Mar 2021 22:48:31 +0300 Subject: [PATCH 067/195] extract function for easier processing a list of emails in future --- main/extra_func.py | 45 +++++++++++++++++++++------------------------ main/views.py | 25 +++++++++---------------- 2 files changed, 30 insertions(+), 40 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 03fd61a..9fb141d 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,11 +1,12 @@ import os from datetime import timedelta, datetime +from django.contrib.auth.models import User from zenpy import Zenpy from zenpy.lib.exception import APIException from access_controller.settings import ZENDESK_ROLES as ROLES -from main.models import UserProfile +from main.models import UserProfile, RoleChangeLogs class ZendeskAdmin: @@ -86,7 +87,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: """ @@ -100,7 +101,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 @@ -174,25 +175,6 @@ def get_user_organization(email: str) -> str: return ZendeskAdmin().get_user_org(email) -def check_user_auth(email: str, password: str) -> bool: - """ - Функция проверяет, верны ли входные данные - - :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован - """ - creds = { - 'email': email, - 'password': password, - 'subdomain': 'ngenix1612197338', - } - try: - user = Zenpy(**creds) - user.search(email, type='user') - except APIException: - return False - return True - - def daterange(start_date, end_date) -> list: """ Возвращает список дней с start_date по end_date исключая правую границу @@ -244,7 +226,7 @@ def get_statistic_from_data(data, start_date, end_date): if int(last_log.new_role) == ROLES['engineer']: for day in daterange(last_log.change_time.date() + timedelta(days=1), end_date + timedelta(days=1)): stat[day] = 24 * 3600 - stat[last_log.change_time.date()] += (timedelta(days=1)-get_timedelta(last_log)).total_seconds() + stat[last_log.change_time.date()] += (timedelta(days=1) - get_timedelta(last_log)).total_seconds() # Цикл по логам for log_index in range(len(data) - 1): if int(data[log_index].new_role) == ROLES['engineer']: @@ -254,10 +236,25 @@ def get_statistic_from_data(data, start_date, end_date): stat[current_log.change_time.date()] += (timedelta(days=1) - get_timedelta(current_log)).total_seconds() stat[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() # Если проработал несколько дней подряд, то заполнить эти дни по 24 часа - for day in daterange(current_log.change_time.date() + timedelta(days=1),next_log.change_time.date()): + for day in daterange(current_log.change_time.date() + timedelta(days=1), next_log.change_time.date()): stat[day] = 24 * 3600 # Если сессия закончилась в тот же день, что и началась else: elapsed_time = next_log.change_time - current_log.change_time stat[current_log.change_time.date()] += elapsed_time.total_seconds() return stat + + +def get_data_logs(context, start_date, end_date, email): + """ + Функция возвращает список из лог-ов в диапазоне дат start_date-end_date для пользователя с почтой email + """ + data = [] + try: + data = RoleChangeLogs.objects.filter( + change_time__range=[start_date, end_date + timedelta(days=1)], + user=User.objects.get(email=email), + ).order_by('change_time') + except User.DoesNotExist: + context['errors'] = ['Пользователь не найден'] + return data diff --git a/main/views.py b/main/views.py index 72918e5..b2d4fc0 100644 --- a/main/views.py +++ b/main/views.py @@ -1,6 +1,6 @@ import logging import os -from datetime import timedelta, date, datetime +from datetime import date, datetime from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordResetForm @@ -21,9 +21,9 @@ from zenpy.lib.api_objects import User as ZenpyUser from access_controller.settings import EMAIL_HOST_USER, ZENDESK_ROLES from main.extra_func import check_user_exist, update_profile, get_user_organization, make_engineer, make_light_agent, \ - get_users_list, last_day_of_month, get_statistic_from_data + get_users_list, last_day_of_month, get_statistic_from_data, get_data_logs from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm -from .models import UserProfile, RoleChangeLogs +from .models import UserProfile content_type_temp = ContentType.objects.get_for_model(UserProfile) permission_temp, created = Permission.objects.get_or_create( @@ -252,20 +252,13 @@ def statistic_page(request): else: start_date, end_date, data = form.cleaned_data['range_start'], form.cleaned_data['range_end'], list() if end_date < start_date or end_date > datetime.now().date(): - context['errors'] += ['Конец диапазона должен быть больше начала диапазона и меньше текущего времени'] + context['errors'] += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] else: - try: - data = RoleChangeLogs.objects.filter( - change_time__range=[start_date, end_date + timedelta(days=1)], - user=User.objects.get(email=form.cleaned_data['email']), - ).order_by('change_time') - except User.DoesNotExist: - context['errors'] = ['Пользователь не найден'] + data = get_data_logs(context, start_date, end_date, form.cleaned_data['email']) stats = get_statistic_from_data(data, start_date, end_date) if stats is None: context['errors'] += ['Не найдено изменений роли в указаном диапазоне'] - interval = form.cleaned_data['interval'] # интервал работы TODO: переделать под html-страницу - show = form.cleaned_data['display_format'] # формат отображения + interval, show = form.cleaned_data['interval'], form.cleaned_data['display_format'] if not (show in ['hours', 'days']): # Работа с форматом отображения context['errors'] += ['Формат отображения должен быть в часах или днях'] @@ -281,15 +274,15 @@ def statistic_page(request): elif interval == 'months' and stats: # Переделываем ключи под формат в прототипе(начало_месяца-конец_месяца) new_stats = {} for key, value in stats.items(): - current_month_start, current_month_end = max(start_date, - date(year=key.year, month=key.month, day=1)), min( - end_date, last_day_of_month(date(year=key.year, month=key.month, day=1))) + current_month_start = max(start_date, date(year=key.year, month=key.month, day=1)) + current_month_end = min(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_stats.get(index): new_stats[index] += value else: new_stats[index] = value stats = new_stats + context['log_stats'] = stats if not context['errors'] else None if request.method == 'GET': form = StatisticForm() From 5efec2641a8ef630e84861a5d60e4d658be2be6b Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Tue, 9 Mar 2021 00:53:11 +0300 Subject: [PATCH 068/195] Refactor statistic page with class StatisticData --- main/extra_func.py | 201 ++++++++++++++++++++++++--------- main/templates/pages/stat.html | 9 ++ main/views.py | 51 +++------ 3 files changed, 174 insertions(+), 87 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 9fb141d..c47e6a1 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,5 +1,5 @@ import os -from datetime import timedelta, datetime +from datetime import timedelta, datetime, date from django.contrib.auth.models import User from zenpy import Zenpy @@ -203,58 +203,155 @@ def last_day_of_month(day): return next_month - timedelta(days=next_month.day) -def get_statistic_from_data(data, start_date, end_date): - """ - Функция возвращает словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд - data - массив объектов RoleChangeLogs, является списком логов пользователя - """ - if not data: - return None - # Обнуление всех дней - stat = {} - for day in daterange(start_date, end_date + timedelta(days=1)): - stat[day] = 0 - first_log, last_log = data[0], data[len(data) - 1] +class StatisticData: + def __init__(self, start_date, end_date, user_email): + self.errors = list() + self.data = None + self.statistic = dict() + self.start_date = start_date + self.end_date = end_date + self.email = user_email + self._set_data() + self._set_statistic() - # Если инженер работал ещё до начала диапазона - if int(first_log.old_role) == ROLES['engineer']: - for day in daterange(start_date, first_log.change_time.date()): - stat[day] = 24 * 3600 - stat[first_log.change_time.date()] += get_timedelta(first_log).total_seconds() + def use_interval(self, interval): + """ + Объединяет ключи и значения в соответствии с интервалом работы + """ + if not self.is_valid_statistic(): + return None + if not (interval in ['days', 'months']): + self.errors += ['Интервал работы должен быть в днях или месяцах'] + return None + stat = {} + if interval == 'months': + # Переделываем ключи под формат, как в прототипе('начало_месяца - конец_месяца') + for key, value in self.get_statistic().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 stat.get(index): + stat[index] += value + else: + stat[index] = value + elif interval == 'days': + stat = self.get_statistic() + return stat - # Если инженер закончил работать после диапазона - if int(last_log.new_role) == ROLES['engineer']: - for day in daterange(last_log.change_time.date() + timedelta(days=1), end_date + timedelta(days=1)): - stat[day] = 24 * 3600 - stat[last_log.change_time.date()] += (timedelta(days=1) - get_timedelta(last_log)).total_seconds() - # Цикл по логам - for log_index in range(len(data) - 1): - if int(data[log_index].new_role) == ROLES['engineer']: - current_log, next_log = data[log_index], data[log_index + 1] - # Если сессия закончилась НЕ в тот же день, что и началась - if current_log.change_time.date() != next_log.change_time.date(): - stat[current_log.change_time.date()] += (timedelta(days=1) - get_timedelta(current_log)).total_seconds() - stat[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() - # Если проработал несколько дней подряд, то заполнить эти дни по 24 часа - for day in daterange(current_log.change_time.date() + timedelta(days=1), next_log.change_time.date()): - stat[day] = 24 * 3600 - # Если сессия закончилась в тот же день, что и началась - else: - elapsed_time = next_log.change_time - current_log.change_time - stat[current_log.change_time.date()] += elapsed_time.total_seconds() - return stat + def use_display(self, display_format): + """ + Приводит данные к формату отображения, возвращает их + """ + if not self.get_statistic(): + return None + if not (display_format in ['hours', 'days']): + self.errors += ['Формат отображения должен быть в часах или днях'] + return None + for key, item in self.statistic.items(): + if display_format == 'hours': + self.statistic[key] = item / 3600 + elif display_format == 'days': + self.statistic[key] = item / 86400 + return self.statistic.copy() + def pop_errors(self): + """ + Возвращает все текущие ошибки + """ + errors = self.errors.copy() + self.errors.clear() + return errors -def get_data_logs(context, start_date, end_date, email): - """ - Функция возвращает список из лог-ов в диапазоне дат start_date-end_date для пользователя с почтой email - """ - data = [] - try: - data = RoleChangeLogs.objects.filter( - change_time__range=[start_date, end_date + timedelta(days=1)], - user=User.objects.get(email=email), - ).order_by('change_time') - except User.DoesNotExist: - context['errors'] = ['Пользователь не найден'] - return data + def get_data(self): + """ + Вернуть данные + data - массив объектов RoleChangeLogs, является списком логов пользователя + """ + if self.is_valid_data(): + return self.data + else: + return None + + def get_statistic(self): + """ + Вернуть словарь statistic или None, если были ошибки при создании + """ + if self.is_valid_statistic(): + return self.statistic.copy() + else: + return None + + def is_valid_statistic(self): + """ + Были ли ошибки при создании статистики + """ + return not self.errors and self.statistic + + def is_valid_data(self): + """ + Были ли ошибки при создании объекта + """ + return not self.errors + + def _set_data(self): + """ + Получение списка из лог-ов в диапазоне дат start_date-end_date для пользователя с почтой email + """ + 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') + self.errors.clear() + except User.DoesNotExist: + self.errors += ['Пользователь не найден'] + + def _set_statistic(self): + """ + Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд + """ + if not self.get_data(): + self.errors += ['Не обнаружены изменения роли в данном промежутке'] + return None + + self.clear_statistic() + first_log, last_log = self.data[0], self.data[len(self.data) - 1] + + # Если инженер работал ещё до начала диапазона + if int(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 int(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() + # Цикл по логам + for log_index in range(len(self.data) - 1): + if int(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() + # Если проработал несколько дней подряд, то заполнить эти дни по 24 часа + 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.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) diff --git a/main/templates/pages/stat.html b/main/templates/pages/stat.html index 2409e0f..69afbde 100644 --- a/main/templates/pages/stat.html +++ b/main/templates/pages/stat.html @@ -31,6 +31,15 @@
  • {{error}}
  • {% endfor %} + {%if form.errors%} +
      + {% for field, errors in form.errors.items %} + {% for error in errors %} +
    • {{error}}
    • + {% endfor %} + {% endfor %} +
    + {%endif%} {% endblock %} diff --git a/main/views.py b/main/views.py index b2d4fc0..bb9434f 100644 --- a/main/views.py +++ b/main/views.py @@ -1,6 +1,6 @@ import logging import os -from datetime import date, datetime +from datetime import datetime from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordResetForm @@ -21,7 +21,7 @@ from zenpy.lib.api_objects import User as ZenpyUser from access_controller.settings import EMAIL_HOST_USER, ZENDESK_ROLES from main.extra_func import check_user_exist, update_profile, get_user_organization, make_engineer, make_light_agent, \ - get_users_list, last_day_of_month, get_statistic_from_data, get_data_logs + get_users_list, StatisticData from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm from .models import UserProfile @@ -247,42 +247,23 @@ def statistic_page(request): } if request.method == "POST": form = StatisticForm(request.POST) - if not form.is_valid(): - context['errors'] += form.errors - else: - start_date, end_date, data = form.cleaned_data['range_start'], form.cleaned_data['range_end'], list() + if form.is_valid(): + start_date, end_date, stats = form.cleaned_data['range_start'], form.cleaned_data['range_end'], dict() + interval, show = form.cleaned_data['interval'], form.cleaned_data['display_format'] if end_date < start_date or end_date > datetime.now().date(): context['errors'] += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] else: - data = get_data_logs(context, start_date, end_date, form.cleaned_data['email']) - stats = get_statistic_from_data(data, start_date, end_date) - if stats is None: - context['errors'] += ['Не найдено изменений роли в указаном диапазоне'] - interval, show = form.cleaned_data['interval'], form.cleaned_data['display_format'] - - if not (show in ['hours', 'days']): # Работа с форматом отображения - context['errors'] += ['Формат отображения должен быть в часах или днях'] - elif stats: - for key, item in stats.items(): - if show == 'hours': - stats[key] = item / 3600 - elif show == 'days': - stats[key] = item / 86400 - - if not (interval in ['days', 'months']): # Работа с интервалом работы - context['errors'] += ['Интервал работы должен быть в днях или месяцах'] - elif interval == 'months' and stats: # Переделываем ключи под формат в прототипе(начало_месяца-конец_месяца) - new_stats = {} - for key, value in stats.items(): - current_month_start = max(start_date, date(year=key.year, month=key.month, day=1)) - current_month_end = min(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_stats.get(index): - new_stats[index] += value - else: - new_stats[index] = value - stats = new_stats - + Data = StatisticData(start_date, end_date, form.cleaned_data['email']) + stats = Data.get_statistic() + if Data.errors: + context['errors'] += Data.pop_errors() + else: + stats = Data.use_display(show) + if stats is None: + context['errors'] += Data.pop_errors() + stats = Data.use_interval(interval) + if stats is None: + context['errors'] += Data.pop_errors() context['log_stats'] = stats if not context['errors'] else None if request.method == 'GET': form = StatisticForm() From 3c43e0e38a7d8d235d4dad5f91cd637c8936ce58 Mon Sep 17 00:00:00 2001 From: Iurii Tatishchev Date: Thu, 11 Mar 2021 00:04:05 -0800 Subject: [PATCH 069/195] Add models help text migration --- main/migrations/0009_models_help_text.py | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 main/migrations/0009_models_help_text.py 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), + ), + ] From c2d6a790f9378a3b5eac569f9d25046e644c2544 Mon Sep 17 00:00:00 2001 From: Iurii Tatishchev Date: Thu, 11 Mar 2021 00:10:44 -0800 Subject: [PATCH 070/195] Add meta with permissions to UserProfile, fix `./manage.py migrate` errors --- main/migrations/0010_userprofile_meta.py | 17 +++++++++++++++++ main/models.py | 5 +++++ main/views.py | 7 ------- 3 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 main/migrations/0010_userprofile_meta.py 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/models.py b/main/models.py index f8f8385..52d7462 100644 --- a/main/models.py +++ b/main/models.py @@ -7,6 +7,11 @@ 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='Код роли пользователя') image = models.URLField(null=True, blank=True, help_text='Аватарка') diff --git a/main/views.py b/main/views.py index 3ec1319..17201c5 100644 --- a/main/views.py +++ b/main/views.py @@ -28,13 +28,6 @@ from access_controller.settings import ZENDESK_ROLES from zenpy.lib.api_objects import User as ZenpyUser -content_type_temp = ContentType.objects.get_for_model(UserProfile) -permission_temp, created = Permission.objects.get_or_create( - codename='has_control_access', - content_type=content_type_temp, -) - - class CustomRegistrationView(RegistrationView): """ Отображение и логика работы страницы регистрации пользователя From b825bcdff3596341b28246c7e45db5927348bd91 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Thu, 11 Mar 2021 17:30:55 +0300 Subject: [PATCH 071/195] Update class StatisticData --- main/extra_func.py | 186 +++++++++++++++++++++++++-------------------- main/forms.py | 3 +- main/models.py | 6 +- main/views.py | 25 +++--- 4 files changed, 116 insertions(+), 104 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index c47e6a1..167fb09 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -204,80 +204,32 @@ def last_day_of_month(day): class StatisticData: - def __init__(self, start_date, end_date, user_email): - self.errors = list() - self.data = None - self.statistic = 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._set_data() - self._set_statistic() - - def use_interval(self, interval): - """ - Объединяет ключи и значения в соответствии с интервалом работы - """ - if not self.is_valid_statistic(): - return None - if not (interval in ['days', 'months']): - self.errors += ['Интервал работы должен быть в днях или месяцах'] - return None - stat = {} - if interval == 'months': - # Переделываем ключи под формат, как в прототипе('начало_месяца - конец_месяца') - for key, value in self.get_statistic().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 stat.get(index): - stat[index] += value - else: - stat[index] = value - elif interval == 'days': - stat = self.get_statistic() - return stat - - def use_display(self, display_format): - """ - Приводит данные к формату отображения, возвращает их - """ - if not self.get_statistic(): - return None - if not (display_format in ['hours', 'days']): - self.errors += ['Формат отображения должен быть в часах или днях'] - return None - for key, item in self.statistic.items(): - if display_format == 'hours': - self.statistic[key] = item / 3600 - elif display_format == 'days': - self.statistic[key] = item / 86400 - return self.statistic.copy() - - def pop_errors(self): - """ - Возвращает все текущие ошибки - """ - errors = self.errors.copy() - self.errors.clear() - return errors - - def get_data(self): - """ - Вернуть данные - data - массив объектов RoleChangeLogs, является списком логов пользователя - """ - if self.is_valid_data(): - return self.data + if stat is None: + self._set_statistic() else: - return None + self.statistic = stat def get_statistic(self): """ - Вернуть словарь statistic или None, если были ошибки при создании + Вернуть словарь statistic с применением формата отображения и интеравала работы(если они есть) + None, если были ошибки при создании """ if self.is_valid_statistic(): - return self.statistic.copy() + stat = self.statistic + self._use_display(stat) + self._use_interval(stat) + return stat else: return None @@ -287,22 +239,96 @@ class StatisticData: """ 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 + for key, item in self.statistic.items(): + if self.display == 'hours': + self.statistic[key] = item / 3600 + elif self.display == 'days': + self.statistic[key] = item / 86400 + + def _use_interval(self, stat): + """ + Объединяет ключи и значения в соответствии с интервалом работы + """ + if not self.is_valid_statistic() or not self.interval: + return + 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 # статистика изначально в днях + self.statistic = 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 + Получение логов в диапазоне дат 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') - self.errors.clear() except User.DoesNotExist: self.errors += ['Пользователь не найден'] @@ -310,43 +336,35 @@ class StatisticData: """ Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд """ - if not self.get_data(): - self.errors += ['Не обнаружены изменения роли в данном промежутке'] - return None - 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 int(first_log.old_role) == ROLES['engineer']: + 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 int(last_log.new_role) == ROLES['engineer']: + 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() - # Цикл по логам for log_index in range(len(self.data) - 1): - if int(self.data[log_index].new_role) == ROLES['engineer']: + 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() - # Если проработал несколько дней подряд, то заполнить эти дни по 24 часа 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 дне + Заполение диапазона дат значением val + по умолчанию val = кол-во секунд в 1 дне """ + self.statistic.clear() for day in daterange(first, last): self.statistic[day] = val diff --git a/main/forms.py b/main/forms.py index e98b7e1..53eb63a 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 @@ -38,7 +39,7 @@ class AdminPageUsers(forms.Form): """ users = forms.ModelMultipleChoiceField( - queryset=UserProfile.objects.filter(role='agent'), + queryset=UserProfile.objects.all(), widget=forms.CheckboxSelectMultiple( attrs={ 'class': 'form-check-input' diff --git a/main/models.py b/main/models.py index 74aa5ba..fa2583d 100644 --- a/main/models.py +++ b/main/models.py @@ -8,7 +8,7 @@ class UserProfile(models.Model): """Модель профиля пользователя""" user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') - role = models.CharField(default='None', max_length=100, help_text='Код роли пользователя') + role = 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='Имя пользователя на нашем сайте') @@ -28,7 +28,7 @@ class RoleChangeLogs(models.Model): """Модель для логирования изменений ролей пользователя""" user = models.ForeignKey(to=User, on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль') name = models.TextField(help_text='Имя пользователя') - old_role = models.TextField(help_text='Старая роль') - new_role = models.TextField(help_text='Присвоенная роль') + old_role = models.IntegerField(help_text='Старая роль') + new_role = models.IntegerField(help_text='Присвоенная роль') change_time = models.DateTimeField(help_text='Дата и время изменения роли') changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') diff --git a/main/views.py b/main/views.py index bb9434f..372adc9 100644 --- a/main/views.py +++ b/main/views.py @@ -243,27 +243,20 @@ def statistic_page(request): return redirect('index') context = { 'pagename': 'страница статистики', - 'errors': [], } if request.method == "POST": form = StatisticForm(request.POST) if form.is_valid(): - start_date, end_date, stats = form.cleaned_data['range_start'], form.cleaned_data['range_end'], dict() + start_date, end_date = form.cleaned_data['range_start'], form.cleaned_data['range_end'] interval, show = form.cleaned_data['interval'], form.cleaned_data['display_format'] - if end_date < start_date or end_date > datetime.now().date(): - context['errors'] += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] - else: - Data = StatisticData(start_date, end_date, form.cleaned_data['email']) - stats = Data.get_statistic() - if Data.errors: - context['errors'] += Data.pop_errors() - else: - stats = Data.use_display(show) - if stats is None: - context['errors'] += Data.pop_errors() - stats = Data.use_interval(interval) - if stats is None: - context['errors'] += Data.pop_errors() + Data = StatisticData(start_date, end_date, form.cleaned_data['email']) + Data.set_display(show) + Data.set_interval(interval) + stats = Data.get_statistic() + if Data.errors: + context['errors'] = Data.errors + if Data.warnings: + context['warnings'] = Data.warnings context['log_stats'] = stats if not context['errors'] else None if request.method == 'GET': form = StatisticForm() From dd08821aea9b06a34b303ee4066763c7a986a512 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Thu, 11 Mar 2021 18:14:57 +0300 Subject: [PATCH 072/195] fix bag --- main/extra_func.py | 33 ++++++----- main/migrations/0005_auto_20210304_0119.py | 67 ---------------------- main/migrations/0011_auto_20210311_1734.py | 28 +++++++++ main/models.py | 10 ++-- main/templates/pages/stat.html | 23 ++++---- main/views.py | 1 + 6 files changed, 68 insertions(+), 94 deletions(-) delete mode 100644 main/migrations/0005_auto_20210304_0119.py create mode 100644 main/migrations/0011_auto_20210311_1734.py diff --git a/main/extra_func.py b/main/extra_func.py index 167fb09..cef57b4 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -185,12 +185,13 @@ def daterange(start_date, end_date) -> list: return dates -def get_timedelta(log) -> timedelta: +def get_timedelta(log,time=None) -> timedelta: """ Возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, - который находится в log (объект класса RoleChangeLogs) + который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён """ - time = log.change_time.time() + if time is None: + time = log.change_time.time() time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) return time @@ -227,8 +228,8 @@ class StatisticData: """ if self.is_valid_statistic(): stat = self.statistic - self._use_display(stat) - self._use_interval(stat) + stat = self._use_display(stat) + stat = self._use_interval(stat) return stat else: return None @@ -281,19 +282,21 @@ class StatisticData: Приводит данные к формату отображения """ if not self.is_valid_statistic() or not self.display: - return - for key, item in self.statistic.items(): + return stat + new_stat = {} + for key, item in stat.items(): if self.display == 'hours': - self.statistic[key] = item / 3600 + new_stat[key] = item / 3600 elif self.display == 'days': - self.statistic[key] = item / 86400 + new_stat[key] = item / 86400 + return new_stat def _use_interval(self, stat): """ Объединяет ключи и значения в соответствии с интервалом работы """ if not self.is_valid_statistic() or not self.interval: - return + return stat new_stat = {} if self.interval == 'months': # Переделываем ключи под формат('начало_месяца - конец_месяца') @@ -307,7 +310,7 @@ class StatisticData: new_stat[index] = value elif self.interval == 'days': new_stat = stat # статистика изначально в днях - self.statistic = new_stat + return new_stat def check_time(self): """ @@ -341,12 +344,16 @@ class StatisticData: 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.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date) + self.statistic[self.end_date + timedelta(days=1)] = get_timedelta(None,datetime.now().time()).total_seconds() self.statistic[last_log.change_time.date()] += (timedelta(days=1) - get_timedelta(last_log)).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] @@ -364,7 +371,6 @@ class StatisticData: Заполение диапазона дат значением val по умолчанию val = кол-во секунд в 1 дне """ - self.statistic.clear() for day in daterange(first, last): self.statistic[day] = val @@ -372,4 +378,5 @@ class StatisticData: """ Обнуление всех дней """ + self.statistic.clear() self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) diff --git a/main/migrations/0005_auto_20210304_0119.py b/main/migrations/0005_auto_20210304_0119.py deleted file mode 100644 index 361ec25..0000000 --- a/main/migrations/0005_auto_20210304_0119.py +++ /dev/null @@ -1,67 +0,0 @@ -# Generated by Django 3.1.6 on 2021-03-03 22:19 - -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', '0004_rolechangelogs'), - ] - - operations = [ - migrations.AddField( - model_name='rolechangelogs', - name='old_role', - field=models.TextField(default='agent', help_text='Старая роль'), - preserve_default=False, - ), - 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/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/models.py b/main/models.py index 72947df..95d38f2 100644 --- a/main/models.py +++ b/main/models.py @@ -31,9 +31,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='Имя пользователя') - old_role = models.IntegerField(help_text='Старая роль') - new_role = models.IntegerField(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/pages/stat.html b/main/templates/pages/stat.html index 69afbde..9279123 100644 --- a/main/templates/pages/stat.html +++ b/main/templates/pages/stat.html @@ -21,26 +21,29 @@ {% endfor %} - {% for key,val in log_stats.items %} -

    {{key}} | {{val}}

    -
    - {% endfor %} -
      {% for error in errors %} -
    • {{error}}
    • +
    • {{error}}
    • {% endfor %}
    {%if form.errors%}
      {% for field, errors in form.errors.items %} - {% for error in errors %} -
    • {{error}}
    • - {% endfor %} + {% for error in errors %} +
    • {{error}}
    • + {% endfor %} {% endfor %}
    {%endif%} - +
      + {% for warning in warnings %} +
    • {{warning}}
    • + {% endfor %} +
    + {% for key,val in log_stats.items %} +

    {{key}} | {{val}}

    +
    + {% endfor %} {% endblock %} diff --git a/main/views.py b/main/views.py index 5a0569f..36365f2 100644 --- a/main/views.py +++ b/main/views.py @@ -236,6 +236,7 @@ def statistic_page(request): return redirect('index') context = { 'pagename': 'страница статистики', + 'errors': list(), } if request.method == "POST": form = StatisticForm(request.POST) From 9b6238fa45f8bbec1fd44651606cf3593f16bae0 Mon Sep 17 00:00:00 2001 From: Kiselev Igor Date: Thu, 11 Mar 2021 19:03:45 +0300 Subject: [PATCH 073/195] 1/2 partof statistic.html --- main/templates/statistic.html | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 main/templates/statistic.html diff --git a/main/templates/statistic.html b/main/templates/statistic.html new file mode 100644 index 0000000..1069fae --- /dev/null +++ b/main/templates/statistic.html @@ -0,0 +1,88 @@ + + + + + + + + Statistic + + + + + +
    + + +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +

    Выберите интервалы времени работы

    +
    +
    + + + + + +
    + +
    + +
    + +
    +
    +
    +
    + + + From 60e1da2825d904f251a1e1513a413d10905c5bb1 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Thu, 11 Mar 2021 19:17:11 +0300 Subject: [PATCH 074/195] 12-hour working day --- access_controller/settings.py | 3 ++- access_controller/urls.py | 2 -- main/extra_func.py | 8 ++++---- .../django_registration/registration_complete.html | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index 96703b6..deadf32 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -135,7 +135,6 @@ ACCOUNT_ACTIVATION_DAYS = 7 LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' - # Название_приложения.Название_файла.Название_класса_обработчика AUTHENTICATION_BACKENDS = [ 'access_controller.auth.EmailAuthBackend', @@ -183,3 +182,5 @@ ZENDESK_ROLES = { 'engineer': 360005209000, 'light_agent': 360005208980, } + +ONE_DAY = 12 # Количество часов в 1 рабочем дне diff --git a/access_controller/urls.py b/access_controller/urls.py index 1406e69..da72f11 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -33,8 +33,6 @@ urlpatterns = [ path('work/', work_page, name="work"), path('work/hand_over/', work_hand_over, name="work_hand_over"), path('work/become_engineer/', work_become_engineer, name="work_become_engineer"), - path('accounts/', include('django_registration.backends.activation.urls')), - path('accounts/login/', include('django.contrib.auth.urls')), path('control/', AdminPageView.as_view(), name='control'), path('statistic/', statistic_page, name='statistic') ] diff --git a/main/extra_func.py b/main/extra_func.py index cef57b4..bb7b084 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -5,7 +5,7 @@ from django.contrib.auth.models import User from zenpy import Zenpy from zenpy.lib.exception import APIException -from access_controller.settings import ZENDESK_ROLES as ROLES +from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY from main.models import UserProfile, RoleChangeLogs @@ -73,7 +73,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 @@ -156,7 +156,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.role = 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() @@ -288,7 +288,7 @@ class StatisticData: if self.display == 'hours': new_stat[key] = item / 3600 elif self.display == 'days': - new_stat[key] = item / 86400 + new_stat[key] = item / (ONE_DAY * 3600) return new_stat def _use_interval(self, stat): diff --git a/main/templates/django_registration/registration_complete.html b/main/templates/django_registration/registration_complete.html index bb064bb..1b4aa88 100644 --- a/main/templates/django_registration/registration_complete.html +++ b/main/templates/django_registration/registration_complete.html @@ -10,5 +10,5 @@ {% block content %}
    -

    Регистрация прошла успешно. Войти сейчас

    +

    Регистрация прошла успешно. Войти сейчас

    {% endblock %} From 72b70cc585f3e2e46ff53e520e3a19326949cd91 Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Thu, 11 Mar 2021 19:29:16 +0300 Subject: [PATCH 075/195] Fixed bug with api response --- main/extra_func.py | 12 +++++++++--- main/serializers.py | 2 +- main/views.py | 13 +++++++++---- static/main/js/control.js | 31 +++++++++++++++++++++++++------ 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 5cfb400..295a677 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,9 +1,12 @@ import os +from django.contrib.auth.models import User from zenpy import Zenpy from zenpy.lib.exception import APIException -from main.models import UserProfile, User +from main.models import UserProfile +from django.core.exceptions import ObjectDoesNotExist + from access_controller.settings import ZENDESK_ROLES as ROLES, ZENDESK_ROLES @@ -223,6 +226,9 @@ def update_users_in_model(): """ users = get_users_list() for user in users: - profile = User.objects.get(email=user.email).userprofile - update_user_in_model(profile, user) + try: + profile = User.objects.get(email=user.email).userprofile + update_user_in_model(profile, user) + except ObjectDoesNotExist: + pass return users diff --git a/main/serializers.py b/main/serializers.py index 26d08c2..f72fc86 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -14,4 +14,4 @@ class ProfileSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = UserProfile - fields = ['user', 'role', 'name'] + fields = ['user', 'id', 'role', 'name'] diff --git a/main/views.py b/main/views.py index 36f82da..e831d34 100644 --- a/main/views.py +++ b/main/views.py @@ -208,7 +208,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): users = get_list_or_404( UserProfile, role='agent') context['users'] = users - context['engineers'], context['light_agents'] = count_users(users) + context['engineers'], context['light_agents'] = count_users(get_users_list()) return context # TODO: need to get profile page url @@ -228,7 +228,12 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): def list(self, request, *args, **kwargs): users = update_users_in_model() - profiles = UserProfile.objects.filter(role='agent') count = count_users(users) - serializer = self.get_serializer(data=profiles, many=True) - return Response(serializer.data + {'engineers': count[0], 'light_agents': count[1]}) + profiles = UserProfile.objects.filter(role='agent') + serializer = self.get_serializer(profiles, many=True) + return Response({ + 'users': serializer.data, + 'engineers': count[0], + 'light_agents': count[1] + }) + diff --git a/static/main/js/control.js b/static/main/js/control.js index 6404741..e645196 100644 --- a/static/main/js/control.js +++ b/static/main/js/control.js @@ -10,7 +10,9 @@ function move_checkboxes() { } } } +move_checkboxes(); +// React class TableRow extends React.Component { render() { return ( @@ -20,7 +22,13 @@ class TableRow extends React.Component { {this.props.user.user.email} {this.props.user.role} - + + + ); } @@ -29,20 +37,31 @@ class TableRow extends React.Component { class TableBody extends React.Component { constructor(props) { super(props); - this.state = { users: [] }; + this.state = { + users: [], + engineers: 0, + light_agents: 0, + }; } get_users() { axios.get("/api/users").then((response) => { - this.setState({ users: response.data }); + this.setState({ + users: response.data.users, + engineers: response.data.engineers, + light_agents: response.data.light_agents, + }); + let elements = document.querySelectorAll(".info-quantity-value"); + console.log(elements) + elements[0].innerHTML = this.state.engineers; + elements[1].innerHTML = this.state.light_agents; }); } componentDidMount() { this.interval = setInterval(() => { this.get_users(); - move_checkboxes(); - }, 1000); + }, 10000); } componentWillUnmount() { @@ -56,5 +75,5 @@ class TableBody extends React.Component { } } -move_checkboxes(); ReactDOM.render(, document.getElementById("table")); + From 9068febd302f3f2bf7d0f3a3f73f7b8e405eeb5f Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Thu, 11 Mar 2021 19:55:09 +0300 Subject: [PATCH 076/195] bugfix --- main/extra_func.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index bb7b084..b76eb55 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -185,7 +185,7 @@ def daterange(start_date, end_date) -> list: return dates -def get_timedelta(log,time=None) -> timedelta: +def get_timedelta(log, time=None) -> timedelta: """ Возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён @@ -350,9 +350,10 @@ class StatisticData: 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) - self.statistic[self.end_date + timedelta(days=1)] = get_timedelta(None,datetime.now().time()).total_seconds() + 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']: From 674df0e66b1f5139c9058596bbf7fbc4bbe0206f Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 11 Mar 2021 20:28:04 +0300 Subject: [PATCH 077/195] Add model for tracking unassigned tickets --- main/migrations/0012_auto_20210311_2027.py | 29 ++++++++++++++++++++++ main/models.py | 13 +++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 main/migrations/0012_auto_20210311_2027.py diff --git a/main/migrations/0012_auto_20210311_2027.py b/main/migrations/0012_auto_20210311_2027.py new file mode 100644 index 0000000..113e51e --- /dev/null +++ b/main/migrations/0012_auto_20210311_2027.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.6 on 2021-03-11 17:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('main', '0011_auto_20210311_1734'), + ] + + operations = [ + migrations.RemoveField( + model_name='rolechangelogs', + name='name', + ), + migrations.CreateModel( + name='UnassignedTicket', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ticket_id', models.IntegerField(help_text='Номер тикера, для которого сняли ответственного')), + ('status', models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются')], default=0)), + ('assignee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/main/models.py b/main/models.py index 95d38f2..4811e8d 100644 --- a/main/models.py +++ b/main/models.py @@ -33,9 +33,20 @@ class RoleChangeLogs(models.Model): """Модель для логирования изменений ролей пользователя""" user = models.ForeignKey(to=User, on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль') - name = 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='Кем была изменена роль') + + +class UnassignedTicketStatus(models.IntegerChoices): + UNASSIGNED = 0, 'Снят с пользователя, перенесён в буферную группу' + RESTORED = 1, 'Авторство восстановлено' + NOT_FOUND = 2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются' + + +class UnassignedTicket(models.Model): + assignee = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='tickets') + ticket_id = models.IntegerField(help_text='Номер тикера, для которого сняли ответственного') + status = models.IntegerField(choices=UnassignedTicketStatus.choices, default=UnassignedTicketStatus.UNASSIGNED) From d2e76fdbd96e6b84980d1b11de85fd923cb43129 Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 11 Mar 2021 20:54:56 +0300 Subject: [PATCH 078/195] Update model, add documentation todos, add sampla code for logging. --- main/extra_func.py | 33 +++++++++++++++++++--- main/migrations/0013_auto_20210311_2040.py | 18 ++++++++++++ main/models.py | 6 ++-- main/views.py | 7 +++-- 4 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 main/migrations/0013_auto_20210311_2040.py diff --git a/main/extra_func.py b/main/extra_func.py index b76eb55..d2f1810 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -6,7 +6,7 @@ from zenpy import Zenpy from zenpy.lib.exception import APIException from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY -from main.models import UserProfile, RoleChangeLogs +from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus class ZendeskAdmin: @@ -128,16 +128,41 @@ def update_role(user_profile: UserProfile, role: str) -> UserProfile: def make_engineer(user_profile: UserProfile) -> UserProfile: """ - Функция **make_engineer** устанавливапет пользователю роль инженера. + Функция **make_engineer** устанавливает пользователю роль инженера. """ + update_role(user_profile, ROLES['engineer']) -def make_light_agent(user_profile: UserProfile) -> UserProfile: +def make_light_agent(user_profile: UserProfile, who_changes: User) -> UserProfile: """ - Функция **make_light_agent** устанавливапет пользователю роль легкого агента. + Функция **make_light_agent** устанавливапет пользователю роль легкого агента. + + .. todo:: + Решить проблему с ошибкой при выполнении этой функции из-за неотвязанных тикетов. А именно: + + - найти все тикеты, ответственным которых является снимаемый аккаунт + - для всех этих тикетов - перенести ответственность на буферную группу. + - [PARTIALY DONE] создать записи о снятых тикетах и их прошлом авторстве. Если тикет уже был закрыт - выставить в логе CLOSED. Иначе UNASSIGNED + - [DONE] после этого снять права c инженера + - [DONE] создать запись в логе о снятии прав инженера """ + + # tickets = [] + # # TODO: set ticket fields correct + # for ticket in tickets: + # UnassignedTicket.create( + # assignee=user_profile.user, + # ticket_id=ticket.number, + # status=UnassignedTicketStatus.UNASSIGNED if ticket.status=='opened' else UnassignedTicketStatus.CLOSED + # ) update_role(user_profile, ROLES['light_agent']) + RoleChangeLogs.create( + user=user_profile.user, + old_role=ROLES['engineer'], + new_role=ROLES['light_agent'], + changed_by=who_changes + ) def get_users_list() -> list: diff --git a/main/migrations/0013_auto_20210311_2040.py b/main/migrations/0013_auto_20210311_2040.py new file mode 100644 index 0000000..5648813 --- /dev/null +++ b/main/migrations/0013_auto_20210311_2040.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.6 on 2021-03-11 17:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0012_auto_20210311_2027'), + ] + + operations = [ + migrations.AlterField( + model_name='unassignedticket', + name='status', + field=models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются'), (3, 'Тикет уже был закрыт. Дополнительные действия не требуются')], default=0), + ), + ] diff --git a/main/models.py b/main/models.py index 4811e8d..087784c 100644 --- a/main/models.py +++ b/main/models.py @@ -2,6 +2,7 @@ from django.db import models from django.contrib.auth.models import User from django.db.models.signals import post_save from django.dispatch import receiver +from django.utils import timezone class UserProfile(models.Model): @@ -12,7 +13,7 @@ class UserProfile(models.Model): ('has_control_access', 'Can view admin page'), ) - user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') + user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь', related_name='user') role = 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='Имя пользователя на нашем сайте') @@ -35,7 +36,7 @@ class RoleChangeLogs(models.Model): help_text='Пользователь, которому присвоили другую роль') old_role = models.IntegerField(default=0, help_text='Старая роль') new_role = models.IntegerField(default=0, help_text='Присвоенная роль') - change_time = models.DateTimeField(help_text='Дата и время изменения роли') + change_time = models.DateTimeField(help_text='Дата и время изменения роли', default=timezone.now) changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') @@ -44,6 +45,7 @@ class UnassignedTicketStatus(models.IntegerChoices): UNASSIGNED = 0, 'Снят с пользователя, перенесён в буферную группу' RESTORED = 1, 'Авторство восстановлено' NOT_FOUND = 2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются' + CLOSED = 3, 'Тикет уже был закрыт. Дополнительные действия не требуются' class UnassignedTicket(models.Model): diff --git a/main/views.py b/main/views.py index 36365f2..4c596ad 100644 --- a/main/views.py +++ b/main/views.py @@ -190,9 +190,10 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): def make_engineers(users): [make_engineer(user) for user in users] - @staticmethod - def make_light_agents(users): - [make_light_agent(user) for user in users] + def make_light_agents(self, users): + for user in users: + make_light_agent(user, self.request.user) + @staticmethod def count_users(users) -> tuple: From 5665282ce5619076a844e048e8ad32f2455c317c Mon Sep 17 00:00:00 2001 From: Artyom Kravchenko Date: Fri, 12 Mar 2021 12:34:45 +0300 Subject: [PATCH 079/195] fixed UserProfile model --- main/extra_func.py | 3 ++- main/migrations/0012_auto_20210312_1225.py | 23 ++++++++++++++++++++++ main/models.py | 3 ++- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 main/migrations/0012_auto_20210312_1225.py diff --git a/main/extra_func.py b/main/extra_func.py index b76eb55..ada4a88 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -156,7 +156,8 @@ def update_profile(user_profile: UserProfile) -> UserProfile: """ user = ZendeskAdmin().get_user(user_profile.user.email) user_profile.name = user.name - user_profile.role = user.custom_role_id if user.custom_role_id else 0 + 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() 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 95d38f2..112e29e 100644 --- a/main/models.py +++ b/main/models.py @@ -13,7 +13,8 @@ class UserProfile(models.Model): ) user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') - role = models.IntegerField(default=0, 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='Имя пользователя на нашем сайте') From abe44fec5f2cd85bc7e7966e7759b474322164a0 Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Fri, 12 Mar 2021 13:01:41 +0300 Subject: [PATCH 080/195] Refactored some functions in AdminPageView --- main/views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/main/views.py b/main/views.py index e831d34..3b96d92 100644 --- a/main/views.py +++ b/main/views.py @@ -184,10 +184,11 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): """ Функция установки ролей пользователям """ + users = form.cleaned_data['users'] if 'engineer' in self.request.POST: - self.make_engineers(form.cleaned_data['users']) + self.make_engineers(users) elif 'light_agent' in self.request.POST: - self.make_light_agents(form.cleaned_data['users']) + self.make_light_agents(users) return super().form_valid(form) @staticmethod @@ -202,8 +203,6 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): """ Функция формирования контента страницы администратора (с проверкой прав доступа) """ - if self.request.user.userprofile.role != 'admin': - raise PermissionDenied context = super().get_context_data(**kwargs) users = get_list_or_404( UserProfile, role='agent') From bd500207a33b0d8b138e70ce5541e18cc52eaf1d Mon Sep 17 00:00:00 2001 From: Artyom Kravchenko Date: Fri, 12 Mar 2021 13:58:27 +0300 Subject: [PATCH 081/195] Fixed AdminPageUSer form --- main/forms.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/main/forms.py b/main/forms.py index 53eb63a..a8c2ec1 100644 --- a/main/forms.py +++ b/main/forms.py @@ -39,10 +39,11 @@ class AdminPageUsers(forms.Form): """ users = forms.ModelMultipleChoiceField( - queryset=UserProfile.objects.all(), + queryset=UserProfile.objects.filter(role='agent'), widget=forms.CheckboxSelectMultiple( attrs={ - 'class': 'form-check-input' + 'class': 'form-check-input', + } ), label='' From f6948d016de2c7899787d16ea636466ca7491de1 Mon Sep 17 00:00:00 2001 From: Artyom Kravchenko Date: Fri, 12 Mar 2021 14:39:51 +0300 Subject: [PATCH 082/195] add some design to menu template(logo and color) --- main/templates/base/menu.html | 6 +++--- static/main/img/logo_real.png | Bin 0 -> 1499 bytes 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 static/main/img/logo_real.png diff --git a/main/templates/base/menu.html b/main/templates/base/menu.html index a389341..92aaec1 100644 --- a/main/templates/base/menu.html +++ b/main/templates/base/menu.html @@ -3,10 +3,10 @@ -