diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0d4799d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +*.pyc +*.pyo +*.mo +*.db +*.css.map +*.egg-info +*.sql.gz +*.sqlite3 +.cache +.env +.project +.idea +.pydevproject +.idea/workspace.xml +.DS_Store +.git/ +.sass-cache +.vagrant/ +__pycache__ +dist +docs +env +logs +src/{{ project_name }}/settings/local.py +src/node_modules +web/media +web/static/CACHE +stats +Dockerfile diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f86db45 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +ACTRL_DEBUG=1 + +ACTRL_SECRET_KEY="v1i_fb\$_jf2#1v_lcsbu&eon4u-os0^px=s^iycegdycqy&5)6" +ACTRL_HOST="actrl.example.com" + +ACTRL_EMAIL_HOST="smtp.mail.ru" +ACTRL_EMAIL_PORT=2525 +ACTRL_EMAIL_TLS=1 +ACTRL_EMAIL_HOST_USER="djgr.02@mail.ru" +ACTRL_EMAIL_HOST_PASSWORD="djangogroup02" +ACTRL_FROM_EMAIL="djgr.02@mail.ru" +ACTRL_SERVER_EMAIL="djgr.02@mail.ru" + +ENG_CROLE_ID=360005209000 +LA_CROLE_ID=360005208980 +EMPL_GROUP="Поддержка" +BUF_GROUP="Сменная группа" +ST_EMAIL="d.krikov@ngenix.net" +LICENSE_NO=3 +SHIFTH=12 + +ACTRL_ZENDESK_SUBDOMAIN="ngenix1612197338" +ACTRL_API_EMAIL="email@example.com" +ACTRL_API_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +ACTRL_API_PASSWORD="" diff --git a/.gitignore b/.gitignore index d798070..2d1bab8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,9 @@ __pycache__/ local_settings.py db.sqlite3 db.sqlite3-journal +db/ media/ +logs/ # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ # in your Git repository. Update and uncomment the following line accordingly. @@ -29,7 +31,6 @@ media/ .Python build/ develop-eggs/ -dist/ downloads/ eggs/ .eggs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..59c861e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.6 +COPY ./ /access_controller +WORKDIR /access_controller/ +RUN pip install -r requirements/prod.txt +RUN python manage.py makemigrations +EXPOSE 8000 +COPY start.sh /var/ +CMD bash /var/start.sh + + diff --git a/README.md b/README.md index db34259..eb66f37 100644 --- a/README.md +++ b/README.md @@ -39,17 +39,117 @@ ## Quickstart +Перед запуском требуется неообходимо `.env` файл. +```bash +cp .env.example .env +``` +Заменить переменные в `.env` на актуальные. ```bash sudo apt install make pip install --upgrade pip -pip install -r requirements.txt -./manage.py migrate -./manage.py shell -c "from django.contrib.auth import get_user_model; get_user_model().objects.create_superuser('vasya', '1@abc.net', 'promprog')" -./manage.py runserver +pip install -r requirements/dev.txt +(set -a && source .env && ./manage.py migrate) +(set -a && source .env && ./manage.py loaddata data.json) +(set -a && source .env && ./manage.py runserver) ``` -Создать токен -Указать почту и токен в окружении +## Перед запуском для тестирования: + +Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация `SYSTEM` +Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk +При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение + + +## Запуск на локальной машине: +- Скопировать репозиторий на локальную машину +- Перейти в папку приложения +- Активировать виртуальное окружение +- Выполнить команду `pip install -r requirements/dev.txt` +- В виртуальное окружение добавить следующие переменные: + + +``` +ACTRL_DEBUG={0/1} - включить режим дебага +ACTRL_HOST={HOSTNAME} - при запуске без дебага, надо указать домен на котором будет работать приложение +ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} - секретный ключ сгенерированый Django + +ACTRL_EMAIL_HOST={SMTP_HOST} - домен почтового сервера через который приложение будет отправлять письма, например "smtp.gmail.com" +ACTRL_EMAIL_PORT={SMTP_PORT} - порт для почтового сервера, например 587, 465 , 2525 +ACTRL_EMAIL_TLS={USE_TLS} - использовать TLS для подключения к почтовому серверу, 0 или 1 +ACTRL_EMAIL_HOST_USER={USERNAME} - логин с которым приложение входит на почтовый сервер +ACTRL_EMAIL_HOST_PASSWORD={PASSWORD} - пароль/ключ с которым приложение входит на почтовый сервер +ACTRL_FROM_EMAIL={EMAIL} - адрес с которого приложение отправляет письма +ACTRL_SERVER_EMAIL={EMAIL} - адрес на который отвечают пользователя + +ACTRL_API_EMAIL={EMAIL} - почта админа в ZenDesk +ACTRL_API_PASSWORD={PASSWORD} - пароль админа ZenDesk +ACTRL_API_TOKEN={API_TOKEN} - API токен зендеск +ACTRL_ZENDESK_SUBDOMAIN={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 migrate` +- Запустить приложение командой `python manage.py runserver` (можно указать в параметрах для файла manage.py) +- Перейти по ссылке в консоли (вероятнее всего откроется по адресу http://127.0.0.1:8000/) + + +## Запуск в Docker: +Требуется установленный и настроенный Docker + +- Скопировать репозиторий на локальную машину +- В командной строке перейти в папку проекта +- Выполнить команду `docker build --tag access_controller:latest .` +- Выполнить команду +```bash +docker run -d -p 8000:8000 \ + ACTRL_DEBUG={0/1} \ + ACTRL_HOST={HOSTNAME} \ + ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} \ + ACTRL_EMAIL_HOST={SMTP_HOST} \ + ACTRL_EMAIL_PORT={SMTP_PORT} \ + ACTRL_EMAIL_TLS={USE_TLS} \ + ACTRL_EMAIL_HOST_USER={USERNAME} \ + ACTRL_EMAIL_HOST_PASSWORD={PASSWORD} \ + ACTRL_FROM_EMAIL={EMAIL} \ + ACTRL_SERVER_EMAIL={EMAIL} \ + ACTRL_API_EMAIL={EMAIL} \ + ACTRL_API_PASSWORD={PASSWORD} \ + ACTRL_API_TOKEN={API_TOKEN} \ + ACTRL_ZENDESK_SUBDOMAIN={DOMAIN} \ + ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID} \ + LA_CROLE_ID={LIGHT_AGENT_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} \ + -v {ABSOLUTE_PATH_TO_DB}:/zendesk-access-controller/db \ + access_controller:latest +``` +- открываем запущеный контейнер в браузере (можно перейти по ссылке http://localhost:8000/) + + +## Запуск с тестовыми юзерами: +На локальной машине - перед запуском команды `python manage.py runserver` выполнить команду `python manage.py loaddata data.json` +Это создаст тестового админа и тестового пользователя в приложении для песочницы ZenDesk. + +- Админ - `admin@gmail.com` / `zendeskadmin` +- Пользователь - `123@test.ru` / `zendeskuser` + +Не сработает если домен песочницы отличается от `ngenix1612197338` (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента +с этими же почтами, назначить им организацию `SYSTEM`) + + +## Параметры тестовой песочницы: +Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). + ## Read more - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) 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() 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 eecfa19..a7585ed 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -19,12 +19,16 @@ BASE_DIR = Path(__file__).resolve().parent.parent # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'v1i_fb$_jf2#1v_lcsbu&eon4u-os0^px=s^iycegdycqy&5)6' +SECRET_KEY = os.getenv('ACTRL_SECRET_KEY','empty') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = bool(int(os.getenv('ACTRL_DEBUG',1))) -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = [ + '127.0.0.1', + 'localhost', + os.getenv('ACTRL_HOST'), +] # Application definition @@ -36,6 +40,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django_registration', + 'rest_framework', 'main', ] @@ -51,6 +56,15 @@ MIDDLEWARE = [ ROOT_URLCONF = 'access_controller.urls' +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = os.getenv('ACTRL_EMAIL_HOST','smtp.gmail.com') +EMAIL_PORT = int(os.getenv('ACTRL_EMAIL_PORT',587)) +EMAIL_USE_TLS = bool(int(os.getenv('ACTRL_EMAIL_TLS',1))) +EMAIL_HOST_USER = os.getenv('ACTRL_EMAIL_HOST_USER','group02django@gmail.com') +EMAIL_HOST_PASSWORD = os.getenv('ACTRL_EMAIL_HOST_PASSWORD','djangogroup02') +DEFAULT_FROM_EMAIL = os.getenv('ACTRL_FROM_EMAIL',EMAIL_HOST_USER) +SERVER_EMAIL = os.getenv('ACTRL_SERVER_EMAIL',EMAIL_HOST_USER) + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', @@ -77,7 +91,7 @@ WSGI_APPLICATION = 'access_controller.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'NAME': BASE_DIR / 'db' / 'zd_db.sqlite3' } } @@ -119,11 +133,47 @@ STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'staticroot') STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'static'), - os.path.join(BASE_DIR, 'media'), ] -MEDIA_ROOT = BASE_DIR / 'media' -MEDIA_URL = '/media/' +ACCOUNT_ACTIVATION_DAYS = 7 LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' + +# Название_приложения.Название_файла.Название_класса_обработчика +AUTHENTICATION_BACKENDS = [ + 'access_controller.auth.EmailAuthBackend', +] + +# Logging system +# https://docs.djangoproject.com/en/3.1/topics/logging/ + + +ZENDESK_ROLES = { + 'engineer': int(os.getenv('ENG_CROLE_ID',0)), + 'light_agent': int(os.getenv('LA_CROLE_ID',0)), +} + +ZENDESK_GROUPS = { + 'employees': os.getenv('EMPL_GROUP'), + 'buffer': os.getenv('BUF_GROUP'), +} + +SOLVED_TICKETS_EMAIL = os.getenv('ST_EMAIL') + +ZENDESK_MAX_AGENTS = int(os.getenv('LICENSE_NO',0)) + +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 = int(os.getenv('SHIFTH',0)) # Количество часов в 1 рабочем дне + +ACTRL_ZENDESK_SUBDOMAIN = os.getenv('ACTRL_ZENDESK_SUBDOMAIN') or os.getenv('ZD_DOMAIN') +ACTRL_API_EMAIL = os.getenv('ACTRL_API_EMAIL') or os.getenv('ACCESS_CONTROLLER_API_EMAIL') +ACTRL_API_TOKEN = os.getenv('ACTRL_API_TOKEN') or os.getenv('ACCESS_CONTROLLER_API_TOKEN') +ACTRL_API_PASSWORD = os.getenv('ACTRL_API_PASSWORD') or os.getenv('ACCESS_CONTROLLER_API_PASSWORD') diff --git a/access_controller/urls.py b/access_controller/urls.py index b2603b2..63dc19f 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -13,21 +13,33 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.conf.urls.static import static from django.contrib import admin -from django.contrib.auth.views import LoginView +from django.contrib.auth import views from django.urls import path, include -from access_controller import settings -from access_controller.settings import DEBUG -from main.views import main_page, profile_page, CustomRegistrationView +from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView, registration_error +from main.views import work_page, work_hand_over, work_become_engineer, work_get_tickets, \ + 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/register/error/', registration_error, name='registration_email_error'), + 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('work/get_tickets', work_get_tickets, name='work_get_tickets'), + path('accounts/', include('django_registration.backends.activation.urls')), + path('control/', AdminPageView.as_view(), name='control'), + path('statistic/', statistic_page, name='statistic'), +] + +# 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/media/.gitkeep b/db/.gitkeep similarity index 100% rename from media/.gitkeep rename to db/.gitkeep diff --git a/docs/Makefile b/docs/Makefile index d0c3cbf..827600b 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,14 +3,17 @@ # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build +ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source +SPHINXOPTS = +SPHINXBUILD = sphinx-build SOURCEDIR = source BUILDDIR = build + # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @echo " spelling to check for typos in documentation" .PHONY: help Makefile @@ -18,3 +21,11 @@ help: # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + + +spelling: + $(SPHINXBUILD) -b spelling -W $(SOURCEDIR) $(BUILDDIR)/spelling + @echo + @echo "Check finished. Wrong words can be found in " \ + "build/spelling/output.txt." + diff --git a/docs/source/_static/admin_manage.png b/docs/source/_static/admin_manage.png new file mode 100644 index 0000000..38764b4 Binary files /dev/null and b/docs/source/_static/admin_manage.png differ diff --git a/docs/source/_static/admin_manage_done.png b/docs/source/_static/admin_manage_done.png new file mode 100644 index 0000000..6ae475b Binary files /dev/null and b/docs/source/_static/admin_manage_done.png differ diff --git a/docs/source/_static/login.png b/docs/source/_static/login.png new file mode 100644 index 0000000..149ba55 Binary files /dev/null and b/docs/source/_static/login.png differ diff --git a/docs/source/_static/main.png b/docs/source/_static/main.png new file mode 100644 index 0000000..78afe48 Binary files /dev/null and b/docs/source/_static/main.png differ diff --git a/docs/source/_static/main_logout.png b/docs/source/_static/main_logout.png new file mode 100644 index 0000000..4dc98b7 Binary files /dev/null and b/docs/source/_static/main_logout.png differ diff --git a/docs/source/_static/profile.png b/docs/source/_static/profile.png new file mode 100644 index 0000000..c3ab5bf Binary files /dev/null and b/docs/source/_static/profile.png differ diff --git a/docs/source/_static/registration.png b/docs/source/_static/registration.png new file mode 100644 index 0000000..b6e8111 Binary files /dev/null and b/docs/source/_static/registration.png differ diff --git a/docs/source/_static/request.png b/docs/source/_static/request.png new file mode 100644 index 0000000..bf80c3f Binary files /dev/null and b/docs/source/_static/request.png differ diff --git a/docs/source/_static/role_change.png b/docs/source/_static/role_change.png new file mode 100644 index 0000000..ff2d983 Binary files /dev/null and b/docs/source/_static/role_change.png differ diff --git a/docs/source/code.rst b/docs/source/code.rst index 1c8af9f..7479081 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -1,16 +1,51 @@ -***** -TODOs -***** +Документация разработчика +========================= +******* +Models +******* + +.. automodule:: main.models + :members: + + +****** +Forms +****** + +.. automodule:: main.forms + :members: + + +*************** Extra Functions ---------------- +*************** .. automodule:: main.extra_func :members: +*************** +Serializers +*************** + +.. automodule:: main.serializers + :members: + + +*************** +API functions +*************** + +.. automodule:: main.apiauth + :members: + + +***** Views ------ +***** .. automodule:: main.views :members: + + diff --git a/docs/source/conf.py b/docs/source/conf.py index 438d5a4..d45b1cd 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,10 +12,34 @@ # import os import sys +import importlib +import inspect +import enchant +from enchant import checker + + 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__ + + django.setup() # -- Project information ----------------------------------------------------- @@ -30,16 +54,93 @@ release = 'v0.01' # -- General configuration --------------------------------------------------- +def process_django_models(app, what, name, obj, options, lines): + """Append params from fields to model documentation.""" + from django.utils.encoding import force_text + from django.utils.html import strip_tags + from django.db import models + + spelling_white_list = ['', '.. spelling::'] + + if inspect.isclass(obj) and issubclass(obj, models.Model): + for field in obj._meta.fields: + help_text = strip_tags(force_text(field.help_text)) + verbose_name = force_text(field.verbose_name).capitalize() + + if help_text: + lines.append(':param %s: %s - %s' % (field.attname, verbose_name, help_text)) + else: + lines.append(':param %s: %s' % (field.attname, verbose_name)) + + if enchant is not None: + from enchant.tokenize import basic_tokenize + + words = verbose_name.replace('-', '.').replace('_', '.').split('.') + words = [s for s in words if s != ''] + for word in words: + spelling_white_list += [" %s" % ''.join(i for i in word if not i.isdigit())] + spelling_white_list += [" %s" % w[0] for w in basic_tokenize(word)] + + field_type = type(field) + module = field_type.__module__ + if 'django.db.models' in module: + # scope with django.db.models * imports + module = 'django.db.models' + lines.append(':type %s: %s.%s' % (field.attname, module, field_type.__name__)) + if enchant is not None: + lines += spelling_white_list + return lines + + +def process_modules(app, what, name, obj, options, lines): + """Add module names to spelling white list.""" + if what != 'module': + return lines + from enchant.tokenize import basic_tokenize + + spelling_white_list = ['', '.. spelling::'] + words = name.replace('-', '.').replace('_', '.').split('.') + words = [s for s in words if s != ''] + for word in words: + spelling_white_list += [" %s" % ''.join(i for i in word if not i.isdigit())] + spelling_white_list += [" %s" % w[0] for w in basic_tokenize(word)] + lines += spelling_white_list + return lines + + +def skip_queryset(app, what, name, obj, skip, options): + """Skip queryset subclasses to avoid database queries.""" + from django.db import models + if isinstance(obj, (models.QuerySet, models.manager.BaseManager)) or name.endswith('objects'): + return True + return skip + + +# def setup(app): +# # Register the docstring processor with sphinx +# app.connect('autodoc-process-docstring', process_django_models) +# app.connect('autodoc-skip-member', skip_queryset) +# app.connect('autodoc-process-docstring', process_modules) + + + # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [ +extensions = { 'sphinx.ext.todo', 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', 'sphinx_rtd_theme', -] + 'sphinx.ext.graphviz', + 'sphinx.ext.inheritance_diagram', + 'sphinx_autodoc_typehints', + 'sphinxcontrib.spelling' + +} + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -56,7 +157,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 +169,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 +177,34 @@ 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 = 'ru_RU' +tokenizer_lang = 'ru_RU' +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 ---------------------------------------------- # 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 + +napoleon_attr_annotations = True diff --git a/docs/source/index.rst b/docs/source/index.rst index 778a091..96f9c69 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,16 +3,16 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to ZenDesk Access Controller's documentation! +Документация контроллера прав доступа ===================================================== - .. toctree:: :maxdepth: 2 :caption: Contents: - code.rst - todo.rst + overview + code + todo Indices and tables diff --git a/docs/source/overview.rst b/docs/source/overview.rst new file mode 100644 index 0000000..a7ca229 --- /dev/null +++ b/docs/source/overview.rst @@ -0,0 +1,97 @@ + +Документация пользователя +========================= + +****************************** +Управление правами доступа +****************************** + + +**ZenDesk Access Controller** - web-приложение, для выдачи прав пользователям системы по запросу самого пользователя. + +Например, из 12 человек 3 сейчас работают с правами администратора, по окончании рабочей смены они сдают свои права (освобождают места) и другие пользователи могут запросить эти права в свое пользование. + +Оставшиеся 9 человек получают права легкого агента - без прав редактирования, а только чтение. + +***************** +Главная страница +***************** + +Меню главной страницы предоставляет Вам выбор: + +* **"Войти"** - если Вы уже являетесь зарегистрированным пользователем +* **"Зарегистрироваться"** - при первом входе + +.. image:: _static/main_logout.png + +Внимание! Для регистрации используется email с сайта Zendesk. Регистрация по каждому email +возможна один раз + +**После авторизации пользователь может выбрать из следующих разделов меню:** + +* **"Профиль"** - просмотреть свои данные и запросить права доступа +* **"Запрос прав"** - получение прав для работы с тикетами или **"Управление"** - доступно для администратора и предоставляет возможность группового назначения ролей пользователям + +.. image:: _static/main.png + +************* +Регистрация +************* + +Для регистрации необходимо ввести email, который указан Вами в Zendesk. + +.. image:: _static/registration.png + +На электронную почту придет ссылка, пройдя по которой, Вам необходимо задать пароль. + +*********** +Авторизация +*********** + +Для входа необходимо ввести email и пароль + +.. image:: _static/login.png + +Если Вы не помните пароль необходимо пройти по ссылке "Забыли пароль" и указать email. +На Вашу почту придет ссылка для установки нового пароля. + +******** +Профиль +******** + +Профиль пользователя - это Ваша рабочая страница. + +Здесь Вы можете просмотреть информацию пользователя (Ваши данные с Zendesk) и запросить права доступа для работы с тикетами. + +.. image:: _static/profile.png + +******************** +Запрос прав доступа +******************** + +На странице запроса прав Вам доступна информация о количестве и списке работающих над тикетами сотрудников, +а также возможность сдать и запросить права. + +.. image:: _static/request.png + +Успешное изменение прав: + +.. image:: _static/role_change.png + +****************************************** +Управление правами доступа администратором +****************************************** + +Для администратора существует удобный интерфейс страницы управления, в котором представлены: + +* Количество свободных инженерных мест +* Количество и список инженеров и легких агентов +* Возможность группового назначения прав с использованием чек-боксов + +.. image:: _static/admin_manage.png + +Изменение прав пользователей наглядно отразится в таблице пользователей: + +.. image:: _static/admin_manage_done.png + +.. |copy| unicode:: 0xA9 .. Школа программистов S101, группа 2. 2021гю diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt new file mode 100644 index 0000000..bd64cf9 --- /dev/null +++ b/docs/source/spelling_wordlist.txt @@ -0,0 +1,88 @@ +тикетами +тикета +тикетов +тикет +web +Indices +and +tables +Models +логирования +User +user +superuser +light +light_agent +admin +agent +bootstrap +form +control +Zendesk +email +Extra +Functions +env +ID +url +None +token +password +engineer +SYSTEM +start_date +end_date +timedelta +log +RoleChangeLogs +time(datetime.time) +stat +statistic +True +False +val +start +end +date +Токен +токеном +аутентифицирован +(datetime.time) +datetime +time +serializer +валидны +html +subdomain +логгирования +логгирование +forms +StatisticForm +Userprofile +login +login_required +required +id +prom +home +PycharmProjects +Access +access +controler +controller +main +views +py +docstring +of +page +API +functions +Serializer +Serializers +Сериализатор +переадресации + + + + diff --git a/docs/source/todo.rst b/docs/source/todo.rst index d6ad446..ad6e4ed 100644 --- a/docs/source/todo.rst +++ b/docs/source/todo.rst @@ -1,5 +1,11 @@ +Что необходимо доделать? +======================== + + + ***** TODOs ***** .. todolist:: + diff --git a/layouts/adm_layout/adm_layout.png b/layouts/adm_layout/adm_layout.png new file mode 100644 index 0000000..eaa954b Binary files /dev/null and b/layouts/adm_layout/adm_layout.png differ diff --git a/layouts/statistic/statistic.png b/layouts/statistic/statistic.png new file mode 100644 index 0000000..931b7e1 Binary files /dev/null and b/layouts/statistic/statistic.png differ diff --git a/layouts/work/work.png b/layouts/work/work.png new file mode 100644 index 0000000..6d4b426 Binary files /dev/null and b/layouts/work/work.png differ diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/main/apiauth.py b/main/apiauth.py new file mode 100644 index 0000000..08a018c --- /dev/null +++ b/main/apiauth.py @@ -0,0 +1,49 @@ +import os + +from zenpy import Zenpy +from zenpy.lib.api_objects import User as ZenpyUser + +from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD + + +def api_auth() -> dict: + """ + Функция создания пользователя с использованием Zendesk API. + + Получает из env Zendesk - email, token, password пользователя. + Если данные валидны и пользователь Zendesk с указанным email и токеном или паролем существует, + создается словарь данных пользователя, полученных через API c Zendesk. + + :return: данные пользователя + """ + credentials = { + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN + } + email = ACTRL_API_EMAIL + token = ACTRL_API_TOKEN + password = ACTRL_API_PASSWORD + + if email is None: + raise ValueError('access_controller email not in env') + credentials['email'] = email + + # prefer token, use password if token not provided + if token: + credentials['token'] = token + elif password: + credentials['password'] = password + else: + raise ValueError('access_controller token or password not in env') + + zenpy_client = Zenpy(**credentials) + zenpy_user: ZenpyUser = zenpy_client.users.search(email).values[0] + + user = { + 'id': zenpy_user.id, + 'name': zenpy_user.name, # Zendesk doesn't have separate first and last name fields + 'email': zenpy_user.email, + 'role': zenpy_user.role, # str like 'admin' or 'agent', not id + 'photo': zenpy_user.photo['content_url'] if zenpy_user.photo is not None else None, + } + + return user diff --git a/main/extra_func.py b/main/extra_func.py index 305e897..0c93ba1 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,104 +1,564 @@ -import os +import logging +from datetime import timedelta, datetime, date +from typing import Optional +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import redirect +from django.utils import timezone from zenpy import Zenpy from zenpy.lib.exception import APIException +from zenpy.lib.api_objects import User as ZenpyUser, Ticket as ZenpyTicket +from zenpy.lib.generator import SearchResultGenerator -from main.models import UserProfile +from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY, ACTRL_ZENDESK_SUBDOMAIN +from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus +from main.zendesk_admin import zenpy -# Дополнительные функции -def set_and_get_name(user_profile: UserProfile): +def update_role(user_profile: UserProfile, role: int) -> None: """ - Функция устанавливает поле :class:`username` текущим именем в Zendesk + Функция меняет роль пользователя. - .. TODO:: - Переделать с получением данных через API - - :param UP: Объект профиля пользователя - :type UP: :class:`main.models.UserProfile` - :return: Имя пользователя - :rtype: :class:`str` + :param user_profile: Профиль пользователя + :param role: Новая роль + :return: Пользователь с обновленной ролью """ - return user_profile.user.username + zendesk = zenpy + 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) -def set_and_get_email(user_profile: UserProfile): # TODO: Переделать с получением данных через API +def make_engineer(user_profile: UserProfile, who_changes: User) -> None: """ - Функция устанавливает поле :class:`user.email` текущей почтой в Zendesk + Функция устанавливает пользователю роль инженера. - :param UP: Объект профиля пользователя - :type UP: :class:`main.models.UserProfile` - :return: Почта пользователя - :rtype: :class:`str` + :param user_profile: Профиль пользователя + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" """ - return user_profile.user.email + update_role(user_profile, ROLES['engineer']) -def set_and_get_role(user_profile: UserProfile): # TODO: Переделать с получением данных через API +def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: """ - Функция устанавливает поле :class:`role` текущей ролью в Zendesk + Функция устанавливает пользователю роль легкого агента. - :param UP: Объект профиля пользователя - :type UP: :class:`main.models.UserProfile` - :return: Роль пользователя - :rtype: :class:`str` + :param user_profile: Профиль пользователя + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" """ - return user_profile.role + tickets: SearchResultGenerator = get_tickets_list(user_profile.user.email) + ticket: ZenpyTicket + for ticket in tickets: + UnassignedTicket.objects.create( + assignee=user_profile.user, + ticket_id=ticket.id, + status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED + ) + if ticket.status == 'solved': + ticket.assignee_id = zenpy.solved_tickets_user_id + else: + ticket.assignee = None + ticket.group_id = zenpy.buffer_group_id + + if tickets.count: + zenpy.admin.tickets.update(tickets.values) + + attempts, success = 5, False + while not success and attempts != 0: + try: + update_role(user_profile, ROLES['light_agent']) + success = True + except APIException as e: + attempts -= 1 + if attempts == 0: + raise e -def load_and_get_image(user_profile: UserProfile): # TODO: Переделать с получением изображения через API +def get_users_list() -> list: """ - Функция загружает и устанавливает изображение в поле :class:`image` - - :param UP: Объект профиля пользователя - :type UP: :class:`main.models.UserProfile` - :return: Название изображения - :rtype: :class:`str` + Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. """ - return user_profile.image.name + zendesk = zenpy + + # У пользователей должна быть организация SYSTEM + org = next(zendesk.admin.search(type='organization', name='SYSTEM')) + users = zendesk.admin.organizations.users(org) + return users + + +def get_tickets_list(email): + """ + Функция возвращает список тикетов пользователя Zendesk + """ + return zenpy.admin.search(assignee=email, type='ticket') + + +def update_profile(user_profile: UserProfile): + """ + Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. + + :param user_profile: Профиль пользователя + :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя + """ + user = zenpy.get_user(user_profile.user.email) + user_profile.name = user.name + user_profile.role = user.role + user_profile.custom_role_id = user.custom_role_id if user.custom_role_id else 0 + user_profile.image = user.photo['content_url'] if user.photo else None + user_profile.save() def check_user_exist(email: str) -> bool: """ - Функция проверяет, существует ли пользователь + Функция проверяет, существует ли пользователь. - :param email: Электронная почта пользователя - :type email: :class:`str` - :return: True, если существует, иначе False - :rtype: :class:`bool` + :param email: Email пользователя + :return: Зарегистрирован ли пользователь в Zendesk """ - admin_creds = { - 'email': os.environ.get('Admin_email'), - 'subdomain': 'ngenix1612197338', - 'token': os.environ.get('Oauth_token'), - } - admin = Zenpy(**admin_creds) - zenpy_user = admin.search(email, type='user') - if zenpy_user: - return True - return False + return zenpy.check_user(email) + + +def get_user_organization(email: str) -> str: + """ + Функция возвращает организацию пользователя. + + :param email: Email пользователя + :return: Организация пользователя + """ + return zenpy.get_user_org(email) def check_user_auth(email: str, password: str) -> bool: """ - Функция проверяет, верны ли входные данные + Функция проверяет, верны ли входные данные. - :param email: Электроная почта пользователя - :type email: :class:`str` - :param password: Пароль пользователя - :type password: :class:`str` - :return: True, если входные данные верны, иначе False - :raise :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован - :rtype: :class:`bool` + :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован """ + creds = { + 'email': email, + 'password': password, + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, + } try: - creds = { - 'email': email, - 'subdomain': 'ngenix1612197338', - 'password': password, - } user = Zenpy(**creds) user.search(email, type='user') except APIException: return False return True + + +def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): + """ + Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. + + :param profile: Профиль пользователя + :param zendesk_user: Данные пользователя в Zendesk + :return: Обновленный профиль пользователя + """ + profile.name = zendesk_user.name + profile.role = zendesk_user.role + profile.image = zendesk_user.photo['content_url'] if zendesk_user.photo else None + if zendesk_user.custom_role_id is not None: + profile.custom_role_id = int(zendesk_user.custom_role_id) + profile.save() + + +def count_users(users) -> tuple: + """ + Функция подсчета количества сотрудников с ролями engineer и light_agent + """ + engineers, light_agents = 0, 0 + for user in users: + if user.custom_role_id == ROLES['engineer']: + engineers += 1 + elif user.custom_role_id == ROLES['light_agent']: + light_agents += 1 + return engineers, light_agents + + +def update_users_in_model(): + """ + Обновляет пользователей в модели UserProfile по списку пользователей в организации + """ + users = get_users_list() + for user in users: + try: + profile = User.objects.get(email=user.email).userprofile + update_user_in_model(profile, user) + except ObjectDoesNotExist: + pass + return users + + +def daterange(start_date, end_date) -> list: + """ + Функция возвращает список дней с start_date по end_date, исключая правую границу. + + :param start_date: Начальная дата + :param end_date: Конечная дата + :return: Список дней, не включая конечную дату + """ + dates = [] + for n in range(int((end_date - start_date).days)): + dates.append(start_date + timedelta(n)) + return dates + + +def get_timedelta(log, time=None) -> timedelta: + """ + Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, + который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. + + :param log: Лог + :param time: Время + :return: Сколько времени прошло от начала суток до события + """ + if time is None: + time = log.change_time.time() + time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) + return time + + +def last_day_of_month(day: int) -> int: + """ + Функция возвращает последний день текущего месяца. + + :param day: Текущий день + :return: Последний день месяца + """ + next_month = day.replace(day=28) + timedelta(days=4) + return next_month - timedelta(days=next_month.day) + + +class StatisticData: + """ + Класс для учета статистики интервалов работы пользователей. + Передаваемые параметры: start_date, end_date, email, stat. + + :param display: Формат отображения времени (часы, минуты) + :type display: :class:`list` + :param interval: Интервал времени в часах и минутах + :type interval: :class:`list` + :param start_date: Дата начала работы + :type start_date: :class:`date` + :param end_date: Дата окончания работы + :type end_date: :class:`date` + :param email: Email пользователя + :type email: :class:`str` + :param errors: Список ошибок + :type errors: :class:`list` + :param warnings: Список предупреждений + :type warnings: :class:`list` + :param data: Ретроспектива смены ролей пользователя + :type data: :class:`dict` + :param statistic: Интервалы работы пользователя + :type statistic: :class:`dict` + """ + + def __init__(self, start_date, end_date, user_email, stat=None): + self.display = None + self.interval = None + self.start_date = start_date + self.end_date = end_date + self.email = user_email + self.errors = list() + self.warnings = list() + self.data = dict() + self.statistic = dict() + self._init_data() + if stat is None: + self._init_statistic() + else: + self.statistic = stat + + def get_statistic(self) -> dict: + """ + Функция возвращает статистику работы пользователя. + + :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). None, если были ошибки при создании. + """ + if self.is_valid_statistic(): + stat = self.statistic + stat = self._use_display(stat) + stat = self._use_interval(stat) + return stat + else: + return None + + def is_valid_statistic(self) -> bool: + """ + Функция проверяет были ли ошибки при создании статистики. + + :return: True, при отсутствии ошибок + """ + return not self.errors and self.statistic + + def set_interval(self, interval: list) -> bool: + """ + Функция проверяет корректность представления интервала работы. + + :param interval: Интервал должен быть указан в днях или месяцах. + :return: True, если указан верно + """ + if interval not in ['months', 'days']: + self.errors += ['Интервал работы должен быть в днях или месяцах'] + return False + self.interval = interval + return True + + def set_display(self, display_format: list) -> bool: + """ + Функция проверяет корректность формата отображения интервала. + + :param display_format: Формат отображения должен быть указан в днях или месяцах. + :return: True, если указан верно + """ + if display_format not in ['days', 'hours']: + self.errors += ['Формат отображения должен быть в часах или днях'] + return False + self.display = display_format + return True + + def get_data(self) -> Optional[dict]: + """ + Функция возвращает данные - список объектов RoleChangeLogs. + """ + if self.is_valid_data(): + return self.data + else: + return None + + def is_valid_data(self) -> bool: + """ + Функция определяет были ли ошибки при получении логов. + + :return: True, если ошибок нет + """ + return not self.errors + + def _use_display(self, stat: list) -> list: + """ + Функция приводит данные к формату отображения. + + :param stat: Список данных статистики пользователя + :return: Обновленный список + """ + if not self.is_valid_statistic() or not self.display: + return stat + new_stat = {} + for key, item in stat.items(): + if self.display == 'hours': + new_stat[key] = item / 3600 + elif self.display == 'days': + new_stat[key] = item / (ONE_DAY * 3600) + return new_stat + + def _use_interval(self, stat: dict) -> dict: + """ + Функция объединяет ключи и значения в соответствии с интервалом работы. + + :param stat: Статистика работы пользователя + :return: Обновленная статистика + """ + if not self.is_valid_statistic() or not self.interval: + return stat + new_stat = {} + if self.interval == 'months': + # Переделываем ключи под формат('начало_месяца - конец_месяца') + for key, value in stat.items(): + current_month_start = max(self.start_date, date(year=key.year, month=key.month, day=1)) + current_month_end = min(self.end_date, last_day_of_month(date(year=key.year, month=key.month, day=1))) + index = ' - '.join([str(current_month_start), str(current_month_end)]) + if new_stat.get(index): + new_stat[index] += value + else: + new_stat[index] = value + elif self.interval == 'days': + new_stat = stat # статистика изначально в днях + return new_stat + + def check_time(self) -> bool: + """ + Функция проверяет корректность введенного времени. + + :return: True, если время указано корректно. Иначе, False + """ + if self.end_date < self.start_date or self.end_date > datetime.now().date(): + return False + return True + + def _init_data(self): + """ + Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. + + :return: Данные о смене статусов пользователя. Если пользователь не найден или интервал времени некорректен - ошибку. + """ + if not self.check_time(): + self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] + return + try: + self.data = RoleChangeLogs.objects.filter( + change_time__range=[self.start_date, self.end_date + timedelta(days=1)], + user=User.objects.get(email=self.email), + ).order_by('change_time') + except User.DoesNotExist: + self.errors += ['Пользователь не найден'] + + def _init_statistic(self) -> dict: + """ + Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. + + :return: Статистика работы пользователя (statistic) + """ + self.clear_statistic() + if not self.get_data(): + self.warnings += ['Не обнаружены изменения роли в данном промежутке'] + return None + first_log, last_log = self.data[0], self.data[len(self.data) - 1] + + if first_log.old_role == ROLES['engineer']: + self.prev_engineer_logic(first_log) + + if last_log.new_role == ROLES['engineer']: + self.post_engineer_logic(last_log) + + for log_index in range(len(self.data) - 1): + if self.data[log_index].new_role == ROLES['engineer']: + self.engineer_logic(log_index) + + def engineer_logic(self, log_index): + """ + Функция обрабатывает основную часть работы инженера + """ + current_log, next_log = self.data[log_index], self.data[log_index + 1] + if current_log.change_time.date() != next_log.change_time.date(): + self.statistic[current_log.change_time.date()] += ( + timedelta(days=1) - get_timedelta(current_log)).total_seconds() + self.statistic[next_log.change_time.date()] += get_timedelta(next_log).total_seconds() + self.fill_daterange(current_log.change_time.date() + timedelta(days=1), next_log.change_time.date()) + else: + elapsed_time = next_log.change_time - current_log.change_time + self.statistic[current_log.change_time.date()] += elapsed_time.total_seconds() + + def post_engineer_logic(self, last_log): + """ + Функция обрабатывает случай, когда нам изветсно что инженер работал и после диапазона + """ + self.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date + timedelta(days=1)) + if last_log.change_time.date() == timezone.now().date(): + self.statistic[last_log.change_time.date()] += ( + get_timedelta(None, timezone.now().time()) - get_timedelta(last_log) + ).total_seconds() + else: + self.statistic[last_log.change_time.date()] += ( + timedelta(days=1) - get_timedelta(last_log)).total_seconds() + if self.end_date == timezone.now().date(): + self.statistic[self.end_date] = get_timedelta(None, timezone.now().time()).total_seconds() + + def prev_engineer_logic(self, first_log): + """ + Функция обрабатывает случай, когда нам изветсно что инженер начал работу до диапазона + """ + self.fill_daterange(max(User.objects.get(email=self.email).date_joined.date(), self.start_date), + first_log.change_time.date()) + self.statistic[first_log.change_time.date()] += get_timedelta(first_log).total_seconds() + + def fill_daterange(self, first: date, last: date, val: int = 24 * 3600) -> dict: + """ + Функция заполняет диапазон дат значением val (по умолчанию val = кол-во секунд в 1 дне). + + :param first: Начальная дата интервала + :param last: Последняя дата интервала + :param val: Количество секунд в одном дне + """ + for day in daterange(first, last): + self.statistic[day] = val + + def clear_statistic(self) -> dict: + """ + Функция осуществляет обновление всех дней. + """ + self.statistic.clear() + self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) + + +class DatabaseHandler(logging.Handler): + def __init__(self): + logging.Handler.__init__(self) + + def emit(self, record): + database = RoleChangeLogs() + users = record.msg + if users[1]: + user = users[0] + admin = users[1] + elif not users[1]: + user = users[0] + admin = users[0] + database.name = user.name + database.user = user.user + database.changed_by = admin.user + if user.custom_role_id == ROLES['engineer']: + database.old_role = ROLES['light_agent'] + elif user.custom_role_id == ROLES['light_agent']: + database.old_role = ROLES['engineer'] + database.new_role = user.custom_role_id + database.save() + + +class CsvFormatter(logging.Formatter): + def __init__(self): + logging.Formatter.__init__(self) + + def format(self, record): + users = record.msg + if users[1]: + user = users[0] + admin = users[1] + elif not users[1]: + user = users[0] + admin = users[0] + msg = '' + msg += user.name + if user.custom_role_id == ROLES['engineer']: + msg += ',engineer,' + elif user.custom_role_id == ROLES['light_agent']: + msg += ',light_agent,' + time = str(timezone.now().today()) + msg += time[:16] + msg += ',' + msg += admin.name + return msg + + +def log(user, admin=0): + """ + Осуществляет запись логов в базу данных и csv файл + :param admin: + :param user: + :return: + """ + users = [user, admin] + logger = logging.getLogger('MY_LOGGER') + if not logger.hasHandlers(): + dbhandler = DatabaseHandler() + csvformatter = CsvFormatter() + csvhandler = logging.FileHandler('logs/logs.csv', "a") + csvhandler.setFormatter(csvformatter) + logger.addHandler(dbhandler) + logger.addHandler(csvhandler) + logger.setLevel('INFO') + logger.info(users) + + +def set_session_params_for_work_page(request, count=None, is_confirm=True): + """ + Функция для страницы получения прав + Устанавливает данные сессии о успешности запроса и количестве назначенных тикетов + """ + request.session['is_confirm'] = is_confirm + request.session['count_tickets'] = count + return redirect('work', request.user.id) diff --git a/main/forms.py b/main/forms.py index fcd8a7f..81b2e8a 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,34 +1,142 @@ from django import forms +from django.contrib.auth.forms import AuthenticationForm from django_registration.forms import RegistrationFormUniqueEmail +from main.models import UserProfile + class CustomRegistrationForm(RegistrationFormUniqueEmail): """ Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail` - с полем для ввода пароля от Zendesk аккаунта и с добавлением bootstrap-класса 'form-control' для всех полей + с добавлением bootstrap-класса "form-control". - :param password_zen: Поле для ввода пароля от Zendesk - :type password_zen: :class:`django.forms.CharField` + :param visible_fields.email: Поле для ввода email, зарегистрированного на Zendesk + :type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail` """ - password_zen = forms.CharField( - required=True, - label="Пароль от Zendesk аккаунта", - strip=False, - widget=forms.PasswordInput(attrs={ - 'class': 'form-control' - }) - ) - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) for visible in self.visible_fields(): if visible.field.widget.attrs.get('class', False): - print(visible.field.widget.attrs['class'].find('form-control')) if visible.field.widget.attrs['class'].find('form-control') < 0: visible.field.widget.attrs['class'] += 'form-control' else: visible.field.widget.attrs['class'] = 'form-control' + if visible.html_name != 'email': + visible.field.required = False class Meta(RegistrationFormUniqueEmail.Meta): fields = RegistrationFormUniqueEmail.Meta.fields - fields.insert(2, 'password_zen') + + +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', + + } + ), + label='' + ) + + +class CustomAuthenticationForm(AuthenticationForm): + """ + Форма для авторизации :class:`django.contrib.auth.forms.AuthenticationForm` + с изменением поля username на email. + + :param username: Поле для ввода email пользователя + :type username: :class:`django.forms.fields.CharField` + """ + username = forms.CharField( + label="Электронная почта", + widget=forms.EmailInput(), + ) + error_messages = { + 'invalid_login': + "Пожалуйста, введите правильные электронную почту и пароль. Оба поля " + "могут быть чувствительны к регистру." + , + 'inactive': "Аккаунт не активен.", + } + + +INTERVAL_CHOICES = [ + ('days', 'Дни'), + ('months', 'Месяцы') +] +DISPLAY_CHOICES = [ + ('hours', 'Часы'), + ('days', 'Дни/Смены') +] + + +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='Электронная почта', + widget=forms.EmailInput( + attrs={ + 'placeholder': 'example@ngenix.ru', + 'class': 'form-control', + } + ), + ) + interval = forms.ChoiceField( + label='Выберите интервалы времени работы', + choices=INTERVAL_CHOICES, + widget=forms.RadioSelect( + attrs={ + 'class': 'btn-check', + } + ) + ) + display_format = forms.ChoiceField( + label='Выберите формат отображения', + choices=DISPLAY_CHOICES, + widget=forms.RadioSelect( + attrs={ + 'class': 'btn-check', + } + ) + ) + range_start = forms.DateField( + label='Начало статистики', + widget=forms.DateInput( + attrs={ + 'type': 'date', + 'class': 'btn btn-secondary text-primary bg-white', + } + ), + ) + range_end = forms.DateField( + label='Конец статистики', + widget=forms.DateInput( + attrs={ + 'type': 'date', + 'class': 'btn btn-secondary text-primary bg-white', + } + ), + ) diff --git a/main/migrations/0003_auto_20210216_2222.py b/main/migrations/0003_auto_20210216_2222.py new file mode 100644 index 0000000..33076ac --- /dev/null +++ b/main/migrations/0003_auto_20210216_2222.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.6 on 2021-02-16 19:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0002_userprofile_name'), + ] + + operations = [ + migrations.AlterField( + model_name='userprofile', + name='image', + field=models.URLField(blank=True, null=True), + ), + migrations.AlterField( + model_name='userprofile', + name='role', + field=models.CharField(default='None', max_length=100), + ), + ] diff --git a/main/migrations/0004_rolechangelogs.py b/main/migrations/0004_rolechangelogs.py new file mode 100644 index 0000000..c3d6328 --- /dev/null +++ b/main/migrations/0004_rolechangelogs.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.6 on 2021-02-17 17:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('main', '0003_auto_20210216_2222'), + ] + + operations = [ + migrations.CreateModel( + name='RoleChangeLogs', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField()), + ('new_role', models.TextField()), + ('change_time', models.DateTimeField()), + ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changed_by', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/main/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/migrations/0015_auto_20210321_1600.py b/main/migrations/0015_auto_20210321_1600.py new file mode 100644 index 0000000..79b5726 --- /dev/null +++ b/main/migrations/0015_auto_20210321_1600.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.6 on 2021-03-21 13:00 + +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/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/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 = [ + ] diff --git a/main/migrations/0017_auto_20210408_1943.py b/main/migrations/0017_auto_20210408_1943.py new file mode 100644 index 0000000..4db02cf --- /dev/null +++ b/main/migrations/0017_auto_20210408_1943.py @@ -0,0 +1,26 @@ +# Generated by Django 3.1.7 on 2021-04-08 16:43 + +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', '0016_merge_20210330_0043'), + ] + + operations = [ + migrations.AlterField( + model_name='unassignedticket', + name='assignee', + field=models.ForeignKey(help_text='Пользователь, с которого снят тикет', on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='unassignedticket', + name='status', + field=models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются'), (3, 'Тикет уже был закрыт. Дополнительные действия не требуются'), (4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL')], default=0, help_text='Статус тикета'), + ), + ] diff --git a/main/models.py b/main/models.py index 96d04db..1920c4d 100644 --- a/main/models.py +++ b/main/models.py @@ -1,11 +1,84 @@ -import os - -from django.contrib.auth.models import User from django.db import models +from django.contrib.auth.models import User +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone + +from access_controller.settings import ZENDESK_ROLES class UserProfile(models.Model): - user = models.OneToOneField(to=User, on_delete=models.CASCADE) - role = models.IntegerField() - image = models.ImageField(upload_to='user_avatars') - name = models.CharField(default='None', max_length=100) + """ + Модель профиля пользователя. + + Профиль создается и изменяется при создании и изменении модель User. + """ + + class Meta: + permissions = ( + ('has_control_access', 'Can view admin page'), + ) + + user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') + role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя') + 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='Имя пользователя на нашем сайте') + + @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): + if created: + UserProfile.objects.create(user=instance) + + +@receiver(post_save, sender=User) +def save_user_profile(sender, instance, **kwargs): + instance.userprofile.save() + + +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(default=timezone.now, help_text='Дата и время изменения роли') + changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') + + +class UnassignedTicketStatus(models.IntegerChoices): + """ + Класс статусов не распределенных тикетов. + + :param UNASSIGNED: Снят с пользователя, перенесён в буферную группу + :param RESTORED: Авторство восстановлено + :param NOT_FOUND: Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются + :param CLOSED: Тикет уже был закрыт. Дополнительные действия не требуются + :param SOLVED: Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL + """ + 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', help_text='Пользователь, с которого снят тикет') + ticket_id = models.IntegerField(help_text='Номер тикера, для которого сняли ответственного') + status = models.IntegerField(choices=UnassignedTicketStatus.choices, default=UnassignedTicketStatus.UNASSIGNED, help_text='Статус тикета') + diff --git a/main/serializers.py b/main/serializers.py new file mode 100644 index 0000000..8436b54 --- /dev/null +++ b/main/serializers.py @@ -0,0 +1,38 @@ +from django.contrib.auth.models import User +from rest_framework import serializers +from main.models import UserProfile +from access_controller.settings import ZENDESK_ROLES + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + """ + Класс serializer для модели User. + """ + class Meta: + model = User + fields = ['email'] + + +class ProfileSerializer(serializers.HyperlinkedModelSerializer): + """Класс serializer для модели профиля пользователя""" + user = UserSerializer() + + class Meta: + model = UserProfile + fields = ['user', 'id', 'name', 'zendesk_role'] + + +class ZendeskUserSerializer(serializers.Serializer): + """Класс serializer для объектов пользователей из zenpy""" + name = serializers.CharField() + zendesk_role = serializers.SerializerMethodField('get_zendesk_role') + email = serializers.EmailField() + + @staticmethod + def get_zendesk_role(obj): + if obj.custom_role_id == ZENDESK_ROLES['engineer']: + return 'engineer' + elif obj.custom_role_id == ZENDESK_ROLES['light_agent']: + return 'light_agent' + else: + return "empty" diff --git a/main/templates/base/base.html b/main/templates/base/base.html index 0d18dcd..166195d 100644 --- a/main/templates/base/base.html +++ b/main/templates/base/base.html @@ -2,57 +2,58 @@ {% load static %} + {% block title %}{% endblock %} - + + } + + {% block extra_css %}{% endblock %} + {% block extra_scripts %}{% endblock %} + -{% include 'base/menu.html' %} + {% include 'base/menu.html' %} -
-
-

- {% block heading %} +
+
+

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

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

- {% block content %} - {% endblock %} -
-
+ + - + - + + diff --git a/main/templates/base/menu.html b/main/templates/base/menu.html index 8448b0c..ef5df18 100644 --- a/main/templates/base/menu.html +++ b/main/templates/base/menu.html @@ -3,20 +3,56 @@ -