Merge branch 'develop' into feature/registration_success/layout

This commit is contained in:
Andrew Smirnov 2021-04-29 20:04:05 +03:00
commit cf4a11a2c0
No known key found for this signature in database
GPG Key ID: 0EFE318E5BB2A82A
99 changed files with 3980 additions and 292 deletions

29
.dockerignore Normal file
View File

@ -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

25
.env.example Normal file
View File

@ -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=""

3
.gitignore vendored
View File

@ -11,7 +11,9 @@ __pycache__/
local_settings.py
db.sqlite3
db.sqlite3-journal
db/
media/
logs/
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.
@ -29,7 +31,6 @@ media/
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/

10
Dockerfile Normal file
View File

@ -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

112
README.md
View File

@ -39,17 +39,117 @@
## Quickstart
Перед запуском требуется неообходимо `.env` файл.
```bash
cp .env.example .env
```
Заменить переменные в `.env` на актуальные.
```bash
sudo apt install make
pip install --upgrade pip
pip install -r requirements.txt
./manage.py migrate
./manage.py shell -c "from django.contrib.auth import get_user_model; get_user_model().objects.create_superuser('vasya', '1@abc.net', 'promprog')"
./manage.py runserver
pip install -r requirements/dev.txt
(set -a && source .env && ./manage.py migrate)
(set -a && source .env && ./manage.py loaddata data.json)
(set -a && source .env && ./manage.py runserver)
```
Создать токен
Указать почту и токен в окружении
## Перед запуском для тестирования:
Убедитесь, что вы зарегистрированы в песочнице ZenDesk, у вас назначена организация `SYSTEM`
Для админов ZenDesk дополнительно - создайте токен доступа в ZenDesk
При запуске в Docker убедитесь что папка, которая будет служить хранилищем для БД, открыта на запись и чтение
## Запуск на локальной машине:
- Скопировать репозиторий на локальную машину
- Перейти в папку приложения
- Активировать виртуальное окружение
- Выполнить команду `pip install -r requirements/dev.txt`
- В виртуальное окружение добавить следующие переменные:
```
ACTRL_DEBUG={0/1} - включить режим дебага
ACTRL_HOST={HOSTNAME} - при запуске без дебага, надо указать домен на котором будет работать приложение
ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} - секретный ключ сгенерированый Django
ACTRL_EMAIL_HOST={SMTP_HOST} - домен почтового сервера через который приложение будет отправлять письма, например "smtp.gmail.com"
ACTRL_EMAIL_PORT={SMTP_PORT} - порт для почтового сервера, например 587, 465 , 2525
ACTRL_EMAIL_TLS={USE_TLS} - использовать TLS для подключения к почтовому серверу, 0 или 1
ACTRL_EMAIL_HOST_USER={USERNAME} - логин с которым приложение входит на почтовый сервер
ACTRL_EMAIL_HOST_PASSWORD={PASSWORD} - пароль/ключ с которым приложение входит на почтовый сервер
ACTRL_FROM_EMAIL={EMAIL} - адрес с которого приложение отправляет письма
ACTRL_SERVER_EMAIL={EMAIL} - адрес на который отвечают пользователя
ACTRL_API_EMAIL={EMAIL} - почта админа в ZenDesk
ACTRL_API_PASSWORD={PASSWORD} - пароль админа ZenDesk
ACTRL_API_TOKEN={API_TOKEN} - API токен зендеск
ACTRL_ZENDESK_SUBDOMAIN={DOMAIN} - домен ZenDesk
ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID} - id роли инженера( custom_role_id сотрдника смены)
LA_CROLE_ID={LIGHT_AGENT_CUSTOM_ROLE_ID} - id роли легкого агента (custom_role_id роли -легкий агент)
EMPL_GROUP={EMPLOYEE_GROUP_NAME} - имя группы которой принадлежат сотрудники ССКС
BUF_GROUP={BUFFER_GROUP_NAME} - имя буферной группы для передачи смен(через нее происходит управление тикетами)
ST_EMAIL={SOLVED_TICKETS_EMAIL} - почта на которую будут переназначятся закрытые тикеты
LICENSE_NO={LICENSE_NO} - количество лицензий, отображаемых как доступные в приложении
SHIFTH={SHIFT_HOURS} - количество часов в рабочей смене (нужно для статистики, пока не реализовано но требует указания значения)
```
- Выполнить команду `python manage.py migrate`
- Запустить приложение командой `python manage.py runserver` (можно указать в параметрах для файла manage.py)
- Перейти по ссылке в консоли (вероятнее всего откроется по адресу http://127.0.0.1:8000/)
## Запуск в Docker:
Требуется установленный и настроенный Docker
- Скопировать репозиторий на локальную машину
- В командной строке перейти в папку проекта
- Выполнить команду `docker build --tag access_controller:latest .`
- Выполнить команду
```bash
docker run -d -p 8000:8000 \
ACTRL_DEBUG={0/1} \
ACTRL_HOST={HOSTNAME} \
ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} \
ACTRL_EMAIL_HOST={SMTP_HOST} \
ACTRL_EMAIL_PORT={SMTP_PORT} \
ACTRL_EMAIL_TLS={USE_TLS} \
ACTRL_EMAIL_HOST_USER={USERNAME} \
ACTRL_EMAIL_HOST_PASSWORD={PASSWORD} \
ACTRL_FROM_EMAIL={EMAIL} \
ACTRL_SERVER_EMAIL={EMAIL} \
ACTRL_API_EMAIL={EMAIL} \
ACTRL_API_PASSWORD={PASSWORD} \
ACTRL_API_TOKEN={API_TOKEN} \
ACTRL_ZENDESK_SUBDOMAIN={DOMAIN} \
ENG_CROLE_ID={ENGINEER_CUSTOM_ROLE_ID} \
LA_CROLE_ID={LIGHT_AGENT_CUSTOM_ROLE_ID} \
EMPL_GROUP={EMPLOYEE_GROUP_NAME} \
BUF_GROUP={BUFFER_GROUP_NAME} \
ST_EMAIL={SOLVED_TICKETS_EMAIL} \
LICENSE_NO={LICENSE_NO} \
SHIFTH={SHIFT_HOURS} \
-v {ABSOLUTE_PATH_TO_DB}:/zendesk-access-controller/db \
access_controller:latest
```
- открываем запущеный контейнер в браузере (можно перейти по ссылке http://localhost:8000/)
## Запуск с тестовыми юзерами:
На локальной машине - перед запуском команды `python manage.py runserver` выполнить команду `python manage.py loaddata data.json`
Это создаст тестового админа и тестового пользователя в приложении для песочницы ZenDesk.
- Админ - `admin@gmail.com` / `zendeskadmin`
- Пользователь - `123@test.ru` / `zendeskuser`
Не сработает если домен песочницы отличается от `ngenix1612197338` (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента
с этими же почтами, назначить им организацию `SYSTEM`)
## Параметры тестовой песочницы:
Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ).
## Read more
- Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au)

View File

@ -9,8 +9,9 @@ https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings')
from django.core.asgi import get_asgi_application
application = get_asgi_application()

19
access_controller/auth.py Normal file
View File

@ -0,0 +1,19 @@
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import User
class EmailAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
try:
user = User.objects.get(email=username)
if user.check_password(password):
return user
return None
except User.DoesNotExist:
return None
def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None

View File

@ -19,12 +19,16 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'v1i_fb$_jf2#1v_lcsbu&eon4u-os0^px=s^iycegdycqy&5)6'
SECRET_KEY = os.getenv('ACTRL_SECRET_KEY','empty')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = bool(int(os.getenv('ACTRL_DEBUG',1)))
ALLOWED_HOSTS = []
ALLOWED_HOSTS = [
'127.0.0.1',
'localhost',
os.getenv('ACTRL_HOST'),
]
# Application definition
@ -36,6 +40,7 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'django_registration',
'rest_framework',
'main',
]
@ -51,6 +56,15 @@ MIDDLEWARE = [
ROOT_URLCONF = 'access_controller.urls'
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = os.getenv('ACTRL_EMAIL_HOST','smtp.gmail.com')
EMAIL_PORT = int(os.getenv('ACTRL_EMAIL_PORT',587))
EMAIL_USE_TLS = bool(int(os.getenv('ACTRL_EMAIL_TLS',1)))
EMAIL_HOST_USER = os.getenv('ACTRL_EMAIL_HOST_USER','group02django@gmail.com')
EMAIL_HOST_PASSWORD = os.getenv('ACTRL_EMAIL_HOST_PASSWORD','djangogroup02')
DEFAULT_FROM_EMAIL = os.getenv('ACTRL_FROM_EMAIL',EMAIL_HOST_USER)
SERVER_EMAIL = os.getenv('ACTRL_SERVER_EMAIL',EMAIL_HOST_USER)
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
@ -77,7 +91,7 @@ WSGI_APPLICATION = 'access_controller.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'NAME': BASE_DIR / 'db' / 'zd_db.sqlite3'
}
}
@ -119,11 +133,47 @@ STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticroot')
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'static'),
os.path.join(BASE_DIR, 'media'),
]
MEDIA_ROOT = BASE_DIR / 'media'
MEDIA_URL = '/media/'
ACCOUNT_ACTIVATION_DAYS = 7
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
# Название_приложения.Названиеайла.Название_класса_обработчика
AUTHENTICATION_BACKENDS = [
'access_controller.auth.EmailAuthBackend',
]
# Logging system
# https://docs.djangoproject.com/en/3.1/topics/logging/
ZENDESK_ROLES = {
'engineer': int(os.getenv('ENG_CROLE_ID',0)),
'light_agent': int(os.getenv('LA_CROLE_ID',0)),
}
ZENDESK_GROUPS = {
'employees': os.getenv('EMPL_GROUP'),
'buffer': os.getenv('BUF_GROUP'),
}
SOLVED_TICKETS_EMAIL = os.getenv('ST_EMAIL')
ZENDESK_MAX_AGENTS = int(os.getenv('LICENSE_NO',0))
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
]
}
ONE_DAY = int(os.getenv('SHIFTH',0)) # Количество часов в 1 рабочем дне
ACTRL_ZENDESK_SUBDOMAIN = os.getenv('ACTRL_ZENDESK_SUBDOMAIN') or os.getenv('ZD_DOMAIN')
ACTRL_API_EMAIL = os.getenv('ACTRL_API_EMAIL') or os.getenv('ACCESS_CONTROLLER_API_EMAIL')
ACTRL_API_TOKEN = os.getenv('ACTRL_API_TOKEN') or os.getenv('ACCESS_CONTROLLER_API_TOKEN')
ACTRL_API_PASSWORD = os.getenv('ACTRL_API_PASSWORD') or os.getenv('ACCESS_CONTROLLER_API_PASSWORD')

View File

@ -13,21 +13,33 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.views import LoginView
from django.contrib.auth import views
from django.urls import path, include
from access_controller import settings
from access_controller.settings import DEBUG
from main.views import main_page, profile_page, CustomRegistrationView
from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView, registration_error
from main.views import work_page, work_hand_over, work_become_engineer, work_get_tickets, \
AdminPageView, statistic_page
from main.urls import router
urlpatterns = [
path('admin/', admin.site.urls, name='admin'),
path('', main_page, name='index'),
path('accounts/profile/', profile_page, name='profile'),
path('accounts/register/', CustomRegistrationView.as_view(), name='registration'),
path('accounts/login/', LoginView.as_view(extra_context={}), name='login'), # TODO add extra context
path('accounts/register/error/', registration_error, name='registration_email_error'),
path('accounts/login/', CustomLoginView.as_view(), name='login'),
path('accounts/', include('django.contrib.auth.urls')),
path('accounts/', include('django_registration.backends.one_step.urls')),
path('work/<int:id>', work_page, name="work"),
path('work/hand_over/', work_hand_over, name="work_hand_over"),
path('work/become_engineer/', work_become_engineer, name="work_become_engineer"),
path('work/get_tickets', work_get_tickets, name='work_get_tickets'),
path('accounts/', include('django_registration.backends.activation.urls')),
path('control/', AdminPageView.as_view(), name='control'),
path('statistic/', statistic_page, name='statistic'),
]
# Django REST
urlpatterns += [
path('api/', include(router.urls))
]

