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 1f6ea85..2d1bab8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ local_settings.py db.sqlite3 db.sqlite3-journal +db/ media/ logs/ @@ -30,7 +31,6 @@ logs/ .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 14e3869..eb66f37 100644 --- a/README.md +++ b/README.md @@ -39,37 +39,53 @@ ## 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 loaddata data.json -./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 Access Controller instruction for eng +## Перед запуском для тестирования: -##Перед запуском для тестирования: - -Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация (SYSTEM) +Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация `SYSTEM` Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение -##Запуск на локальной машине: - -скопировать репозиторий на локальную машину -перейти в папку приложения -активировать вирутальное окружение -выполнить команду pip install -r requirements.txt -в вирутальное окружение добавить следующие переменные : +## Запуск на локальной машине: +- Скопировать репозиторий на локальную машину +- Перейти в папку приложения +- Активировать виртуальное окружение +- Выполнить команду `pip install -r requirements/dev.txt` +- В виртуальное окружение добавить следующие переменные: -ACCESS_CONTROLLER_API_EMAIL={EMAIL} - почта админа в ZenDesk -ACCESS_CONTROLLER_API_PASSWORD={PASSWORD} - пароль админа ZenDesk -ACCESS_CONTROLLER_API_TOKEN={API_TOKEN} - API токен зендеск -ZD_DOMAIN={DOMAIN} - домен ZenDesk +``` +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} - имя группы которой принадлежат сотрудники ССКС @@ -77,48 +93,62 @@ 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/) -выполнить команду python manage.py makemigrations -выполнить команду 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/) -##Запуск в Docker: -Требуется установленный и настроеный Docker +## Запуск с тестовыми юзерами: +На локальной машине - перед запуском команды `python manage.py runserver` выполнить команду `python manage.py loaddata data.json` +Это создаст тестового админа и тестового пользователя в приложении для песочницы ZenDesk. -скопировать репозиторий на локальную машину -в командной строке перейти в папку проекта -выполнить команду 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/) +- Админ - `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 . -Не сработает если домен песочницы отличается от ngenix1612197338 (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента -с этими же почтами, назначить им организацию (SYSTEM)) - - -##Параметры тестовой песочницы: - -ACCESS_CONTROLLER_API_EMAIL={EMAIL} - почта админа в ZenDesk - взять у роководителя(если вы не админ) -ACCESS_CONTROLLER_API_PASSWORD={PASSWORD} - пароль админа ZenDesk - взять у роководителя(если вы не админ) -ACCESS_CONTROLLER_API_TOKEN={API_TOKEN} - API токен зендеск - взять у роководителя(если вы не админ) -ZD_DOMAIN=ngenix1612197338 -ENG_CROLE_ID=360005209000 -LA_CROLE_ID=360005208980 -EMPL_GROUP=Поддержка -BUF_GROUP=Сменная группа -ST_EMAIL=d.krikov@ngenix.net - -LICENSE_NO=3 -SHIFTH=12 +## Параметры тестовой песочницы: +Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). ## Read more diff --git a/access_controller/settings.py b/access_controller/settings.py index f9b5119..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.gmail.com' -EMAIL_PORT = 587 -EMAIL_USE_TLS = True -EMAIL_HOST_USER = 'group02django@gmail.com' -EMAIL_HOST_PASSWORD = 'djangogroup02' -DEFAULT_FROM_EMAIL = EMAIL_HOST_USER -SERVER_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' } } @@ -146,18 +150,18 @@ AUTHENTICATION_BACKENDS = [ 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, @@ -167,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 f6a6754..63dc19f 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -14,6 +14,7 @@ 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 from django.urls import path, include from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView, registration_error @@ -36,8 +37,7 @@ urlpatterns = [ path('accounts/', include('django_registration.backends.activation.urls')), path('control/', AdminPageView.as_view(), name='control'), path('statistic/', statistic_page, name='statistic'), - ] - +] # 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/media/.gitkeep b/db/.gitkeep similarity index 100% rename from media/.gitkeep rename to db/.gitkeep diff --git a/layouts/registration_success/registration_success.png b/layouts/registration_success/registration_success.png new file mode 100644 index 0000000..67e7074 Binary files /dev/null and b/layouts/registration_success/registration_success.png differ diff --git a/layouts/work/workv2.png b/layouts/work/workv2.png new file mode 100644 index 0000000..620ddb1 Binary files /dev/null and b/layouts/work/workv2.png differ 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 27986c8..0c93ba1 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,144 +1,19 @@ import logging -import os 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 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 - - -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 - """ - 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) -> 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) -> str: - """ - Функция возвращает группу по названию - - :param name: Имя пользователя - :return: Группы пользователя (в случае отсутствия None) - """ - groups = self.admin.search(name, type='group') - for group in groups: - return group - return None - - def get_user_org(self, email: str) -> 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) -> None: - """ - Функция создает администратора, проверяя наличие вводимых данных в 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') +from main.zendesk_admin import zenpy def update_role(user_profile: UserProfile, role: int) -> None: @@ -149,7 +24,7 @@ def update_role(user_profile: UserProfile, role: int) -> None: :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 @@ -174,7 +49,8 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: :param user_profile: Профиль пользователя :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" """ - tickets = get_tickets_list(user_profile.user.email) + tickets: SearchResultGenerator = get_tickets_list(user_profile.user.email) + ticket: ZenpyTicket for ticket in tickets: UnassignedTicket.objects.create( assignee=user_profile.user, @@ -182,19 +58,30 @@ def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: 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) - update_role(user_profile, ROLES['light_agent']) + 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 get_users_list() -> list: """ Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. """ - zendesk = ZendeskAdmin() + zendesk = zenpy # У пользователей должна быть организация SYSTEM org = next(zendesk.admin.search(type='organization', name='SYSTEM')) @@ -206,17 +93,17 @@ def get_tickets_list(email): """ Функция возвращает список тикетов пользователя 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 @@ -231,7 +118,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: @@ -241,7 +128,7 @@ 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: @@ -253,7 +140,7 @@ def check_user_auth(email: str, password: str) -> bool: creds = { 'email': email, 'password': password, - 'subdomain': 'ngenix1612197338', + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, } try: user = Zenpy(**creds) @@ -263,7 +150,7 @@ def check_user_auth(email: str, password: str) -> bool: return True -def update_user_in_model(profile: UserProfile, zendesk_user: User): +def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): """ Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. @@ -435,7 +322,7 @@ class StatisticData: self.display = display_format return True - def get_data(self) -> list: + def get_data(self) -> Optional[dict]: """ Функция возвращает данные - список объектов RoleChangeLogs. """ @@ -665,3 +552,13 @@ def log(user, admin=0): 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 22bd76b..81b2e8a 100644 --- a/main/forms.py +++ b/main/forms.py @@ -96,12 +96,11 @@ class StatisticForm(forms.Form): :type range_end: :class:`django.forms.fields.DateField` """ email = forms.EmailField( - label='Электроная почта', + label='Электронная почта', widget=forms.EmailInput( attrs={ 'placeholder': 'example@ngenix.ru', 'class': 'form-control', - 'style': 'background-color:#f2f2f2;' } ), ) diff --git a/main/models.py b/main/models.py index ac6f91c..1920c4d 100644 --- a/main/models.py +++ b/main/models.py @@ -81,3 +81,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/templates/base/menu.html b/main/templates/base/menu.html index 93799a5..ef5df18 100644 --- a/main/templates/base/menu.html +++ b/main/templates/base/menu.html @@ -15,21 +15,27 @@ {% else %} class="btn btn-secondary" {% endif %} - href="{% url 'profile' %}">Профиль + href="{% url 'profile' %}">Профиль {% if perms.main.has_control_access %} Управление + href="{% url 'control' %}">Управление + Статистика {% else %} Запрос прав + href="{% url 'work' request.user.id %}">Запрос прав {% endif %} Выйти @@ -40,13 +46,13 @@ {% else %} class="btn btn-secondary" {% endif %} - href="/accounts/login">Войти + href="/accounts/login">Войти Зарегистрироваться + href="/accounts/register">Зарегистрироваться {% endif %} 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/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index c0a5ad9..dc3cf54 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 %} @@ -37,10 +45,16 @@ + - @@ -93,9 +107,6 @@ {% endblock %} - {% include 'base/success_messages.html' %} - - {% endblock %} diff --git a/main/templates/pages/profile.html b/main/templates/pages/profile.html index c97fa4d..4b7016a 100644 --- a/main/templates/pages/profile.html +++ b/main/templates/pages/profile.html @@ -31,6 +31,7 @@ alt="Нет изображения" > + Сменить пароль

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

