Selfish, администрирование пользователей

avm

Опубликован:  2018-03-31T12:03:14.507293Z
Отредактирован:  2018-04-02T06:27:57.924398Z
1000
Представлено пошаговое описание процесса разработки системы администрирования пользователей web-приложения и продолжения создания системы авторизации пользователей. В результате выполнения описанных здесь действий у Selfish появится система администрирования пользовательских аккаунтов, в том числе страница профиля пользователя и назначение пользовательских разрешений.

1. В предыдущих сериях

Selfish - web-приложение на базе Python3 и Flask. Начальная конфигурация Selfish детально описана в порядке следования в следующих топиках:

Все топики посвященные Selfish можно отфильтровать по метке selfish. На текущий момент Selfish имеет оформленную главную страницу с пользовательским меню, менеджера с возможностью подгружать служебную консоль и запускать отладочный сервер, базу данных и возможность регистрировать пользователей в служебной консоли приложения, страницу входа в сервис (логин), возможность выхода из сервиса (логаут) и соответствующие ссылки в главном меню. Selfish умеет различать пользователей и предоставляет пользователям различные в зависимости от имеющихся у пользователя разрешений возможности.

В этом описании будет разработана система администрирования пользователей, у каждого пользователя появится персональная страница профиля, а администраторы и модераторы получат возможность изменять разрешения каждого конкретного пользователя. У Selfish появится возможность создания аккаунтов новых пользователей непосредственно в сервисе администратором системы, возможность формирования списка всех зарегистрированных пользователей и поиск пользователя по псевдониму в этом списке, а база данных Selfish подвергнется небольшой оптимизации.

2. Оптимизация базы данных

Напомню, разрешения пользователей в базе данных Selfish хранятся в таблице permissions, которой соответствует класс Permission. Этот класс имеет соответствующий метод для начального заполнения таблицы необходимыми данными. В свою очередь данные пользователя хранятся в таблице users, которой соответствует класс User. Таблицы users и permissions связаны отношением Many-to-Many. Проблема в том, что отношение Many-to-Many очень серьёзно осложнит разработку уже в ближайшей перспективе и неблагоприятно отразится на производительности всего приложения в будущем.

Хорошая новость - Selfish использует базу данных PostgreSQL. Нативные возможности этой базы данных позволяют несколько упростить имеющееся на текущий момент решение.

Итак, открываю файл selfish/models/auth.py и дописываю в него ещё один импорт.

...
from sqlalchemy.dialects import postgresql
...

Нахожу в этом файле описание таблицы bonds и удаляю его полностью.

Нахожу в этом файле класс User, в этом классе мне необходимо изменить его свойство permissions, пишу следующий код.

class User(UserMixin, db.Model):
    ...
    permissions = db.Column(postgresql.ARRAY(db.String(32)))
    ...

Метод can этого класса получает следующие изменения.

class User(UserMixin, db.Model):
    ...

    def can(self, permission):
        return permission in self.permissions

    ...

Для регистрации администратора приложения в файле manage.py определена функция create_root. Изменения модели пользователя необходимо отразить в этой функции.

@manager.command
def create_root():
    try:
        Permission.insert_permissions()
    except(OperationalError, ProgrammingError):
        print(ERROR_MESSAGE)
        return None
    username, address, password = get_username(), get_email(), get_password()
    account = Account.query.filter_by(address=address).first()
    if account is None:
        account = Account(address=address)
    root = User(username=username, password=password)
    root.account = account
    root.permissions = roots        # изменения
    db.session.add(root)
    db.session.commit()

Мигрирую.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py db migrate -m"Modify User and Permission"
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.ddl.postgresql] Detected sequence named 'accounts_id_seq' as owned by integer column 'accounts(id)', assuming SERIAL and omitting
INFO  [alembic.ddl.postgresql] Detected sequence named 'permissions_id_seq' as owned by integer column 'permissions(id)', assuming SERIAL and omitting
INFO  [alembic.autogenerate.compare] Detected added column 'users.permissions'
  Generating /home/sadmin/workspace/selfish/migrations/versions/35db70e39606_modif
  y_user_and_permission.py ... done
(venv) sadmin@debian:~/workspace/selfish$ 

Обновляю базу данных.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py db upgrade
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 632025f76e86 -> 35db70e39606, Modify User and Permission
(venv) sadmin@debian:~/workspace/selfish$ 

Поскольку база данных до этого момента уже содержала записи пользователей, мне необходимо изменить эти записи в соответствии с изменениями структуры базы данных. Подгружаю служебную консоль.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py shell
>>> 

Начнём пожалуй с администратора. Фильтрую пользователя и определяю ему разрешения администратора приложения.

>>> from selfish.models.auth_units import roots
>>> support = User.query.filter_by(username='support').first()
>>> support
<User: support>
>>> support.permissions = roots
>>> db.session.add(support)
>>> db.session.commit()
>>> support.can(permissions.CANNOT_LOG_IN)
False
>>> support.can(permissions.ADMINISTER)
True
>>> support.group
'Администраторы'
>>> 

Для пользователя с разрешениями блогера совершаю аналогичные действия.

>>> avm = User.query.filter_by(username='avm').first()
>>> avm
<User: avm>
>>> avm.permissions = [permission.permission for permission in Permission.query.filter_by(default=True)]
>>> db.session.add(avm)
>>> db.session.commit()
>>> avm.can(permissions.WRITE_JOURNAL)
True
>>> avm.group
'Блогеры'
>>> 

Два оставшихся пользователя получают свои разрешения следующим образом.

>>> scain = User.query.filter_by(username='scain').first()
>>> scain
<User: scain>
>>> scain.permissions = [permissions.CANNOT_LOG_IN]
>>> db.session.add(scain)
>>> db.session.commit()
>>> scain.can(permissions.COMMENT)
False
>>> scain.group
'Заблокированные'
>>> fossman = User.query.filter_by(username='fossman').first()
>>> fossman.permissions = [permissions.READ_JOURNAL]
>>> db.session.add(fossman)
>>> db.session.commit()
>>> fossman.can(permissions.POLICE)
False
>>> fossman.group
'Читатели'
>>> 

Покидаю служебную консоль. Теперь мне нужно проверить процесс регистрации администратора.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py create_root
Enter your username: admin
Enter your email address: admin@example.com
Enter your password: 
Confirm the password: 
(venv) sadmin@debian:~/workspace/selfish$ 

Подключаюсь к базе данных при помощи psql.

(venv) sadmin@debian:~/workspace/selfish$ psql -h 192.168.56.102 -d selfish_d
Пароль: 
psql (9.6.7, сервер 9.6.6)
SSL-соединение (протокол: TLSv1.2, шифр: ECDHE-RSA-AES256-GCM-SHA384, бит: 256, сжатие: выкл.)
Введите "help", чтобы получить справку.

selfish_d=> 

Меня интересует в первую очередь состав базы данных.

selfish_d=> \dt
               Список отношений
 Схема  |       Имя       |   Тип   | Владелец 