57
data.json Normal file
View File

@ -0,0 +1,57 @@
[
{
"model": "auth.user",
"pk": 1,
"fields": {
"password": "pbkdf2_sha256$216000$gHBBCr1jBELf$ZkEDW3IEd8Wij7u8vkv+0Eze32CS01bcaYWhcD9OIC4=",
"last_login": null,
"is_superuser": true,
"username": "admin@gmail.com",
"first_name": "",
"last_name": "",
"email": "admin@gmail.com",
"is_staff": true,
"is_active": true,
"date_joined": "2021-03-10T16:38:56.303Z",
"groups": [],
"user_permissions": [33]
}
},
{
"model": "main.userprofile",
"pk": 1,
"fields": {
"name": "ZendeskAdmin",
"user": 1,
"role": "admin"
}
},
{
"model": "auth.user",
"pk": 2,
"fields": {
"password": "pbkdf2_sha256$216000$5qLJgrm2Quq9$KDBNNymVZXkUx0HKBPFst2m83kLe0egPBnkW7KnkORU=",
"last_login": null,
"is_superuser": false,
"username": "123@test.ru",
"first_name": "",
"last_name": "",
"email": "123@test.ru",
"is_staff": false,
"is_active": true,
"date_joined": "2021-03-10T16:38:56.303Z",
"groups": [],
"user_permissions": []
}
},
{
"model": "main.userprofile",
"pk": 2,
"fields": {
"name": "UserForAccessTest",
"user": 2,
"role": "agent",
"custom_role_id": "360005209000"
}
}
]

View File

@ -3,14 +3,17 @@
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
@echo " spelling to check for typos in documentation"
.PHONY: help Makefile
@ -18,3 +21,11 @@ help:
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
spelling:
$(SPHINXBUILD) -b spelling -W $(SOURCEDIR) $(BUILDDIR)/spelling
@echo
@echo "Check finished. Wrong words can be found in " \
"build/spelling/output.txt."

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -1,16 +1,51 @@
*****
TODOs
*****
Документация разработчика
=========================
*******
Models
*******
.. automodule:: main.models
:members:
******
Forms
******
.. automodule:: main.forms
:members:
***************
Extra Functions
---------------
***************
.. automodule:: main.extra_func
:members:
***************
Serializers
***************
.. automodule:: main.serializers
:members:
***************
API functions
***************
.. automodule:: main.apiauth
:members:
*****
Views
-----
*****
.. automodule:: main.views
:members:

View File

@ -12,10 +12,34 @@
#
import os
import sys
import importlib
import inspect
import enchant
from enchant import checker
sys.path.insert(0, os.path.abspath('../../'))
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings')
os.environ.setdefault('DJANGO_CONFIGURATION', 'Dev')
# Fix Django's FileFields
from django.db.models.fields.files import FileDescriptor
FileDescriptor.__get__ = lambda self, *args, **kwargs: self
from django.db.models.manager import ManagerDescriptor
ManagerDescriptor.__get__ = lambda self, *args, **kwargs: self.manager
# Stop Django from executing DB queries
from django.db.models.query import QuerySet
QuerySet.__repr__ = lambda self: self.__class__.__name__
django.setup()
# -- Project information -----------------------------------------------------
@ -30,16 +54,93 @@ release = 'v0.01'
# -- General configuration ---------------------------------------------------
def process_django_models(app, what, name, obj, options, lines):
"""Append params from fields to model documentation."""
from django.utils.encoding import force_text
from django.utils.html import strip_tags
from django.db import models
spelling_white_list = ['', '.. spelling::']
if inspect.isclass(obj) and issubclass(obj, models.Model):
for field in obj._meta.fields:
help_text = strip_tags(force_text(field.help_text))
verbose_name = force_text(field.verbose_name).capitalize()
if help_text:
lines.append(':param %s: %s - %s' % (field.attname, verbose_name, help_text))
else:
lines.append(':param %s: %s' % (field.attname, verbose_name))
if enchant is not None:
from enchant.tokenize import basic_tokenize
words = verbose_name.replace('-', '.').replace('_', '.').split('.')
words = [s for s in words if s != '']
for word in words:
spelling_white_list += [" %s" % ''.join(i for i in word if not i.isdigit())]
spelling_white_list += [" %s" % w[0] for w in basic_tokenize(word)]
field_type = type(field)
module = field_type.__module__
if 'django.db.models' in module:
# scope with django.db.models * imports
module = 'django.db.models'
lines.append(':type %s: %s.%s' % (field.attname, module, field_type.__name__))
if enchant is not None:
lines += spelling_white_list
return lines
def process_modules(app, what, name, obj, options, lines):
"""Add module names to spelling white list."""
if what != 'module':
return lines
from enchant.tokenize import basic_tokenize
spelling_white_list = ['', '.. spelling::']
words = name.replace('-', '.').replace('_', '.').split('.')
words = [s for s in words if s != '']
for word in words:
spelling_white_list += [" %s" % ''.join(i for i in word if not i.isdigit())]
spelling_white_list += [" %s" % w[0] for w in basic_tokenize(word)]
lines += spelling_white_list
return lines
def skip_queryset(app, what, name, obj, skip, options):
"""Skip queryset subclasses to avoid database queries."""
from django.db import models
if isinstance(obj, (models.QuerySet, models.manager.BaseManager)) or name.endswith('objects'):
return True
return skip
# def setup(app):
# # Register the docstring processor with sphinx
# app.connect('autodoc-process-docstring', process_django_models)
# app.connect('autodoc-skip-member', skip_queryset)
# app.connect('autodoc-process-docstring', process_modules)
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
extensions = {
'sphinx.ext.todo',
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinx.ext.viewcode',
'sphinx.ext.napoleon',
'sphinx_rtd_theme',
]
'sphinx.ext.graphviz',
'sphinx.ext.inheritance_diagram',
'sphinx_autodoc_typehints',
'sphinxcontrib.spelling'
}
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@ -56,7 +157,6 @@ language = 'ru'
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
@ -69,7 +169,6 @@ html_theme = "sphinx_rtd_theme"
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# -- Extension configuration -------------------------------------------------
# -- Options for intersphinx extension ---------------------------------------
@ -78,12 +177,34 @@ html_static_path = ['_static']
intersphinx_mapping = {
'https://docs.python.org/3/': None,
'django': (
'https://docs.djangoproject.com/en/dev/',
'https://docs.djangoproject.com/en/dev/_objects/'
),
'https://docs.djangoproject.com/en/dev/',
'https://docs.djangoproject.com/en/dev/_objects/'
),
}
autodoc_default_flags = ['members']
# spell checking
spelling_lang = 'ru_RU'
tokenizer_lang = 'ru_RU'
spelling_exclude_patterns=['ignored_*']
spelling_show_suggestions = True
spelling_show_whole_line=True
spelling_warning=True
spelling_ignore_pypi_package_names = True
spelling_ignore_wiki_words=True
spelling_ignore_acronyms=True
spelling_ignore_python_builtins=True
spelling_ignore_importable_modules=True
spelling_ignore_contributor_names=True
# -- Options for todo extension ----------------------------------------------
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True
set_type_checking_flag = True
typehints_fully_qualified = True
always_document_param_types = True
typehints_document_rtype = True
napoleon_attr_annotations = True

View File

@ -3,16 +3,16 @@
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to ZenDesk Access Controller's documentation!
Документация контроллера прав доступа
=====================================================
.. toctree::
:maxdepth: 2
:caption: Contents:
code.rst
todo.rst
overview
code
todo
Indices and tables

97
docs/source/overview.rst Normal file
View File

@ -0,0 +1,97 @@
Документация пользователя
=========================
******************************
Управление правами доступа
******************************
**ZenDesk Access Controller** - web-приложение, для выдачи прав пользователям системы по запросу самого пользователя.
Например, из 12 человек 3 сейчас работают с правами администратора, по окончании рабочей смены они сдают свои права (освобождают места) и другие пользователи могут запросить эти права в свое пользование.
Оставшиеся 9 человек получают права легкого агента - без прав редактирования, а только чтение.
*****************
Главная страница
*****************
Меню главной страницы предоставляет Вам выбор:
* **"Войти"** - если Вы уже являетесь зарегистрированным пользователем
* **"Зарегистрироваться"** - при первом входе
.. image:: _static/main_logout.png
Внимание! Для регистрации используется email с сайта Zendesk. Регистрация по каждому email
возможна один раз
**После авторизации пользователь может выбрать из следующих разделов меню:**
* **"Профиль"** - просмотреть свои данные и запросить права доступа
* **"Запрос прав"** - получение прав для работы с тикетами или **"Управление"** - доступно для администратора и предоставляет возможность группового назначения ролей пользователям
.. image:: _static/main.png
*************
Регистрация
*************
Для регистрации необходимо ввести email, который указан Вами в Zendesk.
.. image:: _static/registration.png
На электронную почту придет ссылка, пройдя по которой, Вам необходимо задать пароль.
***********
Авторизация
***********
Для входа необходимо ввести email и пароль
.. image:: _static/login.png
Если Вы не помните пароль необходимо пройти по ссылке "Забыли пароль" и указать email.
На Вашу почту придет ссылка для установки нового пароля.
********
Профиль
********
Профиль пользователя - это Ваша рабочая страница.
Здесь Вы можете просмотреть информацию пользователя (Ваши данные с Zendesk) и запросить права доступа для работы с тикетами.
.. image:: _static/profile.png
********************
Запрос прав доступа
********************
На странице запроса прав Вам доступна информация о количестве и списке работающих над тикетами сотрудников,
а также возможность сдать и запросить права.
.. image:: _static/request.png
Успешное изменение прав:
.. image:: _static/role_change.png
******************************************
Управление правами доступа администратором
******************************************
Для администратора существует удобный интерфейс страницы управления, в котором представлены:
* Количество свободных инженерных мест
* Количество и список инженеров и легких агентов
* Возможность группового назначения прав с использованием чек-боксов
.. image:: _static/admin_manage.png
Изменение прав пользователей наглядно отразится в таблице пользователей:
.. image:: _static/admin_manage_done.png
.. |copy| unicode:: 0xA9 .. Школа программистов S101, группа 2. 2021гю

View File

@ -0,0 +1,88 @@
тикетами
тикета
тикетов
тикет
web
Indices
and
tables
Models
логирования
User
user
superuser
light
light_agent
admin
agent
bootstrap
form
control
Zendesk
email
Extra
Functions
env
ID
url
None
token
password
engineer
SYSTEM
start_date
end_date
timedelta
log
RoleChangeLogs
time(datetime.time)
stat
statistic
True
False
val
start
end
date
Токен
токеном
аутентифицирован
(datetime.time)
datetime
time
serializer
валидны
html
subdomain
логгирования
логгирование
forms
StatisticForm
Userprofile
login
login_required
required
id
prom
home
PycharmProjects
Access
access
controler
controller
main
views
py
docstring
of
page
API
functions
Serializer
Serializers
Сериализатор
переадресации

View File

@ -1,5 +1,11 @@
Что необходимо доделать?
========================
*****
TODOs
*****
.. todolist::

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

0
logs/.gitkeep Normal file
View File

View File

