diff --git a/access_controller/auth.py b/access_controller/auth.py new file mode 100644 index 0000000..be707e1 --- /dev/null +++ b/access_controller/auth.py @@ -0,0 +1,19 @@ +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.models import User + + +class EmailAuthBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + try: + user = User.objects.get(email=username) + if user.check_password(password): + return user + return None + except User.DoesNotExist: + return None + + def get_user(self, user_id): + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None diff --git a/access_controller/settings.py b/access_controller/settings.py index febc172..28feeb4 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', ] @@ -135,6 +136,11 @@ ACCOUNT_ACTIVATION_DAYS = 7 LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' +# Название_приложения.Название_файла.Название_класса_обработчика +AUTHENTICATION_BACKENDS = [ + 'access_controller.auth.EmailAuthBackend', +] + # Logging system # https://docs.djangoproject.com/en/3.1/topics/logging/ LOGGING = { @@ -177,3 +183,21 @@ ZENDESK_ROLES = { 'engineer': 360005209000, 'light_agent': 360005208980, } + +ZENDESK_GROUPS = { + 'employees': 'Поддержка', + 'buffer': 'Сменная группа', +} + +SOLVED_TICKETS_EMAIL = 'd.krikov@ngenix.net' + +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' + ] +} + +ONE_DAY = 12 # Количество часов в 1 рабочем дне + diff --git a/access_controller/urls.py b/access_controller/urls.py index 5420838..e174717 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -14,21 +14,30 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.contrib.auth.views import LoginView from django.contrib.auth import views as auth_views from django.urls import path, include -from main.views import main_page, profile_page, CustomRegistrationView, AdminPageView + +from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView +from main.views import work_page, work_hand_over, work_become_engineer, \ + AdminPageView, statistic_page +from main.urls import router + urlpatterns = [ path('admin/', admin.site.urls, name='admin'), path('', main_page, name='index'), path('accounts/profile/', profile_page, name='profile'), path('accounts/register/', CustomRegistrationView.as_view(), name='registration'), - path('accounts/login/', LoginView.as_view(extra_context={}), name='login'), # TODO add extra context + path('accounts/login/', CustomLoginView.as_view(), name='login'), path('accounts/', include('django.contrib.auth.urls')), + path('accounts/', include('django_registration.backends.one_step.urls')), + path('work/', work_page, name="work"), + path('work/hand_over/', work_hand_over, name="work_hand_over"), + path('work/become_engineer/', work_become_engineer, name="work_become_engineer"), path('accounts/', include('django_registration.backends.activation.urls')), path('accounts/login/', include('django.contrib.auth.urls')), - path('control/', AdminPageView.as_view(), name='control') + path('control/', AdminPageView.as_view(), name='control'), + path('statistic/', statistic_page, name='statistic') ] urlpatterns += [ @@ -53,3 +62,8 @@ urlpatterns += [ name='password_reset_complete' ), ] + +# Django REST +urlpatterns += [ + path('api/', include(router.urls)) +] diff --git a/data.json b/data.json new file mode 100644 index 0000000..a4310a4 --- /dev/null +++ b/data.json @@ -0,0 +1,57 @@ +[ + { + "model": "auth.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$216000$gHBBCr1jBELf$ZkEDW3IEd8Wij7u8vkv+0Eze32CS01bcaYWhcD9OIC4=", + "last_login": null, + "is_superuser": true, + "username": "admin@gmail.com", + "first_name": "", + "last_name": "", + "email": "admin@gmail.com", + "is_staff": true, + "is_active": true, + "date_joined": "2021-03-10T16:38:56.303Z", + "groups": [], + "user_permissions": [33] + } + }, + { + "model": "main.userprofile", + "pk": 1, + "fields": { + "name": "ZendeskAdmin", + "user": 1, + "role": "admin" + } + }, + { + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$216000$5qLJgrm2Quq9$KDBNNymVZXkUx0HKBPFst2m83kLe0egPBnkW7KnkORU=", + "last_login": null, + "is_superuser": false, + "username": "123@test.ru", + "first_name": "", + "last_name": "", + "email": "123@test.ru", + "is_staff": false, + "is_active": true, + "date_joined": "2021-03-10T16:38:56.303Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "main.userprofile", + "pk": 2, + "fields": { + "name": "UserForAccessTest", + "user": 2, + "role": "agent", + "custom_role_id": "360005209000" + } + } +] diff --git a/docs/Makefile b/docs/Makefile index d0c3cbf..461302a 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -18,3 +18,6 @@ help: # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +spelling: + $(SPHINXBUILD) -b spelling -W $(ALLSPHINXOPTS) $(BUILDDIR)/spelling diff --git a/docs/source/conf.py b/docs/source/conf.py index 438d5a4..c9efe47 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,10 +12,34 @@ # import os import sys +import importlib +import inspect + sys.path.insert(0, os.path.abspath('../../')) import django + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') +os.environ.setdefault('DJANGO_CONFIGURATION', 'Dev') + +# Fix Django's FileFields +from django.db.models.fields.files import FileDescriptor + +FileDescriptor.__get__ = lambda self, *args, **kwargs: self + +from django.db.models.manager import ManagerDescriptor + +ManagerDescriptor.__get__ = lambda self, *args, **kwargs: self.manager + +# Stop Django from executing DB queries +from django.db.models.query import QuerySet + +QuerySet.__repr__ = lambda self: self.__class__.__name__ +try: + import enchant # NoQA +except ImportError: + enchant = None + django.setup() # -- Project information ----------------------------------------------------- @@ -28,8 +52,81 @@ 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): + """Append params from fields to model documentation.""" + from django.utils.encoding import force_text + from django.utils.html import strip_tags + from django.db import models + + spelling_white_list = ['', '.. spelling::'] + + if inspect.isclass(obj) and issubclass(obj, models.Model): + for field in obj._meta.fields: + help_text = strip_tags(force_text(field.help_text)) + verbose_name = force_text(field.verbose_name).capitalize() + + if help_text: + lines.append(':param %s: %s - %s' % (field.attname, verbose_name, help_text)) + else: + lines.append(':param %s: %s' % (field.attname, verbose_name)) + + if enchant is not None: + from enchant.tokenize import basic_tokenize + + words = verbose_name.replace('-', '.').replace('_', '.').split('.') + words = [s for s in words if s != ''] + for word in words: + spelling_white_list += [" %s" % ''.join(i for i in word if not i.isdigit())] + spelling_white_list += [" %s" % w[0] for w in basic_tokenize(word)] + + field_type = type(field) + module = field_type.__module__ + if 'django.db.models' in module: + # scope with django.db.models * imports + module = 'django.db.models' + lines.append(':type %s: %s.%s' % (field.attname, module, field_type.__name__)) + if enchant is not None: + lines += spelling_white_list + print('ok') + return lines + + +def process_modules(app, what, name, obj, options, lines): + """Add module names to spelling white list.""" + if what != 'module': + return lines + from enchant.tokenize import basic_tokenize + + spelling_white_list = ['', '.. spelling::'] + words = name.replace('-', '.').replace('_', '.').split('.') + words = [s for s in words if s != ''] + for word in words: + spelling_white_list += [" %s" % ''.join(i for i in word if not i.isdigit())] + spelling_white_list += [" %s" % w[0] for w in basic_tokenize(word)] + lines += spelling_white_list + return lines + + +def skip_queryset(app, what, name, obj, skip, options): + """Skip queryset subclasses to avoid database queries.""" + from django.db import models + if isinstance(obj, (models.QuerySet, models.manager.BaseManager)) or name.endswith('objects'): + return True + return skip + + +def setup(app): + # Register the docstring processor with sphinx + app.connect('autodoc-process-docstring', process_django_models) + app.connect('autodoc-skip-member', skip_queryset) + if enchant is not None: + app.connect('autodoc-process-docstring', process_modules) + + # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. @@ -39,8 +136,15 @@ extensions = [ 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode', 'sphinx_rtd_theme', + 'sphinx.ext.graphviz', + 'sphinx.ext.inheritance_diagram', + 'sphinx_autodoc_typehints' + ] +if enchant is not None: + extensions.append('sphinxcontrib.spelling') + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -56,7 +160,6 @@ language = 'ru' # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] - # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for @@ -69,7 +172,6 @@ html_theme = "sphinx_rtd_theme" # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] - # -- Extension configuration ------------------------------------------------- # -- Options for intersphinx extension --------------------------------------- @@ -78,12 +180,25 @@ html_static_path = ['_static'] intersphinx_mapping = { 'https://docs.python.org/3/': None, 'django': ( - 'https://docs.djangoproject.com/en/dev/', - 'https://docs.djangoproject.com/en/dev/_objects/' - ), + 'https://docs.djangoproject.com/en/dev/', + 'https://docs.djangoproject.com/en/dev/_objects/' + ), } +autodoc_default_flags = ['members'] + +# spell checking +spelling_lang = 'en_US' +spelling_word_list_filename = 'spelling_wordlist.txt' +spelling_show_suggestions = True +spelling_ignore_pypi_package_names = True + # -- Options for todo extension ---------------------------------------------- # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True + +set_type_checking_flag = True +typehints_fully_qualified = True +always_document_param_types = True +typehints_document_rtype = True diff --git a/docs/source/todo.rst b/docs/source/todo.rst index a8d024b..ad6e4ed 100644 --- a/docs/source/todo.rst +++ b/docs/source/todo.rst @@ -1,5 +1,5 @@ Что необходимо доделать? -======================= +======================== diff --git a/main/extra_func.py b/main/extra_func.py index 20c3712..6ae7266 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,11 +1,14 @@ import os +from datetime import timedelta, datetime, date +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from django.utils import timezone from zenpy import Zenpy from zenpy.lib.exception import APIException -from main.models import UserProfile - -from access_controller.settings import ZENDESK_ROLES as ROLES +from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY, ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL +from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus class ZendeskAdmin: @@ -13,95 +16,94 @@ class ZendeskAdmin: Класс **ZendeskAdmin** существует, чтобы в каждой фунциии отдельно не проверять аккаунт администратора :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) - :type credentials: :class:`list of dictionaries` + :type credentials: :class:`dict` :param email: Email администратора, указанный в env - :type email: :class:`email` + :type email: :class:`str` :param token: Токен администратора (формируется в Zendesk, указывается в env) :type token: :class:`str` :param password: Пароль администратора, указанный в env :type password: :class:`str` """ - credentials = { + credentials: dict = { 'subdomain': 'ngenix1612197338' } - email = os.getenv('ACCESS_CONTROLLER_API_EMAIL') - token = os.getenv('ACCESS_CONTROLLER_API_TOKEN') - password = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') + email: str = os.getenv('ACCESS_CONTROLLER_API_EMAIL') + token: str = os.getenv('ACCESS_CONTROLLER_API_TOKEN') + password: str = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') def __init__(self): self.create_admin() def check_user(self, email: str) -> bool: """ - Функция **check_user** осуществляет проверку существования пользователя в Zendesk - - :param email: Электронная почта пользователя - :type email: :class:`email` - :return: True, если существует, иначе False - :rtype: :class:`bool` + Функция **check_user** осуществляет проверку существования пользователя в Zendesk по email """ return True if self.admin.search(email, type='user') else False def get_user_name(self, email: str) -> str: """ - Функция **get_user_name** возвращает имя пользователя - - :param user_name: Имя пользователя - :type user_name: :class:`str` + Функция **get_user_name** возвращает имя пользователя по его email """ user = self.admin.users.search(email).values[0] return user.name def get_user_role(self, email: str) -> str: """ - Функция **get_user_role** возвращает роль пользователя - - :param user_role: Роль пользователя - :type user_role: :class:`str` + Функция **get_user_role** возвращает роль пользователя по его email """ user = self.admin.users.search(email).values[0] return user.role def get_user_id(self, email: str) -> str: """ - Функция **get_user_id** возвращает id пользователя - - :param user_id: ID пользователя - :type user_id: :class:`str` + Функция **get_user_id** возвращает id пользователя по его email """ user = self.admin.users.search(email).values[0] return user.id def get_user_image(self, email: str) -> str: """ - Функция **get_user_image** возвращает аватар пользователя - - :param user_image: Аватар пользователя - :type user_image: :class:`img` + Функция **get_user_image** возвращает url-ссылку на аватар пользователя по его email """ user = self.admin.users.search(email).values[0] return user.photo['content_url'] if user.photo else None - def get_user(self, email: str) -> str: + def get_user(self, email: str): + """ + Функция **get_user** возвращает пользователя (объект) по его email + + :param email: email пользователя + :return: email пользователя, найденного в БД + """ return self.admin.users.search(email).values[0] - def get_user_org(self, email: str) -> str: - user = self.admin.users.search(email).values[0] - return user.organization.name + def get_group(self, name): + groups = self.admin.search(name) + for group in groups: + return group + return None - def create_admin(self) -> None: + def get_user_org(self, email: str) -> str: + """ + Функция **get_user_org** возвращает организацию, к которой относится пользователь по его email + """ + 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. :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: raise ValueError('access_controller email not in env') - self.credentials['email'] = os.getenv('ACCESS_CONTROLLER_API_EMAIL') + self.credentials['email'] = self.email if self.token: self.credentials['token'] = self.token @@ -116,38 +118,83 @@ class ZendeskAdmin: raise ValueError('invalid access_controller`s login data') -def update_role(user_profile, role): +def update_role(user_profile: UserProfile, role: str) -> UserProfile: + """ + Функция **update_role** меняет роль пользователя. + """ zendesk = ZendeskAdmin() user = zendesk.get_user(user_profile.user.email) user.custom_role_id = role zendesk.admin.users.update(user) -def make_engineer(user_profile): +def make_engineer(user_profile: UserProfile, 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): +def make_light_agent(user_profile: UserProfile, who_changes: User) -> UserProfile: + """ + Функция **make_light_agent** устанавливапет пользователю роль легкого агента. + """ + tickets = get_tickets_list(user_profile.user.email) + for ticket in tickets: + UnassignedTicket.objects.create( + assignee=user_profile.user, + ticket_id=ticket.id, + status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED + ) + if ticket.status == 'solved': + ticket.assignee = ZendeskAdmin().get_user(SOLVED_TICKETS_EMAIL) + else: + ticket.assignee = None + ticket.group = ZendeskAdmin().get_group(ZENDESK_GROUPS['buffer']) + ZendeskAdmin().admin.tickets.update(ticket) + + RoleChangeLogs.objects.create( + user=user_profile.user, + 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(): +def get_users_list() -> list: + """ + Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации. + """ zendesk = ZendeskAdmin() - admin = zendesk.get_user(zendesk.email) - org = next(zendesk.admin.users.organizations(user=admin)) - return zendesk.admin.organizations.users(org) + + # У пользователей должна быть организация SYSTEM + org = next(zendesk.admin.search(type='organization', name='SYSTEM')) + users = zendesk.admin.organizations.users(org) + return users -def update_profile(user_profile: UserProfile): +def get_tickets_list(email): + """ + Функция возвращает список тикетов пользователя Zendesk + """ + return ZendeskAdmin().admin.search(assignee=email, type='ticket') + + +def update_profile(user_profile: UserProfile) -> UserProfile: """ Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk - - :param user_profile: Объект профиля пользователя - :type user_profile: :class:`main.models.UserProfile` """ user = ZendeskAdmin().get_user(user_profile.user.email) user_profile.name = user.name user_profile.role = user.role + user_profile.custom_role_id = user.custom_role_id if user.custom_role_id else 0 user_profile.image = user.photo['content_url'] if user.photo else None user_profile.save() @@ -155,11 +202,6 @@ def update_profile(user_profile: UserProfile): def check_user_exist(email: str) -> bool: """ Функция проверяет, существует ли пользователь - - :param email: Электронная почта пользователя - :type email: :class:`str` - :return: True, если существует, иначе False - :rtype: :class:`bool` """ return ZendeskAdmin().check_user(email) @@ -167,11 +209,6 @@ def check_user_exist(email: str) -> bool: def get_user_organization(email: str) -> str: """ Функция возвращает организацию пользователя - - :param email: Электронная почта пользователя - :type email: :class:`str` - :return: Название организации - :rtype: :class:`str` """ return ZendeskAdmin().get_user_org(email) @@ -180,13 +217,7 @@ def check_user_auth(email: str, password: str) -> bool: """ Функция проверяет, верны ли входные данные - :param email: Электроная почта пользователя - :type email: :class:`str` - :param password: Пароль пользователя - :type password: :class:`str` - :return: True, если входные данные верны, иначе False :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован - :rtype: :class:`bool` """ creds = { 'email': email, @@ -199,3 +230,254 @@ 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.custom_role_id = zendesk_user.custom_role_id + 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 == ROLES['engineer']: + engineers += 1 + elif user.custom_role_id == ROLES['light_agent']: + light_agents += 1 + return engineers, light_agents + + +def update_users_in_model(): + """ + Обновляет пользователей в модели UserProfile по списку пользователей в организации + """ + users = get_users_list() + for user in users: + try: + profile = User.objects.get(email=user.email).userprofile + update_user_in_model(profile, user) + except ObjectDoesNotExist: + pass + return users + + +def daterange(start_date, end_date) -> list: + """ + Возвращает список дней с start_date по end_date исключая правую границу + """ + dates = [] + for n in range(int((end_date - start_date).days)): + dates.append(start_date + timedelta(n)) + return dates + + +def get_timedelta(log, time=None) -> timedelta: + """ + Возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, + который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён + """ + if time is None: + time = log.change_time.time() + time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) + return time + + +def last_day_of_month(day): + """ + Возвращает последний день любого месяца + """ + next_month = day.replace(day=28) + timedelta(days=4) + return next_month - timedelta(days=next_month.day) + + +class StatisticData: + def __init__(self, start_date, end_date, user_email, stat=None): + self.display = None + self.interval = None + self.start_date = start_date + self.end_date = end_date + self.email = user_email + self.errors = list() + self.warnings = list() + self.data = dict() + self.statistic = dict() + self._init_data() + if stat is None: + self._init_statistic() + else: + self.statistic = stat + + def get_statistic(self): + """ + Вернуть словарь statistic с применением формата отображения и интеравала работы(если они есть) + None, если были ошибки при создании + """ + if self.is_valid_statistic(): + stat = self.statistic + stat = self._use_display(stat) + stat = self._use_interval(stat) + return stat + else: + return None + + def is_valid_statistic(self): + """ + Были ли ошибки при создании статистики + """ + return not self.errors and self.statistic + + def set_interval(self, interval): + """ + Устанавливает интервал работы + """ + if interval not in ['months', 'days']: + self.errors += ['Интервал работы должен быть в днях или месяцах'] + return False + self.interval = interval + return True + + def set_display(self, display_format): + """ + Устанавливает формат отображения + """ + if display_format not in ['days', 'hours']: + self.errors += ['Формат отображения должен быть в часах или днях'] + return False + self.display = display_format + return True + + def get_data(self): + """ + Вернуть данные + data - массив объектов RoleChangeLogs, является списком логов пользователя + data может быть пустым списком + """ + if self.is_valid_data(): + return self.data + else: + return None + + def is_valid_data(self): + """ + Были ли ошибки при получении логов + """ + return not self.errors + + def _use_display(self, stat): + """ + Приводит данные к формату отображения + """ + if not self.is_valid_statistic() or not self.display: + return stat + new_stat = {} + for key, item in stat.items(): + if self.display == 'hours': + new_stat[key] = item / 3600 + elif self.display == 'days': + new_stat[key] = item / (ONE_DAY * 3600) + return new_stat + + def _use_interval(self, stat): + """ + Объединяет ключи и значения в соответствии с интервалом работы + """ + if not self.is_valid_statistic() or not self.interval: + return stat + new_stat = {} + if self.interval == 'months': + # Переделываем ключи под формат('начало_месяца - конец_месяца') + for key, value in stat.items(): + current_month_start = max(self.start_date, date(year=key.year, month=key.month, day=1)) + current_month_end = min(self.end_date, last_day_of_month(date(year=key.year, month=key.month, day=1))) + index = ' - '.join([str(current_month_start), str(current_month_end)]) + if new_stat.get(index): + new_stat[index] += value + else: + new_stat[index] = value + elif self.interval == 'days': + new_stat = stat # статистика изначально в днях + return new_stat + + def check_time(self): + """ + Проверка на правильность введенного времени + """ + if self.end_date < self.start_date or self.end_date > datetime.now().date(): + return False + return True + + def _init_data(self): + """ + Получение логов в диапазоне дат start_date-end_date для пользователя с почтой email + """ + if not self.check_time(): + self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] + return + try: + self.data = RoleChangeLogs.objects.filter( + change_time__range=[self.start_date, self.end_date + timedelta(days=1)], + user=User.objects.get(email=self.email), + ).order_by('change_time') + except User.DoesNotExist: + self.errors += ['Пользователь не найден'] + + def _init_statistic(self): + """ + Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд + """ + self.clear_statistic() + if not self.get_data(): + self.warnings += ['Не обнаружены изменения роли в данном промежутке'] + return None + first_log, last_log = self.data[0], self.data[len(self.data) - 1] + + if first_log.old_role == ROLES['engineer']: + self.statistic[first_log.change_time.date()] += get_timedelta(first_log).total_seconds() + + if last_log.new_role == ROLES['engineer']: + self.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date + timedelta(days=1)) + 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() + + for log_index in range(len(self.data) - 1): + if self.data[log_index].new_role == ROLES['engineer']: + current_log, next_log = self.data[log_index], self.data[log_index + 1] + if current_log.change_time.date() != next_log.change_time.date(): + self.statistic[current_log.change_time.date()] += ( + timedelta(days=1) - get_timedelta(current_log)).total_seconds() + self.statistic[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() + self.fill_daterange(current_log.change_time.date() + timedelta(days=1), next_log.change_time.date()) + else: + elapsed_time = next_log.change_time - current_log.change_time + self.statistic[current_log.change_time.date()] += elapsed_time.total_seconds() + + def fill_daterange(self, first, last, val=24 * 3600): + """ + Заполение диапазона дат значением val + по умолчанию val = кол-во секунд в 1 дне + """ + for day in daterange(first, last): + self.statistic[day] = val + + def clear_statistic(self): + """ + Обнуление всех дней + """ + self.statistic.clear() + self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) diff --git a/main/forms.py b/main/forms.py index d80932c..a8c2ec1 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,19 +1,21 @@ from django import forms +from django.contrib.auth.forms import AuthenticationForm from django_registration.forms import RegistrationFormUniqueEmail +from access_controller.settings import ZENDESK_ROLES from main.models import UserProfile class CustomRegistrationForm(RegistrationFormUniqueEmail): """ - Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` - с добавлением bootstrap-класса 'form-control' + Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` - :param password_zen: Поле для ввода пароля от Zendesk - :type password_zen: :class:`django.forms.CharField` + с добавлением bootstrap-класса "form-control" + + :param visible_fields.email: Поле для ввода email, зарегистирированного на Zendesk + :type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail` """ - - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> RegistrationFormUniqueEmail: super().__init__(*args, **kwargs) for visible in self.visible_fields(): if visible.field.widget.attrs.get('class', False): @@ -21,7 +23,7 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): visible.field.widget.attrs['class'] += 'form-control' else: visible.field.widget.attrs['class'] = 'form-control' - if visible.html_name !='email': + if visible.html_name != 'email': visible.field.required = False class Meta(RegistrationFormUniqueEmail.Meta): @@ -29,12 +31,66 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): class AdminPageUsers(forms.Form): + """ + Форма для установки статусов engineer или light_agent пользователям + + :param users: Поле для установки статуса + :type users: :class:`ModelMultipleChoiceField` + """ + users = forms.ModelMultipleChoiceField( queryset=UserProfile.objects.filter(role='agent'), widget=forms.CheckboxSelectMultiple( attrs={ - 'class': 'form-check-input' + 'class': 'form-check-input', + } ), label='' ) + + +class CustomAuthenticationForm(AuthenticationForm): + """ + Форма для авторизации :class:`django.contrib.auth.forms.AuthenticationForm` + с изменением поля username на email + """ + username = forms.CharField( + label="Электронная почта", + widget=forms.EmailInput(), + ) + error_messages = { + 'invalid_login': + "Пожалуйста, введите правильные электронную почту и пароль. Оба поля " + "могут быть чувствительны к регистру." + , + 'inactive': "Аккаунт не активен.", + } + + +class StatisticForm(forms.Form): + email = forms.EmailField( + label='Электроная почта', + ) + interval = forms.CharField( # TODO: Переделать под html страницу + label='Интервал работы', + ) + display_format = forms.CharField( # TODO: Переделать под html страницу + label='Формат отображения', + ) + range_start = forms.DateField( # TODO: Переделать под html страницу + label='Начало диапазона', + widget=forms.DateInput( + attrs={ + 'type': 'date', + } + ), + ) + range_end = forms.DateField( # TODO: Переделать под html страницу + label='Конец диапазона', + widget=forms.DateInput( + attrs={ + 'type': 'date', + } + ), + ) diff --git a/main/migrations/0005_auto_20210302_2255.py b/main/migrations/0005_auto_20210302_2255.py new file mode 100644 index 0000000..dff2dc2 --- /dev/null +++ b/main/migrations/0005_auto_20210302_2255.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.6 on 2021-03-02 19:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0004_rolechangelogs'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userprofile', + options={'permissions': [('admin', 'Have access to control page')]}, + ), + ] diff --git a/main/migrations/0006_delete_userprofile.py b/main/migrations/0006_delete_userprofile.py new file mode 100644 index 0000000..23adaab --- /dev/null +++ b/main/migrations/0006_delete_userprofile.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1.6 on 2021-03-03 19:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0005_auto_20210302_2255'), + ] + + operations = [ + migrations.DeleteModel( + name='UserProfile', + ), + ] diff --git a/main/migrations/0007_userprofile.py b/main/migrations/0007_userprofile.py new file mode 100644 index 0000000..2b05dd7 --- /dev/null +++ b/main/migrations/0007_userprofile.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.6 on 2021-03-03 19:35 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('main', '0006_delete_userprofile'), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(default='None', max_length=100)), + ('image', models.URLField(blank=True, null=True)), + ('name', models.CharField(default='None', max_length=100)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'permissions': [('control_access', 'User has access to control page')], + }, + ), + ] diff --git a/main/migrations/0008_auto_20210303_2305.py b/main/migrations/0008_auto_20210303_2305.py new file mode 100644 index 0000000..8082682 --- /dev/null +++ b/main/migrations/0008_auto_20210303_2305.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.6 on 2021-03-03 20:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0007_userprofile'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userprofile', + options={}, + ), + ] diff --git a/main/migrations/0009_models_help_text.py b/main/migrations/0009_models_help_text.py new file mode 100644 index 0000000..4bc87e1 --- /dev/null +++ b/main/migrations/0009_models_help_text.py @@ -0,0 +1,61 @@ +# Generated by Django 3.1.6 on 2021-03-11 08:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('main', '0008_auto_20210303_2305'), + ] + + operations = [ + migrations.AlterField( + model_name='rolechangelogs', + name='change_time', + field=models.DateTimeField(help_text='Дата и время изменения роли'), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='changed_by', + field=models.ForeignKey(help_text='Кем была изменена роль', on_delete=django.db.models.deletion.CASCADE, related_name='changed_by', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='name', + field=models.TextField(help_text='Имя пользователя'), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='new_role', + field=models.TextField(help_text='Присвоенная роль'), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='user', + field=models.ForeignKey(help_text='Пользователь, которому присвоили другую роль', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='userprofile', + name='image', + field=models.URLField(blank=True, help_text='Аватарка', null=True), + ), + migrations.AlterField( + model_name='userprofile', + name='name', + field=models.CharField(default='None', help_text='Имя пользователя на нашем сайте', max_length=100), + ), + migrations.AlterField( + model_name='userprofile', + name='role', + field=models.CharField(default='None', help_text='Код роли пользователя', max_length=100), + ), + migrations.AlterField( + model_name='userprofile', + name='user', + field=models.OneToOneField(help_text='Пользователь', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/main/migrations/0010_userprofile_meta.py b/main/migrations/0010_userprofile_meta.py new file mode 100644 index 0000000..28fa435 --- /dev/null +++ b/main/migrations/0010_userprofile_meta.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.6 on 2021-03-11 08:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0009_models_help_text'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userprofile', + options={'permissions': (('has_control_access', 'Can view admin page'),)}, + ), + ] diff --git a/main/migrations/0011_auto_20210311_1734.py b/main/migrations/0011_auto_20210311_1734.py new file mode 100644 index 0000000..c228bfc --- /dev/null +++ b/main/migrations/0011_auto_20210311_1734.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.6 on 2021-03-11 14:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0010_userprofile_meta'), + ] + + operations = [ + migrations.AddField( + model_name='rolechangelogs', + name='old_role', + field=models.IntegerField(default=0, help_text='Старая роль'), + ), + migrations.AlterField( + model_name='rolechangelogs', + name='new_role', + field=models.IntegerField(default=0, help_text='Присвоенная роль'), + ), + migrations.AlterField( + model_name='userprofile', + name='role', + field=models.IntegerField(default=0, help_text='Код роли пользователя'), + ), + ] diff --git a/main/migrations/0012_auto_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/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/migrations/0014_auto_20210314_1455.py b/main/migrations/0014_auto_20210314_1455.py new file mode 100644 index 0000000..77db2ec --- /dev/null +++ b/main/migrations/0014_auto_20210314_1455.py @@ -0,0 +1,29 @@ +# 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', '0013_auto_20210311_2040'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + 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', + field=models.CharField(default='None', help_text='Глобальное имя роли пользователя', max_length=100), + ), + ] diff --git a/main/models.py b/main/models.py index b7b30dd..7bd76bb 100644 --- a/main/models.py +++ b/main/models.py @@ -2,25 +2,22 @@ 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): - """ - Модель профиля пользователя + """Модель профиля пользователя""" - :param user: OneToOneField к модели :class:`django.contrib.auth.models.User` - :param role: Код роли пользователя - :type role: :class:`integer` - :param image: Аватарка - :type image: :class:`img` - :param name: Имя пользователя на нашем сайте - :type name: :class:`str` - """ + class Meta: + permissions = ( + ('has_control_access', 'Can view admin page'), + ) - user = models.OneToOneField(to=User, on_delete=models.CASCADE) - role = models.CharField(default='None', max_length=100) - image = models.URLField(null=True, blank=True) - name = models.CharField(default='None', max_length=100) + user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') + role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя') + custom_role_id = models.IntegerField(default=0, help_text='Код роли пользователя') + image = models.URLField(null=True, blank=True, help_text='Аватарка') + name = models.CharField(default='None', max_length=100, help_text='Имя пользователя на нашем сайте') @receiver(post_save, sender=User) @@ -35,20 +32,25 @@ def save_user_profile(sender, instance, **kwargs): class RoleChangeLogs(models.Model): - """ - Модель для логирования изменений ролей пользователя + """Модель для логирования изменений ролей пользователя""" + user = models.ForeignKey(to=User, on_delete=models.CASCADE, + help_text='Пользователь, которому присвоили другую роль') + old_role = models.IntegerField(default=0, help_text='Старая роль') + new_role = models.IntegerField(default=0, help_text='Присвоенная роль') + 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='Кем была изменена роль') - :param user: Пользователь, которому присвоили другую роль, ForeignKey к модели :class:`django.contrib.auth.models.User` - :param name: Имя пользователя - :type name: :class:`str` - :param new_role: Присвоенная роль - :type new_role: :class:`str` - :param change_time: Дата изменения роли` - :type change_time: :class:`datetime.datetime` - :param changed_by: Кем была изменена роль, ForeignKey к модели :class:`django.contrib.auth.models.User` - """ - user = models.ForeignKey(to=User, on_delete=models.CASCADE) - name = models.TextField() - new_role = models.TextField() - change_time = models.DateTimeField() - changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by') + +class UnassignedTicketStatus(models.IntegerChoices): + UNASSIGNED = 0, 'Снят с пользователя, перенесён в буферную группу' + RESTORED = 1, 'Авторство восстановлено' + NOT_FOUND = 2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются' + CLOSED = 3, 'Тикет уже был закрыт. Дополнительные действия не требуются' + SOLVED = 4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL' + + +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) diff --git a/main/serializers.py b/main/serializers.py new file mode 100644 index 0000000..f72fc86 --- /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', 'id', '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/base/menu.html b/main/templates/base/menu.html index 8448b0c..92aaec1 100644 --- a/main/templates/base/menu.html +++ b/main/templates/base/menu.html @@ -3,14 +3,19 @@ -