--------+-----------------+---------+----------
 public | accounts        | таблица | sadmin
 public | alembic_version | таблица | sadmin
 public | permissions     | таблица | sadmin
 public | users           | таблица | sadmin
(4 строки)

selfish_d=> 

Посмотрим, как теперь определены разрешения пользователей.

selfish_d=> SELECT username, permissions FROM users;
 username |                                                             permissions                                                              
----------+--------------------------------------------------------------------------------------------------------------------------------------
 support  | {"читать журнал","создавать ленту","писать в приват","комментировать журнал","вести журнал","следить за порядком","без ограничений"}
 avm      | {"читать журнал","создавать ленту","писать в приват","комментировать журнал","вести журнал"}
 scain    | {заблокирован}
 fossman  | {"читать журнал"}
 admin    | {"читать журнал","создавать ленту","писать в приват","комментировать журнал","вести журнал","следить за порядком","без ограничений"}
(5 строк)

selfish_d=> 

Замечание: таблица permissions теперь никак не связана с таблицей users, тем не менее я оставил эту таблицу, в перспективе она позволит мне настраивать приложение и изменять набор разрешений для вновь регистрируемых пользователей при этом не изменяя файлы приложения.

3. Профиль пользователя

Профиль пользователя - это персональная для каждого пользователя приложения страница, на которой отображаются его данные, статистика и служебные ссылки. К этой странице будет открыт доступ всем пользователям, имеющим разрешение FOLLOW_USERS, кроме этого каждый авторизованный пользователь будет иметь доступ к своему профилю независимо от наличия или отсутствия соответствующего разрешения. На данном этапе разработки Selfish профиль пользователя получит только часть необходимого функционала.

Профиль пользователя я определю в подпрограмме selfish/main. Открываю файл selfish/main/views.py и определяю в нём новую функцию представления.

profile-function

Здесь следует обратить внимание на то, что функция представления show_profile получила аргумент username, и как это отражено в url этой функции.

@main.route('/society/<username>')
@login_required
def show_profile(username):
    ...

Кроме того, эта функция представления закрыта декоратором login_required, проследовавший по данному url анонимный пользователь будет переадресован на страницу входа в систему.

Далее в теле этой функции я фильтрую из базы данных пользователя по переданному функции аргументу.

    target = User.query.filter_by(username=username).first_or_404()

Метод first_or_404 прервёт исполнение кода этой функции представления, если пользователя с соответствующим псевдонимом в базе данных нет, а браузер покажет ошибку 404.

Далее я проверяю, является ли текущий пользователь хозяином профиля и имеет ли текущий пользователь разрешение FOLLOW_USERS.

    if current_user != target and \
            not current_user.can(permissions.FOLLOW_USERS):
        abort(403)

Если условие в блоке if выполняется, то исполнение кода функции представления будет прервано вызовом abort, а в браузере будет отображена ошибка 403. Таким образом хозяин профиля получит доступ к странице, даже если не имеет соответствующего разрешения.

Создаю шаблон для этой функции представления.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/profile.html
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю этот файл в редакторе и пишу в него следующий код.

{% extends "base.html" %}
{% from "macros/_auth.html" import get_message %}

{% block title %}Профиль {{ target.username }}{% endblock %}

{% block styles %}
  {{ super() }}
  {% assets filters='cssmin', output='generic/css/profile.css',
            'css/base.css', 'css/content-block.css' %}
    <link rel="stylesheet" type="text/css" href="{{ ASSET_URL }}">
  {% endassets %}
{% endblock styles %}

{% block page_content %}
  <!-- profile.html -->
  {% set messages = get_flashed_messages() %}
  {% if messages %}
    <div class="top-flashed-block">
      {{ get_message(messages) }}
    </div>
  {% endif %}
  <div class="content-block">
    <div class="block-header">
      <h3 class="panel-title">Профиль {{ target.username }}</h3>
    </div>
    <div class="block-body user-info-block"></div>
  </div>
{% endblock page_content %}

Чтобы предоставить доступ к собственному профилю в главном меню приложения всем авторизованным пользователям, впишу соответствующий url в уже существующую ссылку в главном меню. Для этого открываю файл базового шаблона selfish/templates/base.html, нахожу в нём тег <ul class="nav navbar-nav navbar-right">, внутри этого тега нахожу тег <ul class="dropdown-menu"> и в нём вписываю url ссылке Профиль.

          <ul class="nav navbar-nav navbar-right">
            ...
              <ul class="dropdown-menu">
                {% if current_user.is_authenticated %}
                ...
                <li>
                  <a href="{{ url_for(
                      'main.show_profile', username=current_user.username) }}">
                    Профиль
                  </a>
                </li>
                {% else %}
                ...
                {% endif %}
              </ul>
            </li>
          </ul>

Теперь можно в браузере постучаться по этой ссылке.

user-profile

У приложения появилась новая функциональная страница. Далее я займусь оформлением профиля пользователя и разработкой необходимого для этой страницы функционала.

4. Форматирование дат и времени

Таблица users базы данных Selfish выглядит следующим образом.

selfish_d=> \d users
                                      Таблица "public.users"
    Столбец    |             Тип             |                    Модификаторы                    
---------------+-----------------------------+----------------------------------------------------
 id            | integer                     | NOT NULL DEFAULT nextval('users_id_seq'::regclass)
 username      | character varying(16)       | NOT NULL
 registered    | timestamp without time zone | 
 last_visit    | timestamp without time zone | 
 password_hash | character varying(128)      | 
 permissions   | character varying(32)[]     | 
Индексы:
    "users_pkey" PRIMARY KEY, btree (id)
    "users_username_key" UNIQUE CONSTRAINT, btree (username)
Ссылки извне:
    TABLE "accounts" CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id)

selfish_d=> 

Столбцы registered и last_visit этой таблицы содержат временную метку без указания часового пояса, при этом данные в оба столбца вписываются в часовом поясе UTC. Чтобы правильно обработать значения из этих столбцов мне понадобится дополнительный инструмент. Создаю новый файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/main/tools.py
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю этот файл в редакторе и пишу в него следующий код.

def format_date(datetime_obj):
    return datetime_obj.isoformat() + 'Z'

Эту функцию мне необходимо добавить в контекст шаблонизатора. Открываю файл selfish/main/__init__.py, импортирую в этом файле только что созданную функцию и добавляю её в контекст шаблонизатора.

...
from .tools import format_date
...


@main.app_context_processor
def inject_tools():
    return {'permissions': permissions, 'format_date': format_date}

При помощи этой простой функции я буду форматировать временные метки в шаблонах.

5. Оформление страницы профиля пользователя