@ -3,18 +3,29 @@ 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():
def api_auth() -> dict:
"""
Функция создания пользователя с использованием Zendesk API.
Получает из env Zendesk - email, token, password пользователя.
Если данные валидны и пользователь Zendesk с указанным email и токеном или паролем существует,
создается словарь данных пользователя, полученных через API c Zendesk.
: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:

View File

@ -1,106 +1,564 @@
import os
import logging
from datetime import timedelta, datetime, date
from typing import Optional
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.shortcuts import redirect
from django.utils import timezone
from zenpy import Zenpy
from zenpy.lib.exception import APIException
from zenpy.lib.api_objects import User as ZenpyUser, Ticket as ZenpyTicket
from zenpy.lib.generator import SearchResultGenerator
from main.models import UserProfile
from access_controller.settings import ZENDESK_ROLES as ROLES, ONE_DAY, ACTRL_ZENDESK_SUBDOMAIN
from main.models import UserProfile, RoleChangeLogs, UnassignedTicket, UnassignedTicketStatus
from main.zendesk_admin import zenpy
# Дополнительные функции
class ZendeskAdmin:
# Класс существует, чтобы в каждой фунциии отдельно не проверять аккаунт администратора
credentials = {
'subdomain': 'ngenix1612197338'
}
email = os.getenv('ACCESS_CONTROLLER_API_EMAIL')
token = os.getenv('ACCESS_CONTROLLER_API_TOKEN')
password = os.getenv('ACCESS_CONTROLLER_API_PASSWORD')
def update_role(user_profile: UserProfile, role: int) -> None:
"""
Функция меняет роль пользователя.
def __init__(self):
self.create_admin()
:param user_profile: Профиль пользователя
:param role: Новая роль
:return: Пользователь с обновленной ролью
"""
zendesk = zenpy
user = zendesk.get_user(user_profile.user.email)
user.custom_role_id = role
user_profile.custom_role_id = role
user_profile.save()
zendesk.admin.users.update(user)
def check_user(self, email: str) -> bool:
return True if self.admin.search(email, type='user') else False
def get_user_name(self, email: str) -> str:
user = self.admin.users.search(email).values[0]
return user.name
def make_engineer(user_profile: UserProfile, who_changes: User) -> None:
"""
Функция устанавливает пользователю роль инженера.
def get_user_role(self, email: str) -> str:
user = self.admin.users.search(email).values[0]
return user.role
:param user_profile: Профиль пользователя
:return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "engineer"
"""
update_role(user_profile, ROLES['engineer'])
def get_user_id(self, email: str) -> str:
user = self.admin.users.search(email).values[0]
return user.id
def get_user_image(self, email: str) -> str:
user = self.admin.users.search(email).values[0]
return user.photo['content_url'] if user.photo else None
def make_light_agent(user_profile: UserProfile, who_changes: User) -> None:
"""
Функция устанавливает пользователю роль легкого агента.
def create_admin(self) -> None:
if self.email is None:
raise ValueError('access_controller email not in env')
self.credentials['email'] = os.getenv('ACCESS_CONTROLLER_API_EMAIL')
if self.token:
self.credentials['token'] = self.token
elif self.password:
self.credentials['password'] = self.password
:param user_profile: Профиль пользователя
:return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent"
"""
tickets: SearchResultGenerator = get_tickets_list(user_profile.user.email)
ticket: ZenpyTicket
for ticket in tickets:
UnassignedTicket.objects.create(
assignee=user_profile.user,
ticket_id=ticket.id,
status=UnassignedTicketStatus.SOLVED if ticket.status == 'solved' else UnassignedTicketStatus.UNASSIGNED
)
if ticket.status == 'solved':
ticket.assignee_id = zenpy.solved_tickets_user_id
else:
raise ValueError('access_controller token or password not in env')
self.admin = Zenpy(**self.credentials)
ticket.assignee = None
ticket.group_id = zenpy.buffer_group_id
if tickets.count:
zenpy.admin.tickets.update(tickets.values)
attempts, success = 5, False
while not success and attempts != 0:
try:
self.admin.search(self.email, type='user')
except APIException:
raise ValueError('invalid access_controller`s login data')
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 = zenpy
# У пользователей должна быть организация SYSTEM
org = next(zendesk.admin.search(type='organization', name='SYSTEM'))
users = zendesk.admin.organizations.users(org)
return users
def get_tickets_list(email):
"""
Функция возвращает список тикетов пользователя Zendesk
"""
return zenpy.admin.search(assignee=email, type='ticket')
def update_profile(user_profile: UserProfile):
"""
Функция обновляет профиль пользователя в соотвтетствии с текущим в Zendesk
Функция обновляет профиль пользователя в соответствии с текущим в Zendesk.
:param user_profile: Объект профиля пользователя
:type user_profile: :class:`main.models.UserProfile`
:param user_profile: Профиль пользователя
:return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя
"""
user_profile.name = ZendeskAdmin().get_user_name(user_profile.user.email)
user_profile.role = ZendeskAdmin().get_user_role(user_profile.user.email)
user_profile.image = ZendeskAdmin().get_user_image(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
user_profile.image = user.photo['content_url'] if user.photo else None
user_profile.save()
def check_user_exist(email: str) -> bool:
"""
Функция проверяет, существует ли пользователь
Функция проверяет, существует ли пользователь.
:param email: Электронная почта пользователя
:type email: :class:`str`
:return: True, если существует, иначе False
:rtype: :class:`bool`
:param email: Email пользователя
:return: Зарегистрирован ли пользователь в Zendesk
"""
return ZendeskAdmin().check_user(email)
return zenpy.check_user(email)
def get_user_organization(email: str) -> str:
"""
Функция возвращает организацию пользователя.
:param email: Email пользователя
:return: Организация пользователя
"""
return zenpy.get_user_org(email)
def check_user_auth(email: str, password: str) -> bool:
"""
Функция проверяет, верны ли входные данные
Функция проверяет, верны ли входные данные.
:param email: Электроная почта пользователя
:type email: :class:`str`
:param password: Пароль пользователя
:type password: :class:`str`
:return: True, если входные данные верны, иначе False
:raise :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован
:rtype: :class:`bool`
:raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован
"""
creds = {
'email': email,
'password': password,
'subdomain': 'ngenix1612197338',
}
'email': email,
'password': password,
'subdomain': ACTRL_ZENDESK_SUBDOMAIN,
}
try:
user = Zenpy(**creds)
user.search(email, type='user')
except APIException:
return False
return True
def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser):
"""
Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk.
:param profile: Профиль пользователя
:param zendesk_user: Данные пользователя в Zendesk
:return: Обновленный профиль пользователя
"""
profile.name = zendesk_user.name
profile.role = zendesk_user.role
profile.image = zendesk_user.photo['content_url'] if zendesk_user.photo else None
if zendesk_user.custom_role_id is not None:
profile.custom_role_id = int(zendesk_user.custom_role_id)
profile.save()
def count_users(users) -> tuple:
"""
Функция подсчета количества сотрудников с ролями engineer и light_agent
"""
engineers, light_agents = 0, 0
for user in users:
if user.custom_role_id == ROLES['engineer']:
engineers += 1
elif user.custom_role_id == ROLES['light_agent']:
light_agents += 1
return engineers, light_agents
def update_users_in_model():
"""
Обновляет пользователей в модели UserProfile по списку пользователей в организации
"""
users = get_users_list()
for user in users:
try:
profile = User.objects.get(email=user.email).userprofile
update_user_in_model(profile, user)
except ObjectDoesNotExist:
pass
return users
def daterange(start_date, end_date) -> list:
"""
Функция возвращает список дней с start_date по end_date, исключая правую границу.
:param start_date: Начальная дата
:param end_date: Конечная дата
:return: Список дней, не включая конечную дату
"""
dates = []
for n in range(int((end_date - start_date).days)):
dates.append(start_date + timedelta(n))
return dates
def get_timedelta(log, time=None) -> timedelta:
"""
Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента,
который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён.
:param log: Лог
:param time: Время
:return: Сколько времени прошло от начала суток до события
"""
if time is None:
time = log.change_time.time()
time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second)
return time
def last_day_of_month(day: int) -> int:
"""
Функция возвращает последний день текущего месяца.
:param day: Текущий день
:return: Последний день месяца
"""
next_month = day.replace(day=28) + timedelta(days=4)
return next_month - timedelta(days=next_month.day)
class StatisticData:
"""
Класс для учета статистики интервалов работы пользователей.
Передаваемые параметры: start_date, end_date, email, stat.
:param display: Формат отображения времени (часы, минуты)
:type display: :class:`list`
:param interval: Интервал времени в часах и минутах
:type interval: :class:`list`
:param start_date: Дата начала работы
:type start_date: :class:`date`
:param end_date: Дата окончания работы
:type end_date: :class:`date`
:param email: Email пользователя
:type email: :class:`str`
:param errors: Список ошибок
:type errors: :class:`list`
:param warnings: Список предупреждений
:type warnings: :class:`list`
:param data: Ретроспектива смены ролей пользователя
:type data: :class:`dict`
:param statistic: Интервалы работы пользователя
:type statistic: :class:`dict`
"""
def __init__(self, start_date, end_date, user_email, stat=None):
self.display = None
self.interval = None
self.start_date = start_date
self.end_date = end_date
self.email = user_email
self.errors = list()
self.warnings = list()
self.data = dict()
self.statistic = dict()
self._init_data()
if stat is None:
self._init_statistic()
else:
self.statistic = stat
def get_statistic(self) -> dict:
"""
Функция возвращает статистику работы пользователя.
:return: Словарь statistic с применением формата отображения и интервала работы(если они есть). None, если были ошибки при создании.
"""
if self.is_valid_statistic():
stat = self.statistic
stat = self._use_display(stat)
stat = self._use_interval(stat)
return stat
else:
return None
def is_valid_statistic(self) -> bool:
"""
Функция проверяет были ли ошибки при создании статистики.
:return: True, при отсутствии ошибок
"""
return not self.errors and self.statistic
def set_interval(self, interval: list) -> bool:
"""
Функция проверяет корректность представления интервала работы.
:param interval: Интервал должен быть указан в днях или месяцах.
:return: True, если указан верно
"""
if interval not in ['months', 'days']:
self.errors += ['Интервал работы должен быть в днях или месяцах']
return False
self.interval = interval
return True
def set_display(self, display_format: list) -> bool:
"""
Функция проверяет корректность формата отображения интервала.
:param display_format: Формат отображения должен быть указан в днях или месяцах.
:return: True, если указан верно
"""
if display_format not in ['days', 'hours']:
self.errors += ['Формат отображения должен быть в часах или днях']
return False
self.display = display_format
return True
def get_data(self) -> Optional[dict]:
"""
Функция возвращает данные - список объектов RoleChangeLogs.
"""
if self.is_valid_data():
return self.data
else:
return None
def is_valid_data(self) -> bool:
"""
Функция определяет были ли ошибки при получении логов.
:return: True, если ошибок нет
"""
return not self.errors
def _use_display(self, stat: list) -> list:
"""
Функция приводит данные к формату отображения.
:param stat: Список данных статистики пользователя
:return: Обновленный список
"""
if not self.is_valid_statistic() or not self.display:
return stat
new_stat = {}
for key, item in stat.items():
if self.display == 'hours':
new_stat[key] = item / 3600
elif self.display == 'days':
new_stat[key] = item / (ONE_DAY * 3600)
return new_stat
def _use_interval(self, stat: dict) -> dict:
"""
Функция объединяет ключи и значения в соответствии с интервалом работы.
:param stat: Статистика работы пользователя
:return: Обновленная статистика
"""
if not self.is_valid_statistic() or not self.interval:
return stat
new_stat = {}
if self.interval == 'months':
# Переделываем ключи под формат('началоесяца - конец_месяца')
for key, value in stat.items():
current_month_start = max(self.start_date, date(year=key.year, month=key.month, day=1))
current_month_end = min(self.end_date, last_day_of_month(date(year=key.year, month=key.month, day=1)))
index = ' - '.join([str(current_month_start), str(current_month_end)])
if new_stat.get(index):
new_stat[index] += value
else:
new_stat[index] = value
elif self.interval == 'days':
new_stat = stat # статистика изначально в днях
return new_stat
def check_time(self) -> bool:
"""
Функция проверяет корректность введенного времени.
:return: True, если время указано корректно. Иначе, False
"""
if self.end_date < self.start_date or self.end_date > datetime.now().date():
return False
return True
def _init_data(self):
"""
Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email.
:return: Данные о смене статусов пользователя. Если пользователь не найден или интервал времени некорректен - ошибку.
"""
if not self.check_time():
self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени']
return
try:
self.data = RoleChangeLogs.objects.filter(
change_time__range=[self.start_date, self.end_date + timedelta(days=1)],
user=User.objects.get(email=self.email),
).order_by('change_time')
except User.DoesNotExist:
self.errors += ['Пользователь не найден']
def _init_statistic(self) -> dict:
"""
Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд.
:return: Статистика работы пользователя (statistic)
"""
self.clear_statistic()
if not self.get_data():
self.warnings += ['Не обнаружены изменения роли в данном промежутке']
return None
first_log, last_log = self.data[0], self.data[len(self.data) - 1]
if first_log.old_role == ROLES['engineer']:
self.prev_engineer_logic(first_log)
if last_log.new_role == ROLES['engineer']:
self.post_engineer_logic(last_log)
for log_index in range(len(self.data) - 1):
if self.data[log_index].new_role == ROLES['engineer']:
self.engineer_logic(log_index)
def engineer_logic(self, log_index):
"""
Функция обрабатывает основную часть работы инженера
"""
current_log, next_log = self.data[log_index], self.data[log_index + 1]
if current_log.change_time.date() != next_log.change_time.date():
self.statistic[current_log.change_time.date()] += (
timedelta(days=1) - get_timedelta(current_log)).total_seconds()
self.statistic[next_log.change_time.date()] += get_timedelta(next_log).total_seconds()
self.fill_daterange(current_log.change_time.date() + timedelta(days=1), next_log.change_time.date())
else:
elapsed_time = next_log.change_time - current_log.change_time
self.statistic[current_log.change_time.date()] += elapsed_time.total_seconds()
def post_engineer_logic(self, last_log):
"""
Функция обрабатывает случай, когда нам изветсно что инженер работал и после диапазона
"""
self.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date + timedelta(days=1))
if last_log.change_time.date() == timezone.now().date():
self.statistic[last_log.change_time.date()] += (
get_timedelta(None, timezone.now().time()) - get_timedelta(last_log)
).total_seconds()
else:
self.statistic[last_log.change_time.date()] += (
timedelta(days=1) - get_timedelta(last_log)).total_seconds()
if self.end_date == timezone.now().date():
self.statistic[self.end_date] = get_timedelta(None, timezone.now().time()).total_seconds()
def prev_engineer_logic(self, first_log):
"""
Функция обрабатывает случай, когда нам изветсно что инженер начал работу до диапазона
"""
self.fill_daterange(max(User.objects.get(email=self.email).date_joined.date(), self.start_date),
first_log.change_time.date())
self.statistic[first_log.change_time.date()] += get_timedelta(first_log).total_seconds()
def fill_daterange(self, first: date, last: date, val: int = 24 * 3600) -> dict:
"""
Функция заполняет диапазон дат значением val (по умолчанию val = кол-во секунд в 1 дне).
:param first: Начальная дата интервала
:param last: Последняя дата интервала
:param val: Количество секунд в одном дне
"""
for day in daterange(first, last):
self.statistic[day] = val
def clear_statistic(self) -> dict:
"""
Функция осуществляет обновление всех дней.
"""
self.statistic.clear()
self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0)
class DatabaseHandler(logging.Handler):
def __init__(self):
logging.Handler.__init__(self)
def emit(self, record):
database = RoleChangeLogs()
users = record.msg
if users[1]:
user = users[0]
admin = users[1]
elif not users[1]:
user = users[0]
admin = users[0]
database.name = user.name
database.user = user.user
database.changed_by = admin.user
if user.custom_role_id == ROLES['engineer']:
database.old_role = ROLES['light_agent']
elif user.custom_role_id == ROLES['light_agent']:
database.old_role = ROLES['engineer']
database.new_role = user.custom_role_id
database.save()
class CsvFormatter(logging.Formatter):
def __init__(self):
logging.Formatter.__init__(self)
def format(self, record):
users = record.msg
if users[1]:
user = users[0]
admin = users[1]
elif not users[1]:
user = users[0]
admin = users[0]
msg = ''
msg += user.name
if user.custom_role_id == ROLES['engineer']:
msg += ',engineer,'
elif user.custom_role_id == ROLES['light_agent']:
msg += ',light_agent,'
time = str(timezone.now().today())
msg += time[:16]
msg += ','
msg += admin.name
return msg
def log(user, admin=0):
"""
Осуществляет запись логов в базу данных и csv файл
:param admin:
:param user:
:return:
"""
users = [user, admin]
logger = logging.getLogger('MY_LOGGER')
if not logger.hasHandlers():
dbhandler = DatabaseHandler()
csvformatter = CsvFormatter()
csvhandler = logging.FileHandler('logs/logs.csv', "a")
csvhandler.setFormatter(csvformatter)
logger.addHandler(dbhandler)
logger.addHandler(csvhandler)
logger.setLevel('INFO')
logger.info(users)
def set_session_params_for_work_page(request, count=None, is_confirm=True):
"""
Функция для страницы получения прав
Устанавливает данные сессии о успешности запроса и количестве назначенных тикетов
"""
request.session['is_confirm'] = is_confirm
request.session['count_tickets'] = count
return redirect('work', request.user.id)

View File

@ -1,34 +1,142 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django_registration.forms import RegistrationFormUniqueEmail
from main.models import UserProfile
class CustomRegistrationForm(RegistrationFormUniqueEmail):
"""
Форма для регистрации :class:`django_registration.forms.RegistrationFormUniqueEmail`
с полем для ввода пароля от Zendesk аккаунта и с добавлением bootstrap-класса 'form-control' для всех полей
с добавлением bootstrap-класса "form-control".
:param password_zen: Поле для ввода пароля от Zendesk
:type password_zen: :class:`django.forms.CharField`
:param visible_fields.email: Поле для ввода email, зарегистрированного на Zendesk
:type visible_fields.email: :class:`django_registration.forms.RegistrationFormUniqueEmail`
"""
password_zen = forms.CharField(
required=True,
label="Пароль от Zendesk аккаунта",
strip=False,
widget=forms.PasswordInput(attrs={
'class': 'form-control'
})
)
def __init__(self, *args, **kwargs):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
for visible in self.visible_fields():
if visible.field.widget.attrs.get('class', False):
print(visible.field.widget.attrs['class'].find('form-control'))
if visible.field.widget.attrs['class'].find('form-control') < 0:
visible.field.widget.attrs['class'] += 'form-control'
else:
visible.field.widget.attrs['class'] = 'form-control'
if visible.html_name != 'email':
visible.field.required = False
class Meta(RegistrationFormUniqueEmail.Meta):
fields = RegistrationFormUniqueEmail.Meta.fields
fields.insert(2, 'password_zen')
class AdminPageUsers(forms.Form):
"""
Форма для установки статусов engineer или light_agent пользователям.
:param users: Поле для установки статуса
:type users: :class:`ModelMultipleChoiceField`
"""
users = forms.ModelMultipleChoiceField(
queryset=UserProfile.objects.filter(role='agent'),
widget=forms.CheckboxSelectMultiple(
attrs={
'class': 'form-check-input',
}
),
label=''
)
class CustomAuthenticationForm(AuthenticationForm):
"""
Форма для авторизации :class:`django.contrib.auth.forms.AuthenticationForm`
с изменением поля username на email.
:param username: Поле для ввода email пользователя
:type username: :class:`django.forms.fields.CharField`
"""
username = forms.CharField(
label="Электронная почта",
widget=forms.EmailInput(),
)
error_messages = {
'invalid_login':
"Пожалуйста, введите правильные электронную почту и пароль. Оба поля "
"могут быть чувствительны к регистру."
,
'inactive': "Аккаунт не активен.",
}
INTERVAL_CHOICES = [
('days', 'Дни'),
('months', 'Месяцы')
]
DISPLAY_CHOICES = [
('hours', 'Часы'),
('days', 'Дни/Смены')
]
class StatisticForm(forms.Form):
"""
Форма отображения интервалов работы пользователя.
:param email: Поле для ввода email пользователя
:type email: :class:`django.forms.fields.EmailField`
:param interval: Расчет интервала рабочего времени
:type interval: :class:`django.forms.fields.CharField`
:param display_format: Формат отображения данных
:type display_format: :class:`django.forms.fields.CharField`
:param range_start: Дата и время начала работы
:type range_start: :class:`django.forms.fields.DateField`
:param range_end: Дата и время окончания работы
:type range_end: :class:`django.forms.fields.DateField`
"""
email = forms.EmailField(
label='Электронная почта',
widget=forms.EmailInput(
attrs={
'placeholder': 'example@ngenix.ru',
'class': 'form-control',
}
),
)
interval = forms.ChoiceField(
label='Выберите интервалы времени работы',
choices=INTERVAL_CHOICES,
widget=forms.RadioSelect(
attrs={
'class': 'btn-check',
}
)
)
display_format = forms.ChoiceField(
label='Выберите формат отображения',
choices=DISPLAY_CHOICES,
widget=forms.RadioSelect(
attrs={
'class': 'btn-check',
}
)
)
range_start = forms.DateField(
label='Начало статистики',
widget=forms.DateInput(
attrs={
'type': 'date',
'class': 'btn btn-secondary text-primary bg-white',
}
),
)
range_end = forms.DateField(
label='Конец статистики',
widget=forms.DateInput(
attrs={
'type': 'date',
'class': 'btn btn-secondary text-primary bg-white',
}
),
)

View File

@ -0,0 +1,27 @@
# Generated by Django 3.1.6 on 2021-02-17 17:25
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('main', '0003_auto_20210216_2222'),
]
operations = [
migrations.CreateModel(
name='RoleChangeLogs',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.TextField()),
('new_role', models.TextField()),
('change_time', models.DateTimeField()),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changed_by', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.1.6 on 2021-03-02 19:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0004_rolechangelogs'),
]
operations = [
migrations.AlterModelOptions(
name='userprofile',
options={'permissions': [('admin', 'Have access to control page')]},
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 3.1.6 on 2021-03-03 19:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0005_auto_20210302_2255'),
]
operations = [
migrations.DeleteModel(
name='UserProfile',
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.1.6 on 2021-03-03 19:35
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('main', '0006_delete_userprofile'),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(default='None', max_length=100)),
('image', models.URLField(blank=True, null=True)),
('name', models.CharField(default='None', max_length=100)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'permissions': [('control_access', 'User has access to control page')],
},
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.1.6 on 2021-03-03 20:05
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0007_userprofile'),
]
operations = [
migrations.AlterModelOptions(
name='userprofile',
options={},
),
]

View File

@ -0,0 +1,61 @@
# Generated by Django 3.1.6 on 2021-03-11 08:00
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('main', '0008_auto_20210303_2305'),
]
operations = [
migrations.AlterField(
model_name='rolechangelogs',
name='change_time',
field=models.DateTimeField(help_text='Дата и время изменения роли'),
),
migrations.AlterField(
model_name='rolechangelogs',
name='changed_by',
field=models.ForeignKey(help_text='Кем была изменена роль', on_delete=django.db.models.deletion.CASCADE, related_name='changed_by', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='rolechangelogs',
name='name',
field=models.TextField(help_text='Имя пользователя'),
),
migrations.AlterField(
model_name='rolechangelogs',
name='new_role',
field=models.TextField(help_text='Присвоенная роль'),
),
migrations.AlterField(
model_name='rolechangelogs',
name='user',
field=models.ForeignKey(help_text='Пользователь, которому присвоили другую роль', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='userprofile',
name='image',
field=models.URLField(blank=True, help_text='Аватарка', null=True),
),
migrations.AlterField(
model_name='userprofile',
name='name',
field=models.CharField(default='None', help_text='Имя пользователя на нашем сайте', max_length=100),
),
migrations.AlterField(
model_name='userprofile',
name='role',
field=models.CharField(default='None', help_text='Код роли пользователя', max_length=100),
),
migrations.AlterField(
model_name='userprofile',
name='user',
field=models.OneToOneField(help_text='Пользователь', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.1.6 on 2021-03-11 08:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0009_models_help_text'),
]
operations = [
migrations.AlterModelOptions(
name='userprofile',
options={'permissions': (('has_control_access', 'Can view admin page'),)},
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 3.1.6 on 2021-03-11 14:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0010_userprofile_meta'),
]
operations = [
migrations.AddField(
model_name='rolechangelogs',
name='old_role',
field=models.IntegerField(default=0, help_text='Старая роль'),
),
migrations.AlterField(
model_name='rolechangelogs',
name='new_role',
field=models.IntegerField(default=0, help_text='Присвоенная роль'),
),
migrations.AlterField(
model_name='userprofile',
name='role',
field=models.IntegerField(default=0, help_text='Код роли пользователя'),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.1.6 on 2021-03-11 17:27
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('main', '0011_auto_20210311_1734'),
]
operations = [
migrations.RemoveField(
model_name='rolechangelogs',
name='name',
),
migrations.CreateModel(
name='UnassignedTicket',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticket_id', models.IntegerField(help_text='Номер тикера, для которого сняли ответственного')),
('status', models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются')], default=0)),
('assignee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.6 on 2021-03-11 17:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0012_auto_20210311_2027'),
]
operations = [
migrations.AlterField(
model_name='unassignedticket',
name='status',
field=models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются'), (3, 'Тикет уже был закрыт. Дополнительные действия не требуются')], default=0),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.1.6 on 2021-03-14 11:55
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('main', '0013_auto_20210311_2040'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='custom_role_id',
field=models.IntegerField(default=0, help_text='Код роли пользователя'),
),
migrations.AlterField(
model_name='rolechangelogs',
name='change_time',
field=models.DateTimeField(default=django.utils.timezone.now, help_text='Дата и время изменения роли'),
),
migrations.AlterField(
model_name='userprofile',
name='role',
field=models.CharField(default='None', help_text='Глобальное имя роли пользователя', max_length=100),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.6 on 2021-03-21 13:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0014_auto_20210314_1455'),
]
operations = [
migrations.AlterField(
model_name='unassignedticket',
name='status',
field=models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются'), (3, 'Тикет уже был закрыт. Дополнительные действия не требуются'), (4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL')], default=0),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.6 on 2021-03-29 21:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0014_auto_20210314_1455'),
]
operations = [
migrations.AlterField(
model_name='unassignedticket',
name='status',
field=models.IntegerField(choices=[(0, 'Снят с пользователя, перенесён в буферную группу'), (1, 'Авторство восстановлено'), (2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются'), (3, 'Тикет уже был закрыт. Дополнительные действия не требуются'), (4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL')], default=0),
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 3.1.6 on 2021-03-29 21:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0015_auto_20210330_0007'),
('main', '0015_auto_20210321_1600'),
]
operations = [
]

View File

@ -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='Статус тикета'),
),
]

View File

@ -1,14 +1,37 @@
from django.contrib.auth.models import User
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from access_controller.settings import ZENDESK_ROLES
class UserProfile(models.Model):
user = models.OneToOneField(to=User, on_delete=models.CASCADE)
role = models.CharField(default='None', max_length=100)
image = models.URLField(null=True, blank=True)
name = models.CharField(default='None', max_length=100)
"""
Модель профиля пользователя.
Профиль создается и изменяется при создании и изменении модель User.
"""
class Meta:
permissions = (
('has_control_access', 'Can view admin page'),
)
user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь')
role = models.CharField(default='None', max_length=100, help_text='Глобальное имя роли пользователя')
custom_role_id = models.IntegerField(default=0, help_text='Код роли пользователя')
image = models.URLField(null=True, blank=True, help_text='Аватарка')
name = models.CharField(default='None', max_length=100, help_text='Имя пользователя на нашем сайте')
@property
def zendesk_role(self):
id = self.custom_role_id
for role, r_id in ZENDESK_ROLES.items():
if r_id == id:
return role
return 'UNDEFINED'
@receiver(post_save, sender=User)
@ -20,3 +43,42 @@ def create_user_profile(sender, instance, created, **kwargs):
@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
instance.userprofile.save()
class RoleChangeLogs(models.Model):
"""
Модель для логирования изменений ролей пользователя.
"""
user = models.ForeignKey(to=User, on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль')
old_role = models.IntegerField(default=0, help_text='Старая роль')
new_role = models.IntegerField(default=0, help_text='Присвоенная роль')
change_time = models.DateTimeField(default=timezone.now, help_text='Дата и время изменения роли')
changed_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль')
class UnassignedTicketStatus(models.IntegerChoices):
"""
Класс статусов не распределенных тикетов.
:param UNASSIGNED: Снят с пользователя, перенесён в буферную группу
:param RESTORED: Авторство восстановлено
:param NOT_FOUND: Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются
:param CLOSED: Тикет уже был закрыт. Дополнительные действия не требуются
:param SOLVED: Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL
"""
UNASSIGNED = 0, 'Снят с пользователя, перенесён в буферную группу'
RESTORED = 1, 'Авторство восстановлено'
NOT_FOUND = 2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются'
CLOSED = 3, 'Тикет уже был закрыт. Дополнительные действия не требуются'
SOLVED = 4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL'
class UnassignedTicket(models.Model):
"""
Модель не распределенного тикета.
"""
assignee = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='tickets', help_text='Пользователь, с которого снят тикет')
ticket_id = models.IntegerField(help_text='Номер тикера, для которого сняли ответственного')
status = models.IntegerField(choices=UnassignedTicketStatus.choices, default=UnassignedTicketStatus.UNASSIGNED, help_text='Статус тикета')

38
main/serializers.py Normal file
View File

@ -0,0 +1,38 @@
from django.contrib.auth.models import User
from rest_framework import serializers
from main.models import UserProfile
from access_controller.settings import ZENDESK_ROLES
class UserSerializer(serializers.HyperlinkedModelSerializer):
"""
Класс serializer для модели User.
"""
class Meta:
model = User
fields = ['email']
class ProfileSerializer(serializers.HyperlinkedModelSerializer):
"""Класс serializer для модели профиля пользователя"""
user = UserSerializer()
class Meta:
model = UserProfile
fields = ['user', 'id', 'name', 'zendesk_role']
class ZendeskUserSerializer(serializers.Serializer):
"""Класс serializer для объектов пользователей из zenpy"""
name = serializers.CharField()
zendesk_role = serializers.SerializerMethodField('get_zendesk_role')
email = serializers.EmailField()
@staticmethod
def get_zendesk_role(obj):
if obj.custom_role_id == ZENDESK_ROLES['engineer']:
return 'engineer'
elif obj.custom_role_id == ZENDESK_ROLES['light_agent']:
return 'light_agent'
else:
return "empty"

View File

@ -2,57 +2,58 @@
<html lang="ru" class="h-100">
{% load static %}
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css"
integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2"
crossorigin="anonymous">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/css/bootstrap.min.css"
integrity="sha384-r4NyP46KrjDleawBgD5tp8Y7UzmLA05oM1iAEQ17CSuDqnUK2+k9luXQOfXJCJ4I" crossorigin="anonymous">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
</style>
}
</style>
{% block extra_css %}{% endblock %}
{% block extra_scripts %}{% endblock %}
</head>
<body class="d-flex flex-column h-100">
{% include 'base/menu.html' %}
{% include 'base/menu.html' %}
<main class="flex-shrink-0">
<div class="container mt-4 mb-4">
<h1 class="mb-4 text-center">
{% block heading %}
<main class="flex-shrink-0">
<div class="container mt-4 mb-4">
<h1 class="mb-4 text-center">
{% block heading %}
{% endblock %}
</h1>
{% block content %}
{% endblock %}
</h1>
{% block content %}
{% endblock %}
</div>
</main>
</div>
</main>
<footer class="footer mt-auto py-3 bg-light">
<div class="container">
<small class="text-muted mt-auto">Сайт сделан учениками Школы Программистов (Группа №02)</small>
</div>
</footer>
<footer class="footer mt-auto py-3 bg-light">
<div class="container">
<small class="text-muted mt-auto">Сайт сделан учениками Школы Программистов (Группа №02)</small>
</div>
</footer>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW"
crossorigin="anonymous"
></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW" crossorigin="anonymous">
</script>
</body>
</html>

View File

@ -3,20 +3,56 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<nav class="navbar navbar-light" style="background-color: #00FF00;">
<nav class="navbar navbar-light" style="background-color: #113A60;">
<a class="navbar-brand" href="{% url 'index' %}">
<img src="{% static 'main/img/logo.png' %}" width="30" height="30" class="d-inline-block align-top" alt="" loading="lazy">
Access Controller
<img src="{% static 'main/img/logo_real.png' %}" width="107" height="22" class="d-inline-block align-top" style="margin-left: 15px" alt="" loading="lazy">
<t style="color:#FFFFFF">Access Controller</t>
</a>
{% if request.user.is_authenticated %}
<div class="btn-group" role="group" aria-label="Basic example">
<a class="btn btn-secondary" href="{% url 'profile' %}">Профиль</a>
<div class="btn-group" role="group" aria-label="Basic example" style="margin-right: 9px">
<a {% if profile_lit %}
class="btn btn-primary"
{% else %}
class="btn btn-secondary"
{% endif %}
href="{% url 'profile' %}">Профиль</a>
{% if perms.main.has_control_access %}
<a {% if control_lit %}
class="btn btn-primary"
{% else %}
class="btn btn-secondary"
{% endif %}
href="{% url 'control' %}">Управление</a>
<a {% if stats_lit %}
class="btn btn-primary"
{% else %}
class="btn btn-secondary"
{% endif %}
href="{% url 'statistic' %}">Статистика</a>
{% else %}
<a {% if work_lit %}
class="btn btn-primary"
{% else %}
class="btn btn-secondary"
{% endif %}
href="{% url 'work' request.user.id %}">Запрос прав</a>
{% endif %}
<a class="btn btn-secondary" href="{% url 'logout' %}">Выйти</a>
</div>
{% else %}
<div class="btn-group" role="group" aria-label="Basic example">
<a class="btn btn-secondary" href="/accounts/login">Войти</a>
<a class="btn btn-secondary" href="/accounts/register">Зарегистрироваться</a>
<div class="btn-group" role="group" aria-label="Basic example" style="margin-right: 9px">
<a {% if login_lit %}
class="btn btn-primary"
{% else %}
class="btn btn-secondary"
{% endif %}
href="/accounts/login">Войти</a>
<a {% if registration_lit %}
class="btn btn-primary"
{% else %}
class="btn btn-secondary"
{% endif %}
href="/accounts/register">Зарегистрироваться</a>
</div>
{% endif %}
</nav>

View File

@ -11,5 +11,5 @@
{% block content %}
<br>
<h4> Нет пользователя с указаным адресом электронной почты, либо был введён неверный пароль</h4>
<h4> Нет пользователя с указаным адресом электронной почты.</h4>
{% endblock %}

View File

@ -10,5 +10,5 @@
{% block content %}
<br>
<h4> Регистрация прошла успешно. <a href="/login/">Войти сейчас</a></h4>
<h4> Регистрация прошла успешно. <a href="{% url 'login'%}">Войти сейчас</a></h4>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}
Регистрация завершена
{% endblock %}
{% block heading %}
Регистрация
{% endblock %}
{% block content %}
<br>
<h4> Произошла ошибка при отправке электронного сообщения.</h4>
{% endblock %}

View File

@ -11,14 +11,12 @@
{% block content %}
<form method="post" action="">
{% csrf_token %}
{% for field in form %}
{{ field.label_tag }}
{{ field }}
{{ form.email.label_tag }}
{{ form.email }}
<br>
{% if field.errors %}
<span>{{ field.errors }}</span>
{% if form.email.errors %}
<span>{{ form.email.errors }}</span>
{% endif %}
{% endfor %}
<input type="submit" value="Зарегистрироваться" class="clearfix">
</form>
{% endblock %}

View File

@ -0,0 +1,112 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Управление{% endblock %}
{% block heading %}Управление{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'main/css/work.css' %}" xmlns="http://www.w3.org/1999/html">
<link rel="stylesheet" href="{% static 'modules/notifications/dist/notifications.css' %}">
{% endblock %}
{% block extra_scripts %}
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="{% static 'modules/notifications/dist/notifications.js' %}"></script>
<script src="{% static 'main/js/control.js'%}" type="text/babel"></script>
<script src="{% static 'main/js/notifications.js' %}"></script>
{% endblock%}
{% block content %}
<div class="container-md">
<div class="new-section">
<p class="row page-description" id="licences_remaining">Свободных Мест:</p>
</div>
{% for message in messages %}
<script>create_notification('{{message}}','','{{message.tags}}',2000)</script>
{% endfor %}
{% block form %}
<form method="post">
{% csrf_token %}
<div class="row justify-content-center new-section">
<div class="col-10">
<h6 class="table-title">Список сотрудников</h6>
{% block table %}
<table class="table table-dark light-table">
<thead>
<th>
<input
type="checkbox"
class="form-check-input"
id="head-checkbox"
/>
</th>
<th>Name</th>
<th>Email</th>
<th>Role</th>
</thead>
<tbody id="tbody"></tbody>
</table>
<p id="loading">Данные загружаются...</p>
{% endblock %}
</div>
</div>
{% block count %}
<div class="row justify-content-center new-section">
<div class="col-5">
<div class="info">
<div class="info-row">
<div class="info-target">Инженеров:</div>
<div class="info-quantity">
<div class="status-circle-small light-green"></div>
<span class="info-quantity-value">{{ engineers }}</span>
</div>
</div>
<div class="info-row">
<div class="info-target">Легких агентов:</div>
<div class="info-quantity">
<div class="status-circle-small light-yellow"></div>
<span class="info-quantity-value">{{ light_agents }}</span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block buttons %}
<div class="col-5">
<button type="submit" name="engineer" class="request-acess-button default-button">
Назначить выбранных на роль инженера
</button>
<button type="submit" name="light_agent" class="hand-over-acess-button default-button">
Назначить выбранных на роль легкого агента
</button>
</div>
{% endblock %}
</div>
</form>
{% endblock %}
</div>
{% endblock %}

View File

@ -10,17 +10,14 @@
{% block extra_css %}
<style>
.img{
<style>
.img {
width:auto;
height:auto;
max-width:100px!important;
max-height:100px!important;
}
</style>
</style>
{% endblock %}
{% block content %}
@ -28,24 +25,31 @@
<div class="row">
<div class="col-auto">
<div class="container">
{% if image_url %}
<img src={{image_url}} class="img img-thumbnail" alt="Аватар">
{% else %}
<img src="{% static 'no_avatar.png' %}" class="img img-thumbnail" alt="Нет изображения">
{% endif %}
<img
src="{% if profile.image %}{{ profile.image }}{% else %}{% static 'no_avatar.png' %}{% endif %}"
class="img img-thumbnail"
alt="Нет изображения"
>
</div>
<a href="{%url 'password_change' %}">Сменить пароль</a>
</div>
<div class="col">
<h5><span class="badge bg-secondary text-light">Имя пользователя</span> {{name}}</h5>
<h5><span class="badge bg-secondary text-light">Имя пользователя</span> {{ profile.name }}</h5>
<br>
<h5><span class="badge bg-secondary text-light">Электронная почта</span> {{email}}</h5>
<h5><span class="badge bg-secondary text-light">Электронная почта</span> {{ profile.user.email }}</h5>
<br>
<h5><span class="badge bg-secondary text-light">Текущая роль</span> {{role}}</h5>
<h5><span class="badge bg-secondary text-light">Текущая роль</span>
{% if profile.custom_role_id == ZENDESK_ROLES.engineer %}
engineer
{% elif profile.custom_role_id == ZENDESK_ROLES.light_agent %}
light_agent
{% endif %}
</h5>
</div>
</div>
<div align="center">
<form action="">
<button class="btn btn-primary"><big>Запросить права доступа</big></button>
<a href="{% url 'work' profile.user.id %}" class="btn btn-primary"><big>Запросить права доступа</big></a>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,106 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}{{ pagename }}{% endblock %}
{% block heading %} Страницы просмотра статистики{% endblock %}
{% block content%}
<div class="mt-5">
<div class="container-fluid" style="font-size:2rem">
<form method="post">
{% csrf_token %}
<div class="row g-3">
<div class="col-auto">
{{ form.email.label }}
</div>
<div class="col-auto mt-4">
{{ form.email }}
</div>
</div>
<div class="row g-3 mt-4">
<div class="col-auto">
{{ form.interval.label }}
</div>
<div class="col-auto">
{% for radio in form.interval%}
{{ radio.tag }}
<label class="btn btn-outline-secondary" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
{% endfor %}
</div>
</div>
<div class="row g-3 mt-4">
<div class="col-auto">
{{ form.display_format.label }}
</div>
<div class="col-auto">
{% for radio in form.display_format%}
{{ radio.tag }}
<label class="btn btn-outline-secondary" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
{% endfor %}
</div>
</div>
<div class="row g-3 mt-4">
<div class="col-auto">
{{ form.range_start.label}}
</div>
<div class="col-auto">
<div class='col-sm-7'>
{{ form.range_start}}
</div>
</div>
</div>
<div class="row g-3 mt-4">
<div class="col-auto">
{{ form.range_end.label}}
</div>
<div class="col-auto">
<div class='col-sm-7'>
{{ form.range_end}}
</div>
</div>
</div>
<div class="form-row text-center">
<div class="col-12">
<button type="submit" class="btn btn-outline-primary">Посмотреть статистику</button>
</div>
</div>
</form>
</div>
<ul>
{% for error in errors %}
<li><span class="badge bg-danger">{{error}}</span></li>
{% endfor %}
</ul>
<ul>
{% for warning in warnings %}
<li><span class="badge bg-warning">{{warning}}</span></li>
{% endfor %}
</ul>
<div class="container-fluid">
<table class="table table-bordered text-center text-secondary mt-5" style="background-color:#f2f2f2;">
<thead>
<tr>
<td scope="col">&nbsp;</td>
{% for date in log_stats.keys %}
<td scope="col">{{ date | date:'d.m' }}</td>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td>{{ form.email.value }}</td>
{% for time in log_stats.values %}
<td>{{ time | floatformat:2 }}</td>
{% endfor %}
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -4,17 +4,22 @@
{% block title %}{{ pagename }}{% endblock %}
{% block heading %}Управление{% endblock %}
{% block heading %}Управление правами{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'main/css/work.css' %}">
<link rel="stylesheet" href="{% static 'main/css/work.css' %}" xmlns="http://www.w3.org/1999/html">
<link rel="stylesheet" href="{% static 'modules/notifications/dist/notifications.css' %}">
{% endblock %}
{% block extra_scripts %}
<script src="{% static 'modules/notifications/dist/notifications.js' %}"></script>
<script src="{% static 'main/js/notifications.js' %}"></script>
{% endblock %}
{% block content %}
<div class="container-md">
<div class="new-section">
<p class="row page-description">Основаная информация о странице</p>
<p class="row page-description">Свободных Мест: {{ licences_remaining }}</p>
</div>
<div class="row justify-content-center new-section">
@ -22,25 +27,16 @@
<h6 class="table-title">Список сотрудников с правами инженера</h6>
<table class="light-table">
<thead>
<th>ID</th>
<th>email</th>
<th>Expiration Date</th>
<th>Name(link to profile)</th>
<th>Email</th>
<th>Name</th>
</thead>
<tbody>
<tr>
<td>1</td>
<td>big_boss123@example.ru</td>
<td>19:30 18.02.21</td>
<td><a href="#">Иван Иванов</a></td>
</tr>
<tr>
<td>2</td>
<td>gachi_cool456@example.ru</td>
<td>21:00 18.02.21</td>
<td><a href="#">Пётр Петров</a></td>
</tr>
{% for engineer in engineers %}
<tr>
<td>{{ engineer.email }}</td>
<td>{{ engineer.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
@ -52,23 +48,33 @@
<div class="info-target">инженеров: </div>
<div class="info-quantity">
<div class="status-circle-small light-green"></div>
<span class="info-quantity-value">13</span>
<span class="info-quantity-value">{{ engineers|length }}</span>
</div>
</div>
<div class="info-row">
<div class="info-target">легких агентов:</div>
<div class="info-quantity">
<div class="status-circle-small light-yellow"></div>
<span class="info-quantity-value">22</span>
<span class="info-quantity-value">{{ agents|length }}</span>
</div>
</div>
</div>
</div>
<div class="col-5">
<button class="request-acess-button default-button">Получить права инженера</button>
<button class="hand-over-acess-button default-button">Сдать права инженера</button>
<a href="/work/become_engineer" class="request-acess-button default-button">Получить права инженера</a>
<a href="/work/hand_over" class="hand-over-acess-button default-button">Сдать права инженера</a>
</div>
<div class="col-10">
<form method="GET" action="/work/get_tickets">
<input class="form-control mb-3" type="number" min="1" value="1" name="count_tickets">
<button type="submit" class="default-button">Взять тикеты в работу</button>
</form>
</div>
{% for message in messages %}
<script>create_notification('{{message}}','','{{message.tags}}',2000)</script>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -1,10 +1,9 @@
{% extends 'base/base.html' %}
{% block title %}
Авторизация
{% endblock %}
{% block heading %}
Авторизация
{% endblock %}
{% block title %}Авторизация{% endblock %}
{% block heading %}Авторизация{% endblock %}
{% block content %}
<div class="container">
<div class="card mx-auto" style="width: 40rem">
@ -31,7 +30,7 @@
{% endif %}
<div class="text-center">
<button type="submit" class="btn btn-primary">Войти</button>
<a href="" class="btn btn-link" style="display: block;">Забыли пароль?</a>
<a href="{% url 'password_reset' %}" class="btn btn-link" style="display: block;">Забыли пароль?</a>
</div>
</form>
</div>

View File

@ -0,0 +1,12 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}Пароль успешно изменен{% endblock title %}
{% block heading %}Пароль успешно изменен{% endblock %}
{% block content %}
<div>
<h4>Ваш пароль был изменен.</h4>
</div>
{% endblock content %}

View File

@ -0,0 +1,14 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}Изменение пароля{% endblock title %}
{% block heading %}Сменить пароль{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Сменить" class="btn btn-success">
</form>
{% endblock content %}

View File

@ -0,0 +1,13 @@
{% extends "base/base.html" %}
{% block title %}
{{ pagename }}
{% endblock %}
{% block heading %}
Восстановление пароля
{% endblock %}
{% block content %}
<p>Ваш новый пароль был установлен. Вы можете <a href="{% url 'login' %}">войти</a> сейчас</p>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base/base.html" %}
{% block title %}
{{ pagename }}
{% endblock %}
{% block heading %}
Восстановление пароля
{% endblock %}
{% block content %}
{% if validlink %}
<p>Пожалуйста, введите пароль дважды:</p>
<form action="." method="post">
{{ form.as_p }}
{% csrf_token %}
<p><input class="btn btn-success" type="submit" value="Сменить пароль"/></p>
</form>
{% else %}
<p>Неверная ссылка восстановления пароля, возможно она уже была использована.
Пожалуйста, запросите новый сброс пароля</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base/base.html" %}
{% block title %}
{{ pagename }}
{% endblock %}
{% block heading %}
Восстановление пароля
{% endblock %}
{% block content %}
<p>Мы отправили вам на почту инструкцию по восстановлению</p>
<p>Если вы не получили сообщение, убедитесь что верно ввели адрес электронной почты.</p>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "base/base.html" %}
{% block title %}
{{ pagename }}
{% endblock %}
{% block heading %}
Забыли пароль?
{% endblock %}
{% block content %}
<p>Введте свой e-mail адрес для восстановления пароля.</p>
<form action="." method="post">
{{ form.as_p }}
<p><input class="btn btn-success" type="submit" value="Отпрваить e-mail"></p>
{% csrf_token %}
</form>
{% endblock %}

View File

@ -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

6
main/urls.py Normal file
View File

@ -0,0 +1,6 @@
from rest_framework.routers import DefaultRouter
from main.views import UsersViewSet
router = DefaultRouter()
router.register(r'users', UsersViewSet)

View File

@ -1,72 +1,390 @@
from django.shortcuts import render
from django.urls import reverse_lazy
from main.extra_func import check_user_exist, check_user_auth, update_profile
from main.models import UserProfile
from django.contrib.auth.models import User
from main.forms import CustomRegistrationForm
from django_registration.views import RegistrationView
from smtplib import SMTPException
from typing import Dict, Any
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.auth.models import User, Permission
from django.contrib.auth.tokens import default_token_generator
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.views import LoginView
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
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 import Zenpy
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, 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, 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
class CustomRegistrationView(RegistrationView):
"""
Отображение и логика работы страницы регистрации пользователя
Отображение и логика работы страницы регистрации пользователя.
:param form_class: Форма, которую необходимо заполнить для регистрации
:type form_class: :class:`forms.CustomRegistrationForm`
:param template_name: Указание пути к html-странице django регистрации
:type template_name: :class:`str`
:param success_url: Указание пути к html-странице завершения регистрации
:type success_url: :class:`django.utils.functional.lazy.<locals>.__proxy__`
:param is_allowed: Определение зарегистрирован ли пользователь с введенным email на Zendesk и принадлежит ли он к организации SYSTEM
:type is_allowed: :class:`bool`
"""
extra_context = setup_context(registration_lit=True)
form_class = CustomRegistrationForm
template_name = 'django_registration/registration_form.html'
success_url = reverse_lazy('django_registration_complete')
is_allowed = True
urls = {
'done': reverse_lazy('password_reset_done'),
'invalid_zendesk_email': reverse_lazy('django_registration_disallowed'),
'email_sending_error': reverse_lazy('registration_email_error'),
}
redirect_url = 'done'
def register(self, form):
self.is_allowed = True
if check_user_exist(form.data['email']) and check_user_auth(form.data['email'], form.data['password_zen']):
user = User.objects.create_user(
username=form.data['username'],
email=form.data['email'],
password=form.data['password1']
def register(self, form: CustomRegistrationForm) -> User:
"""
Функция регистрации пользователя.
1. Ввод email пользователя, указанный на Zendesk
2. В случае если пользователь с данным паролем зарегистрирован на Zendesk и относится к организации SYSTEM,
происходит сброс ссылки с установлением пароля на указанный email
3. Создается пользователь class User, а также его профиль.
:param form: Email пользователя на Zendesk
:return: user
"""
self.redirect_url = 'done'
if check_user_exist(form.data['email']) and get_user_organization(form.data['email']) == 'SYSTEM':
forms = PasswordResetForm(self.request.POST)
if forms.is_valid():
opts = {
'use_https': self.request.is_secure(),
'token_generator': default_token_generator,
'from_email': DEFAULT_FROM_EMAIL,
'email_template_name': 'registration/password_reset_email.html',
'subject_template_name': 'registration/password_reset_subject.txt',
'request': self.request,
'html_email_template_name': None,
'extra_email_context': None,
}
user = User.objects.create_user(
username=form.data['email'],
email=form.data['email'],
password=User.objects.make_random_password(length=50)
)
try:
update_profile(user.userprofile)
self.set_permission(user)
forms.save(**opts)
return user
except SMTPException:
self.redirect_url = 'email_sending_error'
else:
raise ValueError('Непредвиденная ошибка')
else:
self.redirect_url = 'invalid_zendesk_email'
@staticmethod
def set_permission(user: User) -> None:
"""
Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin.
:param user: авторизованный пользователь (получает разрешение, имея роль "admin")
"""
if user.userprofile.role == 'admin':
content_type = ContentType.objects.get_for_model(UserProfile)
permission = Permission.objects.get(
codename='has_control_access',
content_type=content_type,
)
profile = user.userprofile
update_profile(profile)
else:
self.is_allowed = False
user.user_permissions.add(permission)
def get_success_url(self, user=None):
def get_success_url(self, user: User = None):
"""
Возвращает url-адрес страницы, куда нужно перейти после успешной/неуспешной регистрации
Используется самой django-registration
Функция возвращает url-адрес страницы, куда нужно перейти после успешной/не успешной регистрации.
Используется самой django-registration.
:param user: пользователь, пытающийся зарегистрироваться
:return: адресация на страницу успешной регистрации
"""
if self.is_allowed:
return reverse_lazy('django_registration_complete')
else:
return reverse_lazy('django_registration_disallowed')
return self.urls[self.redirect_url]
def registration_error(request):
return render(request, 'django_registration/registration_error.html')
@login_required()
def profile_page(request):
def profile_page(request: WSGIRequest) -> HttpResponse:
"""
Отображение страницы профиля
Функция отображения страницы профиля.
:param request: объект с деталями запроса
:type request: :class:`django.http.HttpResponse`
:return: объект ответа сервера с HTML-кодом внутри
:param request: данные пользователя из БД
:return: адресация на страницу пользователя
"""
user_profile = request.user.userprofile
user_profile: UserProfile = request.user.userprofile
update_profile(user_profile)
context = {
'email': user_profile.user.email,
'name': user_profile.name,
'role': user_profile.role,
'image_url': user_profile.image,
'pagename': 'Страница профиля'
}
context = setup_context(profile_lit=True)
context.update({
'profile': user_profile,
'pagename': 'Страница профиля',
'ZENDESK_ROLES': ZENDESK_ROLES,
})
return render(request, 'pages/profile.html', context)
def main_page(request):
@login_required()
def work_page(request: WSGIRequest, id: int) -> HttpResponse:
"""
Функция отображения страницы "Управления правами" для текущего пользователя (login_required).
:param request: объект пользователя
:param id: id пользователя, используется для динамической адресации
:return: адресация на страницу "Управления правами" (либо на страницу "Авторизации", если id и user.id не совпадают
"""
users = get_users_list()
if request.user.id == id:
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': 'Управление правами',
})
return render(request, 'pages/work.html', context)
return redirect("login")
@login_required()
def work_hand_over(request: WSGIRequest):
"""
Функция позволяет текущему пользователю сдать права, а именно сменить в Zendesk роль с "engineer" на "light_agent"
:param request: данные текущего пользователя (login_required)
:return: перезагрузка текущей страницы после выполнения смены роли
"""
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 роль с "light_agent" на "engineer"
:param request: данные текущего пользователя (login_required)
:return: перезагрузка текущей страницы после выполнения смены роли
"""
make_engineer(request.user.userprofile, request.user)
return set_session_params_for_work_page(request)
@login_required()
def work_get_tickets(request):
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:
"""
Функция переадресации на главную страницу.
"""
return render(request, 'pages/index.html')
class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageMixin, FormView):
"""
Класс отображения страницы администратора.
:param permission_required: Права доступа к странице администратора
:type permission_required: :class:`str`
:param template_name: HTML-шаблон страницы администратора
:type template_name: :class:`str`
:param form_class: Форма страницы администратора
:type form_class: :class:`forms.AdminPageUsersForm`
:param success_url: Адрес страницы администратора
:type success_url: :class:`HttpResponseRedirect`
"""
permission_required = 'main.has_control_access'
template_name = 'pages/adm_ruleset.html'
form_class = AdminPageUsers
success_url = '/control/'
success_message = "Права были изменены."
def form_valid(self, form: AdminPageUsers) -> AdminPageUsers:
"""
Функция обновления страницы AdminPageUsers.
:param form: Форма страницы администратора
:return: Обновленная страница (пользователям проставлены новые статусы)
"""
users = form.cleaned_data['users']
if 'engineer' in self.request.POST:
self.make_engineers(users)
elif 'light_agent' in self.request.POST:
self.make_light_agents(users)
return super().form_valid(form)
def make_engineers(self, users):
"""
Функция проходит по списку пользователей, проставляя статус "engineer".
:param users: Список пользователей
:return: Обновленный список пользователей
"""
for user in users:
make_engineer(user, self.request.user)
log(user, self.request.user.userprofile)
def make_light_agents(self, users):
"""
Функция проходит по списку пользователей, проставляя статус "light agent".
:param users: Список пользователей
:return: Обновленный список пользователей
"""
for user in users:
make_light_agent(user, self.request.user)
log(user, self.request.user.userprofile)
class CustomLoginView(LoginView):
"""
Отображение страницы авторизации пользователя
"""
extra_context = setup_context(login_lit=True)
form_class = CustomAuthenticationForm
class UsersViewSet(viewsets.ReadOnlyModelViewSet):
"""
Класс для получения пользователей с помощью api
"""
queryset = UserProfile.objects.filter(role='agent')
serializer_class = ProfileSerializer
def list(self, request, *args, **kwargs):
users = update_users_in_model()
count = count_users(users.values)
profiles = UserProfile.objects.filter(role='agent')
serializer = self.get_serializer(profiles, many=True)
res = {
'users': serializer.data,
'engineers': count[0],
'light_agents': count[1],
'zendesk_users': self.get_zendesk_users(self.choose_users(users.values, profiles)),
'max_agents': ZENDESK_MAX_AGENTS
}
return Response(res)
@staticmethod
def choose_users(zendesk, model):
users = []
for zendesk_user in zendesk:
if zendesk_user.name not in [user.name for user in model]:
users.append(zendesk_user)
return users
@staticmethod
def get_zendesk_users(users):
zendesk_users = ZendeskUserSerializer(
data=[user for user in users if user.role != 'admin'],
many=True
)
zendesk_users.is_valid()
return zendesk_users.data
@login_required()
def statistic_page(request: WSGIRequest) -> HttpResponse:
"""
Функция отображения страницы статистики (для "superuser").
:param request: данные о пользователе: email, время и интервал работы. Данные получаем через forms.StatisticForm
:return: адресация на страницу статистики
"""
# if not request.user.has_perm('main.has_control_access'):
# raise PermissionDenied
# context = {
if not request.user.has_perm("main.has_control_access"):
return redirect('index')
context = setup_context(stats_lit=True)
context.update({
'pagename': 'страница статистики',
'errors': list(),
})
if request.method == "POST":
form = StatisticForm(request.POST)
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
context['log_stats'] = stats if not context['errors'] else None
elif request.method == 'GET':
form = StatisticForm()
context['form'] = form
return render(request, 'pages/statistic.html', context)

