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..a1195c0 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. 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 ffd6e42..e0b2f61 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,153 @@ -************************* -ZenDesk Access Controller -************************* +# ZenDesk Access Controller -****************************** -Управление правами доступа -****************************** +## Управление правами доступа -**Идея** - Web приложение, выдает права пользователям системы по запросу самого пользователя. Например, из 12 человек 3 -сейчас работают с правами админа, по окончании рабочей смены они сдают свои права (освобождают места) и другие -пользователи могут права запросить. +Идея - написать программу(Web приложение), которая будет выдавать права пользователям системы по запросу самого +пользователя. Например, из 12 человек 3 сейчас работают с правами админа, по окончании рабочей смены они сдают +свои права (освобождают места) и другие пользователи могут запросить эти права в свое пользование. -Оставшиеся 9 человек получают права легкого агента - без прав редактирования, только чтение. +Оставшиеся 9 человек получают права легкого агента - без прав редактирования, а только чтение. -Технологически приложение взаимодействует с **api** системы **Zendesk** (система обращений клиентов - жалобы), -проверяет авторизованного пользователя на права с возможностью менять права напрямую из Zendesk (синхронизация -с приоритетом у Zendesk). +Из технологий - программа должна взаимодействовать с api системы Zendesk(система обращений клиентов - жалобы), +проверять авторизованного пользователя на права(будет возможность менять права напрямую из Zendesk - нужна +синхронизация прав с приоритетом у Zendesk). -Присутствует проверка, регистрации пользователя сайта на сайте Zendesk (по токену). +Если руками в самом Zendesk права у пользователя отобрали или наоборот +присвоили, то наша программа обновляет статус пользователя в соответствии с данными синхронизации +(например, раз в минуту). + +Так же в идеале должна быть проверка, что пользователь сайта существует на сайте Zendesk(по токену). + +Сэндбокс Zendesk нам предоставит моя компания, библиотеку для работы с api уже подсказали. +Сама программа (наша) будет обладать админскими правами и реализовывать контроль и выдачу прав другим пользователям. *Итого:* 1. Реализовать авторизацию пользователей с проверкой по API на существование такого пользователя 2. Реализовать интерфейс со статистикой рабочих мест(занято, свободно, кто занимает) -3. Реализовать логгирование действий(когда взял права, когда отдал - запись в файл и БД) -4. Реализовать передачу прав приложением по запросу от пользователя и замену прав пользователя у которого права отбираются внутри Zendesk (на легкий агент) +3. Реализовать логирование действий(когда взял права, когда отдал - запись в файл и БД) +4. Реализовать передачу прав приложением по запросу от пользователя и замену прав пользователя + у которого права отбираются внутри Zendesk (на легкий агент) 5. Реализовать синхронизацию по API на проверку прав(не менялись ли в системе Zendesk) 6. Реализовать возможность добавить большее количество админских прав 7. Реализовать возможность добавления легких агентов(права только на просмотр) 8. Реализовать на общей странице текущую информацию о пользователе - текущие права, карточка пользователя -************************ -Технологический стек: -************************ + +## Технологический стек: - Python 3 - Django 3 -************** -Quickstart -************** -``sudo apt install make`` -``pip install --upgrade pip`` +## Quickstart +```bash +sudo apt install make +pip install --upgrade pip +pip install -r requirements/dev.txt +./manage.py migrate +./manage.py loaddata data.json +./manage.py runserver +``` -``pip install -r requirements.txt`` +## ZenDesk Access Controller instruction for eng -``./manage.py migrate`` +## Перед запуском для тестирования: -``./manage.py loaddata data.json`` - -``./manage.py runserver`` - -********************************** -Перед запуском для тестирования: -********************************** - -* убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация (SYSTEM) -* для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk -* при запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение - -***************************** -Запуск на локальной машине: -***************************** - -* скопировать репозиторий на локальную машину -* перейти в папку приложения -* активировать виртуальное окружение -* выполнить команду **pip install -r requirements.txt** -* в виртуальное окружение добавить следующие переменные: +Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация `SYSTEM` +Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk +При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение -| *ACCESS_CONTROLLER_API_EMAIL={EMAIL}* - почта админа в ZenDesk -| *ACCESS_CONTROLLER_API_PASSWORD={PASSWORD}* - пароль админа ZenDesk -| *ACCESS_CONTROLLER_API_TOKEN={API_TOKEN}* - API токен ZenDesk -| *ZD_DOMAIN={DOMAIN}* - домен ZenDesk -| *ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID}* - id роли инженера( custom_role_id сотрудника смены) -| *LA_CROLE_ID={LIGHT_AGENT_CUSTOM_ROLE_ID}* - id роли легкого агента (custom_role_id роли -легкий агент) -| *EMPL_GROUP={EMPLOYEE_GROUP_NAME}* - имя группы которой принадлежат сотрудники ССКС -| *BUF_GROUP={BUFFER_GROUP_NAME}* - имя буферной группы для передачи смен(через нее происходит управление тикетами) -| *ST_EMAIL={SOLVED_TICKETS_EMAIL}* - почта на которую будут переназначатся закрытые тикеты -| *LICENSE_NO={LICENSE_NO}* - количество лицензий, отображаемых как доступные в приложении -| *SHIFTH={SHIFT_HOURS}* - количество часов в рабочей смене (нужно для статистики, пока не реализовано но требует указания значения) +## Запуск на локальной машине: +- Скопировать репозиторий на локальную машину +- Перейти в папку приложения +- Активировать виртуальное окружение +- Выполнить команду `pip install -r requirements/dev.txt` +- В виртуальное окружение добавить следующие переменные: -* выполнить команду **python manage.py makemigrations** -* выполнить команду **python manage.py migrate** -* запустить приложение командой **python manage.py runserver** (можно указать в параметрах для файла manage.py) -* перейти по ссылке в консоли (вероятнее всего откроется по адресу http://127.0.0.1:8000/) +``` +ACTRL_DEBUG={0/1} - включить режим дебага +ACTRL_HOST={HOSTNAME} - при запуске без дебага, надо указать домен на котором будет работать приложение +ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} - секретный ключ сгенерированый Django -****************** -Запуск в Docker: -****************** +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**. -* выполнить команду **docker images** (нам нужен id созданного образа) -* выполнить команду **docker run -d -p 8000:8000 -e ACCESS_CONTROLLER_API_EMAIL={EMAIL} -e ACCESS_CONTROLLER_API_PASSWORD={PASSWORD}**...(перечисляем все параметры виртуального окружения, разделяя их -e) -v {абсолютный путь к папке, в которой будет размещена база}:/zendesk-access-controller/db {id образа докера} -* открыть запущенный контейнер в браузере (можно перейти по ссылке http://localhost:8000/) +- Скопировать репозиторий на локальную машину +- В командной строке перейти в папку проекта +- Выполнить команду `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)) +## Запуск с тестовыми юзерами: +На локальной машине - перед запуском команды `python manage.py runserver` выполнить команду `python manage.py loaddata data.json` +Это создаст тестового админа и тестового пользователя в приложении для песочницы ZenDesk. -******************************* -Параметры тестовой песочницы: -******************************* +- Админ - `admin@gmail.com` / `zendeskadmin` +- Пользователь - `123@test.ru` / `zendeskuser` -| *ACCESS_CONTROLLER_API_EMAIL={EMAIL}* - почта админа в ZenDesk - взять у роководителя(если вы не админ) -| *ACCESS_CONTROLLER_API_PASSWORD={PASSWORD}* - пароль админа ZenDesk - взять у роководителя(если вы не админ) -| *ACCESS_CONTROLLER_API_TOKEN={API_TOKEN}* - API токен зендеск - взять у роководителя(если вы не админ) -| *ZD_DOMAIN* =ngenix1612197338 -| *ENG_CROLE_ID* =360005209000 -| *LA_CROLE_ID* =360005208980 -| *EMPL_GROUP* =Поддержка -| *BUF_GROUP* =Сменная группа -| *ST_EMAIL* =d.krikov@ngenix.net -| *LICENSE_NO* =3 -| *SHIFTH* =12 +Не сработает если домен песочницы отличается от `ngenix1612197338` (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента +с этими же почтами, назначить им организацию `SYSTEM`) -************ -Read more -************ + +## Параметры тестовой песочницы: +Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). + + +## Read more - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) - Zendesk API: [https://developer.zendesk.com/rest_api/docs/](https://developer.zendesk.com/rest_api/docs/) diff --git a/access_controller/settings.py b/access_controller/settings.py index cee5805..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 = ['127.0.0.1'] +ALLOWED_HOSTS = [ + '127.0.0.1', + 'localhost', + os.getenv('ACTRL_HOST'), +] # Application definition @@ -53,13 +57,13 @@ MIDDLEWARE = [ ROOT_URLCONF = 'access_controller.urls' EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = 'smtp.mail.ru' -EMAIL_PORT = 2525 -EMAIL_USE_TLS = True -EMAIL_HOST_USER = 'djgr.02@mail.ru' -EMAIL_HOST_PASSWORD = 'djangogroup02' -SERVER_EMAIL = EMAIL_HOST_USER -DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +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 = [ { @@ -87,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' } } @@ -143,55 +147,21 @@ AUTHENTICATION_BACKENDS = [ # Logging system # https://docs.djangoproject.com/en/3.1/topics/logging/ -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', - 'style': '{', - }, - 'simple': { - 'format': '{levelname} {message}', - 'style': '{', - }, - }, - 'handlers': { - 'console': { - 'level': 'INFO', - 'class': 'logging.StreamHandler', - 'formatter': 'simple' - }, - 'mail_admins': { - 'level': 'ERROR', - 'class': 'django.utils.log.AdminEmailHandler', - } - }, - 'loggers': { - 'django': { - 'handlers': ['console'], - 'propagate': True, - }, - 'main.index': { - 'handlers': ['console'], - 'level': 'INFO', - } - } -} + ZENDESK_ROLES = { - 'engineer': 360005209000, - 'light_agent': 360005208980, + 'engineer': int(os.getenv('ENG_CROLE_ID',0)), + 'light_agent': int(os.getenv('LA_CROLE_ID',0)), } ZENDESK_GROUPS = { - 'employees': 'Поддержка', - 'buffer': 'Сменная группа', + 'employees': os.getenv('EMPL_GROUP'), + 'buffer': os.getenv('BUF_GROUP'), } -SOLVED_TICKETS_EMAIL = 'd.krikov@ngenix.net' +SOLVED_TICKETS_EMAIL = os.getenv('ST_EMAIL') -ZENDESK_MAX_AGENTS = 3 +ZENDESK_MAX_AGENTS = int(os.getenv('LICENSE_NO',0)) REST_FRAMEWORK = { # Use Django's standard `django.contrib.auth` permissions, @@ -201,4 +171,9 @@ REST_FRAMEWORK = { ] } -ONE_DAY = 12 # Количество часов в 1 рабочем дне +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 e174717..f6a6754 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -14,54 +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 import views as auth_views from django.urls import path, include -from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView -from main.views import work_page, work_hand_over, work_become_engineer, \ +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/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('accounts/login/', include('django.contrib.auth.urls')), path('control/', AdminPageView.as_view(), name='control'), - path('statistic/', statistic_page, name='statistic') + path('statistic/', statistic_page, name='statistic'), ] -urlpatterns += [ - path( - 'password_reset/', - auth_views.PasswordResetView.as_view(), - name='password_reset' - ), - path( - 'password-reset/done/', - auth_views.PasswordResetDoneView.as_view(), - name='password_reset_done' - ), - path( - 'reset///', - auth_views.PasswordResetConfirmView.as_view(), - name='password_reset_confirm' - ), - path( - 'reset/done/', - auth_views.PasswordResetCompleteView.as_view(), - name='password_reset_complete' - ), -] # Django REST urlpatterns += [ diff --git a/data.json b/data.json index 97678f3..a4310a4 100644 --- a/data.json +++ b/data.json @@ -1,7 +1,7 @@ [ { "model": "auth.user", - "pk": 3, + "pk": 1, "fields": { "password": "pbkdf2_sha256$216000$gHBBCr1jBELf$ZkEDW3IEd8Wij7u8vkv+0Eze32CS01bcaYWhcD9OIC4=", "last_login": null, @@ -19,16 +19,16 @@ }, { "model": "main.userprofile", - "pk": 3, + "pk": 1, "fields": { "name": "ZendeskAdmin", - "user": 3, + "user": 1, "role": "admin" } }, { "model": "auth.user", - "pk": 4, + "pk": 2, "fields": { "password": "pbkdf2_sha256$216000$5qLJgrm2Quq9$KDBNNymVZXkUx0HKBPFst2m83kLe0egPBnkW7KnkORU=", "last_login": null, @@ -46,10 +46,10 @@ }, { "model": "main.userprofile", - "pk": 4, + "pk": 2, "fields": { "name": "UserForAccessTest", - "user": 4, + "user": 2, "role": "agent", "custom_role_id": "360005209000" } diff --git a/db/.gitkeep b/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/conf.py b/docs/source/conf.py index 3330341..cefcc10 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -134,6 +134,7 @@ extensions = { 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', 'sphinx_rtd_theme', 'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram', @@ -208,3 +209,5 @@ 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/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/main/apiauth.py b/main/apiauth.py index c24e85f..08a018c 100644 --- a/main/apiauth.py +++ b/main/apiauth.py @@ -3,6 +3,8 @@ 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: """ @@ -15,15 +17,15 @@ def api_auth() -> dict: :return: данные пользователя """ credentials = { - 'subdomain': 'ngenix1612197338' + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN } - email = os.getenv('ACCESS_CONTROLLER_API_EMAIL') - token = os.getenv('ACCESS_CONTROLLER_API_TOKEN') - password = os.getenv('ACCESS_CONTROLLER_API_PASSWORD') + 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'] = os.getenv('ACCESS_CONTROLLER_API_EMAIL') + credentials['email'] = email # prefer token, use password if token not provided if token: diff --git a/main/extra_func.py b/main/extra_func.py index ea37003..86b6739 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,150 +1,21 @@ -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 access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY, ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL +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 -class ZendeskAdmin: - """ - Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора. - - :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) - :type credentials: :class:`dict` - :param email: Email администратора, указанный в env - :type email: :class:`str` - :param token: Токен администратора (формируется в Zendesk, указывается в env) - :type token: :class:`str` - :param password: Пароль администратора, указанный в env - :type password: :class:`str` - - """ - - credentials: dict = { - 'subdomain': 'ngenix1612197338' - } - 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: - """ - Функция осуществляет проверку существования пользователя в Zendesk по email. - - :param email: Email пользователя - :return: Является ли зарегистрированным - """ - return True if self.admin.search(email, type='user') else False - - def get_user_name(self, email: str) -> str: - """ - Функция **get_user_name** возвращает имя пользователя по его email. - - :param email: Email пользователя - :return: Имя пользователя - """ - user = self.admin.users.search(email).values[0] - return user.name - - def get_user_role(self, email: str) -> str: - """ - Функция возвращает роль пользователя по его email. - - :param email: Email пользователя - :return: Роль пользователя - """ - user = self.admin.users.search(email).values[0] - return user.role - - def get_user_id(self, email: str) -> str: - """ - Функция возвращает id пользователя по его email - - :param email: Email пользователя - :return: ID пользователя - """ - user = self.admin.users.search(email).values[0] - return user.id - - def get_user_image(self, email: str) -> Optional[str]: - """ - Функция возвращает url-ссылку на аватар пользователя по его email. - - :param email: Email пользователя - :return: Аватар пользователя - """ - user = self.admin.users.search(email).values[0] - return user.photo['content_url'] if user.photo else None - - def get_user(self, email: str): - """ - Функция возвращает пользователя (объект) по его email. - - :param email: Email пользователя - :return: Объект пользователя, найденного в БД - """ - return self.admin.users.search(email).values[0] - - def get_group(self, name: str) -> Optional[str]: - """ - Функция возвращает группу, к которой принадлежит пользователь. - - :param name: Имя пользователя - :return: Группы пользователя (в случае отсутствия None) - """ - groups = self.admin.search(name) - for group in groups: - return group - return None - - def get_user_org(self, email: str) -> Optional[str]: - """ - Функция возвращает организацию, к которой относится пользователь по его email. - - :param email: Email пользователя - :return: Организация пользователя - """ - user = self.admin.users.search(email).values[0] - return user.organization.name if user.organization else None - - def create_admin(self) -> Zenpy: - """ - Функция создает администратора, проверяя наличие вводимых данных в 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'] = self.email - - if self.token: - self.credentials['token'] = self.token - elif self.password: - self.credentials['password'] = self.password - else: - raise ValueError('access_controller token or password not in env') - self.admin = Zenpy(**self.credentials) - try: - self.admin.search(self.email, type='user') - except APIException: - raise ValueError('invalid access_controller`s login data') - - -def update_role(user_profile: UserProfile, role: str) -> UserProfile: +def update_role(user_profile: UserProfile, role: int) -> None: """ Функция меняет роль пользователя. @@ -152,7 +23,7 @@ def update_role(user_profile: UserProfile, role: str) -> UserProfile: :param role: Новая роль :return: Пользователь с обновленной ролью """ - zendesk = ZendeskAdmin() + zendesk = zenpy user = zendesk.get_user(user_profile.user.email) user.custom_role_id = role user_profile.custom_role_id = role @@ -160,23 +31,17 @@ def update_role(user_profile: UserProfile, role: str) -> UserProfile: zendesk.admin.users.update(user) -def make_engineer(user_profile: UserProfile, who_changes: User) -> UserProfile: +def make_engineer(user_profile: UserProfile, who_changes: User) -> None: """ Функция устанавливает пользователю роль инженера. :param user_profile: Профиль пользователя :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer" """ - RoleChangeLogs.objects.create( - user=user_profile.user, - old_role=user_profile.custom_role_id, - new_role=ROLES['engineer'], - changed_by=who_changes - ) update_role(user_profile, ROLES['engineer']) -def make_light_agent(user_profile: UserProfile, who_changes: User) -> UserProfile: +def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: """ Функция устанавливает пользователю роль легкого агента. @@ -184,6 +49,7 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> UserProfil :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" """ tickets = get_tickets_list(user_profile.user.email) + ticket: ZenpyTicket for ticket in tickets: UnassignedTicket.objects.create( assignee=user_profile.user, @@ -191,26 +57,29 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> UserProfil status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED ) if ticket.status == 'solved': - ticket.assignee = ZendeskAdmin().get_user(SOLVED_TICKETS_EMAIL) + ticket.assignee_id = zenpy.solved_tickets_user_id else: ticket.assignee = None - ticket.group = ZendeskAdmin().get_group(ZENDESK_GROUPS['buffer']) - ZendeskAdmin().admin.tickets.update(ticket) + ticket.group_id = zenpy.buffer_group_id - 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']) + 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 get_users_list() -> list: """ - Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. + Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. """ - zendesk = ZendeskAdmin() + zendesk = zenpy # У пользователей должна быть организация SYSTEM org = next(zendesk.admin.search(type='organization', name='SYSTEM')) @@ -218,24 +87,21 @@ def get_users_list() -> list: return users -def get_tickets_list(email: str) -> list: +def get_tickets_list(email): """ - Функция возвращает список тикетов пользователя Zendesk. - - :param email: Email пользователя - :return: Список тикетов пользователя + Функция возвращает список тикетов пользователя Zendesk """ - return ZendeskAdmin().admin.search(assignee=email, type='ticket') + return zenpy.admin.search(assignee=email, type='ticket') -def update_profile(user_profile: UserProfile) -> UserProfile: +def update_profile(user_profile: UserProfile): """ Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. :param user_profile: Профиль пользователя :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя """ - user = ZendeskAdmin().get_user(user_profile.user.email) + 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 @@ -250,7 +116,7 @@ def check_user_exist(email: str) -> bool: :param email: Email пользователя :return: Зарегистрирован ли пользователь в Zendesk """ - return ZendeskAdmin().check_user(email) + return zenpy.check_user(email) def get_user_organization(email: str) -> str: @@ -260,21 +126,19 @@ def get_user_organization(email: str) -> str: :param email: Email пользователя :return: Организация пользователя """ - return ZendeskAdmin().get_user_org(email) + return zenpy.get_user_org(email) def check_user_auth(email: str, password: str) -> bool: """ Функция проверяет, верны ли входные данные. - :param email: Email пользователя - :param password: Пароль пользователя :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован """ creds = { 'email': email, 'password': password, - 'subdomain': 'ngenix1612197338', + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, } try: user = Zenpy(**creds) @@ -284,7 +148,7 @@ def check_user_auth(email: str, password: str) -> bool: return True -def update_user_in_model(profile: UserProfile, zendesk_user: User) -> UserProfile: +def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): """ Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. @@ -302,7 +166,7 @@ def update_user_in_model(profile: UserProfile, zendesk_user: User) -> UserProfil def count_users(users) -> tuple: """ - Функция подсчета количества сотрудников с ролями engineer и light_agent + Функция подсчета количества сотрудников с ролями engineer и light_agent """ engineers, light_agents = 0, 0 for user in users: @@ -313,9 +177,9 @@ def count_users(users) -> tuple: return engineers, light_agents -def update_users_in_model() -> list: +def update_users_in_model(): """ - Функция обновляет пользователей в модели UserProfile по списку пользователей в организации + Обновляет пользователей в модели UserProfile по списку пользователей в организации """ users = get_users_list() for user in users: @@ -327,7 +191,7 @@ def update_users_in_model() -> list: return users -def daterange(start_date: date, end_date: date) -> list: +def daterange(start_date, end_date) -> list: """ Функция возвращает список дней с start_date по end_date, исключая правую границу. @@ -341,7 +205,7 @@ def daterange(start_date: date, end_date: date) -> list: return dates -def get_timedelta(log: RoleChangeLogs, time: datetime =None) -> timedelta: +def get_timedelta(log, time=None) -> timedelta: """ Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. @@ -408,7 +272,7 @@ class StatisticData: else: self.statistic = stat - def get_statistic(self) -> Optional[dict]: + def get_statistic(self) -> dict: """ Функция возвращает статистику работы пользователя. @@ -456,7 +320,7 @@ class StatisticData: self.display = display_format return True - def get_data(self) -> Optional[list]: + def get_data(self) -> Optional[dict]: """ Функция возвращает данные - список объектов RoleChangeLogs. """ @@ -524,7 +388,7 @@ class StatisticData: return False return True - def _init_data(self) -> Optional[list]: + def _init_data(self): """ Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. @@ -541,7 +405,7 @@ class StatisticData: except User.DoesNotExist: self.errors += ['Пользователь не найден'] - def _init_statistic(self) -> Optional[dict]: + def _init_statistic(self) -> dict: """ Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. @@ -563,7 +427,7 @@ class StatisticData: if self.data[log_index].new_role == ROLES['engineer']: self.engineer_logic(log_index) - def engineer_logic(self, log_index: int) -> dict: + def engineer_logic(self, log_index): """ Функция обрабатывает основную часть работы инженера """ @@ -577,9 +441,9 @@ class StatisticData: 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: RoleChangeLogs): + 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(): @@ -592,9 +456,9 @@ class StatisticData: 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: RoleChangeLogs) ->dict: + 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()) @@ -617,3 +481,82 @@ class StatisticData: """ 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 dd45b16..81b2e8a 100644 --- a/main/forms.py +++ b/main/forms.py @@ -12,10 +12,9 @@ class CustomRegistrationForm(RegistrationFormUniqueEmail): :param visible_fields.email: Поле для ввода email, зарегистрированного на Zendesk :type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail` - """ - def __init__(self, *args, **kwargs) -> RegistrationFormUniqueEmail: + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) for visible in self.visible_fields(): if visible.field.widget.attrs.get('class', False): @@ -96,13 +95,12 @@ class StatisticForm(forms.Form): :param range_end: Дата и время окончания работы :type range_end: :class:`django.forms.fields.DateField` """ - email: str = forms.EmailField( - label='Электроная почта', + email = forms.EmailField( + label='Электронная почта', widget=forms.EmailInput( attrs={ 'placeholder': 'example@ngenix.ru', 'class': 'form-control', - 'style': 'background-color:#f2f2f2;' } ), ) 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 c723806..e48fb1e 100644 --- a/main/models.py +++ b/main/models.py @@ -80,3 +80,4 @@ 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 index cfc70a8..8436b54 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -1,6 +1,7 @@ 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): @@ -13,9 +14,25 @@ class UserSerializer(serializers.HyperlinkedModelSerializer): 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/menu.html b/main/templates/base/menu.html index 92aaec1..ef5df18 100644 --- a/main/templates/base/menu.html +++ b/main/templates/base/menu.html @@ -5,23 +5,54 @@ diff --git a/main/templates/base/success_messages.html b/main/templates/base/success_messages.html deleted file mode 100644 index fdef313..0000000 --- a/main/templates/base/success_messages.html +++ /dev/null @@ -1,14 +0,0 @@ -
- {% for message in messages %} - - {% endfor %} -
diff --git a/main/templates/django_registration/registration_error.html b/main/templates/django_registration/registration_error.html new file mode 100644 index 0000000..603e103 --- /dev/null +++ b/main/templates/django_registration/registration_error.html @@ -0,0 +1,15 @@ +{% extends 'base/base.html' %} +{% load static %} + +{% block title %} + Регистрация завершена +{% endblock %} + +{% block heading %} + Регистрация +{% endblock %} + +{% block content %} +
+

Произошла ошибка при отправке электронного сообщения.

+{% endblock %} diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index 1fb9baf..dd077c4 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -7,7 +7,8 @@ {% block heading %}Управление{% endblock %} {% block extra_css %} - + + {% endblock %} {% block extra_scripts %} @@ -15,15 +16,22 @@ -{% endblock%} + + + +{% endblock%} {% block content %}
-

Свободных Мест: {{ licences_remaining }}

+

Свободных Мест:

+ {% for message in messages %} + + {% endfor %} + {% block form %}
{% csrf_token %} @@ -34,16 +42,22 @@
Список сотрудников
{% block table %} - +
+ - - - + +
+ + Name Email RoleChecked

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

{% endblock %} @@ -93,9 +107,6 @@
{% endblock %} - {% include 'base/success_messages.html' %}
- - {% endblock %} diff --git a/main/templates/pages/statistic.html b/main/templates/pages/statistic.html index 9a9219b..b467250 100644 --- a/main/templates/pages/statistic.html +++ b/main/templates/pages/statistic.html @@ -26,7 +26,7 @@
{% for radio in form.interval%} {{ radio.tag }} -