Займёмся профилем. Возвращаюсь в редактор к файлу selfish/templates/profile.html. Нахожу в этом файле тег <div class="block-body user-info-block"> и вписываю в этот тег следующее.

    <div class="block-body user-info-block">
      <div class="user-avatar avatar"
           style="background: url({{ target.account.get_ava_url(size=160)}})">
      </div>
      <div class="user-info">
        <h3>{{ target.username }}</h3>
        <div class="personal-details">
          <table class="user-info-table">
            <tbody>
              <tr>
                <td>Группа:&nbsp;</td>
                <td>{{ target.group }}</td>
              </tr>
              {% if current_user == target or
                    (current_user.can(permissions.POLICE) and
                     not target.can(permissions.POLICE)) or
                    current_user.can(permissions.ADMINISTER) %}
                <tr>
                  <td>Адрес:&nbsp;</td>
                  <td>{{ target.account.address }}</td>
                </tr>
              {% endif %}
              <tr>
                <td>Зарегистрирован:&nbsp;</td>
                <td class="date-field">{{ format_date(target.registered) }}</td>
              </tr>
              <tr>
                <td>Последний визит:&nbsp;</td>
                <td class="last-seen">{{ format_date(target.last_visit) }}</td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
      <div class="footer-bottom"></div>
    </div>

Здесь следует обратить внимание, что адрес пользователя в профиле будет показан только в определённых соответствующим условием случаях.

Теперь мне необходимо определиться со стилями. На странице появился аватар пользователя, аватар пользователя будет присутствовать на многих других страницах приложения, поэтому оформление аватара выделяю в отдельный файл. Создаю его.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/css/avatar.css
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю в редакторе и пишу следующее правило.

.avatar {
  border: 1px solid #dedede;
  box-shadow: 0 0 12px #d9dcec;
  border-radius: 6px;
}

На странице появилось flash-сообщение вверху страницы. Оформление этого блока тоже выделю в отдельный файл, так как аналогичный блок будет на других страницах приложения. Создаю новый файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/css/top-flashed.css
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю этот файл в редакторе и пишу в него следующее правило.

.top-flashed-block {
  margin-bottom: 6px;
}

Правила, характерные только для страницы профиля пользователя, определю в отдельном файле. Создаю его.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/static/css/main
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/css/main/profile.css
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю этот файл в редакторе и пишу в него следующие правила.

.user-avatar {
  width: 164px;
  height: 164px;
  float: left;
}

.user-info {
  margin-left: 172px;
  padding: 6px;
}

.user-info h3 {
  margin: 0 0 6px 0;
  font-style: italic;
  text-shadow: 0 0 3px #d9dcec;
}

.user-info-block {
  margin-bottom: 10px;
}

.personal-details {
  text-shadow: 0 0 3px #d9dcec;
  font-style: italic;
}

.user-info-table {
  border-collapse: collapse;
}

.user-info-table tr {
  border-bottom: 1px dashed #d3ddd3;
}

.user-info-table td {
  padding: 4px 2px;
}

Дописываю все вновь созданные файлы в блок styles шаблона selfish/templates/profile.html.

{% block styles %}
  {{ super() }}
  {% assets filters='cssmin', output='generic/css/profile.css',
            'css/base.css', 'css/content-block.css', 'css/avatar.css',
            'css/top-flashed.css', 'css/main/profile.css' %}
    <link rel="stylesheet" type="text/css" href="{{ ASSET_URL }}">
  {% endassets %}
{% endblock styles %}

Открываю браузер и смотрю, что у меня получилось.

user-profile

Вполне... Не совсем хорошо выглядят даты, это поправимо и чуть позже будет исправлено.

6. Управление разрешениями пользователя

Теперь, когда Selfish обзавёлся профилем пользователя, мне необходим инструмент, при помощи которого администратор или модератор сервиса мог бы изменять набор разрешений пользователя. И этот инструмент вполне логично будет разместить в профиле пользователя.

Набор разрешений пользователя я предполагаю изменять при помощи простой формы, которая будет содержать совокупность флажков и кнопку. Форма будет доступна в профиле пользователя только модераторам и администраторам сервиса. В профиле модератора форма будет доступна только администраторам сервиса. В собственном профиле ни модераторы, ни администраторы сервиса не будут иметь доступа к этой форме. При этом набор флажков доступный модераторам сервиса будет ограничен и отличаться от набора флажков доступных администраторам сервиса.

Поскольку форма будет представлять всего лишь совокупность флажков без дополнительных проверок, формировать её я собираюсь вручную непосредственно в шаблоне. Все флажки формы будут однотипными элементами, поэтому я опишу флажок при помощи macro.

Создаю новый файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/macros/_main.html
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю его в редакторе и пишу следующий код.

{% macro render_checkbox(permission_name, target, permission) %}
  <div class="form-group text-left">
    <div class="checkbox">
      <label>
        <input id="{{ permission_name }}"
               name="{{ permission_name }}"
               type="checkbox"
               {% if target.can(permission) %}checked{% endif %}>
        <label for="{{ permission_name }}">{{ permission }}</label>
      </label>
    </div>
  </div>
{% endmacro %}

Открываю в редакторе файл selfish/templates/profile.html и импортирую в нём созданный macro.

{% extends "base.html" %}
{% from "macros/_auth.html" import get_message %}
{% from "macros/_main.html" import render_checkbox %}
...

Далее нахожу в этом файле тег <div class="content-block">...</div> и под ним ниже пишу следующий код.

  {% if current_user != target and
        (current_user.can(permissions.ADMINISTER) or
         (current_user.can(permissions.POLICE) and
          not target.can(permissions.POLICE))) %}
    <div id="permissions-block" class="content-block slidable next-block">
      <div class="block-header">
        <h3 class="panel-title">Разрешения {{ target.username }}</h3>
      </div>
      <div class="block-body">
        <div class="form-form">
          <form class="form" method="POST" role="form">
            <input id="csrf_token"
                   name="csrf_token"
                   type="hidden" value="{{ csrf_token() }}">
            {{ render_checkbox(
                'cannot-log-in', target, permissions.CANNOT_LOG_IN) }}
            {{ render_checkbox(
                'read-journal', target, permissions.READ_JOURNAL) }}
            {{ render_checkbox(
                'follow-users', target, permissions.FOLLOW_USERS) }}
            {{ render_checkbox(
                'send-pm', target, permissions.SEND_PM) }}
            {{ render_checkbox(
                'comment-blog', target, permissions.COMMENT) }}
            {{ render_checkbox(
                'write-journal', target, permissions.WRITE_JOURNAL) }}
            {% if current_user.can(permissions.ADMINISTER) %}
              {{ render_checkbox(
                  'police', target, permissions.POLICE) }}
              {{ render_checkbox(
                  'administer', target, permissions.ADMINISTER) }}
            {% endif %}
            <div class="form-group">
              <input class="btn btn-primary btn-block"
                     id="submit" name="submit" type="submit"
                     value="Утвердить">
            </div>
          </form>
        </div>
      </div>
    </div>
  {% endif %}

Чтобы правильно расставить элементы в форме и определить положение формы в блоке, открываю файл selfish/static/css/main/profile.css и дописываю в него следующие правила.

.form-form {
  width: 50%;
  margin: 0 auto;
  padding: 1px 0 0;
}

.form-group {
  margin: 6px 0 0;
}

.checkbox {
  margin: 0;
}

