From 21b29fdc1104f5e917dd3a1c0fb7ea37f964738d Mon Sep 17 00:00:00 2001 From: Vadim Melnikov Date: Fri, 5 Mar 2021 19:07:13 +0300 Subject: [PATCH 01/49] 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 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 Date: Sun, 7 Mar 2021 21:30:02 +0300 Subject: [PATCH 02/49] 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 @@ - + - - + {% 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 9b6238fa45f8bbec1fd44651606cf3593f16bae0 Mon Sep 17 00:00:00 2001 From: Kiselev Igor Date: Thu, 11 Mar 2021 19:03:45 +0300 Subject: [PATCH 03/49] 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 72b70cc585f3e2e46ff53e520e3a19326949cd91 Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Thu, 11 Mar 2021 19:29:16 +0300 Subject: [PATCH 04/49] 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 674df0e66b1f5139c9058596bbf7fbc4bbe0206f Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 11 Mar 2021 20:28:04 +0300 Subject: [PATCH 05/49] 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 06/49] 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 abe44fec5f2cd85bc7e7966e7759b474322164a0 Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Fri, 12 Mar 2021 13:01:41 +0300 Subject: [PATCH 07/49] 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 c1a10b6f2c7834e51d49e253ce29b245b7d5d0fb Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Fri, 12 Mar 2021 14:54:57 +0300 Subject: [PATCH 08/49] Fixed bug with disappearance of table rows in control page at start --- main/extra_func.py | 9 +++++---- main/templates/pages/adm_ruleset.html | 3 ++- main/views.py | 5 +---- static/main/js/control.js | 10 +++++++--- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 295a677..8ad1b80 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -8,7 +8,7 @@ from main.models import UserProfile from django.core.exceptions import ObjectDoesNotExist -from access_controller.settings import ZENDESK_ROLES as ROLES, ZENDESK_ROLES +from access_controller.settings import ZENDESK_ROLES class ZendeskAdmin: @@ -132,14 +132,14 @@ def make_engineer(user_profile: UserProfile) -> UserProfile: """ Функция **make_engineer** устанавливапет пользователю роль инженера. """ - update_role(user_profile, ROLES['engineer']) + update_role(user_profile, ZENDESK_ROLES['engineer']) def make_light_agent(user_profile: UserProfile) -> UserProfile: """ Функция **make_light_agent** устанавливапет пользователю роль легкого агента. """ - update_role(user_profile, ROLES['light_agent']) + update_role(user_profile, ZENDESK_ROLES['light_agent']) def get_users_list() -> list: @@ -150,7 +150,8 @@ def get_users_list() -> list: # У пользователей должна быть организация SYSTEM org = next(zendesk.admin.search(type='organization', name='SYSTEM')) - return zendesk.admin.organizations.users(org) + users = zendesk.admin.organizations.users(org) + return users def update_profile(user_profile: UserProfile) -> UserProfile: diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index dd9d614..c230862 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -50,7 +50,7 @@ Checked - + {% for user in users %} {{ user.name }} @@ -60,6 +60,7 @@ {% endfor %} + {% endblock%} diff --git a/main/views.py b/main/views.py index 3b96d92..875763a 100644 --- a/main/views.py +++ b/main/views.py @@ -22,16 +22,13 @@ from main.forms import CustomRegistrationForm, AdminPageUsers, CustomAuthenticat 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.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( @@ -226,7 +223,7 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = ProfileSerializer def list(self, request, *args, **kwargs): - users = update_users_in_model() + users = update_users_in_model().values count = count_users(users) profiles = UserProfile.objects.filter(role='agent') serializer = self.get_serializer(profiles, many=True) diff --git a/static/main/js/control.js b/static/main/js/control.js index e645196..62d2daf 100644 --- a/static/main/js/control.js +++ b/static/main/js/control.js @@ -8,6 +8,10 @@ function move_checkboxes() { let el = checkboxes[i].cloneNode(true); fields[i].appendChild(el); } + } else { + alert( + "Количество пользователей агентов не соответствует количеству полей в форме AdminPageUsers" + ); } } move_checkboxes(); @@ -27,6 +31,7 @@ class TableRow extends React.Component { type="checkbox" value={this.props.user.id} className="form-check-input" + name="users" /> @@ -52,7 +57,6 @@ class TableBody extends React.Component { 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; }); @@ -75,5 +79,5 @@ class TableBody extends React.Component { } } -ReactDOM.render(, document.getElementById("table")); - +ReactDOM.render(, document.getElementById("new_tbody")); +setTimeout(() => document.getElementById("old_tbody").remove(), 10000); From a434a4a30b3c4f2409b18edec2f74970c8bf6330 Mon Sep 17 00:00:00 2001 From: Yuriy Kulakov Date: Fri, 12 Mar 2021 15:39:54 +0300 Subject: [PATCH 09/49] Merge feature/periodic into develop --- main/extra_func.py | 1 + main/templates/pages/adm_ruleset.html | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index 58e53a0..e9cafe9 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -204,6 +204,7 @@ 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.custom_role_id = zendesk_user.custom_role_id profile.save() diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index c230862..92a0628 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -19,9 +19,6 @@ {% block content %}
-
-

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