{{ profile.name }}
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 }} -
+ + Name Email RoleChecked
- + {% for date in log_stats.keys %} - + {% endfor %} @@ -96,7 +96,7 @@ {% for time in log_stats.values %} - + {% endfor %} diff --git a/main/templates/pages/work.html b/main/templates/pages/work.html index d9043b4..cb07b6d 100644 --- a/main/templates/pages/work.html +++ b/main/templates/pages/work.html @@ -7,7 +7,12 @@ {% block heading %}Управление правами{% endblock %} {% block extra_css %} - + + +{% endblock %} +{% block extra_scripts %} + + {% endblock %} {% block content %} @@ -66,8 +71,10 @@ + {% for message in messages %} + + {% endfor %} - {% include 'base/success_messages.html' %} {% endblock %} diff --git a/main/templates/registration/password_change_done.html b/main/templates/registration/password_change_done.html new file mode 100644 index 0000000..59870fb --- /dev/null +++ b/main/templates/registration/password_change_done.html @@ -0,0 +1,12 @@ +{% extends "base/base.html" %} +{% load static %} + +{% block title %}Пароль успешно изменен{% endblock title %} + +{% block heading %}Пароль успешно изменен{% endblock %} + +{% block content %} +
+

Ваш пароль был изменен.

+
+{% endblock content %} diff --git a/main/templates/registration/password_change_form.html b/main/templates/registration/password_change_form.html new file mode 100644 index 0000000..5348ed9 --- /dev/null +++ b/main/templates/registration/password_change_form.html @@ -0,0 +1,14 @@ +{% extends "base/base.html" %} +{% load static %} + +{% block title %}Изменение пароля{% endblock title %} + +{% block heading %}Сменить пароль{% endblock %} + +{% block content %} + + {% csrf_token %} + {{ form.as_p }} + + +{% endblock content %} diff --git a/main/tests.py b/main/tests.py index 7ce503c..b733ed1 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase - -# Create your tests here. +from django.test import TestCase, Client +import access_controller.settings as sets diff --git a/main/views.py b/main/views.py index 0129b3a..d21a6e9 100644 --- a/main/views.py +++ b/main/views.py @@ -1,4 +1,5 @@ from smtplib import SMTPException +from typing import Dict, Any from django.contrib import messages from django.contrib.auth.decorators import login_required @@ -11,32 +12,34 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.messages.views import SuccessMessageMixin from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponseRedirect, HttpResponse -from django.shortcuts import render, redirect, get_list_or_404 +from django.shortcuts import render, redirect from django.urls import reverse_lazy, reverse from django.views.generic import FormView from django_registration.views import RegistrationView # Django REST from rest_framework import viewsets from rest_framework.response import Response -from zenpy.lib.api_objects import User as ZenpyUser from access_controller.settings import DEFAULT_FROM_EMAIL, ZENDESK_ROLES, ZENDESK_MAX_AGENTS from main.extra_func import check_user_exist, update_profile, get_user_organization, \ make_engineer, make_light_agent, get_users_list, update_users_in_model, count_users, \ - StatisticData, log, ZendeskAdmin + StatisticData, log, set_session_params_for_work_page +from main.zendesk_admin import zenpy from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm from main.serializers import ProfileSerializer, ZendeskUserSerializer from .models import UserProfile def setup_context(profile_lit: bool = False, control_lit: bool = False, work_lit: bool = False, - registration_lit: bool = False, login_lit: bool = False): + registration_lit: bool = False, login_lit: bool = False, stats_lit: bool = False) -> Dict[str, Any]: + context = { 'profile_lit': profile_lit, 'control_lit': control_lit, 'work_lit': work_lit, 'registration_lit': registration_lit, 'login_lit': login_lit, + 'stats_lit': stats_lit, } return context @@ -155,18 +158,6 @@ def profile_page(request: WSGIRequest) -> HttpResponse: return render(request, 'pages/profile.html', context) -def auth_user(request: WSGIRequest) -> ZenpyUser: - """ - Функция возвращает профиль пользователя на Zendesk. - - :param request: email, subdomain и token пользователя - :return: объект пользователя Zendesk - """ - admin = ZendeskAdmin().admin - zenpy_user: ZenpyUser = admin.users.search(request.user.email).values[0] - return zenpy_user, admin - - @login_required() def work_page(request: WSGIRequest, id: int) -> HttpResponse: """ @@ -178,64 +169,75 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse: """ users = get_users_list() if request.user.id == id: + if request.session.get('is_confirm', None): + messages.success(request, 'Изменения были применены') + elif request.session.get('is_confirm', None) is not None: + messages.error(request, 'Изменения не были применены') + count = request.session.get('count_tickets', None) + if count is not None: + messages.success(request, f'{count} тикетов назначено') + request.session['is_confirm'] = None + request.session['count_tickets'] = None + engineers = [] light_agents = [] for user in users: - if user.custom_role_id == ZENDESK_ROLES['engineer']: engineers.append(user) elif user.custom_role_id == ZENDESK_ROLES['light_agent']: light_agents.append(user) - context = setup_context(work_lit=True) context.update({ 'engineers': engineers, 'agents': light_agents, 'messages': messages.get_messages(request), 'licences_remaining': max(0, ZENDESK_MAX_AGENTS - len(engineers)), - 'pagename': 'Управление правами' + 'pagename': 'Управление правами', }) return render(request, 'pages/work.html', context) return redirect("login") @login_required() -def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: +def work_hand_over(request: WSGIRequest): """ - Функция позволяет текущему пользователю (login_required) сдать права, а именно сменить в Zendesk роль с "engineer" на "light agent" - и установить роль "agent" в БД. Действия выполняются, если исходная роль пользователя "engineer". + Функция позволяет текущему пользователю сдать права, а именно сменить в Zendesk роль с "engineer" на "light_agent" :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли """ - make_light_agent(request.user.userprofile,request.user) - return HttpResponseRedirect(reverse('work', args=(request.user.id,))) + make_light_agent(request.user.userprofile, request.user) + return set_session_params_for_work_page(request) @login_required() def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: """ - Функция меняет роль пользователя в Zendesk на "engineer" и присваивает роль "agent" в БД (в случае, если исходная роль пользователя была "light_agent"). + Функция позволяет текущему пользователю получить права, а именно сменить в Zendesk роль с "light_agent" на "engineer" :param request: данные текущего пользователя (login_required) :return: перезагрузка текущей страницы после выполнения смены роли """ - zenpy_user, admin = auth_user(request) - make_engineer(request.user.userprofile,request.user) - return HttpResponseRedirect(reverse('work', args=(request.user.id,))) + make_engineer(request.user.userprofile, request.user) + return set_session_params_for_work_page(request) + @login_required() def work_get_tickets(request): - zenpy_user, admin = auth_user(request) - count_tickets = int(request.GET["count_tickets"]) - tickets = [ticket for ticket in admin.search(type="ticket") if ticket.group.name == 'Сменная группа' and ticket.assignee is None] - for i in range(len(tickets)): - if i == count_tickets: - return HttpResponseRedirect(reverse('work', args=(request.user.id,))) - tickets[i].assignee = zenpy_user - admin.tickets.update(tickets[i]) - return HttpResponseRedirect(reverse('work', args=(request.user.id,))) + zenpy_user = zenpy.get_user(request.user.email) + if zenpy_user.role == 'admin' or zenpy_user.custom_role_id == ZENDESK_ROLES['engineer']: + tickets = [ticket for ticket in zenpy.admin.search(type="ticket") if + ticket.group.name == 'Сменная группа' and ticket.assignee is None] + count = 0 + for i in range(len(tickets)): + if i == int(request.GET.get('count_tickets')): + return set_session_params_for_work_page(request, count) + tickets[i].assignee = zenpy_user + zenpy.admin.tickets.update(tickets[i]) + count += 1 + return set_session_params_for_work_page(request, count) + return set_session_params_for_work_page(request, is_confirm=False) def main_page(request: WSGIRequest) -> HttpResponse: @@ -300,30 +302,6 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM make_light_agent(user, self.request.user) log(user, self.request.user.userprofile) - def get_context_data(self, **kwargs) -> dict: - """ - Функция формирования контента страницы администратора (с проверкой прав доступа) - """ - - # context = super().get_context_data(**kwargs) - - # context['licences_remaining'] = max(0, ZENDESK_MAX_AGENTS - context['engineers']) - # return context - - context = setup_context(control_lit=True) - context.update(super().get_context_data(**kwargs)) - users = get_list_or_404( - UserProfile, role='agent') - context['engineers'], context['light_agents'] = count_users(get_users_list()) - context.update({ - 'users': users, - 'ZENDESK_ROLES': ZENDESK_ROLES, - 'engineers': context['engineers'], - 'light_agents': context['light_agents'], - 'licences_remaining': max(0, ZENDESK_MAX_AGENTS - context['engineers']), - }) - return context # TODO: need to get profile page url - class CustomLoginView(LoginView): """ @@ -349,7 +327,8 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): 'users': serializer.data, 'engineers': count[0], 'light_agents': count[1], - "zendesk_users": self.get_zendesk_users(self.choose_users(users.values, profiles)) + 'zendesk_users': self.get_zendesk_users(self.choose_users(users.values, profiles)), + 'max_agents': ZENDESK_MAX_AGENTS } return Response(res) @@ -384,9 +363,9 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: # raise PermissionDenied # context = { - if not request.user.is_superuser: + if not request.user.has_perm("main.has_control_access"): return redirect('index') - context = setup_context() + context = setup_context(stats_lit=True) context.update({ 'pagename': 'страница статистики', 'errors': list(), @@ -396,16 +375,16 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: if form.is_valid(): start_date, end_date = form.cleaned_data['range_start'], form.cleaned_data['range_end'] interval, show = form.cleaned_data['interval'], form.cleaned_data['display_format'] - Data = StatisticData(start_date, end_date, form.cleaned_data['email']) - Data.set_display(show) - Data.set_interval(interval) - stats = Data.get_statistic() - if Data.errors: - context['errors'] = Data.errors - if Data.warnings: - context['warnings'] = Data.warnings + data = StatisticData(start_date, end_date, form.cleaned_data['email']) + data.set_display(show) + data.set_interval(interval) + stats = data.get_statistic() + if data.errors: + context['errors'] = data.errors + if data.warnings: + context['warnings'] = data.warnings context['log_stats'] = stats if not context['errors'] else None - if request.method == 'GET': + elif request.method == 'GET': form = StatisticForm() context['form'] = form return render(request, 'pages/statistic.html', context) diff --git a/main/zendesk_admin.py b/main/zendesk_admin.py new file mode 100644 index 0000000..8ecb877 --- /dev/null +++ b/main/zendesk_admin.py @@ -0,0 +1,93 @@ +from typing import Optional, Dict + +from zenpy import Zenpy +from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup +from zenpy.lib.exception import APIException + +from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD, \ + ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL + + +class ZendeskAdmin: + """ + Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора. + + :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) + :type credentials: :class:`Dict[str, str]` + """ + + def __init__(self, credentials: Dict[str, str]): + self.credentials = credentials + self.admin = self.create_admin() + self.buffer_group_id: int = self.get_group(ZENDESK_GROUPS['buffer']).id + self.solved_tickets_user_id: int = self.get_user(SOLVED_TICKETS_EMAIL).id + + 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(self, email: str) -> ZenpyUser: + """ + Функция возвращает пользователя (объект) по его email. + + :param email: Email пользователя + :return: Объект пользователя, найденного в БД + """ + return self.admin.users.search(email).values[0] + + def get_group(self, name: str) -> Optional[ZenpyGroup]: + """ + Функция возвращает группу по названию + + :param name: Имя пользователя + :return: Группы пользователя (в случае отсутствия None) + """ + groups = self.admin.search(name, type='group') + for group in groups: + return group + return None + + def get_user_org(self, email: str) -> 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. + + :raise: :class:`ValueError`: исключение, вызываемое если email не введен в env + :raise: :class:`APIException`: исключение, вызываемое если пользователя с таким email не существует в Zendesk + """ + + if self.credentials.get('email') is None: + raise ValueError('access_controller email not in env') + + if self.credentials.get('token') is None and self.credentials.get('password') is None: + raise ValueError('access_controller token or password not in env') + + admin = Zenpy(**self.credentials) + try: + admin.search(self.credentials['email'], type='user') + except APIException: + raise ValueError('invalid access_controller`s login data') + + return admin + + +zenpy = ZendeskAdmin({ + 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, + 'email': ACTRL_API_EMAIL, + 'token': ACTRL_API_TOKEN, + 'password': ACTRL_API_PASSWORD, +}) diff --git a/requirements.txt b/requirements.txt index 16ae562..b0c3540 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1 @@ -# Engine -Django==3.1.6 -Pillow==8.1.0 -zenpy~=2.0.24 -django_registration==3.1.1 -djangorestframework==3.12.2 -daphne==3.0.1 - - -# Documentation -Sphinx==3.4.3 -sphinx-rtd-theme==0.5.1 -sphinx-autodoc-typehints==1.11.1 -pyenchant==3.2.0 -sphinxcontrib-spelling==7.1.0 +-r requirements/dev.txt diff --git a/requirements/common.txt b/requirements/common.txt new file mode 100644 index 0000000..6b3e7fa --- /dev/null +++ b/requirements/common.txt @@ -0,0 +1,16 @@ +# Contains requirements common to all environments + +# Engine +Django==3.1.6 +Pillow==8.1.0 +zenpy~=2.0.24 +django_registration==3.1.1 +djangorestframework==3.12.2 + + +# Documentation +Sphinx==3.4.3 +sphinx-rtd-theme==0.5.1 +sphinx-autodoc-typehints==1.11.1 +pyenchant==3.2.0 +sphinxcontrib-spelling==7.1.0 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..73c27d0 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,3 @@ +# Development specific dependencies +-r common.txt + diff --git a/requirements/prod.txt b/requirements/prod.txt new file mode 100644 index 0000000..b0e6925 --- /dev/null +++ b/requirements/prod.txt @@ -0,0 +1,5 @@ +# Production specific dependencies +-r common.txt + +daphne==3.0.1 +Twisted[tls,http2]==21.2.0 diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..4041353 --- /dev/null +++ b/start.sh @@ -0,0 +1,6 @@ +cd /access_controller/ + +python manage.py migrate + +python manage.py collectstatic --noinput +daphne -b 0.0.0.0 access_controller.asgi:application diff --git a/static/main/js/control.js b/static/main/js/control.js index 82f9f27..6dd9172 100644 --- a/static/main/js/control.js +++ b/static/main/js/control.js @@ -1,15 +1,18 @@ "use strict"; +function head_checkbox() { + let head_checkbox = document.getElementById("head-checkbox"); + head_checkbox.addEventListener("click", () => { + let checkboxes = document.getElementsByName("users"); + for (let checkbox of checkboxes) checkbox.click(); + }); +} + // React class ModelUserTableRow extends React.Component { render() { return ( - - - + + + ); } @@ -37,13 +45,19 @@ class ModelUserTableRows extends React.Component { class ZendeskUserTableRow extends React.Component { render() { return ( - - - - + + + + ); } @@ -68,9 +82,22 @@ class TableBody extends React.Component { engineers: 0, light_agents: 0, zendesk_users: [], + max_agents: 3, }; } + change_elemnts_html() { + let elements = document.querySelectorAll(".info-quantity-value"); + let licences = document.getElementById("licences_remaining"); + elements[0].innerHTML = this.state.engineers; + elements[1].innerHTML = this.state.light_agents; + let max_licences = Math.max( + this.state.max_agents - this.state.engineers, + 0 + ); + licences.innerHTML = "Свободных мест: " + max_licences; + } + async get_users() { await axios.get("/api/users").then((response) => { this.setState({ @@ -78,11 +105,10 @@ class TableBody extends React.Component { engineers: response.data.engineers, light_agents: response.data.light_agents, zendesk_users: response.data.zendesk_users, + max_agents: response.data.max_agents, }); - let elements = document.querySelectorAll(".info-quantity-value"); - elements[0].innerHTML = this.state.engineers; - elements[1].innerHTML = this.state.light_agents; }); + this.change_elemnts_html(); } delete_pretext() { @@ -111,3 +137,4 @@ class TableBody extends React.Component { } ReactDOM.render(, document.getElementById("tbody")); +head_checkbox(); diff --git a/static/main/js/notifications.js b/static/main/js/notifications.js new file mode 100644 index 0000000..cef9887 --- /dev/null +++ b/static/main/js/notifications.js @@ -0,0 +1,14 @@ +"use strict"; +function create_notification(title,description,theme,time){ + const myNotification = window.createNotification({ + closeOnClick: true, + displayCloseButton: true, + positionClass: 'nfc-top-right', + theme: theme, + showDuration: Number(time), + }); + myNotification({ + title: title, + message: description + }); +}; diff --git a/static/modules/notifications/.babelrc b/static/modules/notifications/.babelrc new file mode 100644 index 0000000..af0f0c3 --- /dev/null +++ b/static/modules/notifications/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015"] +} \ No newline at end of file diff --git a/static/modules/notifications/.eslintrc.js b/static/modules/notifications/.eslintrc.js new file mode 100644 index 0000000..5bebe58 --- /dev/null +++ b/static/modules/notifications/.eslintrc.js @@ -0,0 +1,31 @@ +module.exports = { + "env": { + "browser": true, + "commonjs": true, + "es6": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "sourceType": "module" + }, + "rules": { + "indent": [ + "error", + "tab" + ], + "linebreak-style": [ + "error", + "windows" + ], + "quotes": [ + "error", + "single" + ], + "semi": [ + "error", + "always" + ], + "no-console": 0, + "no-undef": 0 + } +}; \ No newline at end of file diff --git a/static/modules/notifications/.gitignore b/static/modules/notifications/.gitignore new file mode 100644 index 0000000..6909f31 --- /dev/null +++ b/static/modules/notifications/.gitignore @@ -0,0 +1,30 @@ +# IDE files +.idea/ +.DS_Store + +# Build directories +build/ + +# Dependency directories +node_modules/ +jspm_packages/ + +# Lock files +yarn.lock +package-lock.json + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Yarn Integrity file +.yarn-integrity diff --git a/static/modules/notifications/.travis.yml b/static/modules/notifications/.travis.yml new file mode 100644 index 0000000..0fe294a --- /dev/null +++ b/static/modules/notifications/.travis.yml @@ -0,0 +1,3 @@ +language: node_js +node_js: + - "7" diff --git a/static/modules/notifications/LICENSE.md b/static/modules/notifications/LICENSE.md new file mode 100644 index 0000000..50ec29c --- /dev/null +++ b/static/modules/notifications/LICENSE.md @@ -0,0 +1,7 @@ +# Notifications license + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/static/modules/notifications/__tests__/helpers.test.js b/static/modules/notifications/__tests__/helpers.test.js new file mode 100644 index 0000000..1ac9938 --- /dev/null +++ b/static/modules/notifications/__tests__/helpers.test.js @@ -0,0 +1,104 @@ +const { partial, append, isString, createElement, createParagraph } = require('../src/helpers'); + +const addNumbers = (x, y) => x + y; + +const sum = (...numbers) => numbers.reduce((total, current) => total + current, 0); + +describe('Helpers', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + describe('Partial', () => { + it('should return a partially applied function', () => { + expect(typeof partial(addNumbers, 10)).toEqual('function'); + }); + + it('should execute function when partially applied function is called', () => { + expect(partial(addNumbers, 20)(10)).toEqual(30); + }); + + it('should gather argument', () => { + expect(partial(sum, 5, 10)(15, 20, 25)).toEqual(75); + }); + }); + + describe('Append', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + const elementToAppend = document.createElement('h1'); + elementToAppend.classList.add('heading'); + elementToAppend.innerText = 'working'; + + append(container, elementToAppend); + + const element = document.querySelector('.heading'); + expect(element); + + expect(element.innerText).toEqual('working'); + }); + + describe('Is string', () => { + expect(isString(1)).toEqual(false); + expect(isString(null)).toEqual(false); + expect(isString(undefined)).toEqual(false); + expect(isString({})).toEqual(false); + + expect(isString('')).toEqual(true); + expect(isString('a')).toEqual(true); + expect(isString('1')).toEqual(true); + expect(isString('some string')).toEqual(true); + }); + + describe('Create element', () => { + it('should create an element', () => { + expect(createElement('p')).toEqual(document.createElement('p')); + expect(createElement('h1')).toEqual(document.createElement('h1')); + expect(createElement('ul')).toEqual(document.createElement('ul')); + expect(createElement('li')).toEqual(document.createElement('li')); + expect(createElement('div')).toEqual(document.createElement('div')); + expect(createElement('span')).toEqual(document.createElement('span')); + }); + + it('should add class names', () => { + expect(createElement('div', 'someclass1', 'someclass2').classList.contains('someclass2')); + expect(createElement('p', 'para', 'test').classList.contains('para')); + + const mockUl = document.createElement('ul'); + mockUl.classList.add('nav'); + mockUl.classList.add('foo'); + + expect(createElement('ul', 'nav', 'foo').classList).toEqual(mockUl.classList); + }); + }); + + describe('Create paragraph', () => { + it('should create a paragraph', () => { + const p = document.createElement('p'); + p.innerText = 'Some text'; + expect(createParagraph()('Some text')).toEqual(p); + }); + + it('should add class names', () => { + const p = document.createElement('p'); + p.classList.add('body-text'); + p.classList.add('para'); + + expect(createParagraph('body-text', 'para')('')).toEqual(p); + }); + + it('should set inner text', () => { + const p = document.createElement('p'); + p.innerText = 'Hello world!'; + p.classList.add('text'); + + expect(createParagraph('text')('Hello world!')).toEqual(p); + }); + + it('should append to DOM', () => { + append(document.body, createParagraph('text')('hello')); + expect(document.querySelector('.text').innerText).toEqual('hello'); + }); + }); +}); diff --git a/static/modules/notifications/__tests__/index.tests.js b/static/modules/notifications/__tests__/index.tests.js new file mode 100644 index 0000000..071597c --- /dev/null +++ b/static/modules/notifications/__tests__/index.tests.js @@ -0,0 +1,144 @@ +require('../src/index'); + +describe('Notifications', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('should display a console warning if no title or message is passed', () => { + jest.spyOn(global.console, 'warn'); + window.createNotification()(); + expect(console.warn).toBeCalled(); + }); + + it('should render a default notification', () => { + const notification = window.createNotification(); + + const title = 'I am a title'; + + // Should initially not contain any notifications + expect(document.querySelectorAll('.ncf').length).toEqual(0); + + // Create a notification instance with a title + notification({ title }); + + // Should be one notification with the title passed in + expect(document.querySelectorAll('.ncf').length).toEqual(1); + expect(document.querySelector('.ncf-title').innerText).toEqual(title); + + // Create a second instance so there should now be two instances + notification({ title }); + expect(document.querySelectorAll('.ncf').length).toEqual(2); + }); + + it('should close on click if the option is enabled', () => { + const notification = window.createNotification({ + closeOnClick: true + }); + + // Create a notification with a generic body + notification({ message: 'some text' }); + + // Should be one notification instance + expect(document.querySelectorAll('.ncf').length).toEqual(1); + + // Click the notification + document.querySelector('.ncf').click(); + + expect(document.querySelectorAll('.ncf').length).toEqual(0); + }); + + it('should not close on click if the option is disabled', () => { + const notification = window.createNotification({ + closeOnClick: false + }); + + // Create a notification with a generic body + notification({ message: 'some text' }); + + // Should be one notification instance + expect(document.querySelectorAll('.ncf').length).toEqual(1); + + // Click the notification + document.querySelector('.ncf').click(); + + expect(document.querySelectorAll('.ncf').length).toEqual(1); + }); + + it('should set position class if valid', () => { + const validPositions = [ + 'nfc-top-left', + 'nfc-top-right', + 'nfc-bottom-left', + 'nfc-bottom-right' + ]; + + validPositions.forEach(position => { + const notification = window.createNotification({ + positionClass: position + }); + + notification({ title: 'title here' }); + + const className = `.${position}`; + + expect(document.querySelectorAll(className).length).toEqual(1); + + const container = document.querySelector(className); + expect(container.querySelectorAll('.ncf').length).toEqual(1); + }); + }); + + it('should revert to default to default position and warn if class is invalid', () => { + const notification = window.createNotification({ + positionClass: 'invalid-name' + }); + + jest.spyOn(global.console, 'warn'); + + notification({ message: 'test' }); + + expect(console.warn).toBeCalled(); + + expect(document.querySelectorAll('.nfc-top-right').length).toEqual(1); + }); + + it('should allow a custom onclick callback', () => { + let a = 'not clicked'; + + const notification = window.createNotification({ + onclick: () => { + a = 'clicked'; + } + }); + + notification({ message: 'click test' }); + + expect(a).toEqual('not clicked'); + + // Click the notification + document.querySelector('.ncf').click(); + + expect(a).toEqual('clicked'); + }); + + it('should show for correct duration', () => { + const notification = window.createNotification({ + showDuration: 500 + }); + + notification({ message: 'test' }); + + expect(document.querySelectorAll('.ncf').length).toEqual(1); + + // Should exist after 400ms + setTimeout(() => { + expect(document.querySelectorAll('.ncf').length).toEqual(1); + }, 400); + + // Should delete after 500ms + setTimeout(() => { + expect(document.querySelectorAll('.ncf').length).toEqual(0); + }); + }, 501); +}); diff --git a/static/modules/notifications/demo/demo.js b/static/modules/notifications/demo/demo.js new file mode 100644 index 0000000..d809fe2 --- /dev/null +++ b/static/modules/notifications/demo/demo.js @@ -0,0 +1,34 @@ +'use strict'; + +// Written using ES5 JS for browser support +window.addEventListener('DOMContentLoaded', function () { + var form = document.querySelector('form'); + + form.addEventListener('submit', function (e) { + e.preventDefault(); + + // Form elements + var title = form.querySelector('#title').value; + var message = form.querySelector('#message').value; + var position = form.querySelector('#position').value; + var duration = form.querySelector('#duration').value; + var theme = form.querySelector('#theme').value; + var closeOnClick = form.querySelector('#close').checked; + var displayClose = form.querySelector('#closeButton').checked; + + if(!message) { + message = 'You did not enter a message...'; + } + + window.createNotification({ + closeOnClick: closeOnClick, + displayCloseButton: displayClose, + positionClass: position, + showDuration: duration, + theme: theme + })({ + title: title, + message: message + }); + }); +}); \ No newline at end of file diff --git a/static/modules/notifications/demo/index.html b/static/modules/notifications/demo/index.html new file mode 100644 index 0000000..d5dd6a6 --- /dev/null +++ b/static/modules/notifications/demo/index.html @@ -0,0 +1,101 @@ + + + + +Notifications + + + + + + + + + +

