diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..8499040 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,14 @@ +[run] +command_line = manage.py test + +branch = true + +source = + main/ + + +omit = + main/migrations/* + main/apps.py + main/tests.py + diff --git a/.env.example b/.env.example index f86db45..6026a6a 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,8 @@ 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_HOST="smtp.gmail.com" +ACTRL_EMAIL_PORT=587 ACTRL_EMAIL_TLS=1 ACTRL_EMAIL_HOST_USER="djgr.02@mail.ru" ACTRL_EMAIL_HOST_PASSWORD="djangogroup02" @@ -20,6 +20,6 @@ LICENSE_NO=3 SHIFTH=12 ACTRL_ZENDESK_SUBDOMAIN="ngenix1612197338" -ACTRL_API_EMAIL="email@example.com" -ACTRL_API_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -ACTRL_API_PASSWORD="" +ACTRL_API_EMAIL="stepanenko_olga@mail.ru" +ACTRL_API_TOKEN="X1x4QeNa4xRdul2rTIKhac98AsXMwd5bOGAyZOtU" + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..ef3bf26 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,23 @@ +image: python:3-alpine + +stages: + - test + +django_test: + stage: test + before_script: + - pip install -r requirements/dev.txt + script: + - python manage.py test + +coverage: + stage: test + before_script: + - pip install -r requirements/dev.txt + script: + - coverage run + - coverage report -m + - coverage html -d public/coverage + artifacts: + paths: + - public/coverage diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..0f1221a --- /dev/null +++ b/.pylintrc @@ -0,0 +1,627 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10.0 + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS, manage.py + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +#pygtk.require(). +init-hook="from pylint.config import find_pylintrc; import os, sys; sys.path.append(os.path.dirname(find_pylintrc()))" + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins=pylint_django +django-settings-module=access_controller_new.access_controller.settings + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape, + + + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + id, + e, + n, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +#notes-rgx= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: en (aspell), en_AU +# (aspell), en_CA (aspell), en_GB (aspell), en_US (aspell), fr_CA (myspell), +# fr_MC (myspell), fr_CH (myspell), fr_LU (myspell), fr_FR (myspell), fr_BE +# (myspell), de_DE (myspell), es_VE (myspell), es_MX (myspell), es_CL +# (myspell), es_CR (myspell), es_US (myspell), it_CH (myspell), pt_BR +# (myspell), es_DO (myspell), en_ZA (myspell), es_PY (myspell), es_GT +# (myspell), es_CU (myspell), es_SV (myspell), es_PE (myspell), es_CO +# (myspell), de_CH (myspell), ru_RU (myspell), es_NI (myspell), es_ES +# (myspell), es_HN (myspell), it_IT (myspell), pt_PT (myspell), de_DE_frami +# (myspell), es_AR (myspell), de_CH_frami (myspell), es_PR (myspell), es_UY +# (myspell), de_AT_frami (myspell), de_AT (myspell), es_PA (myspell), fr +# (myspell), es_EC (myspell), es_BO (myspell). +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear and the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=10 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=10 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/README.md b/README.md index 8489e7d..1438361 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# ZenDesk Access Controller - ## Управление правами доступа Идея - написать программу(Web приложение), которая будет выдавать права пользователям системы по запросу самого @@ -39,7 +37,7 @@ ## Quickstart -Перед запуском требуется неообходимо `.env` файл. +Перед запуском необходимо создать `.env` файл. ```bash cp .env.example .env ``` @@ -71,7 +69,7 @@ pip install -r requirements/dev.txt ``` ACTRL_DEBUG={0/1} - включить режим дебага ACTRL_HOST={HOSTNAME} - при запуске без дебага, надо указать домен на котором будет работать приложение -ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} - секретный ключ сгенерированый Django +ACTRL_SECRET_KEY={DJANGO_SECRET_KEY} - секретный ключ сгенерированный Django ACTRL_EMAIL_HOST={SMTP_HOST} - домен почтового сервера через который приложение будет отправлять письма, например "smtp.gmail.com" ACTRL_EMAIL_PORT={SMTP_PORT} - порт для почтового сервера, например 587, 465 , 2525 @@ -133,7 +131,7 @@ docker run -d -p 8000:8000 \ -v {ABSOLUTE_PATH_TO_DB}:/zendesk-access-controller/db \ access_controller:latest ``` -- открываем запущеный контейнер в браузере (можно перейти по ссылке http://localhost:8000/) +- открываем запущенный контейнер в браузере (можно перейти по ссылке http://localhost:8000/) ## Запуск с тестовыми юзерами: @@ -144,12 +142,32 @@ docker run -d -p 8000:8000 \ - Пользователь - `123@test.ru` / `zendeskuser` Не сработает если домен песочницы отличается от `ngenix1612197338` (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента -с этими же почтами, назначить им организацию `SYSTEM`) +с этими же email, назначить им организацию `SYSTEM`) ## Параметры тестовой песочницы: Пример полной конфигурации можно найти в [.env.example](.env.example). Почту и токен админа ZenDesk взять у руководителя (если вы не админ). +## Для проверки pylint используем: +```bash +pylint --django-settings-module=access_controller.settings main +``` + +## Для приведения файлов к стандарту PEP8 используем: +```bash +autopep8 --in-place filename +``` + +##Для проверки орфографии: +```bash +(cd docs && make spelling) +``` + +##Для обновления документации: +```bash +m2r README.md +(cd docs && make html) +``` ## Read more - Zenpy: [http://docs.facetoe.com.au](http://docs.facetoe.com.au) diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..f1b08b5 --- /dev/null +++ b/README.rst @@ -0,0 +1,204 @@ + +Управление правами доступа +-------------------------- + +Идея - написать программу(Web приложение), которая будет выдавать права пользователям системы по запросу самого +пользователя. Например, из 12 человек 3 сейчас работают с правами админа, по окончании рабочей смены они сдают +свои права (освобождают места) и другие пользователи могут запросить эти права в свое пользование. + +Оставшиеся 9 человек получают права легкого агента - без прав редактирования, а только чтение. + +Из технологий - программа должна взаимодействовать с api системы Zendesk(система обращений клиентов - жалобы), +проверять авторизованного пользователя на права(будет возможность менять права напрямую из Zendesk - нужна +синхронизация прав с приоритетом у Zendesk). + +Если руками в самом Zendesk права у пользователя отобрали или наоборот +присвоили, то наша программа обновляет статус пользователя в соответствии с данными синхронизации +(например, раз в минуту). + +Так же в идеале должна быть проверка, что пользователь сайта существует на сайте Zendesk(по токену). + +Сэндбокс Zendesk нам предоставит моя компания, библиотеку для работы с api уже подсказали. +Сама программа (наша) будет обладать админскими правами и реализовывать контроль и выдачу прав другим пользователям. + +*Итого:* + + +#. Реализовать авторизацию пользователей с проверкой по API на существование такого пользователя +#. Реализовать интерфейс со статистикой рабочих мест(занято, свободно, кто занимает) +#. Реализовать логирование действий(когда взял права, когда отдал - запись в файл и БД) +#. Реализовать передачу прав приложением по запросу от пользователя и замену прав пользователя + у которого права отбираются внутри Zendesk (на легкий агент) +#. Реализовать синхронизацию по API на проверку прав(не менялись ли в системе Zendesk) +#. Реализовать возможность добавить большее количество админских прав +#. Реализовать возможность добавления легких агентов(права только на просмотр) +#. Реализовать на общей странице текущую информацию о пользователе - текущие права, карточка пользователя + +Технологический стек: +--------------------- + + +* Python 3 +* Django 3 + +Quickstart +---------- + +Перед запуском необходимо создать ``.env`` файл. + +.. code-block:: bash + + cp .env.example .env + +Заменить переменные в ``.env`` на актуальные. + +.. code-block:: bash + + sudo apt install make + pip install --upgrade pip + 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`` +* В виртуальное окружение добавить следующие переменные: + +.. code-block:: + + 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 .`` +* Выполнить команду + .. code-block:: 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`` (на другом домене нужно будет создать сначала пользователей в песочнице с правами админа и легкого агента +с этими же email, назначить им организацию ``SYSTEM``\ ) + +Параметры тестовой песочницы: +----------------------------- + +Пример полной конфигурации можно найти в `.env.example <.env.example>`_. Почту и токен админа ZenDesk взять у руководителя (если вы не админ). + +Для проверки pylint используем: +------------------------------- + +pylint ../access_controller_new + +Вместо "access_controller_new" необходимо указать папку проекта. + + +Для приведения файлов к стандарту PEP8 используем: +-------------------------------------------------- + +autopep8 --in-place filename + +Для проверки орфографии: +------------------------ + +cd docs + +(set -a && source ../.env && make spelling) + +Для обновления документации: +---------------------------- + +m2r README.md + +cd docs + +(set -a && source ../.env && make html) + +Read more +--------- + + +* Zenpy: `http://docs.facetoe.com.au `_ +* Zendesk API: `https://developer.zendesk.com/rest_api/docs/ `_ diff --git a/access_controller/asgi.py b/access_controller/asgi.py index 11dc22e..824ff57 100644 --- a/access_controller/asgi.py +++ b/access_controller/asgi.py @@ -8,10 +8,7 @@ https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ """ import os - - - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') from django.core.asgi import get_asgi_application +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') application = get_asgi_application() diff --git a/access_controller/auth.py b/access_controller/auth.py index be707e1..7cfd71b 100644 --- a/access_controller/auth.py +++ b/access_controller/auth.py @@ -1,19 +1,31 @@ +""" +Авторизация пользователя. +""" from django.contrib.auth.backends import ModelBackend -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model class EmailAuthBackend(ModelBackend): + """ + Класс авторизации пользователя по email. + """ def authenticate(self, request, username=None, password=None, **kwargs): + """ + Функция получения пользователя (модель User) по email. + """ try: - user = User.objects.get(email=username) + user = get_user_model().objects.get(email=username) if user.check_password(password): return user return None - except User.DoesNotExist: + except get_user_model().DoesNotExist: return None def get_user(self, user_id): + """ + Функция получения пользователя по id. + """ try: - return User.objects.get(pk=user_id) - except User.DoesNotExist: + return get_user_model().objects.get(pk=user_id) + except get_user_model().DoesNotExist: return None diff --git a/access_controller/settings.py b/access_controller/settings.py index 7361a60..55af7a5 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -14,6 +14,8 @@ from pathlib import Path from dotenv import load_dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. + + BASE_DIR = Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production @@ -26,7 +28,7 @@ load_dotenv() SECRET_KEY = os.getenv('ACTRL_SECRET_KEY', 'empty') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = bool(int(os.getenv('ACTRL_DEBUG', 1))) +DEBUG = bool(int(os.getenv('ACTRL_DEBUG', '1'))) ALLOWED_HOSTS = [ '127.0.0.1', @@ -62,8 +64,8 @@ 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_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) @@ -144,6 +146,8 @@ ACCOUNT_ACTIVATION_DAYS = 7 LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' + + # Название_приложения.Название_файла.Название_класса_обработчика AUTHENTICATION_BACKENDS = [ 'access_controller.auth.EmailAuthBackend', @@ -154,8 +158,8 @@ AUTHENTICATION_BACKENDS = [ ZENDESK_ROLES = { - 'engineer': int(os.getenv('ENG_CROLE_ID', 0)), - 'light_agent': int(os.getenv('LA_CROLE_ID', 0)), + 'engineer': int(os.getenv('ENG_CROLE_ID', '0')), + 'light_agent': int(os.getenv('LA_CROLE_ID', '0')), } ZENDESK_GROUPS = { @@ -165,7 +169,7 @@ ZENDESK_GROUPS = { SOLVED_TICKETS_EMAIL = os.getenv('ST_EMAIL') -ZENDESK_MAX_AGENTS = int(os.getenv('LICENSE_NO', 0)) +ZENDESK_MAX_AGENTS = int(os.getenv('LICENSE_NO', '0')) REST_FRAMEWORK = { # Use Django's standard `django.contrib.auth` permissions, @@ -175,7 +179,7 @@ REST_FRAMEWORK = { ] } -ONE_DAY = int(os.getenv('SHIFTH', 0)) # Количество часов в 1 рабочем дне +ONE_DAY = int(os.getenv('SHIFTH', '0')) # Количество часов в 1 рабочем дне ACTRL_ZENDESK_SUBDOMAIN = os.getenv('ACTRL_ZENDESK_SUBDOMAIN') or os.getenv('ZD_DOMAIN') ACTRL_API_EMAIL = os.getenv('ACTRL_API_EMAIL') or os.getenv('ACCESS_CONTROLLER_API_EMAIL') diff --git a/access_controller/urls.py b/access_controller/urls.py index 2cab267..58864d0 100644 --- a/access_controller/urls.py +++ b/access_controller/urls.py @@ -16,10 +16,11 @@ Including another URLconf from django.contrib import admin from django.urls import path, include +from main.urls import router from main.views import main_page, profile_page, CustomRegistrationView, CustomLoginView, registration_error +from main.views import registration_failed from main.views import work_page, work_hand_over, work_become_engineer, work_get_tickets, \ AdminPageView, statistic_page -from main.urls import router urlpatterns = [ @@ -30,11 +31,12 @@ urlpatterns = [ 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('work/', work_page, name="work"), + path('work/', work_page, name="work"), path('work/hand_over/', work_hand_over, name="work_hand_over"), path('work/become_engineer/', work_become_engineer, name="work_become_engineer"), path('work/get_tickets', work_get_tickets, name='work_get_tickets'), path('accounts/', include('django_registration.backends.activation.urls')), + path('registration_failed/', registration_failed, name='registration_failed'), path('control/', AdminPageView.as_view(), name='control'), path('statistic/', statistic_page, name='statistic'), ] @@ -42,4 +44,5 @@ urlpatterns = [ # Django REST urlpatterns += [ path('api/', include(router.urls)) + ] diff --git a/docs/source/code.rst b/docs/source/code.rst index 7479081..5473db2 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -6,7 +6,7 @@ Models ******* .. automodule:: main.models - :members: + :members: ****** @@ -33,14 +33,6 @@ Serializers :members: -*************** -API functions -*************** - -.. automodule:: main.apiauth - :members: - - ***** Views ***** diff --git a/docs/source/conf.py b/docs/source/conf.py index d45b1cd..b4be21c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,16 +12,12 @@ # import os import sys -import importlib import inspect import enchant -from enchant import checker - +import django sys.path.insert(0, os.path.abspath('../../')) -import django - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'access_controller.settings') os.environ.setdefault('DJANGO_CONFIGURATION', 'Dev') @@ -39,7 +35,6 @@ from django.db.models.query import QuerySet QuerySet.__repr__ = lambda self: self.__class__.__name__ - django.setup() # -- Project information ----------------------------------------------------- @@ -89,6 +84,7 @@ def process_django_models(app, what, name, obj, options, lines): lines.append(':type %s: %s.%s' % (field.attname, module, field_type.__name__)) if enchant is not None: lines += spelling_white_list + lines.append('') return lines @@ -116,13 +112,17 @@ def skip_queryset(app, what, name, obj, skip, options): 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) +def fix_sig(app, what, name, obj, options, signature, return_annotation): + return "", "" +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) + app.connect("autodoc-process-signature", fix_sig) + # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -132,16 +132,13 @@ extensions = { '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' - + 'sphinxcontrib.spelling', } - # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -171,6 +168,7 @@ html_static_path = ['_static'] # -- Extension configuration ------------------------------------------------- + # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. @@ -183,20 +181,21 @@ intersphinx_mapping = { } autodoc_default_flags = ['members'] +autodoc_typehints = "description" # spell checking spelling_lang = 'ru_RU' -tokenizer_lang = 'ru_RU' -spelling_exclude_patterns=['ignored_*'] +tokenizer_lang = 'en_US' +spelling_exclude_patterns = ['ignored_*', '../../main/models.py'] spelling_show_suggestions = True -spelling_show_whole_line=True -spelling_warning=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 +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 ---------------------------------------------- @@ -206,5 +205,3 @@ set_type_checking_flag = True typehints_fully_qualified = True always_document_param_types = True typehints_document_rtype = True - -napoleon_attr_annotations = True diff --git a/docs/source/index.rst b/docs/source/index.rst index 96f9c69..4de50ad 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ overview code + readme todo diff --git a/docs/source/overview.rst b/docs/source/overview.rst index a7ca229..0e3bebb 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -69,12 +69,15 @@ Запрос прав доступа ******************** -На странице запроса прав Вам доступна информация о количестве и списке работающих над тикетами сотрудников, -а также возможность сдать и запросить права. +На странице запроса прав Вам доступна информация о количестве и списке работающих над тикетами сотрудников. + +Если Вы не являетесь инженером, то на данной странице Вы можете запросить права. + +Если Вы являетесь инженером, то права можно сдать. .. image:: _static/request.png -Успешное изменение прав: +Успешное изменение прав - список инженеров пополнился новым пользователем: .. image:: _static/role_change.png @@ -84,9 +87,9 @@ Для администратора существует удобный интерфейс страницы управления, в котором представлены: -* Количество свободных инженерных мест -* Количество и список инженеров и легких агентов -* Возможность группового назначения прав с использованием чек-боксов +* количество свободных инженерных мест +* количество и список инженеров и легких агентов +* возможность группового назначения прав с использованием чекбоксов .. image:: _static/admin_manage.png diff --git a/docs/source/readme.rst b/docs/source/readme.rst new file mode 100644 index 0000000..25dfdf6 --- /dev/null +++ b/docs/source/readme.rst @@ -0,0 +1,4 @@ +READ.me +================== + +.. include:: ../../README.rst diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index bd64cf9..1e9713d 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -45,7 +45,9 @@ start end date Токен +токен токеном +токену аутентифицирован (datetime.time) datetime @@ -81,8 +83,112 @@ functions Serializer Serializers Сериализатор +Сериализаторы +сериализатор +сериализатора переадресации - - - +чекбоксов +админских +админские +Python +Docker +докер +докера +Докер +репозиторий +zendesk-access-controller/db +-e +-v +e +v +zendesk +db +юзерами +Read +Zenpy +залогинен +т +д +rolchangelogs +извеcтно +role +View +Model +type +param +rtype +return +UsersViewSet +list +engineers +agents +request +rest +framework +response +Сэндбокс +админскими +логирование +code +block +d +p +ACTRL_DEBUG +дебага +ACTRL_HOST +HOSTNAME +ACTRL_SECRET_KEY +DJANGO_SECRET_KEY +ACTRL_EMAIL_HOST +SMTP_HOST +smtp.gmail.com +ACTRL_EMAIL_PORT +SMTP_PORT +ACTRL_EMAIL_TLS +USE_TLS +TLS +ACTRL_EMAIL_HOST_USER +USERNAME +ACTRL_EMAIL_HOST_PASSWORD +PASSWORD +ACTRL_FROM_EMAIL +EMAIL +ACTRL_SERVER_EMAIL +ACTRL_API_EMAIL +ACTRL_API_PASSWORD +ACTRL_API_TOKEN +API_TOKEN +ACTRL_ZENDESK_SUBDOMAIN +DOMAIN +ENG_CROLE_ID +ENGINEER_CUSTOM_ROLE_ID +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 +ABSOLUTE +ABSOLUTE_PATH_TO_DB +PATH +TO +DB +latest +in +place +cd +docs +a +Аватарка +filename diff --git a/fixtures/test_make_engineer.json b/fixtures/test_users.json similarity index 98% rename from fixtures/test_make_engineer.json rename to fixtures/test_users.json index 1154342..d9e164d 100644 --- a/fixtures/test_make_engineer.json +++ b/fixtures/test_users.json @@ -51,7 +51,7 @@ "name": "UserForAccessTest", "user": 2, "role": "agent", - "custom_role_id": "360005209000" + "custom_role_id": "360005208980" } }, { diff --git a/layouts/registration_failed/registration_failed.png b/layouts/registration_failed/registration_failed.png new file mode 100644 index 0000000..766452c Binary files /dev/null and b/layouts/registration_failed/registration_failed.png differ diff --git a/main/admin.py b/main/admin.py index 8c38f3f..b1e5a2d 100644 --- a/main/admin.py +++ b/main/admin.py @@ -1,3 +1,8 @@ -from django.contrib import admin +""" +Встроенный файл +""" + + +# from django.contrib import admin # Register your models here. diff --git a/main/apiauth.py b/main/apiauth.py index 08a018c..e69de29 100644 --- a/main/apiauth.py +++ b/main/apiauth.py @@ -1,49 +0,0 @@ -import os - -from zenpy import Zenpy -from zenpy.lib.api_objects import User as ZenpyUser - -from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, ACTRL_API_PASSWORD - - -def api_auth() -> dict: - """ - Функция создания пользователя с использованием Zendesk API. - - Получает из env Zendesk - email, token, password пользователя. - Если данные валидны и пользователь Zendesk с указанным email и токеном или паролем существует, - создается словарь данных пользователя, полученных через API c Zendesk. - - :return: данные пользователя - """ - credentials = { - 'subdomain': ACTRL_ZENDESK_SUBDOMAIN - } - email = ACTRL_API_EMAIL - token = ACTRL_API_TOKEN - password = ACTRL_API_PASSWORD - - if email is None: - raise ValueError('access_controller email not in env') - credentials['email'] = email - - # prefer token, use password if token not provided - if token: - credentials['token'] = token - elif password: - credentials['password'] = password - else: - raise ValueError('access_controller token or password not in env') - - zenpy_client = Zenpy(**credentials) - zenpy_user: ZenpyUser = zenpy_client.users.search(email).values[0] - - user = { - 'id': zenpy_user.id, - 'name': zenpy_user.name, # Zendesk doesn't have separate first and last name fields - 'email': zenpy_user.email, - 'role': zenpy_user.role, # str like 'admin' or 'agent', not id - 'photo': zenpy_user.photo['content_url'] if zenpy_user.photo is not None else None, - } - - return user diff --git a/main/apps.py b/main/apps.py index 833bff6..b521b37 100644 --- a/main/apps.py +++ b/main/apps.py @@ -1,5 +1,11 @@ +""" +Стандартный файл Django конфигурации приложения. +""" from django.apps import AppConfig class MainConfig(AppConfig): + """ + Старт приложения + """ name = 'main' diff --git a/main/extra_func.py b/main/extra_func.py index 90ec414..6d68944 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,8 +1,14 @@ +""" +Вспомогательные функции со списками пользователей, статистикой и т.д. +""" import logging from datetime import timedelta +from typing import Union -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist +from django.core.handlers.wsgi import WSGIRequest +from django.http import HttpResponseRedirect, HttpResponsePermanentRedirect from django.shortcuts import redirect from django.utils import timezone from zenpy import Zenpy @@ -16,7 +22,7 @@ from main.requester import TicketListRequester from main.zendesk_admin import zenpy -def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None: +def update_role(user_profile: UserProfile, role: int, who_changes: get_user_model()) -> None: """ Функция меняет роль пользователя. @@ -34,7 +40,7 @@ def update_role(user_profile: UserProfile, role: int, who_changes: User) -> None zendesk.update_user(user) -def make_engineer(user_profile: UserProfile, who_changes: User) -> None: +def make_engineer(user_profile: UserProfile, who_changes: get_user_model()) -> None: """ Функция устанавливает пользователю роль инженера. @@ -44,7 +50,7 @@ def make_engineer(user_profile: UserProfile, who_changes: User) -> None: update_role(user_profile, ROLES['engineer'], who_changes) -def make_light_agent(user_profile: UserProfile, who_changes: User) -> None: +def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) -> None: """ Функция устанавливает пользователю роль легкого агента. @@ -89,7 +95,7 @@ def get_users_list() -> list: return users -def get_tickets_list(email): +def get_tickets_list(email) -> list: """ Функция возвращает список тикетов пользователя Zendesk """ @@ -103,7 +109,7 @@ def get_tickets_list_for_group(group_name): return TicketListRequester().get_tickets_list_for_group(zenpy.get_group(group_name)) -def update_profile(user_profile: UserProfile): +def update_profile(user_profile: UserProfile) -> None: """ Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. @@ -157,7 +163,7 @@ def check_user_auth(email: str, password: str) -> bool: return True -def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): +def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser) -> None: """ Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. @@ -173,7 +179,7 @@ def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser): profile.save() -def count_users(users) -> tuple: +def count_users(users: list) -> tuple: """ Функция подсчета количества сотрудников с ролями engineer и light_agent """ @@ -186,21 +192,21 @@ def count_users(users) -> tuple: return engineers, light_agents -def update_users_in_model(): +def update_users_in_model() -> list: """ Обновляет пользователей в модели UserProfile по списку пользователей в организации """ users = get_users_list() for user in users: try: - profile = User.objects.get(email=user.email).userprofile + profile = get_user_model().objects.get(email=user.email).userprofile update_user_in_model(profile, user) except ObjectDoesNotExist: pass return users -def daterange(start_date, end_date) -> list: +def daterange(start_date: timedelta, end_date: timedelta) -> list: """ Функция возвращает список дней с start_date по end_date, исключая правую границу. @@ -214,17 +220,17 @@ def daterange(start_date, end_date) -> list: return dates -def get_timedelta(log, time=None) -> timedelta: +def get_timedelta(current_log: RoleChangeLogs, time: timedelta = None) -> timedelta: """ Функция возвращает объект класса timedelta, который хранит промежуток времени от начала суток до момента, который находится в log (объект класса RoleChangeLogs) или в time(datetime.time), если введён. - :param log: Лог + :param current_log: Лог :param time: Время :return: Сколько времени прошло от начала суток до события """ if time is None: - time = log.change_time.time() + time = current_log.change_time.time() time = timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) return time @@ -241,6 +247,9 @@ def last_day_of_month(day: int) -> int: class DatabaseHandler(logging.Handler): + """ + Класс записи изменений ролей в базу данных. + """ def __init__(self): logging.Handler.__init__(self) @@ -265,10 +274,19 @@ class DatabaseHandler(logging.Handler): class CsvFormatter(logging.Formatter): + """ + Класс преобразования смены ролей пользователей в строковый формат. + """ def __init__(self): logging.Formatter.__init__(self) - def format(self, record): + def format(self, record: logging.LogRecord) -> str: + """ + Функция форматирует запись смены роли пользователя в строку. + + :param record: Запись смены роли пользователя. + :return: Строка с записью смены пользователя. + """ users = record.msg if users[1]: user = users[0] @@ -291,10 +309,10 @@ class CsvFormatter(logging.Formatter): def log(user, admin=None): """ - Осуществляет запись логов в базу данных и csv файл - :param admin: - :param user: - :return: + Функция осуществляет запись логов в базу данных и csv файл. + + :param admin: Админ, который меняет роль + :param user: Пользователь, которому изменена роль """ users = [user, admin] logger = logging.getLogger('MY_LOGGER') @@ -309,10 +327,16 @@ def log(user, admin=None): logger.info(users) -def set_session_params_for_work_page(request, count=None, is_confirm=True): +def set_session_params_for_work_page(request: WSGIRequest, count: int = None, is_confirm: bool = True) -> \ + Union[HttpResponsePermanentRedirect, HttpResponseRedirect]: """ - Функция для страницы получения прав - Устанавливает данные сессии о успешности запроса и количестве назначенных тикетов + Функция для страницы получения прав, устанавливает данные сессии о успешности запроса и количестве + назначенных тикетов. + + :param request: Получение данных с рабочей страницы пользователя + :param count: Количество запрошенных тикетов + :param is_confirm: Назначение тикетов + :return: Перезагрузка страницы "Управление правами" соответствующего пользователя """ request.session['is_confirm'] = is_confirm request.session['count_tickets'] = count diff --git a/main/forms.py b/main/forms.py index 8ca6f13..929f3d9 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,3 +1,6 @@ +""" +Формы. +""" from django import forms from django.contrib.auth.forms import AuthenticationForm from django_registration.forms import RegistrationFormUniqueEmail @@ -56,6 +59,8 @@ class CustomAuthenticationForm(AuthenticationForm): :param username: Поле для ввода email пользователя :type username: :class:`django.forms.fields.CharField` + :param error_messages: Список ошибок авторизации + :type error_messages: :class:`dict` """ username = forms.CharField( label="Электронная почта", @@ -64,8 +69,7 @@ class CustomAuthenticationForm(AuthenticationForm): error_messages = { 'invalid_login': "Пожалуйста, введите правильные электронную почту и пароль. Оба поля " - "могут быть чувствительны к регистру." - , + "могут быть чувствительны к регистру.", 'inactive': "Аккаунт не активен.", } diff --git a/main/migrations/0018_alter_unassignedticket_ticket_id.py b/main/migrations/0018_alter_unassignedticket_ticket_id.py new file mode 100644 index 0000000..7899c3c --- /dev/null +++ b/main/migrations/0018_alter_unassignedticket_ticket_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.3 on 2021-05-20 17:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0017_auto_20210408_1943'), + ] + + operations = [ + migrations.AlterField( + model_name='unassignedticket', + name='ticket_id', + field=models.IntegerField(help_text='Номер тикета, для которого сняли ответственного'), + ), + ] diff --git a/main/models.py b/main/models.py index 1920c4d..c934ab1 100644 --- a/main/models.py +++ b/main/models.py @@ -1,5 +1,8 @@ +""" +Модели, использующиеся в приложении. +""" +from django.contrib.auth import get_user_model 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 @@ -19,42 +22,64 @@ class UserProfile(models.Model): ('has_control_access', 'Can view admin page'), ) - user = models.OneToOneField(to=User, on_delete=models.CASCADE, help_text='Пользователь') + user = models.OneToOneField(to=get_user_model(), 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 + def zendesk_role(self) -> str: + """ + Функция возвращает роль пользователя в Zendesk. + + В формате str, либо UNDEFINED, если пользователь не найден + + :return: Роль пользователя в Zendesk + """ for role, r_id in ZENDESK_ROLES.items(): - if r_id == id: + if r_id == self.custom_role_id: return role return 'UNDEFINED' -@receiver(post_save, sender=User) -def create_user_profile(sender, instance, created, **kwargs): +@receiver(post_save, sender=get_user_model()) +def create_user_profile(instance, created, **kwargs) -> None: + """ + Функция создания профиля пользователя (Userprofile) при регистрации пользователя. + + :param instance: Экземпляр класса User + :param created: Создание профиля пользователя + :param kwargs: Параметры + :return: Обновленный список объектов профилей пользователей + """ if created: UserProfile.objects.create(user=instance) -@receiver(post_save, sender=User) -def save_user_profile(sender, instance, **kwargs): +@receiver(post_save, sender=get_user_model()) +def save_user_profile(instance, **kwargs) -> None: + """ + Функция записи БД профиля пользователя. + + :param instance: Экземпляр класса User + :param kwargs: Параметры + :return: Запись профиля пользователя + """ instance.userprofile.save() class RoleChangeLogs(models.Model): """ - Модель для логирования изменений ролей пользователя. + Модель для логгирования изменений ролей пользователя """ - - user = models.ForeignKey(to=User, on_delete=models.CASCADE, help_text='Пользователь, которому присвоили другую роль') + user = models.ForeignKey(to=get_user_model(), 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='Кем была изменена роль') + changed_by = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name='changed_by', + help_text='Кем была изменена роль') class UnassignedTicketStatus(models.IntegerChoices): @@ -63,13 +88,15 @@ class UnassignedTicketStatus(models.IntegerChoices): :param UNASSIGNED: Снят с пользователя, перенесён в буферную группу :param RESTORED: Авторство восстановлено - :param NOT_FOUND: Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются + :param NOT_FOUND: Пока нас не было, тикет испарился из буферной группы. + Дополнительные действия не требуются :param CLOSED: Тикет уже был закрыт. Дополнительные действия не требуются :param SOLVED: Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL """ UNASSIGNED = 0, 'Снят с пользователя, перенесён в буферную группу' RESTORED = 1, 'Авторство восстановлено' - NOT_FOUND = 2, 'Пока нас не было, тикет испарился из буферной группы. Дополнительные действия не требуются' + NOT_FOUND = 2, 'Пока нас не было, тикет испарился из ' \ + 'буферной группы. Дополнительные действия не требуются' CLOSED = 3, 'Тикет уже был закрыт. Дополнительные действия не требуются' SOLVED = 4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL' @@ -78,7 +105,8 @@ 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='Статус тикета') - + assignee = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name='tickets', + help_text='Пользователь, с которого снят тикет') + ticket_id = models.IntegerField(help_text='Номер тикета, для которого сняли ответственного') + status = models.IntegerField(choices=UnassignedTicketStatus.choices, default=UnassignedTicketStatus.UNASSIGNED, + help_text='Статус тикета') diff --git a/main/requester.py b/main/requester.py index 468abee..d0c57ed 100644 --- a/main/requester.py +++ b/main/requester.py @@ -1,3 +1,6 @@ +""" +Обработка тикетов. +""" import requests from zenpy import TicketApi from zenpy.lib.api_objects import Ticket @@ -6,6 +9,9 @@ from main.zendesk_admin import zenpy class TicketListRequester: + """ + Класс обработки тикетов. + """ def __init__(self): self.email = zenpy.credentials['email'] if zenpy.credentials.get('token'): @@ -15,11 +21,17 @@ class TicketListRequester: self.token_or_password = zenpy.credentials.get('password') self.prefix = f'https://{zenpy.credentials.get("subdomain")}.zendesk.com/api/v2/' - def get_tickets_list_for_user(self, zendesk_user): + def get_tickets_list_for_user(self, zendesk_user: zenpy) -> str: + """ + Функция получения списка тикетов пользователя Zendesk. + """ url = self.prefix + f'users/{zendesk_user.id}/tickets/assigned' return self._get_tickets(url) - def get_tickets_list_for_group(self, group): + def get_tickets_list_for_group(self, group: zenpy) -> list(): + """ + Функция получения списка тикетов группы пользователей Zendesk. + """ url = self.prefix + '/tickets' all_tickets = self._get_tickets(url) tickets = list() @@ -28,7 +40,10 @@ class TicketListRequester: tickets.append(ticket) return tickets - def _get_tickets(self, url): + def _get_tickets(self, url: str) -> list(): + """ + Функция получения полного списка тикетов по url. + """ response = requests.get(url, auth=(self.email, self.token_or_password)) tickets = [] if response.status_code != 200: diff --git a/main/serializers.py b/main/serializers.py index 8436b54..70c4352 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -1,4 +1,7 @@ -from django.contrib.auth.models import User +""" +Сериализаторы. +""" +from django.contrib.auth import get_user_model from rest_framework import serializers from main.models import UserProfile from access_controller.settings import ZENDESK_ROLES @@ -7,14 +10,28 @@ from access_controller.settings import ZENDESK_ROLES class UserSerializer(serializers.HyperlinkedModelSerializer): """ Класс serializer для модели User. + + :param model: Модель, на основании которой создается сериализатор + :type model: :class:`django.contrib.auth.Models` + :param fields: Передаваемые поля + :type email: :class:`list` """ class Meta: - model = User + model = get_user_model() fields = ['email'] class ProfileSerializer(serializers.HyperlinkedModelSerializer): - """Класс serializer для модели профиля пользователя""" + """ + Класс serializer для модели профиля пользователя. + + :param user: Вложенный сериализатор + :type user: :class:`UserSerializer` + :param model: Модель, на основании которой создается сериализатор + :type model: :class:`django.contrib.auth.Models` + :param fields: Передаваемые поля + :type email: :class:`list` + """ user = UserSerializer() class Meta: @@ -23,16 +40,36 @@ class ProfileSerializer(serializers.HyperlinkedModelSerializer): class ZendeskUserSerializer(serializers.Serializer): - """Класс serializer для объектов пользователей из zenpy""" + """ + Класс serializer для объектов пользователей из Zenpy. + + :param name: Имя пользователя + :type name: :class:`str` + :param zendesk_role: Роль из Zendesk + :type zendesk_role: :class:`str` + :param email: Email пользователя + :type email: :class:`str` + """ name = serializers.CharField() zendesk_role = serializers.SerializerMethodField('get_zendesk_role') email = serializers.EmailField() @staticmethod - def get_zendesk_role(obj): + def get_zendesk_role(obj) -> str: + """ + Функция строкового заполнения поля сериализатора zendesk_role. + + :param obj: объект пользователя Zendesk + :return: роль engineer либо light_agent + """ if obj.custom_role_id == ZENDESK_ROLES['engineer']: return 'engineer' - elif obj.custom_role_id == ZENDESK_ROLES['light_agent']: + if obj.custom_role_id == ZENDESK_ROLES['light_agent']: return 'light_agent' - else: - return "empty" + return "empty" + + def create(self, validated_data): + pass + + def update(self, instance, validated_data): + pass diff --git a/main/statistic_data.py b/main/statistic_data.py index fa1ab24..f569c68 100644 --- a/main/statistic_data.py +++ b/main/statistic_data.py @@ -1,7 +1,10 @@ +""" +Обработка статистики. +""" from datetime import date, datetime, timedelta from typing import Optional -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.utils import timezone from access_controller.settings import ONE_DAY, ZENDESK_ROLES as ROLES @@ -50,19 +53,19 @@ class StatisticData: else: self.statistic = stat - def get_statistic(self) -> dict: + def get_statistic(self) -> Optional[dict]: """ Функция возвращает статистику работы пользователя. - :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). None, если были ошибки при создании. + :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 + return None def is_valid_statistic(self) -> bool: """ @@ -104,8 +107,7 @@ class StatisticData: """ if self.is_valid_data(): return self.data - else: - return None + return None def is_valid_data(self) -> bool: """ @@ -170,7 +172,8 @@ class StatisticData: """ Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. - :return: Данные о смене статусов пользователя. Если пользователь не найден или интервал времени некорректен - ошибку. + :return: Данные о смене статусов пользователя. Если пользователь не найден или + интервал времени некорректен - ошибку. """ if not self.check_time(): self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] @@ -178,12 +181,12 @@ class StatisticData: 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), + user=get_user_model().objects.get(email=self.email), ).order_by('change_time') - except User.DoesNotExist: + except get_user_model().DoesNotExist: self.errors += ['Пользователь не найден'] - def _init_statistic(self) -> dict: + def _init_statistic(self) -> None: """ Функция заполняет словарь, в котором ключ - дата, значение - кол-во проработанных в этот день секунд. @@ -192,18 +195,18 @@ class StatisticData: 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] + else: + 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 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) + 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) + 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): """ @@ -238,7 +241,7 @@ class StatisticData: """ Функция обрабатывает случай, когда нам изветсно что инженер начал работу до диапазона """ - self.fill_daterange(max(User.objects.get(email=self.email).date_joined.date(), self.start_date), + self.fill_daterange(max(get_user_model().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() diff --git a/main/templates/pages/profile.html b/main/templates/pages/profile.html index 5cd8420..4b7016a 100644 --- a/main/templates/pages/profile.html +++ b/main/templates/pages/profile.html @@ -6,7 +6,7 @@ {% block title %}{{ pagename }}{% endblock %} -{% block heading %}Профиль{% endblock %} +{% block heading %}

Профиль

{% endblock %} {% block extra_css %} @@ -22,7 +22,7 @@ {% block content %}
-
+
Сменить пароль
-
Имя пользователя {{ profile.name }}
+

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

{{ profile.name }}

-
Электронная почта {{ profile.user.email }}
+

Электронная почта

{{ profile.user.email }}

-
Текущая роль +

Текущая роль

{% if profile.custom_role_id == ZENDESK_ROLES.engineer %} - engineer +
engineer
{% elif profile.custom_role_id == ZENDESK_ROLES.light_agent %} - light_agent +
light_agent
+ {% else %} +
None
{% endif %} - +
-
+
+ {% endblock %} diff --git a/main/templates/pages/registration_failed.html b/main/templates/pages/registration_failed.html new file mode 100644 index 0000000..5cae8c3 --- /dev/null +++ b/main/templates/pages/registration_failed.html @@ -0,0 +1,12 @@ +{% extends 'base/base.html' %} + +{% block title %} +Регистрация закрыта +{% endblock %} + +{% block content %} +
+

К сожалению, регистрация закрыта.

+ На главную +
+{% endblock %} \ No newline at end of file diff --git a/main/templates/registration/password_reset_form.html b/main/templates/registration/password_reset_form.html index 39cf045..f2980e4 100644 --- a/main/templates/registration/password_reset_form.html +++ b/main/templates/registration/password_reset_form.html @@ -12,7 +12,7 @@

Введте свой e-mail адрес для восстановления пароля.

{{ form.as_p }} -

+

{% csrf_token %}
{% endblock %} diff --git a/main/tests.py b/main/tests.py index b086488..7946c01 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,43 +1,85 @@ +""" +Тесты. +""" + + import random from unittest.mock import patch, Mock -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core import mail from django.http import HttpResponseRedirect from django.template.loader import render_to_string from django.test import TestCase, Client from django.urls import reverse, reverse_lazy -from django.utils import translation +from django.utils import translation, timezone import access_controller.settings as sets from main.zendesk_admin import zenpy +from main.extra_func import log -class RegistrationTestCase(TestCase): - fixtures = ['fixtures/data.json'] + +class UsersBaseTestCase(TestCase): + """Базовый класс загружения данных для тестов с пользователями""" + fixtures = ['fixtures/test_users.json'] def setUp(self): + """Добавление в переменные почт и клиентов для пользователей""" + self.light_agent = '123@test.ru' + self.admin = 'admin@gmail.com' + self.engineer = 'customer@example.com' + self.agent_client = Client() + self.agent_client.force_login(get_user_model().objects.get(email=self.light_agent)) + self.admin_client = Client() + self.admin_client.force_login(get_user_model().objects.get(email=self.admin)) + self.engineer_client = Client() + self.engineer_client.force_login(get_user_model().objects.get(email=self.engineer)) + + +class RegistrationTestCase(TestCase): + """ + Класс тестирования регистрации пользователя. + """ + fixtures = ['fixtures/data.json'] + + def setUp(self) -> None: + """ + Функция предтестовых настроек. + """ self.email_backend = 'django.core.mail.backends.locmem.EmailBackend' self.any_zendesk_user_email = 'idar.sokurov.05@mail.ru' self.zendesk_admin_email = 'idar.sokurov.05@mail.ru' self.client = Client() - def test_registration_complete_redirect(self): + def test_registration_complete_redirect(self) -> None: + """ + Функция тестирования успешно завершенной регистрации. + """ with self.settings(EMAIL_BACKEND=self.email_backend): resp = self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) self.assertRedirects(resp, reverse('password_reset_done')) def test_registration_fail_redirect(self): + """ + Функция тестирования неуспешной регистрации. + """ with self.settings(EMAIL_BACKEND=self.email_backend): resp = self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email + 'asd'}) self.assertRedirects(resp, reverse('django_registration_disallowed')) def test_registration_user_already_exist(self): + """ + Функция тестирования попытки регистрации уже зарегистрированного пользователя. + """ with self.settings(EMAIL_BACKEND=self.email_backend) and translation.override('ru'): resp = self.client.post(reverse('registration'), data={'email': '123@test.ru'}) self.assertContains(resp, 'Этот адрес электронной почты уже используется', count=1, status_code=200) def test_registration_send_email(self): + """ + Функция тестирования отправки email. + """ with self.settings(EMAIL_BACKEND=self.email_backend): response: HttpResponseRedirect = \ self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) @@ -53,108 +95,223 @@ class RegistrationTestCase(TestCase): self.assertEqual(mail.outbox[0].body, correct_body) def test_registration_user_creating(self): + """ + Функция тестирования регистрации пользователя (сверяем имя с именем в Zendesk. + """ with self.settings(EMAIL_BACKEND=self.email_backend): self.client.post(reverse('registration'), data={'email': self.any_zendesk_user_email}) - user = User.objects.get(email=self.any_zendesk_user_email) + user = get_user_model().objects.get(email=self.any_zendesk_user_email) zendesk_user = zenpy.get_user(self.any_zendesk_user_email) self.assertEqual(user.userprofile.name, zendesk_user.name) def test_permissions_applying(self): + """ + Функция тестирования проверке присвоения роли admin. + """ with self.settings(EMAIL_BACKEND=self.email_backend): self.client.post(reverse('registration'), data={'email': self.zendesk_admin_email}) - user = User.objects.get(email=self.zendesk_admin_email) + user = get_user_model().objects.get(email=self.zendesk_admin_email) self.assertEqual(user.userprofile.role, 'admin') self.assertTrue(user.has_perm('main.has_control_access')) -class MakeEngineerTestCase(TestCase): - fixtures = ['fixtures/test_make_engineer.json'] - - def setUp(self): - self.light_agent = '123@test.ru' - self.admin = 'admin@gmail.com' - self.engineer = 'customer@example.com' - self.client = Client() - self.client.force_login(User.objects.get(email=self.light_agent)) - self.admin_client = Client() - self.admin_client.force_login(User.objects.get(email=self.admin)) - +class MakeEngineerTestCase(UsersBaseTestCase): + """ + Класс тестов для проверки функции назначения роли engineer. + """ @patch('main.extra_func.zenpy') - def test_redirect(self, ZenpyMock): - user = User.objects.get(email=self.light_agent) - resp = self.client.post(reverse_lazy('work_become_engineer')) + def test_become_engineer_redirect(self, _zenpy_mock): + """ + Функция проверки переадресации пользователя на рабочую страницу после назначения роли engineer. + """ + user = get_user_model().objects.get(email=self.light_agent) + resp = self.agent_client.post(reverse_lazy('work_become_engineer')) self.assertRedirects(resp, reverse('work', args=[user.id])) self.assertEqual(resp.status_code, 302) + self.assertFalse(_zenpy_mock.called) @patch('main.extra_func.zenpy') - def test_light_agent_make_engineer(self, ZenpyMock): - self.client.post(reverse_lazy('work_become_engineer')) - self.assertEqual(ZenpyMock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) + def test_light_agent_make_engineer(self, zenpy_mock): + """ + Функция проверки назначения light_agent на роль engineer. + """ + self.agent_client.post(reverse_lazy('work_become_engineer')) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_admin_make_engineer(self, ZenpyMock): + def test_admin_make_engineer(self, zenpy_mock): + """ + Функция проверки назначения admin на роль engineer. + """ self.admin_client.post(reverse_lazy('work_become_engineer')) - self.assertEqual(ZenpyMock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_engineer_make_engineer(self, ZenpyMock): - client = Client() - client.force_login(User.objects.get(email=self.engineer)) - client.post(reverse_lazy('work_become_engineer')) - self.assertEqual(ZenpyMock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) + def test_engineer_make_engineer(self, zenpy_mock): + """ + Функция проверки назначения engineer на роль engineer. + """ + self.engineer_client.post(reverse_lazy('work_become_engineer')) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_control_page_make_one(self, ZenpyMock): + def test_control_page_make_engineer_one(self, zenpy_mock): + """ + Функция проверки назначения администратором на роль engineer одного пользователя. + """ self.admin_client.post( reverse_lazy('control'), - data={'users': [User.objects.get(email=self.light_agent).userprofile.id], 'engineer': 'engineer'} + data={'users': [get_user_model().objects.get(email=self.light_agent).userprofile.id], + 'engineer': 'engineer'} ) - call_list = ZenpyMock.update_user.call_args_list + call_list = zenpy_mock.update_user.call_args_list mock_object = call_list[0][0][0] self.assertEqual(len(call_list), 1) self.assertEqual(mock_object.custom_role_id, sets.ZENDESK_ROLES['engineer']) @patch('main.extra_func.zenpy') - def test_control_page_make_many(self, ZenpyMock): + def test_control_page_make_engineer_many(self, zenpy_mock): + """ + Функция проверки назначения администратором на роль engineer нескольких пользователей. + """ self.admin_client.post( reverse_lazy('control'), data={ 'users': [ - User.objects.get(email=self.light_agent).userprofile.id, - User.objects.get(email=self.engineer).userprofile.id, + get_user_model().objects.get(email=self.light_agent).userprofile.id, + get_user_model().objects.get(email=self.engineer).userprofile.id, ], 'engineer': 'engineer' } ) - call_list = ZenpyMock.update_user.call_args_list + call_list = zenpy_mock.update_user.call_args_list mock_objects = list(call_list) self.assertEqual(len(call_list), 2) for obj in mock_objects: self.assertEqual(obj[0][0].custom_role_id, sets.ZENDESK_ROLES['engineer']) -class PasswordResetTestCase(TestCase): - fixtures = ['fixtures/test_make_engineer.json'] +class MakeLightAgentTestCase(UsersBaseTestCase): + @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) + @patch('main.extra_func.zenpy') + def test_hand_over_redirect(self, _zenpy_mock, _user_tickets_mock): + user = get_user_model().objects.get(email=self.engineer) + resp = self.engineer_client.post(reverse_lazy('work_hand_over')) + self.assertRedirects(resp, reverse('work', args=[user.id])) + self.assertEqual(resp.status_code, 302) + + @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) + @patch('main.extra_func.zenpy') + def test_engineer_make_light_agent_no_tickets(self, zenpy_mock, _user_tickets_mock): + self.engineer_client.post(reverse_lazy('work_hand_over')) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) + + @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[ + [Mock(id=1, status='solved'), Mock(id=2, status='open'), Mock(id=3, status='open')] + ]) + @patch('main.extra_func.zenpy') + def test_engineer_make_light_agent_with_tickets(self, zenpy_mock, _user_tickets_mock): + zenpy_mock.solved_tickets_user_id = Mock() + self.engineer_client.post(reverse_lazy('work_hand_over')) + + tickets_update = zenpy_mock.admin.tickets.update.call_args[0][0] + self.assertEqual(tickets_update[0].assignee_id, zenpy_mock.solved_tickets_user_id) + self.assertIsNone(tickets_update[1].assignee) + self.assertIsNone(tickets_update[2].assignee) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) + + @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) + @patch('main.extra_func.zenpy') + def test_admin_make_light_agent_no_tickets(self, zenpy_mock, _user_tickets_mock): + self.admin_client.post(reverse_lazy('work_hand_over')) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) + + @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[ + [Mock(id=1, status='solved'), Mock(id=2, status='open'), Mock(id=3, status='open')] + ]) + @patch('main.extra_func.zenpy') + def test_admin_make_light_agent_with_tickets(self, zenpy_mock, _user_tickets_mock): + zenpy_mock.solved_tickets_user_id = Mock() + self.admin_client.post(reverse_lazy('work_hand_over')) + + tickets_update = zenpy_mock.admin.tickets.update.call_args[0][0] + self.assertEqual(tickets_update[0].assignee_id, zenpy_mock.solved_tickets_user_id) + self.assertIsNone(tickets_update[1].assignee) + self.assertIsNone(tickets_update[2].assignee) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) + + @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) + @patch('main.extra_func.zenpy') + def test_light_agent_make_light_agent(self, zenpy_mock, _user_tickets_mock): + self.agent_client.post(reverse_lazy('work_hand_over')) + self.assertEqual(zenpy_mock.update_user.call_args[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) + + @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[]]) + @patch('main.extra_func.zenpy') + def test_control_page_make_light_agent_one(self, zenpy_mock, _user_tickets_mock): + self.admin_client.post( + reverse_lazy('control'), + data={ + 'users': [get_user_model().objects.get(email=self.engineer).userprofile.id], + 'light_agent': 'light_agent' + } + ) + call_list = zenpy_mock.update_user.call_args_list + mock_object = call_list[0][0][0] + self.assertEqual(len(call_list), 1) + self.assertEqual(mock_object.custom_role_id, sets.ZENDESK_ROLES['light_agent']) + + @patch('main.requester.TicketListRequester.get_tickets_list_for_user', side_effect=[[], []]) + @patch('main.extra_func.zenpy') + def test_control_page_make_light_agent_many(self, zenpy_mock, _user_tickets_mock): + self.admin_client.post( + reverse_lazy('control'), + data={ + 'users': [ + get_user_model().objects.get(email=self.light_agent).userprofile.id, + get_user_model().objects.get(email=self.engineer).userprofile.id, + ], + 'light_agent': 'light_agent' + } + ) + call_list = zenpy_mock.update_user.call_args_list + mock_objects = list(call_list) + self.assertEqual(len(call_list), 2) + for obj in mock_objects: + self.assertEqual(obj[0][0].custom_role_id, sets.ZENDESK_ROLES['light_agent']) + + +class PasswordResetTestCase(UsersBaseTestCase): + """ + Класс тестов сброса пароля. + """ def setUp(self): - self.user = '123@test.ru' + """ + Предустановленные значения для проведения тестов. + """ + super().setUp() self.email_backend = 'django.core.mail.backends.locmem.EmailBackend' - self.client = Client() - self.client.force_login(User.objects.get(email=self.user)) def test_redirect(self): + """ + Функция проверки переадресации на страницу уведомления о сбросе пароля на email. + """ with self.settings(EMAIL_BACKEND=self.email_backend): - resp = self.client.post(reverse_lazy('password_reset'), data={'email': self.user}) + resp = self.agent_client.post(reverse_lazy('password_reset'), data={'email': self.light_agent}) self.assertRedirects(resp, reverse('password_reset_done')) self.assertEqual(resp.status_code, 302) def test_send_email(self): + """ + Функция проверки содержания и отправки письма для установки пароля. + """ with self.settings(EMAIL_BACKEND=self.email_backend): response: HttpResponseRedirect = \ - self.client.post(reverse_lazy('password_reset'), data={'email': self.user}) + self.agent_client.post(reverse_lazy('password_reset'), data={'email': self.light_agent}) self.assertEqual(response.status_code, 302) self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].to, [self.user]) + self.assertEqual(mail.outbox[0].to, [self.light_agent]) # context that the email template was rendered with email_context = response.context[0].dicts[1] @@ -164,35 +321,54 @@ class PasswordResetTestCase(TestCase): self.assertEqual(mail.outbox[0].body, correct_body) def test_email_invalid(self): + """ + Функция проверки уведомления клиента о некорректности введенного email. + """ with self.settings(EMAIL_BACKEND=self.email_backend) and translation.override('ru'): - resp = self.client.post(reverse_lazy('password_reset'), data={'email': 1}) + resp = self.agent_client.post(reverse_lazy('password_reset'), data={'email': 1}) self.assertContains(resp, 'Введите правильный адрес электронной почты.', count=1, status_code=200) def test_user_does_not_exist(self): + """ + Функция корректности отработки неверно введенного email. + """ with self.settings(EMAIL_BACKEND=self.email_backend): - resp = self.client.post(reverse_lazy('password_reset'), data={'email': self.user + str(random.random())}) + resp = self.agent_client.post( + reverse_lazy('password_reset'), + data={ + 'email': self.light_agent + str(random.random()) + } + ) self.assertRedirects(resp, reverse('password_reset_done')) self.assertEqual(resp.status_code, 302) self.assertEqual(len(mail.outbox), 0) -class PasswordChangeTestCase(TestCase): - fixtures = ['fixtures/test_make_engineer.json'] - +class PasswordChangeTestCase(UsersBaseTestCase): + """ + Класс тестирования смены пароля. + """ def setUp(self): - self.user = '123@test.ru' - self.client = Client() - self.client.force_login(User.objects.get(email=self.user)) + """ + Предустановленные значения для проведения тестов. + """ + super().setUp() self.set_password() def set_password(self): - user: User = User.objects.get(email=self.user) + """ + Пароль, сформированный для тестирования. + """ + user: get_user_model() = get_user_model().objects.get(email=self.light_agent) user.set_password('ImpossiblyHardPassword') user.save() - self.client.force_login(User.objects.get(email=self.user)) + self.agent_client.force_login(get_user_model().objects.get(email=self.light_agent)) def test_change_successful(self): - self.client.post( + """ + Функция тестирования успешного изменения пароля. + """ + self.agent_client.post( reverse_lazy('password_change'), data={ 'old_password': 'ImpossiblyHardPassword', @@ -200,12 +376,15 @@ class PasswordChangeTestCase(TestCase): 'new_password2': 'EasyPassword', } ) - user = User.objects.get(email=self.user) + user = get_user_model().objects.get(email=self.light_agent) self.assertTrue(user.check_password('EasyPassword')) def test_invalid_old_password(self): + """ + Функция тестирования отработки неверно введенного старого пароля при смене. + """ with translation.override('ru'): - resp = self.client.post( + resp = self.agent_client.post( reverse_lazy('password_change'), data={ 'old_password': 'EasyPassword', @@ -216,8 +395,11 @@ class PasswordChangeTestCase(TestCase): self.assertContains(resp, 'Ваш старый пароль введен неправильно', count=1, status_code=200) def test_different_new_passwords(self): + """ + Функция тестирования случая с вводом двух разных новых паролей. + """ with translation.override('ru'): - resp = self.client.post( + resp = self.agent_client.post( reverse_lazy('password_change'), data={ 'old_password': 'ImpossiblyHardPassword', @@ -228,8 +410,11 @@ class PasswordChangeTestCase(TestCase): self.assertContains(resp, 'Введенные пароли не совпадают', count=1, status_code=200) def test_invalid_new_password1(self): + """ + Функция тестирования случая с неправильно подобранным новым паролем (слишком короткий). + """ with translation.override('ru'): - resp = self.client.post( + resp = self.agent_client.post( reverse_lazy('password_change'), data={ 'old_password': 'ImpossiblyHardPassword', @@ -240,8 +425,11 @@ class PasswordChangeTestCase(TestCase): self.assertContains(resp, 'Введённый пароль слишком короткий', count=1, status_code=200) def test_invalid_new_password2(self): + """ + Функция тестирования случая с неправильно подобранным новым паролем (употребляются только цифры). + """ with translation.override('ru'): - resp = self.client.post( + resp = self.agent_client.post( reverse_lazy('password_change'), data={ 'old_password': 'ImpossiblyHardPassword', @@ -252,105 +440,97 @@ class PasswordChangeTestCase(TestCase): self.assertContains(resp, 'Введённый пароль состоит только из цифр', count=1, status_code=200) def test_invalid_new_password3(self): + """ + Функция тестирования случая с неправильно подобранным новым паролем (совпадает с именем пользователя). + """ with translation.override('ru'): - resp = self.client.post( + resp = self.agent_client.post( reverse_lazy('password_change'), data={ 'old_password': 'ImpossiblyHardPassword', - 'new_password1': self.user, - 'new_password2': self.user, + 'new_password1': self.light_agent, + 'new_password2': self.light_agent, } ) self.assertContains(resp, 'Введённый пароль слишком похож на имя пользователя', count=1, status_code=200) -class GetTicketsTestCase(TestCase): +class GetTicketsTestCase(UsersBaseTestCase): """ Класс тестов для проверки функции получения тикетов. """ - fixtures = ['fixtures/test_make_engineer.json'] - - def setUp(self): - """ - Предустановленные значения для проведения тестов. - """ - self.light_agent = '123@test.ru' - self.engineer = 'customer@example.com' - self.client = Client() - self.client.force_login(User.objects.get(email=self.engineer)) - self.light_agent_client = Client() - self.light_agent_client.force_login(User.objects.get(email=self.light_agent)) @patch('main.views.zenpy.get_user') @patch('main.extra_func.zenpy') - def test_redirect(self, ZenpyMock, GetUserMock): + def test_redirect(self, _zenpy_mock, get_user_mock): """ Функция проверки переадресации пользователя на рабочую страницу. """ - GetUserMock.return_value = Mock() - user = User.objects.get(email=self.engineer) - resp = self.client.post(reverse('work_get_tickets')) + get_user_mock.return_value = Mock() + user = get_user_model().objects.get(email=self.engineer) + resp = self.engineer_client.post(reverse('work_get_tickets')) self.assertRedirects(resp, reverse('work', args=[user.id])) self.assertEqual(resp.status_code, 302) + self.assertFalse(_zenpy_mock.called) @patch('main.views.zenpy') @patch('main.views.get_tickets_list_for_group') - def test_take_one_ticket(self, TicketsMock, ZenpyViewsMock): + def test_take_one_ticket(self, group_tickets_mock, zenpy_mock): """ Функция проверки назначения одного тикета на engineer. """ - TicketsMock.return_value = [Mock()] - ZenpyViewsMock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) - self.client.post(reverse('work_get_tickets'), data={'count_tickets': 1}) - tickets = ZenpyViewsMock.update_tickets.call_args - self.assertEqual(tickets[0][0][0].assignee, ZenpyViewsMock.get_user.return_value) + group_tickets_mock.return_value = [Mock()] + zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) + self.engineer_client.post(reverse('work_get_tickets'), data={'count_tickets': 1}) + tickets = zenpy_mock.update_tickets.call_args + self.assertEqual(tickets[0][0][0].assignee, zenpy_mock.get_user.return_value) @patch('main.views.get_tickets_list_for_group') @patch('main.views.zenpy') - def test_take_many_tickets(self, ZenpyMock, TicketsMock): + def test_take_many_tickets(self, zenpy_mock, group_tickets_mock): """ Функция проверки назначения нескольких тикетов на engineer. """ - TicketsMock.return_value = [Mock()] * 3 - ZenpyMock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) - self.client.post(reverse('work_get_tickets'), data={'count_tickets': 3}) - tickets = ZenpyMock.update_tickets.call_args + group_tickets_mock.return_value = [Mock()] * 3 + zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) + self.engineer_client.post(reverse('work_get_tickets'), data={'count_tickets': 3}) + tickets = zenpy_mock.update_tickets.call_args for ticket in tickets[0][0]: - self.assertEqual(ticket.assignee, ZenpyMock.get_user.return_value) + self.assertEqual(ticket.assignee, zenpy_mock.get_user.return_value) @patch('main.views.zenpy.get_user') @patch('main.views.zenpy') - def test_light_agent_take_ticket(self, ZenpyMock, GetUserMock): + def test_light_agent_take_ticket(self, zenpy_mock, get_user_mock): """ Функция проверки попытки назначения тикета на light_agent. """ - GetUserMock.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['light_agent']) - self.light_agent_client.post(reverse('work_get_tickets'), data={'count_tickets': 3}) - tickets = ZenpyMock.update_tickets.call_args + get_user_mock.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['light_agent']) + self.agent_client.post(reverse('work_get_tickets'), data={'count_tickets': 3}) + tickets = zenpy_mock.update_tickets.call_args self.assertIsNone(tickets) @patch('main.views.zenpy') @patch('main.views.get_tickets_list_for_group') - def test_take_zero_tickets(self, TicketsMock, ZenpyMock): + def test_take_zero_tickets(self, tickets_mock, zenpy_mock): """ Функция проверки попытки назначения нуля тикета на engineer. """ - TicketsMock.return_value = [Mock()] * 3 - ZenpyMock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) - self.client.post(reverse('work_get_tickets'), data={'count_tickets': 0}) - tickets = ZenpyMock.update_tickets.call_args[0][0] + tickets_mock.return_value = [Mock()] * 3 + zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) + self.engineer_client.post(reverse('work_get_tickets'), data={'count_tickets': 0}) + tickets = zenpy_mock.update_tickets.call_args[0][0] self.assertListEqual(tickets, []) @patch('main.views.get_tickets_list_for_group') @patch('main.views.zenpy') - def test_take_invalid_count_tickets(self, ZenpyMock, TicketsMock, ): + def test_take_invalid_count_tickets(self, zenpy_mock, group_tickets_mock): """ Функция проверки попытки назначения нуля тикетов на engineer. """ - TicketsMock.return_value = [Mock()] * 3 - ZenpyMock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) - self.client.post(reverse('work_get_tickets'), data={'count_tickets': 'asd'}) - tickets = ZenpyMock.update_tickets.call_args + group_tickets_mock.return_value = [Mock()] * 3 + zenpy_mock.get_user.return_value = Mock(role='agent', custom_role_id=sets.ZENDESK_ROLES['engineer']) + self.engineer_client.post(reverse('work_get_tickets'), data={'count_tickets': 'asd'}) + tickets = zenpy_mock.update_tickets.call_args self.assertIsNone(tickets) @@ -367,9 +547,9 @@ class ProfileTestCase(TestCase): self.zendesk_agent_email = 'krav-88@mail.ru' self.zendesk_admin_email = 'idar.sokurov.05@mail.ru' self.client = Client() - self.client.force_login(User.objects.get(email=self.zendesk_agent_email)) + self.client.force_login(get_user_model().objects.get(email=self.zendesk_agent_email)) self.admin_client = Client() - self.admin_client.force_login(User.objects.get(email=self.zendesk_admin_email)) + self.admin_client.force_login(get_user_model().objects.get(email=self.zendesk_admin_email)) def test_correct_username(self): """ @@ -412,3 +592,42 @@ class ProfileTestCase(TestCase): resp = self.client.get(reverse('profile')) user = zenpy.get_user(self.zendesk_agent_email) self.assertEqual(resp.context['profile'].image, user.photo['content_url'] if user.photo else None) + + +class LoggingTestCase(UsersBaseTestCase): + + def setUp(self): + super().setUp() + self.admin_profile = get_user_model().objects.get(email=self.admin).userprofile + self.agent_profile = get_user_model().objects.get(email=self.light_agent).userprofile + self.engineer_profile = get_user_model().objects.get(email=self.engineer).userprofile + + @staticmethod + def get_file_output(): + with open('logs/logs.csv', 'r') as file: + file_output = file.readlines()[-1] + return file_output + + def test_engineer_with_admin(self): + log(self.engineer_profile, self.admin_profile) + file_output = self.get_file_output() + self.assertEqual(file_output, f'UserForAccessTest,engineer,' + f'{str(timezone.now().today())[:16]},ZendeskAdmin\n') + + def test_engineer_without_admin(self): + log(self.engineer_profile) + file_output = self.get_file_output() + self.assertEqual(file_output, f'UserForAccessTest,engineer,' + f'{str(timezone.now().today())[:16]},UserForAccessTest\n') + + def test_light_agent_with_admin(self): + log(self.agent_profile, self.admin_profile) + file_output = self.get_file_output() + self.assertEqual(file_output, f'UserForAccessTest,light_agent,' + f'{str(timezone.now().today())[:16]},ZendeskAdmin\n') + + def test_light_agent_without_admin(self): + log(self.agent_profile) + file_output = self.get_file_output() + self.assertEqual(file_output, f'UserForAccessTest,light_agent,' + f'{str(timezone.now().today())[:16]},UserForAccessTest\n') diff --git a/main/urls.py b/main/urls.py index fffe11d..5c55d9d 100644 --- a/main/urls.py +++ b/main/urls.py @@ -1,3 +1,7 @@ +""" +REST framework adds support for automatic URL routing to Django. +""" + from rest_framework.routers import DefaultRouter from main.views import UsersViewSet diff --git a/main/views.py b/main/views.py index 4602bcf..7c00d8f 100644 --- a/main/views.py +++ b/main/views.py @@ -1,10 +1,17 @@ +""" +View функции. +""" + + from smtplib import SMTPException +from typing import Dict, Any, Optional from django.contrib import messages +from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin -from django.contrib.auth.models import User, Permission +from django.contrib.auth.models import Permission from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.views import LoginView from django.contrib.contenttypes.models import ContentType @@ -15,7 +22,7 @@ from django.shortcuts import render, redirect from django.urls import reverse_lazy 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 @@ -23,14 +30,35 @@ from access_controller.settings import DEFAULT_FROM_EMAIL, ZENDESK_ROLES, ZENDES 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, \ set_session_params_for_work_page, get_tickets_list_for_group -from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, StatisticForm, \ - WorkGetTicketsForm +from main.forms import AdminPageUsers, CustomRegistrationForm, CustomAuthenticationForm, \ + StatisticForm, WorkGetTicketsForm from main.serializers import ProfileSerializer, ZendeskUserSerializer from main.zendesk_admin import zenpy from .models import UserProfile from .statistic_data import StatisticData +def setup_context(**kwargs) -> Dict[str, Any]: + """ + Функция добавления в контекст статуса пользователя. + + :param profile_lit: True, при создании профиля пользователя, иначе False + :param control_lit: False + :param work_lit: True, при установке пользователю рабочей роли, иначе False + :param registration_lit: True, при регистрации пользователя, иначе False + :param login_lit: True, если пользователь залогинен, иначе False + :param stats_lit: True, при получении пользователем прав администратора (просмотр статистики), иначе False + :return: Контекст (context) + """ + context = {} + for key in ('profile_lit', 'control_lit', 'work_lit', 'registration_lit', 'login_lit', 'stats_lit'): + if key in kwargs: + context.update({key: True}) + else: + context.update({key: False}) + return context + + class CustomRegistrationView(RegistrationView): """ Отображение и логика работы страницы регистрации пользователя. @@ -41,9 +69,11 @@ class CustomRegistrationView(RegistrationView): :type template_name: :class:`str` :param success_url: Указание пути к html-странице завершения регистрации :type success_url: :class:`django.utils.functional.lazy..__proxy__` - :param is_allowed: Определение зарегистрирован ли пользователь с введенным email на Zendesk и принадлежит ли он к организации SYSTEM + :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' urls = { @@ -53,7 +83,7 @@ class CustomRegistrationView(RegistrationView): } redirect_url = 'done' - def register(self, form: CustomRegistrationForm) -> User: + def register(self, form: CustomRegistrationForm) -> Optional[get_user_model()]: """ Функция регистрации пользователя. 1. Ввод email пользователя, указанный на Zendesk @@ -62,7 +92,7 @@ class CustomRegistrationView(RegistrationView): 3. Создается пользователь class User, а также его профиль. :param form: Email пользователя на Zendesk - :return: user + :return: User """ self.redirect_url = 'done' if check_user_exist(form.data['email']) and get_user_organization(form.data['email']) == 'SYSTEM': @@ -78,10 +108,10 @@ class CustomRegistrationView(RegistrationView): 'html_email_template_name': None, 'extra_email_context': None, } - user = User.objects.create_user( + user = get_user_model().objects.create_user( username=form.data['email'], email=form.data['email'], - password=User.objects.make_random_password(length=50) + password=get_user_model().objects.make_random_password(length=50) ) try: update_profile(user.userprofile) @@ -90,13 +120,16 @@ class CustomRegistrationView(RegistrationView): return user except SMTPException: self.redirect_url = 'email_sending_error' + return None else: self.redirect_url = 'email_sending_error' + return None else: self.redirect_url = 'invalid_zendesk_email' + return None @staticmethod - def set_permission(user: User) -> None: + def set_permission(user: get_user_model()) -> None: """ Функция дает разрешение на просмотр страница администратора, если пользователь имеет роль admin. @@ -110,7 +143,7 @@ class CustomRegistrationView(RegistrationView): ) user.user_permissions.add(permission) - def get_success_url(self, user: User = None): + def get_success_url(self, user: get_user_model() = None) -> Dict: """ Функция возвращает url-адрес страницы, куда нужно перейти после успешной/не успешной регистрации. Используется самой django-registration. @@ -121,7 +154,13 @@ class CustomRegistrationView(RegistrationView): return self.urls[self.redirect_url] -def registration_error(request): +def registration_error(request: WSGIRequest) -> HttpResponse: + """ + Функция отображения страницы ошибки регистрации. + + :param request: регистрация + :return: адресация на страницу ошибки + """ return render(request, 'django_registration/registration_error.html') @@ -144,7 +183,7 @@ def profile_page(request: WSGIRequest) -> HttpResponse: @login_required() -def work_page(request: WSGIRequest, id: int) -> HttpResponse: +def work_page(request: WSGIRequest, required_id: int) -> HttpResponse: """ Функция отображения страницы "Управления правами" для текущего пользователя (login_required). @@ -153,7 +192,7 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse: :return: адресация на страницу "Управления правами" (либо на страницу "Авторизации", если id и user.id не совпадают """ users = get_users_list() - if request.user.id == id: + if request.user.id == required_id: if request.session.get('is_confirm', None): messages.success(request, 'Изменения были применены') elif request.session.get('is_confirm', None) is not None: @@ -184,7 +223,7 @@ def work_page(request: WSGIRequest, id: int) -> HttpResponse: @login_required() -def work_hand_over(request: WSGIRequest): +def work_hand_over(request: WSGIRequest) -> HttpResponseRedirect: """ Функция позволяет текущему пользователю сдать права, а именно сменить в Zendesk роль с "engineer" на "light_agent" @@ -198,18 +237,23 @@ def work_hand_over(request: WSGIRequest): @login_required() def work_become_engineer(request: WSGIRequest) -> HttpResponseRedirect: """ - Функция позволяет текущему пользователю получить права, а именно сменить в Zendesk роль с "light_agent" на "engineer" + Функция позволяет текущему пользователю получить права, а именно сменить в 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): +def work_get_tickets(request: WSGIRequest) -> HttpResponse: + """ + + :param request: + :return: + """ zenpy_user = zenpy.get_user(request.user.email) if request.method == 'POST': @@ -266,7 +310,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM self.make_light_agents(users) return super().form_valid(form) - def make_engineers(self, users): + def make_engineers(self, users: list) -> None: """ Функция проходит по списку пользователей, проставляя статус "engineer". @@ -276,7 +320,7 @@ class AdminPageView(LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageM for user in users: make_engineer(user, self.request.user) - def make_light_agents(self, users): + def make_light_agents(self, users: list) -> None: """ Функция проходит по списку пользователей, проставляя статус "light agent". @@ -291,17 +335,30 @@ class CustomLoginView(LoginView): """ Отображение страницы авторизации пользователя """ + extra_context = setup_context(login_lit=True) form_class = CustomAuthenticationForm class UsersViewSet(viewsets.ReadOnlyModelViewSet): """ - Класс для получения пользователей с помощью api + Класс для получения пользователей с помощью api. + + :param queryset: Список пользователей с ролью 'agent' + :type queryset: :class:`str` + :param serializer_class: Класс сериализатор для модели профиля пользователя + :type serializer_class: :class:`ProfileSerializer` """ queryset = UserProfile.objects.filter(role='agent') serializer_class = ProfileSerializer - def list(self, request, *args, **kwargs): + def list(self, request: WSGIRequest, *args, **kwargs) -> Response: + """ + Функция возвращает список пользователей, список пользователей Zendesk, количество engineers и light-agents. + :param request: Запрос + :param args: Аргументы + :param kwargs: Параметры + :return: Список пользователей + """ users = update_users_in_model() count = count_users(users.values) profiles = UserProfile.objects.filter(role='agent') @@ -316,7 +373,13 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): return Response(res) @staticmethod - def choose_users(zendesk, model): + def choose_users(zendesk: list, model: list) -> list: + """ + Функция формирует список пользователей, которые не зарегистрированы у нас. + :param zendesk: Список пользователей Zendesk + :param model: Список пользователей (модель Userprofile) + :return: Список + """ users = [] for zendesk_user in zendesk: if zendesk_user.name not in [user.name for user in model]: @@ -324,7 +387,12 @@ class UsersViewSet(viewsets.ReadOnlyModelViewSet): return users @staticmethod - def get_zendesk_users(users): + def get_zendesk_users(users: list) -> list: + """ + Получение списка пользователей Zendesk, не являющихся админами. + :param users: Список пользователей + :return: Список пользователей, не являющимися администраторами. + """ zendesk_users = ZendeskUserSerializer( data=[user for user in users if user.role != 'admin'], many=True @@ -370,3 +438,6 @@ def statistic_page(request: WSGIRequest) -> HttpResponse: form = StatisticForm() context['form'] = form return render(request, 'pages/statistic.html', context) + +def registration_failed(request): + return render(request, 'pages/registration_failed.html') diff --git a/main/zendesk_admin.py b/main/zendesk_admin.py index 627d900..92c1f57 100644 --- a/main/zendesk_admin.py +++ b/main/zendesk_admin.py @@ -1,26 +1,36 @@ +""" +Функционал работы администратора Zendesk. +""" + from typing import Optional, Dict, List from zenpy import Zenpy from zenpy.lib.api_objects import User as ZenpyUser, Group as ZenpyGroup, Ticket as ZenpyTicket 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 +from access_controller.settings import ACTRL_ZENDESK_SUBDOMAIN, ACTRL_API_EMAIL, ACTRL_API_TOKEN, \ + ZENDESK_GROUPS, SOLVED_TICKETS_EMAIL #ACTRL_API_PASSWORD, class ZendeskAdmin: """ - Класс **ZendeskAdmin** существует, чтобы в каждой функции отдельно не проверять аккаунт администратора. + Класс **ZendeskAdmin** содержит описание всего функционала администратора. :param credentials: Полномочия (первым указывается учетная запись организации в Zendesk) :type credentials: :class:`Dict[str, str]` + :param admin: Администратор + :type admin: :class:`Zenpy` + :param buffer_group_id: ID буферной группы + :type buffer_group_id: :class:`int` + :param solved_tickets_user_id: ID пользователя, который решил тикет + :type solved_tickets_user_id: :class:`int` """ 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 + self.buffer_group_id= self.get_group(ZENDESK_GROUPS['buffer']).id + self.solved_tickets_user_id = self.get_user(SOLVED_TICKETS_EMAIL).id def update_user(self, user: ZenpyUser) -> bool: """ @@ -46,7 +56,7 @@ class ZendeskAdmin: :param email: Email пользователя :return: Является ли зарегистрированным """ - return True if self.admin.search(email, type='user') else False + return bool(self.admin.search(email, type='user')) def get_user(self, email: str) -> ZenpyUser: """ @@ -96,9 +106,8 @@ class ZendeskAdmin: admin = Zenpy(**self.credentials) try: admin.search(self.credentials['email'], type='user') - except APIException: - raise ValueError('invalid access_controller`s login data') - + except APIException as invalid_data: + raise ValueError('invalid access_controller`s login data') from invalid_data return admin @@ -106,5 +115,5 @@ zenpy = ZendeskAdmin({ 'subdomain': ACTRL_ZENDESK_SUBDOMAIN, 'email': ACTRL_API_EMAIL, 'token': ACTRL_API_TOKEN, - 'password': ACTRL_API_PASSWORD, + #'password': ACTRL_API_PASSWORD, }) diff --git a/requirements/common.txt b/requirements/common.txt index 0bbb21b..7452fc5 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -1,19 +1,10 @@ # Contains requirements common to all environments # Engine -Django==3.1.6 -Pillow==8.1.0 +Django==3.2.3 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 +django_registration==3.1.2 +djangorestframework==3.12.4 # Misc python-dotenv==0.17.1 diff --git a/requirements/dev.txt b/requirements/dev.txt index 73c27d0..80ddb87 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,3 +1,18 @@ # Development specific dependencies -r common.txt +# Documentation +Sphinx==3.5.4 +sphinx-rtd-theme==0.5.2 +sphinx-autodoc-typehints==1.12.0 +pyenchant==3.2.0 +sphinxcontrib-spelling==7.2.1 +m2r == 0.2.1 + +# Tests +coverage==5.5 + +# Code style +pylint == 2.8.2 +pylint-django == 2.4.4 +autopep8 == 1.5.6 diff --git a/requirements/prod.txt b/requirements/prod.txt index b0e6925..479c608 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,5 +1,5 @@ # Production specific dependencies -r common.txt -daphne==3.0.1 +daphne==3.0.2 Twisted[tls,http2]==21.2.0