На странице профиля появился сворачиваемый блок, для него дописываю в файл selfish/static/css/content-block.css следующие правила.

.slidable .block-header {
  cursor: pointer;
}

.to-be-hidden {
  display: none;
}

Проверяю в браузере.

profile-permissions

На данный момент форма не имеет продолжения в серверной логике и в сущности является бутафорией. Это очень легко исправить.

Чтобы определить серверную логику для изменения набора разрешений пользователя мне понадобится ещё один служебный объект. Открываю в редакторе файл selfish/models/auth_units.py и определяю объект average.

average = {'read-journal': permissions.READ_JOURNAL,
           'follow-users': permissions.FOLLOW_USERS,
           'send-pm': permissions.SEND_PM,
           'comment-blog': permissions.COMMENT,
           'write-journal': permissions.WRITE_JOURNAL,
           'police': permissions.POLICE}

В файл selfish/main/views.py дописываю в функцию представления show_profile обработку созданной формы.

...
from flask import (
    abort, current_app, flash, render_template,
    redirect, request, send_from_directory, url_for)
...

from .. import db
...
from ..models.auth_units import average, permissions, roots
...


@main.route('/society/<username>', methods=['GET', 'POST'])
@login_required
def show_profile(username):
    target = User.query.filter_by(username=username).first_or_404()
    if current_user != target and \
            not current_user.can(permissions.FOLLOW_USERS):
        abort(403)
    if request.method == 'POST' and \
            (current_user != target and
             (current_user.can(permissions.ADMINISTER) or
              (current_user.can(permissions.POLICE) and
               not target.can(permissions.POLICE)))):
        if request.form.get('cannot-log-in', None, type=str):
            target.permissions=[permissions.CANNOT_LOG_IN]
        elif request.form.get('administer', None, type=str):
            target.permissions = roots
        else:
            current = list()
            for each in average:
                if request.form.get(each, None, type=str):
                    current.append(average[each])
            target.permissions = current or [permissions.CANNOT_LOG_IN]
        db.session.add(target)
        db.session.commit()
        flash('Разрешения {0} успешно изменены.'.format(target.username))
        return redirect(url_for('main.show_profile', username=target.username))
    return render_template('profile.html', target=target)

Логика достаточно элементарна. В url функции представления добавлен метод POST, теперь эта функция представления может обрабатывать запросы с этим методом. В теле функции проверяется метод запроса и дублируется проверка прав текущего пользователя и пользователя, которому изменяются права. Далее обрабатывается форма и в зависимости от переданных в форме аргументов пользователю target назначаются соответствующие разрешения. Поскольку форма защищена csrf-жетоном, запросы не имеющие соответствующих данных будут прерваны соответствующей ошибкой. Более того, при обработке запроса дополнительно проверяются разрешения текущего пользователя, таким образом без соответствующей сессии набор разрешений изменить не получится.

Теперь можно поэкспериментировать с формой в браузере, чтобы убедиться, что форма работает и разрешения назначаются в соответствии с предложенной логикой.

changed-permissions

7. Поведение профиля

Сейчас с помощью инструментов jQuery я задам профилю пользователя несколько событий, на которые интерфейс страницы будет откликаться. Чтобы модель поведения страниц Selfish была единой, я выделю основные элементы этой модели в отдельные файлы и в процессе разработки буду подключать эти файлы в шаблоны страниц, на которых эти элементы будут использоваться.

Создаю новый файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/js/scroll_panel.js
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю его в редакторе и пишу следующий код.

function scrollPanel(element) {
  $('body,html').animate({scrollTop: element.offset().top});
}

Создаю ещё один файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/js/show_hide.js
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю его в редакторе и пишу код.

function showHideBlock() {
  var $body = $(this).siblings('.block-body');
  var $parent = $(this).parents('.slidable');
  if ($body.is(':hidden')) {
    $body.slideDown('slow');
    scrollPanel($parent);
  } else {
    $body.slideUp('slow');
  }
}

Создаю новый файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/js/show_datetime.js
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю его в редакторе и пишу код.

function formatDateTime(elem, moment) {
  var $datetime = $.trim(elem.text());
  elem.text(moment($datetime).format('LLL'));
}

Создаю новый файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/js/top_flashed.js
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю его в редакторе и пишу код.

function closeTopFlashed() {
  $(this).parents('.top-flashed-block').remove();
}

Создаю ещё один файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/js/main/profile.js
(venv) sadmin@debian:~/workspace/selfish$ 

Теперь открываю файл selfish/templates/profile.html и дописываю в этот файл новый блок scripts.

{% block scripts %}
  {{ super() }}
  {% assets filters='jsmin', output='generic/js/profile.js',
            'js/custom_moment.js', 'js/scroll_panel.js',
            'js/show_hide.js', 'js/show_datetime.js', 'js/top_flashed.js',
            'js/main/profile.js' %}
    <script src="{{ ASSET_URL }}"></script>
  {% endassets %}
{% endblock scripts %}

Открываю файл selfish/static/js/main/profile.js. Этот файл будет определять конечное поведение страницы. В нём мне необходимо определить дополнительные вспомогательные функции, которые будут использоваться только в этом сценарии. Определяю их.

function checkBox(id) {
  var box = $(id);
  if (box.length && !box.is(':checked')) box.prop("checked", true);
}

function uncheckBox(id) {
  var box = $(id);
  if (box.length && box.is(':checked')) box.prop("checked", false);
}

function checkAverage(id) {
  var average = $(id);
  if (average.length) {
    average.on('change', function() {
      if ($(this).is(':checked')) {
        checkBox('#read-journal');
        uncheckBox('#cannot-log-in');
      } else {
        uncheckBox('#administer');
      }
    });
  }
}

При помощи этих функций я буду регулировать поведение флажков формы, которая управляет разрешениями пользователей. Всё готово, чтобы определить модель поведения страницы профиля пользователя. В этот же файл дописываю следующий код.

$(document).ready(function() {
  moment = customizeMoment(moment);
  $('.close').on('click', closeTopFlashed);
  $('.slidable .block-header').on('click', showHideBlock);
  $('.date-field').each(function() {formatDateTime($(this), moment);});
  var $last_seen = $.trim($('.last-seen').text());
  $('.last-seen').text(moment($last_seen).fromNow());

  var $blocker = $('#cannot-log-in');
  if ($blocker.length) {
    $blocker.on('change', function() {
      if ($(this).is(':checked')) {
        uncheckBox('#read-journal');
        uncheckBox('#follow-users');
        uncheckBox('#send-pm');
        uncheckBox('#comment-blog');
        uncheckBox('#write-journal');
        uncheckBox('#police');
        uncheckBox('#administer');
      } else {
        checkBox('#read-journal');
      }
    });
  }

  var $reader = $('#read-journal');
  if ($reader.length) {
    $reader.on('change', function() {
      if ($(this).is(':checked')) {
        uncheckBox('#cannot-log-in');
      } else {
        checkBox('#cannot-log-in');
        uncheckBox('#follow-users');
        uncheckBox('#send-pm');
        uncheckBox('#comment-blog');
        uncheckBox('#write-journal');
        uncheckBox('#police');
        uncheckBox('#administer');
      }
    });
  }

  var $admin = $('#administer');
  if ($admin.length) {
    $admin.on('change', function() {
      if ($(this).is(':checked')) {
        uncheckBox('#cannot-log-in');
        checkBox('#read-journal');
        checkBox('#follow-users');
        checkBox('#send-pm');
        checkBox('#comment-blog');
        checkBox('#write-journal');
        checkBox('#police');
      }
    });
  }

  checkAverage('#follow-users');
  checkAverage('#send-pm');
  checkAverage('#comment-blog');
  checkAverage('#write-journal');
  checkAverage('#police');

});