Notifications

+
+ +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + + + +
+ + + + + + + + + + + \ No newline at end of file diff --git a/static/modules/notifications/dist/notifications.css b/static/modules/notifications/dist/notifications.css new file mode 100644 index 0000000..90d9e61 --- /dev/null +++ b/static/modules/notifications/dist/notifications.css @@ -0,0 +1 @@ +.ncf-container{font-size:14px;box-sizing:border-box;position:fixed;z-index:999999}.ncf-container.nfc-top-left{top:12px;left:12px}.ncf-container.nfc-top-right{top:12px;right:12px}.ncf-container.nfc-bottom-right{bottom:12px;right:12px}.ncf-container.nfc-bottom-left{bottom:12px;left:12px}@media (max-width:767px){.ncf-container{left:0;right:0}}.ncf-container .ncf{background:#fff;transition:.3s ease;position:relative;pointer-events:auto;overflow:hidden;margin:0 0 6px;padding:30px;width:300px;border-radius:3px 3px 3px 3px;box-shadow:0 0 12px #999;color:#000;opacity:.9;-ms-filter:progid:DXImageTransform.Microsoft.Alpha(Opacity=90);filter:alpha(opacity=90);background-position:15px!important;background-repeat:no-repeat!important;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ncf-container .ncf:hover{box-shadow:0 0 12px #000;opacity:1;cursor:pointer}.ncf-container .ncf .ncf-title{font-weight:700;font-size:16px;text-align:left;margin-top:0;margin-bottom:6px;word-wrap:break-word}.ncf-container .ncf .nfc-message{margin:0;text-align:left;word-wrap:break-word}.ncf-container .success{background:#51a351;color:#fff;padding:15px 15px 15px 50px;background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg==")}.ncf-container .info{background:#2f96b4;color:#fff;padding:15px 15px 15px 50px;background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII=")}.ncf-container .warning{background:#f87400;color:#fff;padding:15px 15px 15px 50px;background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII=")}.ncf-container .error{background:#bd362f;color:#fff;padding:15px 15px 15px 50px;background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHOSURBVEhLrZa/SgNBEMZzh0WKCClSCKaIYOED+AAKeQQLG8HWztLCImBrYadgIdY+gIKNYkBFSwu7CAoqCgkkoGBI/E28PdbLZmeDLgzZzcx83/zZ2SSXC1j9fr+I1Hq93g2yxH4iwM1vkoBWAdxCmpzTxfkN2RcyZNaHFIkSo10+8kgxkXIURV5HGxTmFuc75B2RfQkpxHG8aAgaAFa0tAHqYFfQ7Iwe2yhODk8+J4C7yAoRTWI3w/4klGRgR4lO7Rpn9+gvMyWp+uxFh8+H+ARlgN1nJuJuQAYvNkEnwGFck18Er4q3egEc/oO+mhLdKgRyhdNFiacC0rlOCbhNVz4H9FnAYgDBvU3QIioZlJFLJtsoHYRDfiZoUyIxqCtRpVlANq0EU4dApjrtgezPFad5S19Wgjkc0hNVnuF4HjVA6C7QrSIbylB+oZe3aHgBsqlNqKYH48jXyJKMuAbiyVJ8KzaB3eRc0pg9VwQ4niFryI68qiOi3AbjwdsfnAtk0bCjTLJKr6mrD9g8iq/S/B81hguOMlQTnVyG40wAcjnmgsCNESDrjme7wfftP4P7SP4N3CJZdvzoNyGq2c/HWOXJGsvVg+RA/k2MC/wN6I2YA2Pt8GkAAAAASUVORK5CYII=")!important}.ncf-container button{position:relative;right:-.3em;top:-.3em;float:right;font-weight:700;color:#fff;text-shadow:0 1px 0 #fff;opacity:.8;line-height:1;font-size:16px;padding:0;cursor:pointer;background:transparent;border:0}.ncf-container button:hover{opacity:1} \ No newline at end of file diff --git a/static/modules/notifications/dist/notifications.js b/static/modules/notifications/dist/notifications.js new file mode 100644 index 0000000..34b339e --- /dev/null +++ b/static/modules/notifications/dist/notifications.js @@ -0,0 +1 @@ +!function(t){function n(i){if(e[i])return e[i].exports;var o=e[i]={i:i,l:!1,exports:{}};return t[i].call(o.exports,o,o.exports,n),o.l=!0,o.exports}var e={};n.m=t,n.c=e,n.d=function(t,e,i){n.o(t,e)||Object.defineProperty(t,e,{configurable:!1,enumerable:!0,get:i})},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,n){return Object.prototype.hasOwnProperty.call(t,n)},n.p="",n(n.s=0)}([function(t,n,e){e(1),t.exports=e(4)},function(t,n,e){"use strict";var i=Object.assign||function(t){for(var n=1;n-1}(t.positionClass)||(console.warn("An invalid notification position class has been specified."),t.positionClass=c.positionClass),t.onclick&&"function"!=typeof t.onclick&&(console.warn("Notification on click must be a function."),t.onclick=c.onclick),"number"!=typeof t.showDuration&&(t.showDuration=c.showDuration),(0,o.isString)(t.theme)&&0!==t.theme.length||(console.warn("Notification theme must be a string with length"),t.theme=c.theme),t}function e(t){return t=n(t),function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=n.title,i=n.message,c=r(t.positionClass);if(!e&&!i)return console.warn("Notification must contain a title or a message!");var a=(0,o.createElement)("div","ncf",t.theme);if(!0===t.closeOnClick&&a.addEventListener("click",function(){return c.removeChild(a)}),t.onclick&&a.addEventListener("click",function(n){return t.onclick(n)}),t.displayCloseButton){var s=(0,o.createElement)("button");s.innerText="X",!1===t.closeOnClick&&s.addEventListener("click",function(){return c.removeChild(a)}),(0,o.append)(a,s)}if((0,o.isString)(e)&&e.length&&(0,o.append)(a,(0,o.createParagraph)("ncf-title")(e)),(0,o.isString)(i)&&i.length&&(0,o.append)(a,(0,o.createParagraph)("nfc-message")(i)),(0,o.append)(c,a),t.showDuration&&t.showDuration>0){var l=setTimeout(function(){c.removeChild(a),0===c.querySelectorAll(".ncf").length&&document.body.removeChild(c)},t.showDuration);(t.closeOnClick||t.displayCloseButton)&&a.addEventListener("click",function(){return clearTimeout(l)})}}}function r(t){var n=document.querySelector("."+t);return n||(n=(0,o.createElement)("div","ncf-container",t),(0,o.append)(document.body,n)),n}var c={closeOnClick:!0,displayCloseButton:!1,positionClass:"nfc-top-right",onclick:!1,showDuration:3500,theme:"success"};t.createNotification?console.warn("Window already contains a create notification function. Have you included the script twice?"):t.createNotification=e}(window)},function(t,n,e){"use strict";!function(){function t(t){this.el=t;for(var n=t.className.replace(/^\s+|\s+$/g,"").split(/\s+/),i=0;i1?n-1:0),i=1;i1?n-1:0),i=1;i1?n-1:0),i=1;i1?n-1:0),c=1;c` + +2. Link to notifications.js `` + +## Usage +### Custom options +- closeOnClick - Close the notification dialog when a click is invoked. +- displayCloseButton - Display a close button in the top right hand corner of the notification. +- positionClass - Set the position of the notification dialog. Accepted positions: ('nfc-top-right', 'nfc-bottom-right', 'nfc-bottom-left', 'nfc-top-left'). +- onClick - Call a callback function when a click is invoked on a notification. +- showDuration - Milliseconds the notification should be visible (0 for a notification that will remain open until clicked) +- theme - Set the position of the notification dialog. Accepted positions: ('success', 'info', 'warning', 'error', 'A custom clasName'). +``` +const defaultOptions = { + closeOnClick: true, + displayCloseButton: false, + positionClass: 'nfc-top-right', + onclick: false, + showDuration: 3500, + theme: 'success' +}; +``` + +## Example + +### Success notification +``` +// Create a success notification instance +const successNotification = window.createNotification({ + theme: 'success', + showDuration: 5000 +}); + +// Invoke success notification +successNotification({ + message: 'Simple success notification' +}); + +// Use the same instance but pass a title +successNotification({ + title: 'Working', + message: 'Simple success notification' +}); +``` + +### Information notification +``` +// Only running it once? Invoke immediately like this +window.createNotification({ + theme: 'success', + showDuration: 5000 +})({ + message: 'I have some information for you...' +}); +``` + +### Todo +~~1. Add to NPM~~ +2. Improve documentation +3. Further device testing +4. Add contributor instructions \ No newline at end of file diff --git a/static/modules/notifications/src/helpers.js b/static/modules/notifications/src/helpers.js new file mode 100644 index 0000000..7a9f0dc --- /dev/null +++ b/static/modules/notifications/src/helpers.js @@ -0,0 +1,24 @@ +export const partial = (fn, ...presetArgs) => (...laterArgs) => fn(...presetArgs, ...laterArgs); + +export const append = (el, ...children) => children.forEach(child => el.appendChild(child)); + +export const isString = input => typeof input === 'string'; + +export const createElement = (elementType, ...classNames) => { + const element = document.createElement(elementType); + + if(classNames.length) { + classNames.forEach(currentClass => element.classList.add(currentClass)); + } + + return element; +}; + +const setInnerText = (element, text) => { + element.innerText = text; + return element; +}; + +const createTextElement = (elementType, ...classNames) => partial(setInnerText, createElement(elementType, ...classNames)); + +export const createParagraph = (...classNames) => createTextElement('p', ...classNames); \ No newline at end of file diff --git a/static/modules/notifications/src/index.js b/static/modules/notifications/src/index.js new file mode 100644 index 0000000..e3375cd --- /dev/null +++ b/static/modules/notifications/src/index.js @@ -0,0 +1,148 @@ +'use strict'; + +// Polyfills +import './polyfills/classList'; + +import { + append, + createElement, + createParagraph, + isString +} from './helpers'; + +(function Notifications(window) { + // Default notification options + const defaultOptions = { + closeOnClick: true, + displayCloseButton: false, + positionClass: 'nfc-top-right', + onclick: false, + showDuration: 3500, + theme: 'success' + }; + + function configureOptions(options) { + // Create a copy of options and merge with defaults + options = Object.assign({}, defaultOptions, options); + + // Validate position class + function validatePositionClass(className) { + const validPositions = [ + 'nfc-top-left', + 'nfc-top-right', + 'nfc-bottom-left', + 'nfc-bottom-right' + ]; + + return validPositions.indexOf(className) > -1; + } + + // Verify position, if invalid reset to default + if (!validatePositionClass(options.positionClass)) { + console.warn('An invalid notification position class has been specified.'); + options.positionClass = defaultOptions.positionClass; + } + + // Verify onClick is a function + if (options.onclick && typeof options.onclick !== 'function') { + console.warn('Notification on click must be a function.'); + options.onclick = defaultOptions.onclick; + } + + // Verify show duration + if(typeof options.showDuration !== 'number') { + options.showDuration = defaultOptions.showDuration; + } + + // Verify theme + if(!isString(options.theme) || options.theme.length === 0) { + console.warn('Notification theme must be a string with length'); + options.theme = defaultOptions.theme; + } + + return options; + } + + // Create a new notification instance + function createNotification(options) { + // Validate options and set defaults + options = configureOptions(options); + + // Return a notification function + return function notification({ title, message } = {}) { + const container = createNotificationContainer(options.positionClass); + + if(!title && !message) { + return console.warn('Notification must contain a title or a message!'); + } + + // Create the notification wrapper + const notificationEl = createElement('div', 'ncf', options.theme); + + // Close on click + if(options.closeOnClick === true) { + notificationEl.addEventListener('click', () => container.removeChild(notificationEl)); + } + + // Custom click callback + if(options.onclick) { + notificationEl.addEventListener('click', (e) => options.onclick(e)); + } + + // Display close button + if(options.displayCloseButton) { + const closeButton = createElement('button'); + closeButton.innerText = 'X'; + + // Use the wrappers close on click to avoid useless event listeners + if(options.closeOnClick === false){ + closeButton.addEventListener('click', () =>container.removeChild(notificationEl)); + } + + append(notificationEl, closeButton); + } + + // Append title and message + isString(title) && title.length && append(notificationEl, createParagraph('ncf-title')(title)); + isString(message) && message.length && append(notificationEl, createParagraph('nfc-message')(message)); + + // Append to container + append(container, notificationEl); + + // Remove element after duration + if(options.showDuration && options.showDuration > 0) { + const timeout = setTimeout(() => { + container.removeChild(notificationEl); + + // Remove container if empty + if(container.querySelectorAll('.ncf').length === 0) { + document.body.removeChild(container); + } + }, options.showDuration); + + // If close on click is enabled and the user clicks, cancel timeout + if(options.closeOnClick || options.displayCloseButton) { + notificationEl.addEventListener('click', () => clearTimeout(timeout)); + } + } + }; + } + + function createNotificationContainer(position) { + let container = document.querySelector(`.${position}`); + + if(!container) { + container = createElement('div', 'ncf-container', position); + append(document.body, container); + } + + return container; + } + + // Add Notifications to window to make globally accessible + if (window.createNotification) { + console.warn('Window already contains a create notification function. Have you included the script twice?'); + } else { + window.createNotification = createNotification; + } +})(window); diff --git a/static/modules/notifications/src/polyfills/classList.js b/static/modules/notifications/src/polyfills/classList.js new file mode 100644 index 0000000..cd8a786 --- /dev/null +++ b/static/modules/notifications/src/polyfills/classList.js @@ -0,0 +1,68 @@ +(function () { + if (typeof window.Element === 'undefined' || 'classList' in document.documentElement) return; + + var prototype = Array.prototype, + push = prototype.push, + splice = prototype.splice, + join = prototype.join; + + function DOMTokenList(el) { + this.el = el; + // The className needs to be trimmed and split on whitespace + // to retrieve a list of classes. + var classes = el.className.replace(/^\s+|\s+$/g,'').split(/\s+/); + for (var i = 0; i < classes.length; i++) { + push.call(this, classes[i]); + } + } + + DOMTokenList.prototype = { + add: function(token) { + if(this.contains(token)) return; + push.call(this, token); + this.el.className = this.toString(); + }, + contains: function(token) { + return this.el.className.indexOf(token) != -1; + }, + item: function(index) { + return this[index] || null; + }, + remove: function(token) { + if (!this.contains(token)) return; + for (var i = 0; i < this.length; i++) { + if (this[i] == token) break; + } + splice.call(this, i, 1); + this.el.className = this.toString(); + }, + toString: function() { + return join.call(this, ' '); + }, + toggle: function(token) { + if (!this.contains(token)) { + this.add(token); + } else { + this.remove(token); + } + + return this.contains(token); + } + }; + + window.DOMTokenList = DOMTokenList; + + function defineElementGetter (obj, prop, getter) { + if (Object.defineProperty) { + Object.defineProperty(obj, prop,{ + get : getter + }); + } else { + obj.__defineGetter__(prop, getter); + } + } + + defineElementGetter(Element.prototype, 'classList', function () { + return new DOMTokenList(this); + }); +})(); \ No newline at end of file diff --git a/static/modules/notifications/src/style.scss b/static/modules/notifications/src/style.scss new file mode 100644 index 0000000..13c37b9 --- /dev/null +++ b/static/modules/notifications/src/style.scss @@ -0,0 +1,134 @@ +// Base colors +$success: #51A351; +$info: #2F96B4; +$warning: #f87400; +$error: #BD362F; +$grey: #999999; + +.ncf-container { + font-size: 14px; + box-sizing: border-box; + position: fixed; + z-index: 999999; + + &.nfc-top-left { + top: 12px; + left: 12px; + } + + &.nfc-top-right { + top: 12px; + right: 12px; + } + + &.nfc-bottom-right { + bottom: 12px; + right: 12px; + } + + &.nfc-bottom-left { + bottom: 12px; + left: 12px; + } + + @media (max-width: 767px) { + left: 0; + right: 0; + } + + .ncf { + background: #ffffff; + transition: .3s ease; + position: relative; + pointer-events: auto; + overflow: hidden; + margin: 0 0 6px; + padding: 30px; + width: 300px; + border-radius: 3px 3px 3px 3px; + box-shadow: 0 0 12px $grey; + color: #000000; + opacity: 0.9; + -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=90); + filter: alpha(opacity=90); + background-position: 15px center !important; + background-repeat: no-repeat !important; + + // Prevent annoying text selection + -webkit-user-select: none; /* Chrome all / Safari all */ + -moz-user-select: none; /* Firefox all */ + -ms-user-select: none; /* IE 10+ */ + user-select: none; /* Likely future */ + + &:hover { + box-shadow: 0 0 12px #000000; + opacity: 1; + cursor: pointer; + } + + .ncf-title { + font-weight: bold; + font-size: 16px; + text-align: left; + margin-top: 0; + margin-bottom: 6px; + word-wrap: break-word; + } + + .nfc-message { + margin: 0; + text-align: left; + word-wrap: break-word; + } + } + + // Themes + .success { + background: $success; + color: #ffffff; + padding: 15px 15px 15px 50px; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg=="); + } + + .info { + background: $info; + color: #ffffff; + padding: 15px 15px 15px 50px; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII="); + } + + .warning { + background: $warning; + color: #ffffff; + padding: 15px 15px 15px 50px; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII="); + } + + .error { + background: $error; + color: #ffffff; + padding: 15px 15px 15px 50px; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHOSURBVEhLrZa/SgNBEMZzh0WKCClSCKaIYOED+AAKeQQLG8HWztLCImBrYadgIdY+gIKNYkBFSwu7CAoqCgkkoGBI/E28PdbLZmeDLgzZzcx83/zZ2SSXC1j9fr+I1Hq93g2yxH4iwM1vkoBWAdxCmpzTxfkN2RcyZNaHFIkSo10+8kgxkXIURV5HGxTmFuc75B2RfQkpxHG8aAgaAFa0tAHqYFfQ7Iwe2yhODk8+J4C7yAoRTWI3w/4klGRgR4lO7Rpn9+gvMyWp+uxFh8+H+ARlgN1nJuJuQAYvNkEnwGFck18Er4q3egEc/oO+mhLdKgRyhdNFiacC0rlOCbhNVz4H9FnAYgDBvU3QIioZlJFLJtsoHYRDfiZoUyIxqCtRpVlANq0EU4dApjrtgezPFad5S19Wgjkc0hNVnuF4HjVA6C7QrSIbylB+oZe3aHgBsqlNqKYH48jXyJKMuAbiyVJ8KzaB3eRc0pg9VwQ4niFryI68qiOi3AbjwdsfnAtk0bCjTLJKr6mrD9g8iq/S/B81hguOMlQTnVyG40wAcjnmgsCNESDrjme7wfftP4P7SP4N3CJZdvzoNyGq2c/HWOXJGsvVg+RA/k2MC/wN6I2YA2Pt8GkAAAAASUVORK5CYII=") !important; + } + + button { + position: relative; + right: -0.3em; + top: -0.3em; + float: right; + font-weight: bold; + color: #FFFFFF; + text-shadow: 0 1px 0 #ffffff; + opacity: 0.8; + line-height: 1; + font-size: 16px; + padding: 0; + cursor: pointer; + background: transparent; + border: 0; + + &:hover { + opacity: 1; + } + } +} diff --git a/static/modules/notifications/webpack.config.js b/static/modules/notifications/webpack.config.js new file mode 100644 index 0000000..beba2e3 --- /dev/null +++ b/static/modules/notifications/webpack.config.js @@ -0,0 +1,41 @@ +const webpack = require('webpack'); +const ExtractTextPlugin = require('extract-text-webpack-plugin'); + +const extractSass = new ExtractTextPlugin({ + filename: 'notifications.css', + disable: process.env.NODE_ENV === 'development' +}); + +module.exports = { + entry: ['./src/index.js', './src/style.scss'], + output: { + path: __dirname + '/dist', + filename: 'notifications.js' + }, + module: { + rules: [ + { + test: /\.js$/, + loader: 'babel-loader', + query: { + presets: ['babel-preset-es2015', 'es2015-ie'] + } + }, + { + test: /\.scss$/, + use: extractSass.extract({ + use: [{ + loader: 'css-loader' + }, { + loader: 'sass-loader' + }], + // use style-loader in development + fallback: 'style-loader' + }) + } + ], + }, + plugins: [ + extractSass + ] +}; \ No newline at end of file
Пользователи/Даты {{date}}{{ date | date:'d.m' }}
{{ form.email.value }}{{time}}{{ time | floatformat:2 }}
- {this.props.user.name} - {this.props.user.user.email}{this.props.user.zendesk_role} + {this.props.user.name} + {this.props.user.user.email}{this.props.user.zendesk_role}
- {this.props.user.name} - {this.props.user.email}{this.props.user.zendesk_role}
+ + {this.props.user.name} + + + {this.props.user.email} + + {this.props.user.zendesk_role} +