93
main/zendesk_admin.py Normal file
View File

@ -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,
})

View File

@ -6,7 +6,8 @@ import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings')
os.environ.setdefault('DJANGO_SETTINGS_MODULE',
'access_controller.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:

View File

@ -1,11 +1 @@
# Engine
Django==3.1.6
Pillow==8.1.0
zenpy~=2.0.24
django-registration==3.1.1
# Documentation
Sphinx==3.4.3
sphinx-rtd-theme==0.5.1
-r requirements/dev.txt

16
requirements/common.txt Normal file
View File

@ -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

3
requirements/dev.txt Normal file
View File

@ -0,0 +1,3 @@
# Development specific dependencies
-r common.txt

5
requirements/prod.txt Normal file
View File

@ -0,0 +1,5 @@
# Production specific dependencies
-r common.txt
daphne==3.0.1
Twisted[tls,http2]==21.2.0

6
start.sh Normal file
View File

@ -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

View File

@ -12,6 +12,12 @@
height: 100vh;
background: #45729C;
} */
.form-check-input {
border-radius: 0;
width: 30px;
height: 30px;
background-size: 20px auto;
}
.page-title {
margin: auto;
@ -118,4 +124,7 @@
padding: 10px;
background: #3B91D4;
color: white;
width: 100%;
}