Запускаю браузер, захожу на страницу профиля и обращаю внимание на интерпретированные даты.

profile-page

Блок разрешений теперь может сворачиваться кликом мыши, а флажки формы получили взаимные связи, и при установке одних флажков изменяется состояние других.

На этом работа с профилем на данном этапе разработки Selfish завершена.

8. Рефакторинг

Прежде чем приступить к разработке следующей функциональной страницы Selfish, мне нужно привести в порядок некоторый код на стороне клиента. Начну с JavaScript.

На текущий момент две функциональный страницы Selfish имеют один и тот же функциональный элемент - часы на главной странице и на странице входа в сервис. И на текущий момент обе страницы имеют два экземпляра одного и того же кода в файлах selfish/static/js/main/index.js и selfish/static/js/auth/auth.js. Почему это плохо? Если будет необходимо как-то изменить этот код, то править придётся сразу два файла. Сейчас я это поправлю. Создаю новый файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/js/today_field.js
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю этот файл в редакторе и пишу следующее.

function showDateTime(moment) {
  return moment().format('LL') + ', ' + moment().format('HH:mm:ss');
}

function renderTodayField(cls, moment) {
  $(cls).text(showDateTime(moment));
  setInterval(
    function() {
      $(cls).each(function() {
        $(this).text(showDateTime(moment));
      });
    }, 1000);
}

Теперь мне необходимо добавить этот файл в шаблоны. Открываю в редакторе шаблон selfish/templates/index.html и привожу блок scripts к следующему виду.

{% block scripts %}
  {{ super() }}
  {% assets filters='jsmin', output='generic/js/index.js',
            'js/custom_moment.js', 'js/today_field.js',
            'js/main/index.js' %}
    <script src="{{ ASSET_URL }}"></script>
  {% endassets %}
{% endblock scripts %}

Открываю в редакторе шаблон selfish/templates/auth/auth_base.html и изменяю блок scripts аналогичным образом.

{% block scripts %}
  {{ super() }}
  {% assets filters='jsmin', output='generic/js/auth.js',
            'js/custom_moment.js', 'js/today_field.js',
            'js/auth/auth.js' %}
    <script src="{{ ASSET_URL }}"></script>
  {% endassets %}
{% endblock scripts %}

Изменения необходимо отразить в целевых сценариях. Открываю файл selfish/static/js/main/index.js и привожу его в следующему виду.

$(document).ready(function() {
  moment = customizeMoment(moment);
  renderTodayField('.today-field', moment);
});

Открываю файл selfish/static/js/auth/auth.js и изменяю его код соответствующим образом.

$(document).ready(function() {
  moment = customizeMoment(moment);
  renderTodayField('.today-field', moment);
});

У меня получились два файла с одним и тем же содержанием, и я оставлю оба, так как в процессе дальнейшей разработки эти файлы получат дополнительный код и перестанут повторять друг друга.

Обязательно проверяю в браузере, что на отредактированных страницах ничего не сломалось.

Второй момент, который меня не устраивает - это таблицы стилей на странице входа. Дело в том, что login содержит форму, и оформление этой формы описано в файле selfish/static/css/auth/form-block.css. Открываю этот файл выделяю в нём и вырезаю в буфер обмена следующий код.

.form-form {
  margin: 0;
}

.form-group {
  margin: 6px 0 0;
}

.form-label {
  width: 25%;
  float: left;
  padding-top: 7px;
}

.checkbox {
  margin: 0;
}

.checkbox label label {
  padding-left: 0;
}

.form-input {
  margin-left: 26%;
}

Затем создаю новый файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/css/labeled-form.css
(venv) sadmin@debian:~/workspace/selfish$ 

И копирую в него код из буфера обмена, а файл selfish/static/css/auth/form-block.css переименовываю.

(venv) sadmin@debian:~/workspace/selfish$ git mv -v selfish/static/css/auth/form-block.css selfish/static/css/auth/auth.css
Переименование selfish/static/css/auth/form-block.css в selfish/static/css/auth/auth.css
(venv) sadmin@debian:~/workspace/selfish$ 

Отражаю изменения в блоке styles шаблона selfish/templates/auth/auth_base.html.

{% block styles %}
  {{ super() }}
  {% assets filters='cssmin', output='generic/css/auth.css',
            'css/base.css', 'css/content-block.css',
            'css/labeled-form.css', 'css/auth/auth.css' %}
    <link rel="stylesheet" type="text/css" href="{{ ASSET_URL }}">
  {% endassets %}
{% endblock styles %}

Проверяю в браузере соответствующую страницу.

login-page

Теперь стили этой формы я смогу использовать на других страницах Selfish.

9. Постраничная фильтрация и отображение данных

Selfish на текущий момент имеет функциональную страницу /admin/society с дифференцированным доступом пользователей.

admin-page

Пришло время разработать для этой страницы соответствующий функционал. Я планирую, что все пользователи, имеющие доступ к этой странице, увидят на ней список всех зарегистрированных в системе пользователей. Этот список будет содержать ссылки на профили пользователей, дату регистрации и название группы. Если в сервисе зарегистрировано большое количество пользователей, то список будет разделён на соответствующее количество страниц, для которых внизу списка будут расположены соответствующие ссылки. Для этого мне понадобится новый функциональный элемент. Создаю новый файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/macros/_admin.html
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю этот файл в редакторе и определяю в нём новый macro.

{% macro pagination_widget(pagination, endpoint) %}
  <ul class="pagination pagination-sm">
    <li {% if not pagination.has_prev %}class="disabled hidden"{% endif %}>
      <a href="{% if pagination.has_prev %}
                 {{ url_for(endpoint, page=pagination.page-1, **kwargs) }}
               {% else %}#{% endif %}">&laquo;</a>
    </li>
    {% for p in pagination.iter_pages() %}
      {% if p %}
        <li {% if p == pagination.page %}class="active"{% endif %}>
          <a href="{{ url_for(endpoint, page=p, **kwargs) }}">{{ p }}</a>
        </li>
      {% else %}
        <li class="disabled">
          <a href="#">&hellip;</a>
        </li>
      {% endif %}
    {% endfor %}
    <li {% if not pagination.has_next %}class="disabled hidden"{% endif %}>
      <a href="{% if pagination.has_next %}
                 {{ url_for(endpoint, page=pagination.page+1, **kwargs) }}
               {% else %}#{% endif %}">&raquo;</a>
    </li>
  </ul>
{% endmacro %}