-
{% block form %}
From d50a88e8bc80ee5ced11cbe2929097909708e6fe 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: Fri, 12 Mar 2021 18:38:03 +0300 Subject: [PATCH 10/49] Add params to extra_func --- docs/source/code.rst | 4 +- main/extra_func.py | 172 +++++++++++++++++++++++++++++++------------ main/models.py | 12 ++- 3 files changed, 136 insertions(+), 52 deletions(-) diff --git a/docs/source/code.rst b/docs/source/code.rst index 85d207b..7c5a05d 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -2,9 +2,9 @@ ========================= -****** +******* Models -****** +******* .. automodule:: main.models :members: diff --git a/main/extra_func.py b/main/extra_func.py index ada4a88..f51be0f 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -11,16 +11,16 @@ from main.models import UserProfile, RoleChangeLogs class ZendeskAdmin: """ - Класс **ZendeskAdmin** существует, чтобы в каждой фунциии отдельно не проверять аккаунт администратора + Класс **ZendeskAdmin** существует, чтобы в каждой фунциии отдельно не проверять аккаунт администратора. - :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) - :type credentials: :class:`dict` - :param email: Email администратора, указанный в env - :type email: :class:`str` - :param token: Токен администратора (формируется в Zendesk, указывается в env) - :type token: :class:`str` - :param password: Пароль администратора, указанный в env - :type password: :class:`str` + :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) + :type credentials: :class:`dict` + :param email: Email администратора, указанный в env + :type email: :class:`str` + :param token: Токен администратора (формируется в Zendesk, указывается в env) + :type token: :class:`str` + :param password: Пароль администратора, указанный в env + :type password: :class:`str` """ credentials: dict = { @@ -41,41 +41,56 @@ class ZendeskAdmin: def check_user(self, email: str) -> bool: """ - Функция **check_user** осуществляет проверку существования пользователя в Zendesk по email + Функция осуществляет проверку существования пользователя в Zendesk по email. + + :param email: + :return: """ return True if self.admin.search(email, type='user') else False def get_user_name(self, email: str) -> str: """ - Функция **get_user_name** возвращает имя пользователя по его email + Функция возвращает имя пользователя по его email. + + :param email: + :return: """ user = self.admin.users.search(email).values[0] return user.name def get_user_role(self, email: str) -> str: """ - Функция **get_user_role** возвращает роль пользователя по его email + Функция возвращает роль пользователя по его email. + + :param email: + :return: """ user = self.admin.users.search(email).values[0] return user.role def get_user_id(self, email: str) -> str: """ - Функция **get_user_id** возвращает id пользователя по его email + Функция возвращает id пользователя по его email + + :param email: + :return: """ user = self.admin.users.search(email).values[0] return user.id def get_user_image(self, email: str) -> str: """ - Функция **get_user_image** возвращает url-ссылку на аватар пользователя по его email + Функция возвращает url-ссылку на аватар пользователя по его email. + + :param email: + :return: """ user = self.admin.users.search(email).values[0] return user.photo['content_url'] if user.photo else None def get_user(self, email: str): """ - Функция **get_user** возвращает пользователя (объект) по его email + Функция возвращает пользователя (объект) по его email :param email: email пользователя :return: email пользователя, найденного в БД @@ -84,19 +99,22 @@ class ZendeskAdmin: def get_user_org(self, email: str) -> str: """ - Функция **get_user_org** возвращает организацию, к которой относится пользователь по его email + Функция возвращает организацию, к которой относится пользователь по его email. + + :param email: + :return: """ user = self.admin.users.search(email).values[0] return user.organization.name if user.organization else None def create_admin(self) -> Zenpy: """ - Функция **Create_admin()** создает администратора, проверяя наличие вводимых данных в env. + Функция создает администратора, проверяя наличие вводимых данных в env. - :param credentials: В список полномочий администратора вносятся email, token, password из env - :type credentials: :class:`dict` - :raise: :class:`ValueError`: исключение, вызываемое если email не введен в env - :raise: :class:`APIException`: исключение, вызываемое если пользователя с таким email не существует в Zendesk + :param credentials: В список полномочий администратора вносятся email, token, password из env + :type credentials: :class:`dict` + :raise: :class:`ValueError`: исключение, вызываемое если email не введен в env + :raise: :class:`APIException`: исключение, вызываемое если пользователя с таким email не существует в Zendesk """ if self.email is None: @@ -118,7 +136,11 @@ class ZendeskAdmin: def update_role(user_profile: UserProfile, role: str) -> UserProfile: """ - Функция **update_role** меняет роль пользователя. + Функция меняет роль пользователя. + + :param user_profile: + :param role: + :return: """ zendesk = ZendeskAdmin() user = zendesk.get_user(user_profile.user.email) @@ -128,21 +150,28 @@ def update_role(user_profile: UserProfile, role: str) -> UserProfile: def make_engineer(user_profile: UserProfile) -> UserProfile: """ - Функция **make_engineer** устанавливапет пользователю роль инженера. + Функция устанавливапет пользователю роль инженера. + + :param user_profile: + :return: """ update_role(user_profile, ROLES['engineer']) def make_light_agent(user_profile: UserProfile) -> UserProfile: """ - Функция **make_light_agent** устанавливапет пользователю роль легкого агента. + Функция устанавливапет пользователю роль легкого агента. + + :param user_profile: + :return: """ update_role(user_profile, ROLES['light_agent']) def get_users_list() -> list: """ - Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации. + Функция возвращает список пользователей Zendesk, относящихся к организации. + :return: """ zendesk = ZendeskAdmin() admin = zendesk.get_user(zendesk.email) @@ -152,7 +181,10 @@ def get_users_list() -> list: def update_profile(user_profile: UserProfile) -> UserProfile: """ - Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk + Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk. + + :param user_profile: + :return: """ user = ZendeskAdmin().get_user(user_profile.user.email) user_profile.name = user.name @@ -164,21 +196,31 @@ def update_profile(user_profile: UserProfile) -> UserProfile: def check_user_exist(email: str) -> bool: """ - Функция проверяет, существует ли пользователь + Функция проверяет, существует ли пользователь. + + :param email: + :return: """ return ZendeskAdmin().check_user(email) def get_user_organization(email: str) -> str: """ - Функция возвращает организацию пользователя + Функция возвращает организацию пользователя. + + :param email: + :return: """ return ZendeskAdmin().get_user_org(email) def daterange(start_date, end_date) -> list: """ - Возвращает список дней с start_date по end_date исключая правую границу + Функция возвращает список дней с start_date по end_date, исключая правую границу. + + :param start_date: + :param end_date: + :return: """ dates = [] for n in range(int((end_date - start_date).days)): @@ -188,8 +230,12 @@ def daterange(start_date, end_date) -> list: def get_timedelta(log, time=None) -> timedelta: """ - Возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, - который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён + Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, + который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. + + :param log: + :param time: + :return: """ if time is None: time = log.change_time.time() @@ -199,7 +245,10 @@ def get_timedelta(log, time=None) -> timedelta: def last_day_of_month(day): """ - Возвращает последний день любого месяца + Функция возвращает последний день любого месяца. + + :param day: + :return: """ next_month = day.replace(day=28) + timedelta(days=4) return next_month - timedelta(days=next_month.day) @@ -224,8 +273,10 @@ class StatisticData: def get_statistic(self): """ - Вернуть словарь statistic с применением формата отображения и интеравала работы(если они есть) - None, если были ошибки при создании + Функция возвращает словарь statistic с применением формата отображения и интеравала работы(если они есть). + None, если были ошибки при создании. + + :return: """ if self.is_valid_statistic(): stat = self.statistic @@ -237,13 +288,18 @@ class StatisticData: def is_valid_statistic(self): """ - Были ли ошибки при создании статистики + Функция проверяет были ли ошибки при создании статистики. + + :return: """ return not self.errors and self.statistic def set_interval(self, interval): """ - Устанавливает интервал работы + Функция устанавливает интервал работы. + + :param interval: + :return: """ if interval not in ['months', 'days']: self.errors += ['Интервал работы должен быть в днях или месяцах'] @@ -253,7 +309,10 @@ class StatisticData: def set_display(self, display_format): """ - Устанавливает формат отображения + Функция устанавливает формат отображения. + + :param display_format: + :return: """ if display_format not in ['days', 'hours']: self.errors += ['Формат отображения должен быть в часах или днях'] @@ -263,9 +322,9 @@ class StatisticData: def get_data(self): """ - Вернуть данные - data - массив объектов RoleChangeLogs, является списком логов пользователя - data может быть пустым списком + Функция возвращает данные - массив объектов RoleChangeLogs. + + :return: """ if self.is_valid_data(): return self.data @@ -274,13 +333,18 @@ class StatisticData: def is_valid_data(self): """ - Были ли ошибки при получении логов + Функция определяет были ли ошибки при получении логов. + + :return: """ return not self.errors def _use_display(self, stat): """ - Приводит данные к формату отображения + Функция приводит данные к формату отображения. + + :param stat: + :return: """ if not self.is_valid_statistic() or not self.display: return stat @@ -294,7 +358,10 @@ class StatisticData: def _use_interval(self, stat): """ - Объединяет ключи и значения в соответствии с интервалом работы + Функция объединяет ключи и значения в соответствии с интервалом работы. + + :param stat: + :return: """ if not self.is_valid_statistic() or not self.interval: return stat @@ -323,7 +390,9 @@ class StatisticData: def _set_data(self): """ - Получение логов в диапазоне дат start_date-end_date для пользователя с почтой email + Функция возвращает логи в диапазоне дат start_date-end_date для пользователя с указанным email. + + :return: """ if not self.check_time(): self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] @@ -338,7 +407,9 @@ class StatisticData: def _set_statistic(self): """ - Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд + Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. + + :return: """ self.clear_statistic() if not self.get_data(): @@ -370,15 +441,20 @@ class StatisticData: def fill_daterange(self, first, last, val=24 * 3600): """ - Заполение диапазона дат значением val - по умолчанию val = кол-во секунд в 1 дне + Функция заполеняет диапазон дат значением val (по умолчанию val = кол-во секунд в 1 дне). + + :param first: + :param last: + :param val: + :return: """ for day in daterange(first, last): self.statistic[day] = val def clear_statistic(self): """ - Обнуление всех дней + Функция осуществляет обновление всех дней + :return: """ self.statistic.clear() self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) diff --git a/main/models.py b/main/models.py index 112e29e..b3e235c 100644 --- a/main/models.py +++ b/main/models.py @@ -5,7 +5,10 @@ from django.dispatch import receiver class UserProfile(models.Model): - """Модель профиля пользователя""" + """ + Модель профиля пользователя + + """ class Meta: permissions = ( @@ -31,7 +34,11 @@ 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='Имя пользователя') @@ -40,3 +47,4 @@ class RoleChangeLogs(models.Model): change_time = models.DateTimeField(help_text='Дата и время изменения роли') changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') + From be6005c4be287187e393d34739a92d5753f71dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sat, 13 Mar 2021 16:41:28 +0300 Subject: [PATCH 11/49] Correct view documentation using typehints --- main/forms.py | 2 +- main/templates/registration/login.html | 2 +- main/views.py | 54 +++++++++++++++++++++----- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/main/forms.py b/main/forms.py index a8c2ec1..dab07bb 100644 --- a/main/forms.py +++ b/main/forms.py @@ -32,7 +32,7 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): class AdminPageUsers(forms.Form): """ - Форма для установки статусов engineer или light_agent пользователям + Форма для установки статусов engineer или light_agent пользователям. :param users: Поле для установки статуса :type users: :class:`ModelMultipleChoiceField` diff --git a/main/templates/registration/login.html b/main/templates/registration/login.html index 7a3ab68..ecbaa31 100644 --- a/main/templates/registration/login.html +++ b/main/templates/registration/login.html @@ -30,7 +30,7 @@ {% endif %}
diff --git a/main/views.py b/main/views.py index 36365f2..d1155e0 100644 --- a/main/views.py +++ b/main/views.py @@ -27,7 +27,7 @@ from .models import UserProfile class CustomRegistrationView(RegistrationView): """ - Отображение и логика работы страницы регистрации пользователя + Класс отображения и логики работы страницы регистрации пользователя 1. Ввод email пользователя, указанный на Zendesk 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk и относится к определенной организации, происходит сброс ссылки с установлением пароля на указанный email @@ -68,9 +68,11 @@ class CustomRegistrationView(RegistrationView): self.is_allowed = False @staticmethod - def set_permission(user) -> None: + def set_permission(user: User) -> None: """ - Дает разрешение на просмотр страница администратора, если пользователь имеет роль admin + Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin. + + :param user: авторизованный пользователь (получает разрешение, имея роль "admin" """ if user.userprofile.role == 'admin': content_type = ContentType.objects.get_for_model(UserProfile) @@ -82,8 +84,11 @@ class CustomRegistrationView(RegistrationView): def get_success_url(self, user: User = None) -> success_url: """ - Возвращает url-адрес страницы, куда нужно перейти после успешной/неуспешной регистрации - Используется самой django-registration + Функция возвращает url-адрес страницы, куда нужно перейти после успешной/неуспешной регистрации. + Используется самой django-registration. + + :param user: пользователь, пытающийся зарегистроваться + :return: адресация на страницу успешной регистрации """ if self.is_allowed: return reverse_lazy('password_reset_done') @@ -94,7 +99,10 @@ class CustomRegistrationView(RegistrationView): @login_required() def profile_page(request: WSGIRequest) -> HttpResponse: """ - Отображение страницы профиля + Функция отображения страницы профиля. + + :param request: данные пользователя из БД + :return: адресация на страницу пользователя """ user_profile: UserProfile = request.user.userprofile update_profile(user_profile) @@ -105,7 +113,13 @@ def profile_page(request: WSGIRequest) -> HttpResponse: return render(request, 'pages/profile.html', context) -def auth_user(request): +def auth_user(request: WSGIRequest) -> ZenpyUser: + """ + Функция возвращает профиль пользователя на Zendesk. + + :param request: email, subdomain и token пользователя + :return: объект пользователя Zendesk + """ admin_creds = { 'email': os.environ.get('ACCESS_CONTROLLER_API_EMAIL'), 'subdomain': 'ngenix1612197338', @@ -117,7 +131,14 @@ def auth_user(request): @login_required() -def work_page(request, id): +def work_page(request: WSGIRequest, id: int) -> HttpResponse: + """ + Функция отображения страницы "Управления правами" для текущего пользователя (login_required). + + :param request: объект пользователя + :param id: id пользователя, используется для динамической адресации + :return: адресация на страницу "Управления правами" (либо на страницу "Авторизации", если id и user.id не совпадают + """ users = get_users_list() if request.user.id == id: engineers = [] @@ -139,7 +160,14 @@ def work_page(request, id): @login_required() -def work_hand_over(request): +def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: + """ + Функция позволяет текущему пользователю (login_required) сдать права, а именно сменить в Zendesk роль с "engineer" на "light agent" + и установить роль "agent" в БД. Действия выполняются, если исходная роль пользователя "engineer". + + :param request: данные текущего пользователя (login_required) + :return: перезагрузка текущей страницы после выполнения смены роли + """ zenpy_user, admin = auth_user(request) if zenpy_user.custom_role_id == ZENDESK_ROLES['engineer']: @@ -151,7 +179,13 @@ def work_hand_over(request): @login_required() -def work_become_engineer(request): +def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: + """ + Функция меняет роль пользователя в Zendesk на "engineer" и присваевает роль "agent" в БД (в случае, если исходная роль пользователя была "light_agent"). + + :param request: данные текущего пользователя (login_required) + :return: перезагрузка текущей страницы после выполнения смены роли + """ zenpy_user, admin = auth_user(request) if zenpy_user.custom_role_id == ZENDESK_ROLES['light_agent']: zenpy_user.custom_role_id = ZENDESK_ROLES['engineer'] From c9ae302103742599d6cef69844298f33dd4c51f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sun, 14 Mar 2021 12:42:18 +0300 Subject: [PATCH 12/49] Add documentation to new forms, and first part of extra_func --- main/extra_func.py | 132 +++++++++++++++++++++++---------------------- main/forms.py | 36 +++++++++---- main/views.py | 52 ++++++++++++++---- 3 files changed, 138 insertions(+), 82 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index f51be0f..627a93c 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -43,8 +43,8 @@ class ZendeskAdmin: """ Функция осуществляет проверку существования пользователя в Zendesk по email. - :param email: - :return: + :param email: Email пользователя + :return: Является ли зарегистрированным """ return True if self.admin.search(email, type='user') else False @@ -52,8 +52,8 @@ class ZendeskAdmin: """ Функция возвращает имя пользователя по его email. - :param email: - :return: + :param email: Email пользователя + :return: Имя пользователя """ user = self.admin.users.search(email).values[0] return user.name @@ -62,8 +62,8 @@ class ZendeskAdmin: """ Функция возвращает роль пользователя по его email. - :param email: - :return: + :param email: Email пользователя + :return: Роль пользователя """ user = self.admin.users.search(email).values[0] return user.role @@ -72,8 +72,8 @@ class ZendeskAdmin: """ Функция возвращает id пользователя по его email - :param email: - :return: + :param email: Email пользователя + :return: ID пользователя """ user = self.admin.users.search(email).values[0] return user.id @@ -82,18 +82,18 @@ class ZendeskAdmin: """ Функция возвращает url-ссылку на аватар пользователя по его email. - :param email: - :return: + :param email: Email пользователя + :return: Аватар пользователя """ user = self.admin.users.search(email).values[0] return user.photo['content_url'] if user.photo else None def get_user(self, email: str): """ - Функция возвращает пользователя (объект) по его email + Функция возвращает пользователя (объект) по его email. - :param email: email пользователя - :return: email пользователя, найденного в БД + :param email: Email пользователя + :return: Объект пользователя, найденного в БД """ return self.admin.users.search(email).values[0] @@ -101,8 +101,8 @@ class ZendeskAdmin: """ Функция возвращает организацию, к которой относится пользователь по его email. - :param email: - :return: + :param email: Email пользователя + :return: Организация пользователя """ user = self.admin.users.search(email).values[0] return user.organization.name if user.organization else None @@ -138,9 +138,9 @@ def update_role(user_profile: UserProfile, role: str) -> UserProfile: """ Функция меняет роль пользователя. - :param user_profile: - :param role: - :return: + :param user_profile: Профиль пользователя + :param role: Новая роль + :return: Пользователь с обновленной ролью """ zendesk = ZendeskAdmin() user = zendesk.get_user(user_profile.user.email) @@ -152,8 +152,8 @@ def make_engineer(user_profile: UserProfile) -> UserProfile: """ Функция устанавливапет пользователю роль инженера. - :param user_profile: - :return: + :param user_profile: Профиль пользователя + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "endineer" """ update_role(user_profile, ROLES['engineer']) @@ -162,8 +162,8 @@ def make_light_agent(user_profile: UserProfile) -> UserProfile: """ Функция устанавливапет пользователю роль легкого агента. - :param user_profile: - :return: + :param user_profile: Профиль пользователя + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" """ update_role(user_profile, ROLES['light_agent']) @@ -171,7 +171,6 @@ def make_light_agent(user_profile: UserProfile) -> UserProfile: def get_users_list() -> list: """ Функция возвращает список пользователей Zendesk, относящихся к организации. - :return: """ zendesk = ZendeskAdmin() admin = zendesk.get_user(zendesk.email) @@ -183,8 +182,8 @@ def update_profile(user_profile: UserProfile) -> UserProfile: """ Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk. - :param user_profile: - :return: + :param user_profile: Профиль пользователя + :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя """ user = ZendeskAdmin().get_user(user_profile.user.email) user_profile.name = user.name @@ -198,8 +197,8 @@ def check_user_exist(email: str) -> bool: """ Функция проверяет, существует ли пользователь. - :param email: - :return: + :param email: Email пользователя + :return: Зарегистрирован ли пользователь в Zendesk """ return ZendeskAdmin().check_user(email) @@ -208,19 +207,19 @@ def get_user_organization(email: str) -> str: """ Функция возвращает организацию пользователя. - :param email: - :return: + :param email: Email пользователя + :return: Организацния пользователя """ return ZendeskAdmin().get_user_org(email) -def daterange(start_date, end_date) -> list: +def daterange(start_date: date, end_date: date) -> list: """ Функция возвращает список дней с start_date по end_date, исключая правую границу. - :param start_date: - :param end_date: - :return: + :param start_date: Начальная дата + :param end_date: Конечная дата + :return: Список дней, не включая конечную дату """ dates = [] for n in range(int((end_date - start_date).days)): @@ -228,14 +227,14 @@ def daterange(start_date, end_date) -> list: return dates -def get_timedelta(log, time=None) -> timedelta: +def get_timedelta(log: RoleChangeLogs, time=None) -> timedelta: """ Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. - :param log: - :param time: - :return: + :param log: Лог + :param time: Время + :return: Сколько времени прошло от начала суток до события """ if time is None: time = log.change_time.time() @@ -245,16 +244,26 @@ def get_timedelta(log, time=None) -> timedelta: def last_day_of_month(day): """ - Функция возвращает последний день любого месяца. + Функция возвращает последний день текущего месяца. - :param day: - :return: + .. todo:: + Дописать документацию по данной функции (не поняла типы и суть) + + :param day: Текущий день + :return: Последний день месяца """ next_month = day.replace(day=28) + timedelta(days=4) return next_month - timedelta(days=next_month.day) class StatisticData: + """ + Класс для учета статистики интервалов работы пользователей. + Передаваемые параметры: start_date, end_date, user_email. + + .. todo:: + Дописать описание данного класса + """ def __init__(self, start_date, end_date, user_email, stat=None): self.display = None self.interval = None @@ -271,12 +280,11 @@ class StatisticData: else: self.statistic = stat - def get_statistic(self): + def get_statistic(self) -> dict: """ - Функция возвращает словарь statistic с применением формата отображения и интеравала работы(если они есть). - None, если были ошибки при создании. + Функция возвращает статистику работы пользователя. - :return: + :return: Cловарь statistic с применением формата отображения и интеравала работы(если они есть). None, если были ошибки при создании. """ if self.is_valid_statistic(): stat = self.statistic @@ -286,20 +294,20 @@ class StatisticData: else: return None - def is_valid_statistic(self): + def is_valid_statistic(self) -> bool: """ Функция проверяет были ли ошибки при создании статистики. - :return: + :return: True, при отутствии ошибок """ return not self.errors and self.statistic - def set_interval(self, interval): + def set_interval(self, interval: list) -> bool: """ - Функция устанавливает интервал работы. + Функция проверяет корректность представления интервала работы. - :param interval: - :return: + :param interval: Интервал должен быть указан в днях или месяцах. + :return: True, если указан верно """ if interval not in ['months', 'days']: self.errors += ['Интервал работы должен быть в днях или месяцах'] @@ -307,12 +315,12 @@ class StatisticData: self.interval = interval return True - def set_display(self, display_format): + def set_display(self, display_format: list) -> bool: """ - Функция устанавливает формат отображения. + Функция проверяет корректность формата отображения интервала. - :param display_format: - :return: + :param display_format: Формат отображения должен быть указан в днях или месяцах. + :return: True, если указан верно """ if display_format not in ['days', 'hours']: self.errors += ['Формат отображения должен быть в часах или днях'] @@ -320,31 +328,29 @@ class StatisticData: self.display = display_format return True - def get_data(self): + def get_data(self) -> list: """ - Функция возвращает данные - массив объектов RoleChangeLogs. - - :return: + Функция возвращает данные - список объектов RoleChangeLogs. """ if self.is_valid_data(): return self.data else: return None - def is_valid_data(self): + def is_valid_data(self) -> bool: """ Функция определяет были ли ошибки при получении логов. - :return: + :return: True, если ошибок нет """ return not self.errors - def _use_display(self, stat): + def _use_display(self, stat: list) -> list: """ Функция приводит данные к формату отображения. - :param stat: - :return: + :param stat: Список данных статистики пользователя + :return: Обновленный список """ if not self.is_valid_statistic() or not self.display: return stat diff --git a/main/forms.py b/main/forms.py index dab07bb..5f4531f 100644 --- a/main/forms.py +++ b/main/forms.py @@ -8,12 +8,11 @@ from main.models import UserProfile class CustomRegistrationForm(RegistrationFormUniqueEmail): """ - Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` + Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` + с добавлением bootstrap-класса "form-control". - с добавлением bootstrap-класса "form-control" - - :param visible_fields.email: Поле для ввода email, зарегистирированного на Zendesk - :type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail` + :param visible_fields.email: Поле для ввода email, зарегистирированного на Zendesk + :type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail` """ def __init__(self, *args, **kwargs) -> RegistrationFormUniqueEmail: super().__init__(*args, **kwargs) @@ -32,10 +31,10 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): class AdminPageUsers(forms.Form): """ - Форма для установки статусов engineer или light_agent пользователям. + Форма для установки статусов engineer или light_agent пользователям. - :param users: Поле для установки статуса - :type users: :class:`ModelMultipleChoiceField` + :param users: Поле для установки статуса + :type users: :class:`ModelMultipleChoiceField` """ users = forms.ModelMultipleChoiceField( @@ -52,8 +51,11 @@ class AdminPageUsers(forms.Form): class CustomAuthenticationForm(AuthenticationForm): """ - Форма для авторизации :class:`django.contrib.auth.forms.AuthenticationForm` - с изменением поля username на email + Форма для авторизации :class:`django.contrib.auth.forms.AuthenticationForm` + с изменением поля username на email. + + :param username: Поле для ввода email пользователя + :type username: :class:`django.forms.fields.CharField` """ username = forms.CharField( label="Электронная почта", @@ -69,6 +71,20 @@ class CustomAuthenticationForm(AuthenticationForm): class StatisticForm(forms.Form): + """ + Форма отображения интервалов работы пользователя. + + :param email: Поле для ввода email пользователя + :type email: :class:`django.forms.fields.EmailField` + :param interval: Расчет интервала рабочего времени + :type interval: :class:`django.forms.fields.CharField` + :param display_format: Формат отображения данных + :type display_format: :class:`django.forms.fields.CharField` + :param range_start: Дата и время начала работы + :type range_start: :class:`django.forms.fields.DateField` + :param range_end: Дата и время окончания работы + :type range_end: :class:`django.forms.fields.DateField` + """ email = forms.EmailField( label='Электроная почта', ) diff --git a/main/views.py b/main/views.py index d1155e0..e20be04 100644 --- a/main/views.py +++ b/main/views.py @@ -29,9 +29,14 @@ class CustomRegistrationView(RegistrationView): """ Класс отображения и логики работы страницы регистрации пользователя - 1. Ввод email пользователя, указанный на Zendesk - 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk и относится к определенной организации, происходит сброс ссылки с установлением пароля на указанный email - 3. Создается пользователь class User, а также его профиль + :param form_class: Форма, которую необходимо заполнить для регистрации + :type form_class: :class:`forms.CustomRegistrationForm` + :param template_name: Указание пути к html-странице django регистрации + :type template_name: :class:`str` + :param success_url: Указание пути к html-странице завершения регистрации + :type success_url: :class:`django.utils.functional.lazy..__proxy__` + :param is_allowed: Определение зарегистрирован ли пользователь с введенным email на Zendesk и принадлежит ли он к организации SYSTEM + :type is_allowed: :class:`bool` """ form_class = CustomRegistrationForm template_name = 'django_registration/registration_form.html' @@ -39,6 +44,16 @@ class CustomRegistrationView(RegistrationView): is_allowed = True def register(self, form: CustomRegistrationForm) -> User: + """ + Функция регистрации пользователя. + 1. Ввод email пользователя, указанный на Zendesk + 2. В случае если пользователь с данным паролем зарегистрирован на Zendesk и относится к организации SYSTEM, + происходит сброс ссылки с установлением пароля на указанный email + 3. Создается пользователь class User, а также его профиль. + + :param form: Email пользователя на Zendesk + :return: user + """ self.is_allowed = True if check_user_exist(form.data['email']) and get_user_organization(form.data['email']) == 'SYSTEM': forms = PasswordResetForm(self.request.POST) @@ -72,7 +87,7 @@ class CustomRegistrationView(RegistrationView): """ Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin. - :param user: авторизованный пользователь (получает разрешение, имея роль "admin" + :param user: авторизованный пользователь (получает разрешение, имея роль "admin") """ if user.userprofile.role == 'admin': content_type = ContentType.objects.get_for_model(UserProfile) @@ -195,9 +210,15 @@ def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: return HttpResponseRedirect(reverse('work', args=(request.user.id,))) -def main_page(request): +def main_page(request: WSGIRequest) -> HttpResponse: """ - Отображение логгирования на главной странице + Функция отображения логгирования на главной странице. + + .. todo:: + Дописать параметры в документацию: + + :param request: + :return: """ logger = logging.getLogger('main.index') logger.info('Index page opened') @@ -205,6 +226,13 @@ def main_page(request): class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): + """ + Класс отображения страницы администратора. + + .. todo:: + Документация для данного класса: + + """ permission_required = 'main.has_control_access' template_name = 'pages/adm_ruleset.html' form_class = AdminPageUsers @@ -231,7 +259,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): @staticmethod def count_users(users) -> tuple: """ - Функция подсчета количества сотрудников с ролями engineer и light_a + Функция подсчета количества сотрудников с ролями "engineer" и "light_agent". .. todo:: this func counts users from all zendesk instead of just from a model: @@ -259,13 +287,19 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): class CustomLoginView(LoginView): """ - Отображение страницы авторизации пользователя + Классс отображения страницы авторизации пользователя. """ form_class = CustomAuthenticationForm @login_required() -def statistic_page(request): +def statistic_page(request: WSGIRequest) -> HttpResponse: + """ + Функция отображения страницы статистики (для "superuser"). + + :param request: данные о пользователе: email, время и интервал работы. Данные получаем через forms.StatisticForm + :return: адресация на страницу статистики + """ if not request.user.is_superuser: return redirect('index') context = { From 79b7418b6109ee752eb910fb8c5996cf3ad9b181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sun, 14 Mar 2021 15:41:58 +0300 Subject: [PATCH 13/49] Add enchant without correct working --- docs/source/conf.py | 29 ++++++++++++++++------------- main/views.py | 2 +- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index c9efe47..87fb134 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,6 +14,8 @@ import os import sys import importlib import inspect +import enchant + sys.path.insert(0, os.path.abspath('../../')) @@ -35,10 +37,10 @@ ManagerDescriptor.__get__ = lambda self, *args, **kwargs: self.manager from django.db.models.query import QuerySet QuerySet.__repr__ = lambda self: self.__class__.__name__ -try: - import enchant # NoQA -except ImportError: - enchant = None +# try: +# import enchant # NoQA +# except ImportError: +# enchant = None django.setup() @@ -52,8 +54,6 @@ author = 'SHP S101, group 2' release = 'v0.01' -# Django sphinx setup by https://gist.github.com/codingjoe/314bda5a07ff3b41f247 - # -- General configuration --------------------------------------------------- def process_django_models(app, what, name, obj, options, lines): @@ -91,7 +91,6 @@ def process_django_models(app, what, name, obj, options, lines): lines.append(':type %s: %s.%s' % (field.attname, module, field_type.__name__)) if enchant is not None: lines += spelling_white_list - print('ok') return lines @@ -118,6 +117,9 @@ def skip_queryset(app, what, name, obj, skip, options): return True return skip +def process_signature(app, what, name, obj, options, signature, return_annotation): + return None + def setup(app): # Register the docstring processor with sphinx @@ -125,12 +127,14 @@ def setup(app): app.connect('autodoc-skip-member', skip_queryset) if enchant is not None: app.connect('autodoc-process-docstring', process_modules) + app.connect('autodoc-process-signature', process_signature) + # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [ +extensions = { 'sphinx.ext.todo', 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', @@ -138,12 +142,11 @@ extensions = [ 'sphinx_rtd_theme', 'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram', - 'sphinx_autodoc_typehints' + 'sphinx_autodoc_typehints', + 'sphinxcontrib.spelling' -] +} -if enchant is not None: - extensions.append('sphinxcontrib.spelling') # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -188,7 +191,7 @@ intersphinx_mapping = { autodoc_default_flags = ['members'] # spell checking -spelling_lang = 'en_US' +spelling_lang = 'ru_RU' spelling_word_list_filename = 'spelling_wordlist.txt' spelling_show_suggestions = True spelling_ignore_pypi_package_names = True diff --git a/main/views.py b/main/views.py index e20be04..570866b 100644 --- a/main/views.py +++ b/main/views.py @@ -27,7 +27,7 @@ from .models import UserProfile class CustomRegistrationView(RegistrationView): """ - Класс отображения и логики работы страницы регистрации пользователя + Классccc отображения и логики работы страницы регистрации пользователя :param form_class: Форма, которую необходимо заполнить для регистрации :type form_class: :class:`forms.CustomRegistrationForm` From 4f240e19f64de1349d32d4086a4ae3c224a014ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=B0=D0=BD=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0?= Date: Sun, 14 Mar 2021 16:30:14 +0300 Subject: [PATCH 14/49] Add spelling settings and install tox but spelling is not still working --- docs/Makefile | 1 + docs/source/conf.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/Makefile b/docs/Makefile index 461302a..e2faff5 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -21,3 +21,4 @@ help: spelling: $(SPHINXBUILD) -b spelling -W $(ALLSPHINXOPTS) $(BUILDDIR)/spelling +# $(SPHINXBUILD) -b spelling 'docs/source' 'docs/build' diff --git a/docs/source/conf.py b/docs/source/conf.py index 87fb134..25cd641 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -192,9 +192,18 @@ autodoc_default_flags = ['members'] # spell checking spelling_lang = 'ru_RU' -spelling_word_list_filename = 'spelling_wordlist.txt' +tokenizer_lang = 'ru_RU' +spelling_word_list_filename = ['spelling_wordlist.txt', 'another_list.txt'] +spelling_exclude_patterns=['ignored_*'] spelling_show_suggestions = True +spelling_show_whole_line=True +spelling_warning=True spelling_ignore_pypi_package_names = True +spelling_ignore_wiki_words=True +spelling_ignore_acronyms=True +spelling_ignore_python_builtins=True +spelling_ignore_importable_modules=True +spelling_ignore_contributor_names=True # -- Options for todo extension ---------------------------------------------- From a430ee871d20439a1a885e526285d65fdc16a706 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Sun, 14 Mar 2021 17:13:41 +0300 Subject: [PATCH 15/49] Add ticket unassignment(exclude solved tickets) --- access_controller/settings.py | 6 ++ main/extra_func.py | 73 ++++++++++--------- ...312_1225.py => 0014_auto_20210314_1455.py} | 10 ++- main/views.py | 20 ++--- 4 files changed, 59 insertions(+), 50 deletions(-) rename main/migrations/{0012_auto_20210312_1225.py => 0014_auto_20210314_1455.py} (61%) diff --git a/access_controller/settings.py b/access_controller/settings.py index deadf32..953d2f6 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -183,4 +183,10 @@ ZENDESK_ROLES = { 'light_agent': 360005208980, } +ZENDESK_GROUPS = { + 'employees': 'Поддержка', + 'buffer': 'Сменная группа', +} + ONE_DAY = 12 # Количество часов в 1 рабочем дне + diff --git a/main/extra_func.py b/main/extra_func.py index 3583867..c7946fc 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, ONE_DAY +from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY, ZENDESK_GROUPS from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus @@ -29,12 +29,6 @@ class ZendeskAdmin: email: str = os.getenv('ACCESS_CONTROLLER_API_EMAIL') token: str = os.getenv('ACCESS_CONTROLLER_API_TOKEN') password: str = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') - _instance = None - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance def __init__(self): self.create_admin() @@ -82,6 +76,12 @@ class ZendeskAdmin: """ return self.admin.users.search(email).values[0] + def get_group(self, name): + groups = self.admin.search(name) + for group in groups: + return group + return None + def get_user_org(self, email: str) -> str: """ Функция **get_user_org** возвращает организацию, к которой относится пользователь по его email @@ -126,43 +126,42 @@ def update_role(user_profile: UserProfile, role: str) -> UserProfile: zendesk.admin.users.update(user) -def make_engineer(user_profile: UserProfile) -> UserProfile: +def make_engineer(user_profile: UserProfile, who_changes: User) -> UserProfile: """ Функция **make_engineer** устанавливает пользователю роль инженера. """ - + RoleChangeLogs.objects.create( + user=user_profile.user, + old_role=user_profile.custom_role_id, + new_role=ROLES['engineer'], + changed_by=who_changes + ) update_role(user_profile, ROLES['engineer']) def make_light_agent(user_profile: UserProfile, who_changes: User) -> UserProfile: """ Функция **make_light_agent** устанавливапет пользователю роль легкого агента. - - .. todo:: - Решить проблему с ошибкой при выполнении этой функции из-за неотвязанных тикетов. А именно: - - - найти все тикеты, ответственным которых является снимаемый аккаунт - - для всех этих тикетов - перенести ответственность на буферную группу. - - [PARTIALY DONE] создать записи о снятых тикетах и их прошлом авторстве. Если тикет уже был закрыт - выставить в логе CLOSED. Иначе UNASSIGNED - - [DONE] после этого снять права c инженера - - [DONE] создать запись в логе о снятии прав инженера """ + tickets = get_ticket_list(user_profile.user.email) + for ticket in tickets: + if ticket.status=='solved': + continue + UnassignedTicket.objects.create( + assignee=user_profile.user, + ticket_id=ticket.id, + status=UnassignedTicketStatus.UNASSIGNED + ) + ticket.group = ZendeskAdmin().get_group(ZENDESK_GROUPS['buffer']) + ZendeskAdmin().admin.tickets.update(ticket) - # 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( + RoleChangeLogs.objects.create( user=user_profile.user, - old_role=ROLES['engineer'], + old_role=user_profile.custom_role_id, new_role=ROLES['light_agent'], changed_by=who_changes ) + update_role(user_profile, ROLES['light_agent']) def get_users_list() -> list: @@ -175,9 +174,13 @@ def get_users_list() -> list: return zendesk.admin.organizations.users(org) +def get_ticket_list(email): + return ZendeskAdmin().admin.search(assignee=email, type='ticket') + + def update_profile(user_profile: UserProfile) -> UserProfile: """ - Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk + Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk """ user = ZendeskAdmin().get_user(user_profile.user.email) user_profile.name = user.name @@ -189,7 +192,7 @@ def update_profile(user_profile: UserProfile) -> UserProfile: def check_user_exist(email: str) -> bool: """ - Функция проверяет, существует ли пользователь + Функция проверяет, существует ли пользователь """ return ZendeskAdmin().check_user(email) @@ -241,9 +244,9 @@ class StatisticData: self.warnings = list() self.data = dict() self.statistic = dict() - self._set_data() + self._init_data() if stat is None: - self._set_statistic() + self._init_statistic() else: self.statistic = stat @@ -346,7 +349,7 @@ class StatisticData: return False return True - def _set_data(self): + def _init_data(self): """ Получение логов в диапазоне дат start_date-end_date для пользователя с почтой email """ @@ -361,7 +364,7 @@ class StatisticData: except User.DoesNotExist: self.errors += ['Пользователь не найден'] - def _set_statistic(self): + def _init_statistic(self): """ Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд """ diff --git a/main/migrations/0012_auto_20210312_1225.py b/main/migrations/0014_auto_20210314_1455.py similarity index 61% rename from main/migrations/0012_auto_20210312_1225.py rename to main/migrations/0014_auto_20210314_1455.py index 6d33580..77db2ec 100644 --- a/main/migrations/0012_auto_20210312_1225.py +++ b/main/migrations/0014_auto_20210314_1455.py @@ -1,12 +1,13 @@ -# Generated by Django 3.1.6 on 2021-03-12 09:25 +# Generated by Django 3.1.6 on 2021-03-14 11:55 from django.db import migrations, models +import django.utils.timezone class Migration(migrations.Migration): dependencies = [ - ('main', '0011_auto_20210311_1734'), + ('main', '0013_auto_20210311_2040'), ] operations = [ @@ -15,6 +16,11 @@ class Migration(migrations.Migration): name='custom_role_id', field=models.IntegerField(default=0, help_text='Код роли пользователя'), ), + migrations.AlterField( + model_name='rolechangelogs', + name='change_time', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Дата и время изменения роли'), + ), migrations.AlterField( model_name='userprofile', name='role', diff --git a/main/views.py b/main/views.py index 4c596ad..221e693 100644 --- a/main/views.py +++ b/main/views.py @@ -1,6 +1,5 @@ import logging import os -from datetime import datetime from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordResetForm @@ -19,11 +18,12 @@ from django_registration.views import RegistrationView from zenpy import Zenpy from zenpy.lib.api_objects import User as ZenpyUser -from access_controller.settings import EMAIL_HOST_USER, ZENDESK_ROLES +from access_controller.settings import EMAIL_HOST_USER, ZENDESK_ROLES, ZENDESK_GROUPS from main.extra_func import check_user_exist, update_profile, get_user_organization, make_engineer, make_light_agent, \ - get_users_list, StatisticData + get_users_list, StatisticData, get_ticket_list, ZendeskAdmin from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm -from .models import UserProfile +from .models import UserProfile, UnassignedTicket, UnassignedTicketStatus + class CustomRegistrationView(RegistrationView): """ @@ -106,12 +106,7 @@ def profile_page(request: WSGIRequest) -> HttpResponse: def auth_user(request): - admin_creds = { - 'email': os.environ.get('ACCESS_CONTROLLER_API_EMAIL'), - 'subdomain': 'ngenix1612197338', - 'token': os.environ.get('ACCESS_CONTROLLER_API_TOKEN'), - } - admin = Zenpy(**admin_creds) + admin = ZendeskAdmin().admin zenpy_user: ZenpyUser = admin.users.search(request.user.email).values[0] return zenpy_user, admin @@ -163,7 +158,7 @@ def work_become_engineer(request): def main_page(request): """ - Отображение логгирования на главной странице + Отображение логгирования на главной странице """ logger = logging.getLogger('main.index') logger.info('Index page opened') @@ -178,7 +173,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): def form_valid(self, form: AdminPageUsers) -> AdminPageUsers: """ - Функция установки ролей пользователям + Функция установки ролей пользователям """ if 'engineer' in self.request.POST: self.make_engineers(form.cleaned_data['users']) @@ -194,7 +189,6 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): for user in users: make_light_agent(user, self.request.user) - @staticmethod def count_users(users) -> tuple: """ From 0cc788c039a5263309bbf54798748ef280ffc2fa Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Sun, 14 Mar 2021 17:26:41 +0300 Subject: [PATCH 16/49] minor fix --- main/extra_func.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main/extra_func.py b/main/extra_func.py index c7946fc..0e0c0ed 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -152,6 +152,7 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> UserProfil ticket_id=ticket.id, status=UnassignedTicketStatus.UNASSIGNED ) + ticket.assignee = None ticket.group = ZendeskAdmin().get_group(ZENDESK_GROUPS['buffer']) ZendeskAdmin().admin.tickets.update(ticket) From 95c52f9302dc695f86ec50760a6596e9e0cbb0a5 Mon Sep 17 00:00:00 2001 From: Kiselev Igor Date: Mon, 15 Mar 2021 05:18:08 +0300 Subject: [PATCH 17/49] Added and modified statistic.html --- main/templates/statistic.html | 138 +++++++++++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 10 deletions(-) diff --git a/main/templates/statistic.html b/main/templates/statistic.html index 1069fae..8c70467 100644 --- a/main/templates/statistic.html +++ b/main/templates/statistic.html @@ -9,6 +9,42 @@ + + + + + + + + + + + + - - -
- - -
-
-
-
-
-
-

Введите email пользователя

-
-
- -
-
-
-
-

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

-
-
- - - - - -
-
-
-
-

Начало статистики

-
- -
-
-
-
- -
- -
-
-
-
-
-
-

Начало статистики

-
- -
-
-
- -
- -
-
-
-
-
- -
-
- -
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Пользователи/Даты05/01/2021-01/02/202101/02/2021-28/02/202101/03/2021-05/03/2021
keanu@gmail.com15211
vadim@mail.ru2153
example@ngenix.ru0194
-
-
-
- - - - - diff --git a/main/views.py b/main/views.py index cae5d90..1e35105 100644 --- a/main/views.py +++ b/main/views.py @@ -244,8 +244,6 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): @login_required() def statistic_page(request): - if not request.user.is_superuser: - return redirect('index') context = { 'pagename': 'страница статистики', 'errors': list(), @@ -267,4 +265,6 @@ def statistic_page(request): if request.method == 'GET': form = StatisticForm() context['form'] = form - return render(request, 'pages/stat.html', context) + a = form['interval'] + print(form['interval'].auto_id,form['interval'].id_for_label) + return render(request, 'pages/statistic.html', context) From f47840f292264a1d096bf9c6956193aac6387e94 Mon Sep 17 00:00:00 2001 From: Sokurov Idar Date: Mon, 29 Mar 2021 22:37:18 +0300 Subject: [PATCH 39/49] small refactoring --- main/extra_func.py | 68 +++++++++++++++++++++++++++++----------------- main/forms.py | 2 +- main/views.py | 2 +- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/main/extra_func.py b/main/extra_func.py index b87ac27..a2e997e 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -379,6 +379,7 @@ class StatisticData: :param statistic: Интервалы работы пользователя :type statistic: :class:`dict` """ + def __init__(self, start_date, end_date, user_email, stat=None): self.display = None self.interval = None @@ -541,31 +542,51 @@ class StatisticData: first_log, last_log = self.data[0], self.data[len(self.data) - 1] if first_log.old_role == ROLES['engineer']: - self.statistic[first_log.change_time.date()] += get_timedelta(first_log).total_seconds() + self.prev_engineer_logic(first_log) - if last_log.new_role == ROLES['engineer']: # TODO отдельная функция - self.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date + timedelta(days=1)) - if last_log.change_time.date() == timezone.now().date(): - self.statistic[last_log.change_time.date()] += ( - get_timedelta(None, timezone.now().time()) - get_timedelta(last_log) - ).total_seconds() - else: - self.statistic[last_log.change_time.date()] += ( - timedelta(days=1) - get_timedelta(last_log)).total_seconds() - if self.end_date == timezone.now().date(): - self.statistic[self.end_date] = get_timedelta(None, timezone.now().time()).total_seconds() + if last_log.new_role == ROLES['engineer']: + self.post_engineer_logic(last_log) - for log_index in range(len(self.data) - 1): # TODO отдельная функция + for log_index in range(len(self.data) - 1): if self.data[log_index].new_role == ROLES['engineer']: - current_log, next_log = self.data[log_index], self.data[log_index + 1] - if current_log.change_time.date() != next_log.change_time.date(): - self.statistic[current_log.change_time.date()] += ( - timedelta(days=1) - get_timedelta(current_log)).total_seconds() - self.statistic[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() - self.fill_daterange(current_log.change_time.date() + timedelta(days=1), next_log.change_time.date()) - else: - elapsed_time = next_log.change_time - current_log.change_time - self.statistic[current_log.change_time.date()] += elapsed_time.total_seconds() + self.engineer_logic(log_index) + + def engineer_logic(self, log_index): + """ + Функция обрабатывает основную часть работы инженера + """ + current_log, next_log = self.data[log_index], self.data[log_index + 1] + if current_log.change_time.date() != next_log.change_time.date(): + self.statistic[current_log.change_time.date()] += ( + timedelta(days=1) - get_timedelta(current_log)).total_seconds() + self.statistic[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() + self.fill_daterange(current_log.change_time.date() + timedelta(days=1), next_log.change_time.date()) + else: + elapsed_time = next_log.change_time - current_log.change_time + self.statistic[current_log.change_time.date()] += elapsed_time.total_seconds() + + def post_engineer_logic(self, last_log): + """ + Функция обрабатывает случай, когда нам изветсно что инженер работал и после диапазона + """ + self.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date + timedelta(days=1)) + if last_log.change_time.date() == timezone.now().date(): + self.statistic[last_log.change_time.date()] += ( + get_timedelta(None, timezone.now().time()) - get_timedelta(last_log) + ).total_seconds() + else: + self.statistic[last_log.change_time.date()] += ( + timedelta(days=1) - get_timedelta(last_log)).total_seconds() + if self.end_date == timezone.now().date(): + self.statistic[self.end_date] = get_timedelta(None, timezone.now().time()).total_seconds() + + def prev_engineer_logic(self, first_log): + """ + Функция обрабатывает случай, когда нам изветсно что инженер начал работу до диапазона + """ + self.fill_daterange(max(User.objects.get(email=self.email).date_joined.date(), self.start_date), + first_log.change_time.date()) + self.statistic[first_log.change_time.date()] += get_timedelta(first_log).total_seconds() def fill_daterange(self, first: date, last: date, val: int = 24 * 3600) -> dict: """ @@ -574,7 +595,6 @@ class StatisticData: :param first: Начальная дата интервала :param last: Последняя дата интервала :param val: Количество секунд в одном дне - :return: Статистику пользователя с указанным количеством секунд в заданных днях """ for day in daterange(first, last): self.statistic[day] = val @@ -582,8 +602,6 @@ class StatisticData: def clear_statistic(self) -> dict: """ Функция осуществляет обновление всех дней. - - :return: Статистику пользователя с количеством рабочих секунд = 0 """ self.statistic.clear() self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) diff --git a/main/forms.py b/main/forms.py index c25e53f..613fc34 100644 --- a/main/forms.py +++ b/main/forms.py @@ -76,7 +76,7 @@ INTERVAL_CHOICES = [ ] DISPLAY_CHOICES = [ ('hours', 'Часы'), - ('days', 'Дни') + ('days', 'Дни/Смены') ] diff --git a/main/views.py b/main/views.py index dcf3e42..05bb157 100644 --- a/main/views.py +++ b/main/views.py @@ -361,4 +361,4 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: if request.method == 'GET': form = StatisticForm() context['form'] = form - return render(request, 'pages/stat.html', context) + return render(request, 'pages/statistic.html', context) From 3aa84cd04cafb1bcdfb3f0019f52dc7441c65f30 Mon Sep 17 00:00:00 2001 From: Timofey Mazurov Date: Tue, 30 Mar 2021 00:29:47 +0300 Subject: [PATCH 40/49] Fixed role display on profile and admin pages --- main/migrations/0015_auto_20210330_0007.py | 18 ++++++++++++++++++ main/templates/pages/adm_ruleset.html | 7 ++++++- main/templates/pages/profile.html | 8 +++++++- main/views.py | 4 +++- 4 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 main/migrations/0015_auto_20210330_0007.py diff --git a/main/migrations/0015_auto_20210330_0007.py b/main/migrations/0015_auto_20210330_0007.py new file mode 100644 index 0000000..91398ba --- /dev/null +++ b/main/migrations/0015_auto_20210330_0007.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.6 on 2021-03-29 21:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0014_auto_20210314_1455'), + ] + + operations = [ + migrations.AlterField( + model_name='unassignedticket', + name='status', + field=models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются'), (3, 'Тикет уже был закрыт. Дополнительные действия не требуются'), (4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL')], default=0), + ), + ] diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index 92686f1..98700e7 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -56,7 +56,12 @@ {{ user.name }} {{ user.user.email }} - {{ user.role }} + {% if user.custom_role_id == ZENDESK_ROLES.engineer %} + engineer + {% elif user.custom_role_id == ZENDESK_ROLES.light_agent %} + light_agent + {% endif %} + {% endfor %} diff --git a/main/templates/pages/profile.html b/main/templates/pages/profile.html index a0f21f9..1dd6005 100644 --- a/main/templates/pages/profile.html +++ b/main/templates/pages/profile.html @@ -37,7 +37,13 @@
Электронная почта {{ profile.user.email }}

-
Текущая роль {{ profile.role }}
+
Текущая роль + {% if profile.custom_role_id == ZENDESK_ROLES.engineer %} + engineer + {% elif profile.custom_role_id == ZENDESK_ROLES.light_agent %} + light_agent + {% endif %} +
diff --git a/main/views.py b/main/views.py index a8b3af5..1a2ad73 100644 --- a/main/views.py +++ b/main/views.py @@ -104,7 +104,8 @@ def profile_page(request: WSGIRequest) -> HttpResponse: update_profile(user_profile) context = { 'profile': user_profile, - 'pagename': 'Страница профиля' + 'pagename': 'Страница профиля', + 'ZENDESK_ROLES': ZENDESK_ROLES, } return render(request, 'pages/profile.html', context) @@ -203,6 +204,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): users = get_list_or_404( UserProfile, role='agent') context['users'] = users + context['ZENDESK_ROLES'] = ZENDESK_ROLES context['engineers'], context['light_agents'] = count_users(get_users_list()) context['licences_remaining'] = max(0, ZENDESK_MAX_AGENTS - context['engineers']) return context # TODO: need to get profile page url From 99979858f07877db6ef9aede17c2744b30e4a75e Mon Sep 17 00:00:00 2001 From: Timofey Mazurov Date: Tue, 30 Mar 2021 00:30:38 +0300 Subject: [PATCH 41/49] Deleted main page logging example --- main/views.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/main/views.py b/main/views.py index 1a2ad73..1bbe593 100644 --- a/main/views.py +++ b/main/views.py @@ -163,11 +163,6 @@ def work_become_engineer(request): def main_page(request): - """ - Отображение логгирования на главной странице - """ - logger = logging.getLogger('main.index') - logger.info('Index page opened') return render(request, 'pages/index.html') From 17328650c8aeb68ce8268208a88a33055fceb255 Mon Sep 17 00:00:00 2001 From: Timofey Mazurov Date: Tue, 30 Mar 2021 00:45:32 +0300 Subject: [PATCH 42/49] Fixed control page role changing --- main/extra_func.py | 2 ++ main/migrations/0016_merge_20210330_0043.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 main/migrations/0016_merge_20210330_0043.py diff --git a/main/extra_func.py b/main/extra_func.py index b87ac27..077615f 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -150,6 +150,8 @@ def update_role(user_profile: UserProfile, role: str) -> UserProfile: zendesk = ZendeskAdmin() user = zendesk.get_user(user_profile.user.email) user.custom_role_id = role + user_profile.custom_role_id = role + user_profile.save() zendesk.admin.users.update(user) diff --git a/main/migrations/0016_merge_20210330_0043.py b/main/migrations/0016_merge_20210330_0043.py new file mode 100644 index 0000000..efb1d45 --- /dev/null +++ b/main/migrations/0016_merge_20210330_0043.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.6 on 2021-03-29 21:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0015_auto_20210330_0007'), + ('main', '0015_auto_20210321_1600'), + ] + + operations = [ + ] From 59882094bd92eed8eb02cf5988052fca47c64729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=80=D0=B0=D0=B2=D1=87=D0=B5=D0=BD=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=90=D1=80=D1=82=D0=B5=D0=BC?= Date: Thu, 1 Apr 2021 17:01:38 +0000 Subject: [PATCH 43/49] Update README.md --- README.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 47468bb..14e3869 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,79 @@ pip install -r requirements.txt ./manage.py loaddata data.json ./manage.py runserver ``` -Создать токен -Указать почту и токен в окружении +##ZenDesk Access Controller instruction for eng + +##Перед запуском для тестирования: + +Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация (SYSTEM) +Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk +При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение + + +##Запуск на локальной машине: + +скопировать репозиторий на локальную машину +перейти в папку приложения +активировать вирутальное окружение +выполнить команду pip install -r requirements.txt +в вирутальное окружение добавить следующие переменные : + + +ACCESS_CONTROLLER_API_EMAIL={EMAIL} - почта админа в ZenDesk +ACCESS_CONTROLLER_API_PASSWORD={PASSWORD} - пароль админа ZenDesk +ACCESS_CONTROLLER_API_TOKEN={API_TOKEN} - API токен зендеск +ZD_DOMAIN={DOMAIN} - домен ZenDesk +ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID} - id роли инженера( custom_role_id сотрдника смены) +LA_CROLE_ID={LIGHT_AGENT_CUSTOM_ROLE_ID} - id роли легкого агента (custom_role_id роли -легкий агент) +EMPL_GROUP={EMPLOYEE_GROUP_NAME} - имя группы которой принадлежат сотрудники ССКС +BUF_GROUP={BUFFER_GROUP_NAME} - имя буферной группы для передачи смен(через нее происходит управление тикетами) +ST_EMAIL={SOLVED_TICKETS_EMAIL} - почта на которую будут переназначятся закрытые тикеты +LICENSE_NO={LICENSE_NO} - количество лицензий, отображаемых как доступные в приложении +SHIFTH={SHIFT_HOURS} - количество часов в рабочей смене (нужно для статистики, пока не реализовано но требует указания значения) + + +выполнить команду python manage.py makemigrations +выполнить команду python manage.py migrate +запустить приложение командой python manage.py runserver (можно указать в параметрах для файла manage.py) +перейти по ссылке в консоли (вероятнее всего откроется по адресу http://127.0.0.1:8000/) + + +##Запуск в Docker: +Требуется установленный и настроеный Docker + +скопировать репозиторий на локальную машину +в командной строке перейти в папку проекта +выполнить команду docker build . +выполнить команду docker images (нам нужен id созданного образа) +выполнить команду docker run -d -p 8000:8000 -e ACCESS_CONTROLLER_API_EMAIL={EMAIL} -e ACCESS_CONTROLLER_API_PASSWORD={PASSWORD} +...(перечисляем все параметры виртуального окружени разделяя их -e) -v {абсолютный путь к папке, в которой будет размещена база}:/zendesk-access-controller/db {id образа докера} +открываем запущеный контейнер в браузере (можно перейти по ссылке http://localhost:8000/) + + +##Запуск с тестовыми юзерами: + +На локальной машине - перед запуском команды python manage.py runserver выполнить команду python manage.py loaddata data.json +Это создаст тестового админа и тестового пользователя в приложении для песочницы ZenDesk. Админ - admin@gmail.com / zendeskadmin , пользователь - 123@test.ru / zendeskuser . +Не сработает если домен песочницы отличается от ngenix1612197338 (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента +с этими же почтами, назначить им организацию (SYSTEM)) + + +##Параметры тестовой песочницы: + +ACCESS_CONTROLLER_API_EMAIL={EMAIL} - почта админа в ZenDesk - взять у роководителя(если вы не админ) +ACCESS_CONTROLLER_API_PASSWORD={PASSWORD} - пароль админа ZenDesk - взять у роководителя(если вы не админ) +ACCESS_CONTROLLER_API_TOKEN={API_TOKEN} - API токен зендеск - взять у роководителя(если вы не админ) +ZD_DOMAIN=ngenix1612197338 +ENG_CROLE_ID=360005209000 +LA_CROLE_ID=360005208980 +EMPL_GROUP=Поддержка +BUF_GROUP=Сменная группа +ST_EMAIL=d.krikov@ngenix.net + +LICENSE_NO=3 +SHIFTH=12 + ## Read more - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) From 603ba30d598fd754b5e4e422537d8bddd5118039 Mon Sep 17 00:00:00 2001 From: Iurii Tatishchev Date: Thu, 1 Apr 2021 10:03:21 -0700 Subject: [PATCH 44/49] #44 | fix work.css form-check-input --- static/main/css/work.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/static/main/css/work.css b/static/main/css/work.css index 790ac6d..d20922d 100644 --- a/static/main/css/work.css +++ b/static/main/css/work.css @@ -13,8 +13,7 @@ background: #45729C; } */ .form-check-input { - border-radius: 0px; - background-image: url("../img/check.png"); + border-radius: 0; width: 30px; height: 30px; background-size: 20px auto; @@ -125,4 +124,4 @@ padding: 10px; background: #3B91D4; color: white; -} \ No newline at end of file +} From 3aaae33c4453c83fcf3cb61e2f050e8b5a523cf3 Mon Sep 17 00:00:00 2001 From: Andrew Smirnov Date: Thu, 1 Apr 2021 20:46:07 +0300 Subject: [PATCH 45/49] Fix user role display. Migrate to displayable roles, defined in settings.ZENDESK_ROLES --- main/models.py | 10 ++++++++++ main/serializers.py | 6 ++---- static/main/js/control.js | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/main/models.py b/main/models.py index 782b79f..ac6f91c 100644 --- a/main/models.py +++ b/main/models.py @@ -4,6 +4,8 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from access_controller.settings import ZENDESK_ROLES + class UserProfile(models.Model): """ @@ -23,6 +25,14 @@ class UserProfile(models.Model): image = models.URLField(null=True, blank=True, help_text='Аватарка') name = models.CharField(default='None', max_length=100, help_text='Имя пользователя на нашем сайте') + @property + def zendesk_role(self): + id = self.custom_role_id + for role, r_id in ZENDESK_ROLES.items(): + if r_id == id: + return role + return 'UNDEFINED' + @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): diff --git a/main/serializers.py b/main/serializers.py index 5a4fd1a..cfc70a8 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -13,11 +13,9 @@ class UserSerializer(serializers.HyperlinkedModelSerializer): class ProfileSerializer(serializers.HyperlinkedModelSerializer): - """ - Класс serializer для модель профиля пользователя. - """ + """Сериализатор для модели профиля пользователя""" user = UserSerializer() class Meta: model = UserProfile - fields = ['user', 'id', 'role', 'name'] + fields = ['user', 'id', 'name', 'zendesk_role'] diff --git a/static/main/js/control.js b/static/main/js/control.js index e518c36..2da9b56 100644 --- a/static/main/js/control.js +++ b/static/main/js/control.js @@ -25,7 +25,7 @@ class TableRow extends React.Component { {this.props.user.name} {this.props.user.user.email} - {this.props.user.role} + {this.props.user.zendesk_role} Date: Sun, 4 Apr 2021 20:03:35 +0300 Subject: [PATCH 46/49] Removed django context, now uses only React --- main/templates/pages/adm_ruleset.html | 33 ++++++--------------------- main/views.py | 6 +---- static/main/js/control.js | 28 +++++++---------------- 3 files changed, 16 insertions(+), 51 deletions(-) diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index c2a354c..1fb9baf 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -30,14 +30,6 @@
- {% block hidden_form %} -
- {% for field in form.users %} - {{ field.tag }} - {% endfor %} -
- {% endblock %} -
Список сотрудников
@@ -50,25 +42,11 @@ Role Checked - - - {% for user in users %} - - {{ user.name }} - {{ user.user.email }} - {% if user.custom_role_id == ZENDESK_ROLES.engineer %} - engineer - {% elif user.custom_role_id == ZENDESK_ROLES.light_agent %} - light_agent - {% endif %} - - - - {% endfor %} + - - {% endblock%} +

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

+ {% endblock %}
@@ -96,7 +74,9 @@
+ {% endblock %} + {% block buttons %}
+ {% endblock %} + - {% endblock %} {% endblock %} diff --git a/main/views.py b/main/views.py index 18d7e52..c4e981f 100644 --- a/main/views.py +++ b/main/views.py @@ -285,13 +285,9 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin,SuccessMessageMi Функция формирования контента страницы администратора (с проверкой прав доступа) """ context = super().get_context_data(**kwargs) - users = get_list_or_404( - UserProfile, role='agent') - context['users'] = users - context['ZENDESK_ROLES'] = ZENDESK_ROLES context['engineers'], context['light_agents'] = count_users(get_users_list()) context['licences_remaining'] = max(0, ZENDESK_MAX_AGENTS - context['engineers']) - return context # TODO: need to get profile page url + return context class CustomLoginView(LoginView): diff --git a/static/main/js/control.js b/static/main/js/control.js index 2da9b56..55d1d1a 100644 --- a/static/main/js/control.js +++ b/static/main/js/control.js @@ -1,21 +1,5 @@ "use strict"; -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); - } - } else { - alert( - "Количество пользователей агентов не соответствует количеству полей в форме AdminPageUsers" - ); - } -} -move_checkboxes(); - // React class TableRow extends React.Component { render() { @@ -49,8 +33,8 @@ class TableBody extends React.Component { }; } - get_users() { - axios.get("/api/users").then((response) => { + async get_users() { + await axios.get("/api/users").then((response) => { this.setState({ users: response.data.users, engineers: response.data.engineers, @@ -62,7 +46,12 @@ class TableBody extends React.Component { }); } + delete_pretext() { + document.getElementById("loading").remove(); + } + componentDidMount() { + this.get_users().then(() => this.delete_pretext()); this.interval = setInterval(() => { this.get_users(); }, 60000); @@ -79,5 +68,4 @@ class TableBody extends React.Component { } } -ReactDOM.render(, document.getElementById("new_tbody")); -setTimeout(() => document.getElementById("old_tbody").remove(), 60000); +ReactDOM.render(, document.getElementById("tbody")); From b3db81203f4ec79851a5436b8138b3327648a8e0 Mon Sep 17 00:00:00 2001 From: Artyom Kravchenko Date: Mon, 5 Apr 2021 14:53:47 +0300 Subject: [PATCH 47/49] add daphne to requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index b32a382..16ae562 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ Pillow==8.1.0 zenpy~=2.0.24 django_registration==3.1.1 djangorestframework==3.12.2 +daphne==3.0.1 # Documentation From 870cbca99c874ee0ec95d1844c163c6d76644769 Mon Sep 17 00:00:00 2001 From: Artyom Kravchenko Date: Tue, 6 Apr 2021 18:04:13 +0300 Subject: [PATCH 48/49] tmp commit --- access_controller/asgi.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/access_controller/asgi.py b/access_controller/asgi.py index 7f60a1a..11dc22e 100644 --- a/access_controller/asgi.py +++ b/access_controller/asgi.py @@ -9,8 +9,9 @@ https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ import os -from django.core.asgi import get_asgi_application + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') +from django.core.asgi import get_asgi_application application = get_asgi_application() From 19fc23808cfef7265bc8b905d676ffda82a55bbd Mon Sep 17 00:00:00 2001 From: Artyom Kravchenko Date: Thu, 8 Apr 2021 15:39:47 +0300 Subject: [PATCH 49/49] test nginx --- access_controller/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/access_controller/settings.py b/access_controller/settings.py index 020480d..cee5805 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -24,7 +24,7 @@ SECRET_KEY = 'v1i_fb$_jf2#1v_lcsbu&eon4u-os0^px=s^iycegdycqy&5)6' # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['127.0.0.1'] # Application definition