BIN
static/main/img/check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

140
static/main/js/control.js Normal file
View File

@ -0,0 +1,140 @@
"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 (
<tr className={"table-dark"}>
<td>
<input
type="checkbox"
value={this.props.user.id}
className="form-check-input"
name="users"
/>
</td>
<td>
<a href="#">{this.props.user.name}</a>
</td>
<td>{this.props.user.user.email}</td>
<td>{this.props.user.zendesk_role}</td>
</tr>
);
}
}
class ModelUserTableRows extends React.Component {
render() {
return ReactDOM.createPortal(
this.props.users.map((user, key) => (
<ModelUserTableRow user={user} key={key} />
)),
document.getElementById("tbody")
);
}
}
class ZendeskUserTableRow extends React.Component {
render() {
return (
<tr className={"table-secondary text-secondary"}>
<td></td>
<td>
<a href="#" style={{ color: "grey", fontStyle: "italic" }}>
{this.props.user.name}
</a>
</td>
<td style={{ color: "grey", fontStyle: "italic" }}>
{this.props.user.email}
</td>
<td style={{ color: "grey", fontStyle: "italic" }}>
{this.props.user.zendesk_role}
</td>
</tr>
);
}
}
class ZendeskUserTableRows extends React.Component {
render() {
return ReactDOM.createPortal(
this.props.users.map((user, key) => (
<ZendeskUserTableRow user={user} key={key} />
)),
document.getElementById("tbody")
);
}
}
class TableBody extends React.Component {
constructor(props) {
super(props);
this.state = {
users: [],
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({
users: response.data.users,
engineers: response.data.engineers,
light_agents: response.data.light_agents,
zendesk_users: response.data.zendesk_users,
max_agents: response.data.max_agents,
});
});
this.change_elemnts_html();
}
delete_pretext() {
document.getElementById("loading").remove();
}
componentDidMount() {
this.get_users().then(() => this.delete_pretext());
this.interval = setInterval(() => {
this.get_users();
}, 60000);
}
componentWillUnmount() {
clearInterval(this.interval);
}
render() {
return (
<tr>
<ModelUserTableRows users={this.state.users} />
<ZendeskUserTableRows users={this.state.zendesk_users} />
</tr>
);
}
}
ReactDOM.render(<TableBody />, document.getElementById("tbody"));
head_checkbox();