При помощи этого macro я буду отображать постраничные ссылки.

Список пользователей я тоже опишу при помощи macro, определяю его в этом же файле.

{% macro show_society(items, page, view, format_date) %}
  <div class="content-block slidable {% if page == 1 %}next-block{% endif %}">
    <div class="block-header">
      <h3 class="panel-title">
        {% if view == 1 %}
          Список пользователей сервиса
        {% elif view == 2 %}
          Список найденных по запросу пользователей
        {% endif %}
      </h3>
    </div>
    <div class="block-body">
      {% if items %}
        <table class="society-table">
          <tbody>
            <tr>
              <th>Псевдоним</th>
              <th>Группа</th>
              <th class="text-right">Зарегистрирован</th>
            </tr>
            {% for user in items %}
              <tr {% if not loop.last %}class="bordered"{% endif %}>
                <td class="username-field">
                  <a href="{{ url_for(
                              'main.show_profile', username=user.username) }}">
                    {{ user.username }}</a>
                </td>
                <td>{{ user.group }}</td>
                <td class="date-field text-right">
                  {{ format_date(user.registered) }}
                </td>
              </tr>
            {% endfor %}
          </tbody>
        </table>
      {% else %}
        <div class="alert alert-warning">
          В сервисе нет активных аккаунтов.
        </div>
      {% endif %}
    </div>
  </div>
{% endmacro %}

Открываю файл selfish/admin/views.py и переписываю функцию представления show_users следующим образом.

from flask import current_app, render_template, request
from flask_login import current_user, login_required

...
from ..models.auth import User
...


@admin.route('/society')
@login_required
@permission_required(permissions.FOLLOW_USERS)
def show_users():
    pagination = User.query.filter(User.username != current_user.username)\
        .order_by(User.last_visit.desc()).paginate(
        page=request.args.get('page', 1, type=int),
        per_page=current_app.config.get('USERS_PER_PAGE', 3),
        error_out=True)
    return render_template('admin/society.html', pagination=pagination)

Открываю файл шаблона selfish/templates/admin/society.html и переписываю полностью блок page_content в этом шаблоне.

{% block page_content %}
  <!-- admin/society.html -->
  {% set messages = get_flashed_messages() %}
  {% if messages %}
    <div class="top-flashed-block">
      {{ get_message(messages) }}
    </div>
  {% endif %}
  {% if pagination.page == 1 %}
    <h4>Страница в стадии разработки</h4>
  {% endif %}
  <div id="center-panel">
    {{ show_society(pagination.items, pagination.page, 1, format_date) }}
    {% if pagination.has_prev or pagination.has_next %}
      <div class="next-block entity-pagination text-center">
        {{ pagination_widget(pagination, 'admin.show_users') }}
      </div>
    {% endif %}
  </div>
{% endblock page_content %}

Поскольку в этом коде использованы новые macro, их нужно импортировать, дописываю импорты вверху шаблона.

{% extends "base.html" %}
{% from "macros/_admin.html" import pagination_widget %}
{% from "macros/_admin.html" import show_society %}
{% from "macros/_auth.html" import get_message %}

Теперь можно посмотреть эту страницу в браузере.

society-list

Выглядит ужасно, над внешним видом поработаю чуть позже, но функциональные возможности страницы работают уже сейчас. Список зарегистрированных пользователей (всего 5 пользователей на текущий момент) разделён на две страницы, в нём для каждого пользователя имеется ссылка на профиль пользователя, внизу страницы имеется виджет постраничного деления с соответствующими ссылками, текущий пользователь в этом списке отсутствует, а другие пользователи показаны в порядке следования в соответствии с их временной меткой last_seen. На каждой странице будет отображаться не более 3-х пользователей. Это число можно регулировать. Открываю файл config.py и дописываю классу Production новое свойство.

class Production(Config):
    SEND_FILE_MAX_AGE_DEFAULT = int(timedelta(days=28).total_seconds())
    USERS_PER_PAGE = 30        # новое свойство
    SQLALCHEMY_DATABASE_URI = \
        'postgresql+psycopg2://{0}:{1}@192.168.56.102/selfish'.format(
            os.getenv('SDBU', 'sadmin'),
            os.getenv('SDBP', 'aa'))

Именно это свойство определяет, сколько пользователей в списке будет отображаться на одной странице.

10. Регистрация аккаунтов администратором сервиса

Администратор Selfish - персона могущественная, он может изменять разрешения пользователей. Сейчас я дам администратору возможность создавать аккаунты пользователей непосредственно в web-интерфейсе системы.

Для создания аккаунта пользователя администратором системы мне потребуется соответствующая форма. Создаю новый файл.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/admin/forms.py
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю этот файл в редакторе и создаю в нём новую форму.

from flask_wtf import FlaskForm
from wtforms.fields import StringField, PasswordField, SubmitField
from wtforms.validators import (
    DataRequired, Email, EqualTo, Length, Regexp, ValidationError)

from ..models.auth import Account, User


class CreateUser(FlaskForm):
    username = StringField(
        'Псевдоним:',
        validators=[DataRequired(message='без псевдонима никак, увы'),
                    Length(min=3, max=16,
                           message='от 3-х до 16-ти знаков'),
                    Regexp(r'^[A-Za-z][a-zA-Z0-9\-_.]*$',
                           message='латинские буквы, цифры, дефис, знак \
                                   подчёркивания, точка, первый символ - \
                                   латинская буква')])
    address = StringField(
        'Адрес эл.почты:',
        validators=[DataRequired(message='без почты никак, увы'),
                    Email(message='нужен адрес электронной почты'),
                    Length(max=128,
                           message='максимальная длина адреса - 128 знаков')])
    password = PasswordField(
        'Новый пароль:',
        validators=[DataRequired(message='без пароля никак, увы'),
                    EqualTo('confirmation', message='пароли не совпадают')])
    confirmation = PasswordField(
        'Повторите:',
        validators=[DataRequired(message='без пароля никак, увы'),
                   EqualTo('password', message='пароли не совпадают')])
    submit = SubmitField('Создать')

    def validate_username(self, field):
        if User.query.filter_by(username=field.data).first():
            raise ValidationError('этот псевдоним уже используется')

    def validate_address(self, field):
        account = Account.query.filter_by(address=field.data).first()
        swapped = Account.query.filter_by(swap=field.data).first()
        if (account and account.user) or swapped:
            raise ValidationError('этот адрес уже используется')

Здесь следует обратить внимание, что форма имеет достаточно широкий набор валидаторов, которые ограничивают ввод пользователем в эту форму неверных данных. Теперь достаточно импортировать эту форму в файле selfish/admin/views.py и правильно её применить в функции представления show_users.

from flask import (
    current_app, flash, redirect, render_template, request, url_for)
...
from .. import db
...
from ..models.auth import Account, Permission, User
...
from .forms import CreateUser


