diff --git a/README.md b/README.md index ccaefad..c7310d0 100644 --- a/README.md +++ b/README.md @@ -186,13 +186,13 @@ autopep8 --in-place filename ##Для проверки орфографии: cd docs -(set -a && source ../.env && make spelling) +make spelling ##Для обновления документации: m2r README.md cd docs -(set -a && source ../.env && make html) +make html ## Read more diff --git a/README.rst b/README.rst index f1b08b5..219cdd7 100644 --- a/README.rst +++ b/README.rst @@ -57,9 +57,9 @@ Quickstart 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) + ./manage.py migrate + ./manage.py loaddata data.json + ./manage.py runserver Перед запуском для тестирования: -------------------------------- @@ -76,7 +76,7 @@ Quickstart * Перейти в папку приложения * Активировать виртуальное окружение * Выполнить команду ``pip install -r requirements/dev.txt`` -* В виртуальное окружение добавить следующие переменные: +* В файл ``.env`` добавить следующие переменные: .. code-block:: @@ -170,10 +170,7 @@ Quickstart Для проверки pylint используем: ------------------------------- -pylint ../access_controller_new - -Вместо "access_controller_new" необходимо указать папку проекта. - +pylint ../access_controller (каталог, где лежит проект) Для приведения файлов к стандарту PEP8 используем: -------------------------------------------------- @@ -185,7 +182,7 @@ autopep8 --in-place filename cd docs -(set -a && source ../.env && make spelling) +make spelling Для обновления документации: ---------------------------- @@ -194,7 +191,7 @@ m2r README.md cd docs -(set -a && source ../.env && make html) +make html Read more --------- diff --git a/access_controller/settings.py b/access_controller/settings.py index 6d2c8d5..96567e4 100644 --- a/access_controller/settings.py +++ b/access_controller/settings.py @@ -10,6 +10,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/3.1/ref/settings/ """ import os + from pathlib import Path from dotenv import load_dotenv @@ -151,7 +152,6 @@ LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' - # Название_приложения.Название_файла.Название_класса_обработчика AUTHENTICATION_BACKENDS = [ 'access_controller.auth.EmailAuthBackend', @@ -190,5 +190,5 @@ ACTRL_API_EMAIL = os.getenv('ACTRL_API_EMAIL') or os.getenv('ACCESS_CONTROLLER_A ACTRL_API_TOKEN = os.getenv('ACTRL_API_TOKEN') or os.getenv('ACCESS_CONTROLLER_API_TOKEN') ACTRL_API_PASSWORD = os.getenv('ACTRL_API_PASSWORD') or os.getenv('ACCESS_CONTROLLER_API_PASSWORD') -NODE_PACKAGE_JSON = BASE_DIR / 'static/main/js/control_page_js_modules/package.json' -NODE_MODULES_ROOT = BASE_DIR / 'static/main/js/control_page_js_modules/node_modules' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/data.json b/data.json index a4310a4..8b2e666 100644 --- a/data.json +++ b/data.json @@ -22,8 +22,9 @@ "pk": 1, "fields": { "name": "ZendeskAdmin", - "user": 1, - "role": "admin" + "user": 3, + "role": "admin", + "user_id": 1 } }, { diff --git a/docs/source/_static/statistic.png b/docs/source/_static/statistic.png new file mode 100644 index 0000000..279df9b Binary files /dev/null and b/docs/source/_static/statistic.png differ diff --git a/docs/source/_static/take_tickets.png b/docs/source/_static/take_tickets.png new file mode 100644 index 0000000..09cd2b8 Binary files /dev/null and b/docs/source/_static/take_tickets.png differ diff --git a/docs/source/code.rst b/docs/source/code.rst index 5473db2..3c1df33 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -41,3 +41,33 @@ Views :members: +***************** +Обработка тикетов +***************** + +.. automodule:: main.requester + :members: + + +********************* +Обработка статистики +********************* + +.. automodule:: main.statistic_data + :members: + + +********************************* +Функционал администратора Zendesk +********************************* + +.. automodule:: main.zendesk_admin + :members: + + +******** +Тесты +******** + +.. automodule:: main.tests + :members: diff --git a/docs/source/overview.rst b/docs/source/overview.rst index 0e3bebb..42ef622 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -81,6 +81,12 @@ .. image:: _static/role_change.png +Являясь инженером, Вы можете запросить в работу необходимое количество тикетов. + +.. image:: _static/take_tickets.png + +Назначенные тикеты будут доступны в Zendesk. + ****************************************** Управление правами доступа администратором ****************************************** @@ -97,4 +103,13 @@ .. image:: _static/admin_manage_done.png -.. |copy| unicode:: 0xA9 .. Школа программистов S101, группа 2. 2021гю +Вы можете смотреть статистику работы пользователя. +Для этого на странице статистика необходимо указать: + +* email пользователя +* период, за который необходима статистика +* формат отображения данных + +.. image:: _static/statistic.png + +.. |copy| unicode:: 0xA9 .. Школа программистов S101, группа 2. 2021г. diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 1e9713d..706d045 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -191,4 +191,58 @@ docs a Аватарка filename - +work +form +work_get_tickets +get +tickets +Do +takes +whatever +it +to +actually +log +the +specified +logging +record +This +version +is +intended +be +implemented +by +subclasses +so +new +тикеты +StatisticForm +patch +zenpy +Mock +редирект +редиректа +предустановки +TicketListRequester +get_tickets_list_for_user +side +effect +for +залогиненный +предустанавливает +переадресация +фикстуры +profile +json +аватарки +аватарке +locmem +бэкенд +has +control +disallowed +test +users +Contents diff --git a/main/extra_func.py b/main/extra_func.py index 6d68944..e652a7e 100644 --- a/main/extra_func.py +++ b/main/extra_func.py @@ -1,9 +1,9 @@ """ -Вспомогательные функции со списками пользователей, статистикой и т.д. +Вспомогательные функции. """ import logging -from datetime import timedelta -from typing import Union +from datetime import timedelta, date +from typing import Union, Optional from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist @@ -29,7 +29,6 @@ def update_role(user_profile: UserProfile, role: int, who_changes: get_user_mode :param user_profile: Профиль пользователя :param role: Новая роль :param who_changes: Пользователь, меняющий роль - :return: Пользователь с обновленной ролью """ zendesk = zenpy user = zendesk.get_user(user_profile.user.email) @@ -55,7 +54,8 @@ def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) - Функция устанавливает пользователю роль легкого агента. :param user_profile: Профиль пользователя - :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent" + :return: Вызов функции **update_role** с параметрами: профиль пользователя, роль "light_agent". + Предварительно снимаем тикеты, находящие в работы у пользователя. """ tickets: SearchResultGenerator = get_tickets_list(user_profile.user.email) ticket: ZenpyTicket @@ -85,7 +85,7 @@ def make_light_agent(user_profile: UserProfile, who_changes: get_user_model()) - def get_users_list() -> list: """ - Функция **get_users_list** возвращает список пользователей Zendesk, относящихся к организации SYSTEM. + Функция возвращает список пользователей Zendesk, относящихся к организации SYSTEM. """ zendesk = zenpy @@ -95,23 +95,29 @@ def get_users_list() -> list: return users -def get_tickets_list(email) -> list: +def get_tickets_list(email: str) -> Optional[list]: """ - Функция возвращает список тикетов пользователя Zendesk + Функция возвращает список тикетов пользователя Zendesk. + + :param email: Email пользователя + :return: Список тикетов пользователя """ return TicketListRequester().get_tickets_list_for_user(zenpy.get_user(email)) -def get_tickets_list_for_group(group_name): +def get_tickets_list_for_group(group_name: str) -> Optional[list]: """ - Функция возвращает список неназначенных, нерешённых тикетов группы Zendesk + Функция возвращает список не назначенных, не решённых тикетов группы Zendesk. + + :param group_name: Название группы пользователя + :return: Список тикетов группы """ return TicketListRequester().get_tickets_list_for_group(zenpy.get_group(group_name)) def update_profile(user_profile: UserProfile) -> None: """ - Функция обновляет профиль пользователя в соответствии с текущим в Zendesk. + Функция обновляет профиль пользователя в БД в соответствии с текущим в Zendesk. :param user_profile: Профиль пользователя :return: Обновленный, в соответствие с текущими данными в Zendesk, профиль пользователя @@ -148,6 +154,9 @@ def check_user_auth(email: str, password: str) -> bool: """ Функция проверяет, верны ли входные данные. + :param email: Email пользователя + :param password: Пароль пользователя + :return: Существует ли пользователь :raise: :class:`APIException`: исключение, вызываемое если пользователь не аутентифицирован """ creds = { @@ -165,7 +174,7 @@ def check_user_auth(email: str, password: str) -> bool: def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser) -> None: """ - Функция обновляет профиль пользователя при изменении данных пользователя на Zendesk. + Функция обновляет профиль пользователя в модели при изменении данных пользователя на Zendesk. :param profile: Профиль пользователя :param zendesk_user: Данные пользователя в Zendesk @@ -181,7 +190,10 @@ def update_user_in_model(profile: UserProfile, zendesk_user: ZenpyUser) -> None: def count_users(users: list) -> tuple: """ - Функция подсчета количества сотрудников с ролями engineer и light_agent + Функция подсчета количества сотрудников с ролями engineer и light_agent. + + :param users: Список пользователей + :return: Количество инженеров, количество light_agents """ engineers, light_agents = 0, 0 for user in users: @@ -194,7 +206,7 @@ def count_users(users: list) -> tuple: def update_users_in_model() -> list: """ - Обновляет пользователей в модели UserProfile по списку пользователей в организации + Обновляет пользователей в модели UserProfile по списку пользователей в организации. """ users = get_users_list() for user in users: @@ -206,7 +218,7 @@ def update_users_in_model() -> list: return users -def daterange(start_date: timedelta, end_date: timedelta) -> list: +def daterange(start_date: date, end_date: date) -> list: """ Функция возвращает список дней с start_date по end_date, исключая правую границу. @@ -253,7 +265,13 @@ class DatabaseHandler(logging.Handler): def __init__(self): logging.Handler.__init__(self) - def emit(self, record): + def emit(self, record: logging.LogRecord) -> None: + """ + Функция записи в базу данных лога с изменением роли пользователя. + + :param record: Лог смены роли пользователя + :return: Запись в БД лога по смене роли пользователя с указанием новой и старой роли, а также автора изменения + """ database = RoleChangeLogs() users = record.msg if users[1]: @@ -284,7 +302,7 @@ class CsvFormatter(logging.Formatter): """ Функция форматирует запись смены роли пользователя в строку. - :param record: Запись смены роли пользователя. + :param record: Лог смены роли пользователя. :return: Строка с записью смены пользователя. """ users = record.msg @@ -307,7 +325,7 @@ class CsvFormatter(logging.Formatter): return msg -def log(user, admin=None): +def log(user: get_user_model(), admin: get_user_model() = None) -> None: """ Функция осуществляет запись логов в базу данных и csv файл. @@ -335,7 +353,7 @@ def set_session_params_for_work_page(request: WSGIRequest, count: int = None, is :param request: Получение данных с рабочей страницы пользователя :param count: Количество запрошенных тикетов - :param is_confirm: Назначение тикетов + :param is_confirm: Назначены ли тикеты :return: Перезагрузка страницы "Управление правами" соответствующего пользователя """ request.session['is_confirm'] = is_confirm diff --git a/main/forms.py b/main/forms.py index 929f3d9..e4db54a 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,5 +1,5 @@ """ -Формы. +Формы, использующиеся в приложении. """ from django import forms from django.contrib.auth.forms import AuthenticationForm diff --git a/main/models.py b/main/models.py index c934ab1..5f2e716 100644 --- a/main/models.py +++ b/main/models.py @@ -13,7 +13,6 @@ from access_controller.settings import ZENDESK_ROLES class UserProfile(models.Model): """ Модель профиля пользователя. - Профиль создается и изменяется при создании и изменении модель User. """ @@ -24,18 +23,14 @@ class UserProfile(models.Model): 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='Код роли пользователя') + custom_role_id = models.BigIntegerField(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) -> str: """ - Функция возвращает роль пользователя в Zendesk. - - В формате str, либо UNDEFINED, если пользователь не найден - - :return: Роль пользователя в Zendesk + Роль пользователя в Zendesk, либо UNDEFINED, если пользователь не найден. """ for role, r_id in ZENDESK_ROLES.items(): if r_id == self.custom_role_id: @@ -44,12 +39,12 @@ class UserProfile(models.Model): @receiver(post_save, sender=get_user_model()) -def create_user_profile(instance, created, **kwargs) -> None: +def create_user_profile(instance: get_user_model(), created: bool, **kwargs) -> None: """ Функция создания профиля пользователя (Userprofile) при регистрации пользователя. :param instance: Экземпляр класса User - :param created: Создание профиля пользователя + :param created: Существует ли пользователь :param kwargs: Параметры :return: Обновленный список объектов профилей пользователей """ @@ -58,7 +53,7 @@ def create_user_profile(instance, created, **kwargs) -> None: @receiver(post_save, sender=get_user_model()) -def save_user_profile(instance, **kwargs) -> None: +def save_user_profile(instance: get_user_model(), **kwargs) -> None: """ Функция записи БД профиля пользователя. @@ -75,8 +70,8 @@ class RoleChangeLogs(models.Model): """ 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='Присвоенная роль') + old_role = models.BigIntegerField(default=0, help_text='Старая роль') + new_role = models.BigIntegerField(default=0, help_text='Присвоенная роль') change_time = models.DateTimeField(default=timezone.now, help_text='Дата и время изменения роли') changed_by = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name='changed_by', help_text='Кем была изменена роль') @@ -84,7 +79,7 @@ class RoleChangeLogs(models.Model): class UnassignedTicketStatus(models.IntegerChoices): """ - Класс статусов не распределенных тикетов. + Модель статусов не распределенных тикетов. :param UNASSIGNED: Снят с пользователя, перенесён в буферную группу :param RESTORED: Авторство восстановлено @@ -95,7 +90,7 @@ class UnassignedTicketStatus(models.IntegerChoices): """ UNASSIGNED = 0, 'Снят с пользователя, перенесён в буферную группу' RESTORED = 1, 'Авторство восстановлено' - NOT_FOUND = 2, 'Пока нас не было, тикет испарился из ' \ + NOT_FOUND = 2, 'Пока нас не было, тикет был перенесен из ' \ 'буферной группы. Дополнительные действия не требуются' CLOSED = 3, 'Тикет уже был закрыт. Дополнительные действия не требуются' SOLVED = 4, 'Тикет решён. Записан на пользователя с почтой SOLVED_TICKETS_EMAIL' diff --git a/main/requester.py b/main/requester.py index d0c57ed..f5ba9e4 100644 --- a/main/requester.py +++ b/main/requester.py @@ -1,6 +1,8 @@ """ -Обработка тикетов. +Обработка тикетов, составление списков тикетов для пользователя и группы пользователей. """ +from typing import Optional + import requests from zenpy import TicketApi from zenpy.lib.api_objects import Ticket @@ -11,6 +13,13 @@ from main.zendesk_admin import zenpy class TicketListRequester: """ Класс обработки тикетов. + + :param email: Email пользователя + :type display: :class:`str` + :param token_or_password: Токен или пароль + :type display: :class:`str` + :param prefix: Формат строка url страницы Zendesk + :type display: :class:`str` """ def __init__(self): self.email = zenpy.credentials['email'] @@ -21,16 +30,22 @@ 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: zenpy) -> str: + def get_tickets_list_for_user(self, zendesk_user: zenpy) -> Optional[list]: """ Функция получения списка тикетов пользователя Zendesk. + + :param zendesk_user: Пользователь Zendesk + :return: Список тикетов, назначенных на данного пользователя в Zendesk """ url = self.prefix + f'users/{zendesk_user.id}/tickets/assigned' return self._get_tickets(url) - def get_tickets_list_for_group(self, group: zenpy) -> list(): + def get_tickets_list_for_group(self, group: zenpy) -> Optional[list]: """ Функция получения списка тикетов группы пользователей Zendesk. + + :param group: Название группы + :return: Список тикетов """ url = self.prefix + '/tickets' all_tickets = self._get_tickets(url) @@ -40,9 +55,12 @@ class TicketListRequester: tickets.append(ticket) return tickets - def _get_tickets(self, url: str) -> list(): + def _get_tickets(self, url: str) -> Optional[list]: """ Функция получения полного списка тикетов по url. + + :param url: Url Zendesk c указанием тикетов, назначенных на пользователя + :return: Список тикетов """ response = requests.get(url, auth=(self.email, self.token_or_password)) tickets = [] diff --git a/main/serializers.py b/main/serializers.py index 70c4352..00b85a6 100644 --- a/main/serializers.py +++ b/main/serializers.py @@ -1,5 +1,5 @@ """ -Сериализаторы. +Сериализаторы, используемые в приложении. """ from django.contrib.auth import get_user_model from rest_framework import serializers diff --git a/main/statistic_data.py b/main/statistic_data.py index f569c68..cfb506e 100644 --- a/main/statistic_data.py +++ b/main/statistic_data.py @@ -1,6 +1,9 @@ """ Обработка статистики. + +Обнаруживает факт изменения роли пользователя и вычисляет отработанное на смене время. """ + from datetime import date, datetime, timedelta from typing import Optional @@ -14,7 +17,7 @@ from main.models import RoleChangeLogs class StatisticData: """ - Класс для учета статистики интервалов работы пользователей. + Класс для учета статистики времени работы пользователей. Передаваемые параметры: start_date, end_date, email, stat. :param display: Формат отображения времени (часы, минуты) @@ -37,7 +40,7 @@ class StatisticData: :type statistic: :class:`dict` """ - def __init__(self, start_date, end_date, user_email, stat=None): + def __init__(self, start_date, end_date, user_email: str, stat=None): self.display = None self.interval = None self.start_date = start_date @@ -57,7 +60,8 @@ class StatisticData: """ Функция возвращает статистику работы пользователя. - :return: Словарь statistic с применением формата отображения и интервала работы(если они есть). + :return: Словарь statistic с применением формата отображения + и интервала работы (если они есть). None, если были ошибки при создании. """ if self.is_valid_statistic(): @@ -117,7 +121,7 @@ class StatisticData: """ return not self.errors - def _use_display(self, stat: list) -> list: + def _use_display(self, stat: dict) -> dict: """ Функция приводит данные к формату отображения. @@ -136,7 +140,9 @@ class StatisticData: def _use_interval(self, stat: dict) -> dict: """ - Функция объединяет ключи и значения в соответствии с интервалом работы. + Переупаковка результата в соответствии с указанным временным диапазоном + + Сжимает набор дней в месяцы, если указан режим работы "по месяцам" :param stat: Статистика работы пользователя :return: Обновленная статистика @@ -172,8 +178,8 @@ class StatisticData: """ Функция возвращает логи в диапазоне дат start_date - end_date для пользователя с указанным email. - :return: Данные о смене статусов пользователя. Если пользователь не найден или - интервал времени некорректен - ошибку. + :return: Данные о смене статусов пользователя. + Если пользователь не найден или интервал времени некорректен - ошибку. """ if not self.check_time(): self.errors += ['Конец диапазона должен быть позже начала диапазона и раньше текущего времени'] @@ -208,9 +214,12 @@ class StatisticData: if self.data[log_index].new_role == ROLES['engineer']: self.engineer_logic(log_index) - def engineer_logic(self, log_index): + def engineer_logic(self, log_index: int) -> None: """ - Функция обрабатывает основную часть работы инженера + Функция вычисляет время работы инженера. + + :param log_index: Индекс текущего лога + :return: Дополняет статистику работы инженера временем между текущим и последующим логом """ current_log, next_log = self.data[log_index], self.data[log_index + 1] if current_log.change_time.date() != next_log.change_time.date(): @@ -222,9 +231,14 @@ class StatisticData: elapsed_time = next_log.change_time - current_log.change_time self.statistic[current_log.change_time.date()] += elapsed_time.total_seconds() - def post_engineer_logic(self, last_log): + def post_engineer_logic(self, last_log: RoleChangeLogs) -> None: """ - Функция обрабатывает случай, когда нам изветсно что инженер работал и после диапазона + Обработка случая, в котором инженер не закрыл смену. + + В таком случае считается всё время от момента открытия смены до текущего момента. + + :param last_log: Последний лог изменения роли, в результате которого пользователь назначен инженером. + :return: Дополняет статистику работы """ self.fill_daterange(last_log.change_time.date() + timedelta(days=1), self.end_date + timedelta(days=1)) if last_log.change_time.date() == timezone.now().date(): @@ -237,15 +251,25 @@ class StatisticData: if self.end_date == timezone.now().date(): self.statistic[self.end_date] = get_timedelta(None, timezone.now().time()).total_seconds() - def prev_engineer_logic(self, first_log): + def prev_engineer_logic(self, first_log: RoleChangeLogs) -> None: """ - Функция обрабатывает случай, когда нам изветсно что инженер начал работу до диапазона + Обработка случая, в котором инженер закрыл смену в отражаемом периоде, а открыл её до этого периода. + + В таком случае должен быть учтён только период от начала отображаемого диапазона до закрытия смены. + + :param first_log_log: Первый лог в диапазоне, в результате которого пользователь назначен легким агентом. + :return: Дополняет статистику работы """ - self.fill_daterange(max(get_user_model().objects.get(email=self.email).date_joined.date(), self.start_date), - first_log.change_time.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() - def fill_daterange(self, first: date, last: date, val: int = 24 * 3600) -> dict: + def fill_daterange(self, first: date, last: date, val: int = 24 * 3600) -> None: """ Функция заполняет диапазон дат значением val (по умолчанию val = кол-во секунд в 1 дне). @@ -256,9 +280,11 @@ class StatisticData: for day in daterange(first, last): self.statistic[day] = val - def clear_statistic(self) -> dict: + def clear_statistic(self) -> None: """ - Функция осуществляет обновление всех дней. + Чистка статистики и установка времени по умолчанию. + + Устанавливает время смены в 0 """ self.statistic.clear() self.fill_daterange(self.start_date, self.end_date + timedelta(days=1), 0) diff --git a/main/templates/base/base.html b/main/templates/base/base.html index 166195d..e82af52 100644 --- a/main/templates/base/base.html +++ b/main/templates/base/base.html @@ -19,6 +19,8 @@ user-select: none; } + + @media (min-width: 768px) { .bd-placeholder-img-lg { font-size: 3.5rem; diff --git a/main/templates/pages/adm_ruleset.html b/main/templates/pages/adm_ruleset.html index cff4681..1c36f24 100644 --- a/main/templates/pages/adm_ruleset.html +++ b/main/templates/pages/adm_ruleset.html @@ -24,7 +24,7 @@ {% csrf_token %}