View File

@ -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
});
};

View File

@ -0,0 +1,3 @@
{
"presets": ["es2015"]
}

View File

@ -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
}
};

30
static/modules/notifications/.gitignore vendored Normal file
View File

@ -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

View File

@ -0,0 +1,3 @@
language: node_js
node_js:
- "7"

View File

@ -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.

View File

@ -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');
});
});
});

View File

@ -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);
});

View File

@ -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
});
});
});

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
</hea>
<meta charset="UTF-8">
<title>Notifications</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: floralwhite;
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
text-align: center;
padding: 30px;
}
h1 {
margin-top: 0;
}
form {
text-align: left;
max-width: 600px;
width: 100%;
margin: 0 auto;
display: block;
}
input, select {
width: 100%;
margin-top: 5px;
margin-bottom: 20px;
padding: 5px;
box-sizing: border-box;
}
input[type=checkbox] {
width: auto;
}
input[type=submit] {
background: #2f96b4;
border: 0;
color: #fff;
margin-bottom: 0;
padding: 10px 5px;
}
</style>
<!-- Notifications styling -->
<link rel="stylesheet" href="../dist/notifications.css" type="text/css">
<head>
<body>
<h1>Notifications</h1>
<form>
<label for="title">Title (optional)</label>
<br/>
<input type="text" id="title" placeholder="Enter a title..." value="Notification">
<label for="message">Message</label>
<br/>
<input type="text" id="message" placeholder="Enter a message..." value="I am a default message">
<label for="position">Notification position:</label>
<br/>
<select id="position">
<option value="nfc-top-right">Top Right</option>
<option value="nfc-bottom-right">Bottom Right</option>
<option value="nfc-top-left">Top Left</option>
<option value="nfc-bottom-left">Bottom Left</option>
</select>
<label for="duration">Show Duration (ms)</label>
<br/>
<input id="duration" type="number" value="3000"/>
<label for="theme">Theme</label>
<br/>
<select id="theme">
<option value="success">Success</option>
<option value="info">Information</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="none">None</option>
</select>
<label for="close">Close on click</label>
<input id="close" type="checkbox" value="Close on click" checked>
<br/>
<label for="closeButton">Display a close button</label>
<input id="closeButton" type="checkbox" value="Display close button">
<input type="submit" value="Display notification">
</form>
<script src="../dist/notifications.js" type="text/javascript"></script>
<script src="./demo.js" type="text/javascript"></script>
</body>
</html>