@admin.route('/society', methods=['GET', 'POST'])
@login_required
@permission_required(permissions.FOLLOW_USERS)
def show_users():
    form = None
    if current_user.can(permissions.ADMINISTER):
        form = CreateUser()
        if form.validate_on_submit():
            account = Account.query.filter_by(address=form.address.data).first()
            if not account:
                account = Account(address=form.address.data)
            user = User(
                username=form.username.data,
                password=form.password.data,
                permissions=[permission.permission for permission in
                             Permission.query.filter_by(default=True)])
            user.account = account
            db.session.add(user)
            db.session.commit()
            flash('Создан аккаунт пользователя {0}.'.format(user.username))
            return redirect(
                url_for('main.show_profile', username=user.username))
    pagination = User.query.filter(User.username != current_user.username)\
        .order_by(User.last_visit.desc()).paginate(
        page=request.args.get('page', 1, type=int),
        per_page=current_app.config.get('USERS_PER_PAGE', 3),
        error_out=True)
    return render_template(
        'admin/society.html', pagination=pagination, form=form)

Логика достаточно проста. Создаётся переменная form в значении None. Если текущий пользователь имеет разрешение ADMINISTER, то переменная form становится экземпляром класса CreateUser. Функция преставления получает возможность обрабатывать запросы по методу POST. Если форма в запросе POST проходит валидацию без ошибок, то создаётся аккаунт пользователя и пользователь в соответствии с данными переданными в форме POST-запроса, созданный пользователь получает разрешения заданные в соответствующей таблице базы данных с меткой default. В завершение этих действий система перенаправляет текущего пользователя на страницу профиля созданного пользователя, где ему можно назначить новый набор разрешений.

Чтобы форма появилась на странице, её следует интерпретировать в шаблоне. Открываю файл selfish/templates/admin/society.html, нахожу в этом шаблоне такой блок.

  {% if pagination.page == 1 %}
    <h4>Страница в стадии разработки</h4>
  {% endif %}

Полностью переписываю этот блок.

  {% if pagination.page == 1 %}
    {% if form %}
      <div class="content-block slidable">
        <div class="block-header">
          <h3 class="panel-title">Создайте новый аккаунт пользователя</h3>
        </div>
        <div class="block-body to-be-hidden">
          <div class="today-field"></div>
          <div class="form-help">
            <p>
              Здесь можно создать новый аккаунт пользователя, для этого
              заполните форму ниже. Имейте ввиду, псевдоним должен быть
              уникальным, может осдержать от 3-х до 16-ти знаков, включая
              латинские буквы, цифры, знак подчёркивания, дефис, точку и должен
              начинаться с латинской буквы. Адрес электронной почты нового
              ползователя должен умещаться в 128 знаков, адрес должен быть
              уникальным и незарегистрированным в этом сервисе.
            </p>
          </div>
          <div class="form-form">
            <form class="form" method="POST" role="form">
              {{ form.hidden_tag() }}
              {{ render_form_group(
                  form, form.username, 'username', 'введите псевдоним') }}
              {{ render_form_group(
                  form, form.address, 'address', 'введите адрес эл. почты') }}
              {{ render_form_group(
                  form, form.password, 'password', 'введите новый пароль') }}
              {{ render_form_group(
                  form, form.confirmation,
                  'confirmation', 'повторите пароль') }}
              <div class="form-group">
                <div class="form-input">
                  {{ form.submit(class="btn btn-primary btn-block") }}
                </div>
              </div>
            </form>
          </div>
        </div>
      </div>
    {% endif %}
    {% if pagination.has_next %}
      <div class="content-block find-username
                  {% if form %}next-block{% endif %}">
        <div class="form-label text-right">
          <label for="username-input">Найти по имени:</label>
        </div>
        <div class="form-input">
          <input type="text"
                 id="username-input" name="username-input"
                 class="form-control"
                 placeholder="вводите псевдоним пользователя">
        </div>
      </div>
    {% endif %}
  {% endif %}

Дописываю ещё один импорт к существующим в шаблоне импортам.

{% from "macros/_auth.html" import render_form_group %}

На текущий момент страница получила все задуманные функциональные элементы. Можно заняться её оформлением. На странице появился постраничный виджет, создаю для этого виджета файл стилей.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/css/pagination.css
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю его в редакторе и пишу следующие правила.