View File

@ -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}

View File

@ -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<arguments.length;n++){var e=arguments[n];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[i]=e[i])}return t};e(2);var o=e(3);!function(t){function n(t){return t=i({},c,t),function(t){return["nfc-top-left","nfc-top-right","nfc-bottom-left","nfc-bottom-right"].indexOf(t)>-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;i<n.length;i++)e.call(this,n[i])}if(!(void 0===window.Element||"classList"in document.documentElement)){var n=Array.prototype,e=n.push,i=n.splice,o=n.join;t.prototype={add:function(t){this.contains(t)||(e.call(this,t),this.el.className=this.toString())},contains:function(t){return-1!=this.el.className.indexOf(t)},item:function(t){return this[t]||null},remove:function(t){if(this.contains(t)){for(var n=0;n<this.length&&this[n]!=t;n++);i.call(this,n,1),this.el.className=this.toString()}},toString:function(){return o.call(this," ")},toggle:function(t){return this.contains(t)?this.remove(t):this.add(t),this.contains(t)}},window.DOMTokenList=t,function(t,n,e){Object.defineProperty?Object.defineProperty(t,n,{get:e}):t.__defineGetter__(n,e)}(Element.prototype,"classList",function(){return new t(this)})}}()},function(t,n,e){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var i=n.partial=function(t){for(var n=arguments.length,e=Array(n>1?n-1:0),i=1;i<n;i++)e[i-1]=arguments[i];return function(){for(var n=arguments.length,i=Array(n),o=0;o<n;o++)i[o]=arguments[o];return t.apply(void 0,e.concat(i))}},o=(n.append=function(t){for(var n=arguments.length,e=Array(n>1?n-1:0),i=1;i<n;i++)e[i-1]=arguments[i];return e.forEach(function(n){return t.appendChild(n)})},n.isString=function(t){return"string"==typeof t},n.createElement=function(t){for(var n=arguments.length,e=Array(n>1?n-1:0),i=1;i<n;i++)e[i-1]=arguments[i];var o=document.createElement(t);return e.length&&e.forEach(function(t){return o.classList.add(t)}),o}),r=function(t,n){return t.innerText=n,t},c=function(t){for(var n=arguments.length,e=Array(n>1?n-1:0),c=1;c<n;c++)e[c-1]=arguments[c];return i(r,o.apply(void 0,[t].concat(e)))};n.createParagraph=function(){for(var t=arguments.length,n=Array(t),e=0;e<t;e++)n[e]=arguments[e];return c.apply(void 0,["p"].concat(n))}},function(t,n){}]);

View File

@ -0,0 +1,58 @@
{
"name": "styled-notifications",
"version": "1.0.1",
"description": "A simple JavaScript notifications library",
"main": "dist/notifications.js",
"scripts": {
"start": "webpack --watch",
"build": "webpack -p",
"test": "jest",
"prepare": "yarn run test && yarn run build"
},
"pre-commit": [
"prepare"
],
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "git+https://github.com/JamieLivingstone/Notifications.git"
},
"keywords": [
"notification",
"popup",
"alert",
"toast"
],
"author": "Jamie Livingstone",
"contributors": [
{
"name": "Jamie Livingstone (https://github.com/JamieLivingstone)"
},
{
"name": "cavebeavis (https://github.com/cavebeavis)"
}
],
"license": "ISC",
"bugs": {
"url": "https://github.com/JamieLivingstone/Notifications/issues"
},
"homepage": "https://github.com/JamieLivingstone/Notifications#readme",
"devDependencies": {
"babel-core": "^6.26.0",
"babel-jest": "^21.0.2",
"babel-loader": "^7.1.2",
"babel-preset-es2015": "^6.24.1",
"babel-preset-es2015-ie": "^6.7.0",
"css-loader": "^0.28.7",
"eslint": "^4.6.1",
"extract-text-webpack-plugin": "^3.0.0",
"jest": "^21.0.2",
"node-sass": "^4.5.3",
"pre-commit": "^1.2.2",
"sass-loader": "^6.0.6",
"style-loader": "^0.18.2",
"webpack": "^3.5.6"
}
}

View File

@ -0,0 +1,82 @@
[![Build Status](https://travis-ci.org/JamieLivingstone/Notifications.svg?branch=master)](https://travis-ci.org/JamieLivingstone/Notifications)
# Notifications
**Notifications** is a Javascript library for notifications heavily inspired by toastr but does not require any dependencies such as jQuery.
Works on browsers: IE9+, Safari, Chrome, FireFox, opera, edge
## npm Installation
Do either
```
npm i styled-notifications
```
or add the following to your `package.json`:
```
"dependencies": {
"styled-notifications": "^1.0.1"
},
```
## Installation
Download files from the dist folder and then:
1. Link to notifications.css `<link href="notifications.css" rel="stylesheet"/>`
2. Link to notifications.js `<script src="notifications.js"></script>`
## Usage
### Custom options
- closeOnClick <bool> - Close the notification dialog when a click is invoked.
- displayCloseButton <bool> - Display a close button in the top right hand corner of the notification.
- positionClass <string> - Set the position of the notification dialog. Accepted positions: ('nfc-top-right', 'nfc-bottom-right', 'nfc-bottom-left', 'nfc-top-left').
- onClick <function(event)> - Call a callback function when a click is invoked on a notification.
- showDuration <integer> - Milliseconds the notification should be visible (0 for a notification that will remain open until clicked)
- theme <string> - 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

View File

@ -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);

View File

@ -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);

View File

@ -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);
});
})();

View File

@ -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;
}
}
}

View File

@ -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
]
};