.entity-pagination {
  border-radius: 3px;
  border: 1px solid #d3ddd3;
  box-shadow: 0 0 6px #f1dbc2;
  background: linear-gradient(to bottom, #fffef2, #f3f2e7);
}

.pagination {
  margin: 4px 0 0 0;
}

Создаю ещё один файл стилей.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/static/css/admin
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/css/admin/society.css
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю его в редакторе и пишу следующие правила.

.find-username {
  padding: 12px;
}

.society-table {
  width: 100%;
  border-collapse: collapse;
}

.society-table th {
  border-bottom: 2px solid #dddddd;
  padding: 8px 4px;
}

.username-field {
  text-shadow: 0 0 3px #d9dcec;
}

.society-table td {
  padding: 8px 4px 4px;
}

.bordered {
  border-bottom: 1px dashed #dddddd;
}

.date-field {
  font-style: italic;
}

Немного JavaScript совсем не помешает. Создаю файл нового сценария.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/static/js/admin
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/js/admin/society.js
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю его в редакторе и пишу следующий код.

$(document).ready(function() {
  moment = customizeMoment(moment);
  renderTodayField('.today-field', moment);
  $('.slidable .block-header').on('click', showHideBlock);
  var $form = $('.to-be-hidden');
  if ($form.find('.error').length) {
    $form.siblings('.block-header').trigger('click');
  }
  $('.date-field').each(function() {formatDateTime($(this), moment);});
  $('.close').on('click', closeTopFlashed);
});

Чтобы всё это великолепие заработало как задумано, переписываю в шаблоне selfish/templates/admin/society.html блок styles.

{% block styles %}
  {{ super() }}
  {% assets filters='cssmin', output='generic/css/society.css',
            'css/base.css', 'css/content-block.css', 'css/pagination.css',
            'css/labeled-form.css', 'css/top-flashed.css',
            'css/admin/society.css' %}
    <link rel="stylesheet" type="text/css" href="{{ ASSET_URL }}">
  {% endassets %}
{% endblock styles %}

И дописываю в конец шаблона блок scripts.

{% block scripts %}
  {{ super() }}
  {% assets filters='jsmin', output='generic/js/society.js',
            'js/custom_moment.js', 'js/today_field.js',
            'js/scroll_panel.js', 'js/show_hide.js', 'js/show_datetime.js',
            'js/top_flashed.js', 'js/admin/society.js' %}
    <script src="{{ ASSET_URL }}"></script>
  {% endassets %}
{% endblock scripts %}

Загружаю страницу в браузере, навожу мышь на блок Создайте новый аккаунт пользователя и делаю одиночный клик.

society-page

И получаю в своё распоряжение форму для создания аккаунта нового пользователя.

society-form

Форма на этой странице будет доступна только и исключительно администраторам Selfish, то есть пользователям, имеющим разрешение ADMINISTER.

Ещё не конец. Далее я научу Selfish находить пользователей по первым символам псевдонима по мере их ввода в строке Найти по имени.

11. Поиск пользователей по псевдониму

Список зарегистрированных пользователей в зависимости от популярности ресурса может расти, а в связи с тем, что сортировка в этом списке осуществляется не по псевдониму пользователя, желательно иметь возможность автоматического поиска и фильтрации списка пользователей по начальным символам псевдонима.

На странице /admin/society имеется функциональный элемент для ввода псевдонима пользователей. На данный момент ввод в это поле не сопровождается никаким полезным событием. Сейчас я научу Selfish искать пользователей по псевдониму в соответствии с символами введёнными в это поле.

Создаю новую подпрограмму ajax.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/ajax
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/ajax/__init__.py
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/ajax/views.py
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю файл selfish/ajax/__init__.py и создаю в нём новый экземпляр класса Blueprint.

from flask import Blueprint

ajax = Blueprint('ajax', __name__)

from . import views

Регистрирую эту подпрограмму в функции create_app файла selfish/__init__.py.

...


def create_app(config_name):
    ...
    from .ajax import ajax as ajax_blueprint
    app.register_blueprint(ajax_blueprint, url_prefix='/ajax')
    return app

Открываю файл selfish/ajax/views.py и определяю в нём новую функцию представления.

from flask import jsonify, render_template, request
from flask_login import current_user

from ..models.auth import User
from ..models.auth_units import permissions
from . import ajax


@ajax.route('/society/find-username', methods=['POST'])
def find_username():
    result = {'empty': True}
    found = User.query.filter(User.username.like(
        '{}%'.format(request.form.get('value', None, type=str)))).order_by(
        User.last_visit.desc())
    if current_user.can(permissions.FOLLOW_USERS):
        result = {'empty': False,
                  'html': render_template('ajax/find-username.html',
                                          found=found)}
    return jsonify(result)

Логика этой функции представления достаточно проста. Создаётся объект result в значении словаря с единственным ключом empty в значении True. Обрабатывается запрос, а функция может обрабатывать только запросы по методу POST, запросы по методу GET будут возвращать ошибку 405 в соответствии с заданным этой функции преставления url. Из формы запроса находится значение value и по этому значению из базы данных фильтруются пользователи, начальные символы псевдонима которых соответствуют символам переданным в форме запроса (value). Затем проверяется разрешение текущего пользователя FOLLOW_USERS, и если он имеет такое разрешение, то объект result переопределяется соответствующим образом. Затем объект result преобразуется в JSON и возвращается.

Создаю шаблон для этой функции представления.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/templates/ajax
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/ajax/find-username.html
(venv) sadmin@debian:~/workspace/selfish$ 

Этот шаблон будет иметь следующий вид.

{% from "macros/_admin.html" import show_society %}

{% if found.first() %}
  {{ show_society(found, 1, 2, format_date) }}
{% else %}
  <div class="content-block next-block">
    <div class="block-header">
      <h3 class="panel-title">Найдено по запросу</h3>
    </div>
    <div class="block-body">
      <div class="alert alert-warning">
        Нет пользователей с таким псевдонимом.
      </div>
    </div>
  </div>
{% endif %}

Теперь мне необходимо связать соответствующее поле ввода с созданной функцией представления. Для этого открываю в редакторе шаблон selfish/templates/admin/society.html, нахожу в нём такой код.

          <input type="text"
                 id="username-input" name="username-input"
                 class="form-control"
                 placeholder="вводите псевдоним пользователя">

И дописываю этому элементу новые атрибуты.

          <input type="text"
                 id="username-input" name="username-input"
                 data-tee="{{ csrf_token() }}"
                 data-url="{{ url_for('ajax.find_username') }}"
                 data-home="{{ url_for('admin.show_users') }}"
                 class="form-control"
                 placeholder="вводите псевдоним пользователя">

Здесь data-tee - csrf-брелок, data-url - url функции представления созданной только что, data-home - url страницы для отката поиска.

Осталось привязать к этому элементу соответствующее событие. Открываю файл selfish/static/js/admin/society.js и внутри фигурных скобок формирую новое событие.

$(document).ready(function() {
  ...

  $('#username-input').on('keyup', function(event) {
    var list = [0, 8, 9, 13, 17, 18, 20, 27, 32, 33, 34, 35, 36, 37, 38, 39,
                40, 45, 46, 91, 93, 144];
    var $value = $(this).val();
    var home = $(this).data().home;
    if ($.inArray(event.which, list) == -1 ||
        ((event.which == 8 || event.which == 46) && $value != '')) {
      $.ajax({
        method: "POST",
        url: $(this).data().url,
        data: {
                value: $value,
                csrf_token: $(this).data().tee,
              },
        success: function(data) {
          if (!data.empty) {
            $('#center-panel').empty().append(data.html);
            $('.date-field').each(function() {
              formatDateTime($(this), moment);
            });
          }
        },
        error: function(data) {
          var result = $.trim($(data.responseText).find('h1').text());
          if (result === 'CSRF Error') {
            var html = '<div class="content-block next-block">' +
                       '  <div class="block-header">' +
                       '    <h3 class="panel-title">' +
                              result +
                       '    </h3>' +
                       '  </div>' +
                       '  <div class="block-body">' +
                       '    <div class="alert alert-warning">' +
                       'Обновите страницу в браузере и повторите попытку.' +
                       '    </div>' +
                       '  </div>' +
                       '</div>';
            $('#center-panel').empty().append(html);
          }
        },
        dataType: 'json'
      });
    }
    if ((event.which == 46 || event.which == 8) && $value == '') {
      window.location.replace(home);
    }
  });
});

Таким образом при отпускании пользователем соответствующих клавиш клавиатуры будет осуществляться AJAX-запрос к заданному url-адресу с заданными данными. Открываю браузер, захожу на страницу.

user-search

Начинаю последовательно вбивать символы в поле Найти по имени:.

user-search

user-search

user-search

Если полностью очистить поле и нажать Backspace или Delete страница вернётся к прежнему состоянию.

Если в браузере проследовать по url-адресу функции представления find-username, то система покажет соответствующую ошибку.

405

Вообще, можно в этой функции обработать и GET-запрос, но в рамках этого описания этого не требуется.

12. Продолжение следует

Этот этап разработки Selfish завершен. Мне осталось сохранить изменения в git. Не забываю скопировать config.py - он изменился.

(venv) sadmin@debian:~/workspace/selfish$ cp config.py config.template.py 
(venv) sadmin@debian:~/workspace/selfish$ 

Текущая версия кода Selfish доступна на github.com.

Продолжение следует. В следующей серии я дам возможность пользователям Selfish самостоятельно регистрироваться в сервисе и восстанавливать забытый пароль. У Selfish появится очередь задач и другие интересные детали.

 
Осталось: 14
Комментарии: