Пишем web на Python3, управляем разрешениями пользователя

newbie

Опубликован:  2019-04-02T14:04:59.937934Z
Отредактирован:  2019-04-04T12:11:43.365816Z
Продолжаем работу над проектом selfish. В этом обзоре я займусь администрированием пользователей приложения и начну разработку профиля пользователя, создам инструменты, которые позволят администратору сервиса управлять разрешениями пользователей непосредственно в web-интерфейсе, а у приложения появится ещё одна функциональная страница с новыми возможностями - профиль пользователя.

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

Selfish - web-приложение на базе Python3 и Flask, начальная конфигурация и разработка которого детально описаны в предыдущих выпусках этого блога. Все посвященные разработке этого приложения выпуски можно отфильтровать по метке selfish.

2. Создаём профиль пользователя

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

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

XQvmG30LC1.png

Здесь следует обратить внимание, что функция представления 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. Таким образом хозяин профиля получит доступ к странице, даже если не имеет соответствующего разрешения.

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

touch selfish/templates/profile.html

Замечание: все консольные команды в рамках этого описания я буду выполнять в терминале с активированным виртуальным окружением selfish и находясь в базовом каталоге приложения.

nNb1MpQmwI.png

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

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

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

{% block styles %}
  {{ super() }}
  {% assets filters='cssmin', output='generic/css/main/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-адрес в уже существующую ссылку в базовом шаблоне, для этого открываю файл базового шаблона и нахожу в нём соответствующий тег.

FNV90cpSyH.png

Дописываю в этот тег url-адрес профиля пользователя.

                <li>
                  <a href="{{ url_for(
                      'main.show_profile', username=current_user.username) }}">
                    Профиль
                  </a>
                </li>

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

Fw45mpGTTi.png

A6UvrJbQ8u.png

7Nah8PBTPI.png

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

aSLntUSBC5.png

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

3. Форматируем временные метки

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

touch selfish/main/tools.py

Открываю его в редакторе и вписываю новую функцию.

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

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

BmMgCPEyYA.png

Эта простая функция и поможет мне правильно форматировать временные метки в шаблонах.

4. Оформляем профиль пользователя

Займёмся профилем, возвращаюсь в редактор к файлу 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>

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

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

touch selfish/static/css/avatar.css

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

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

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

touch selfish/static/css/top-flashed.css

Открываю его в редакторе и даю ему такой код.

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

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

touch selfish/static/css/main/profile.css

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

.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 шаблона profile.html.

{% block styles %}
  {{ super() }}
  {% assets filters='cssmin', output='generic/css/main/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 %}

Иду в браузер и смотрю, что у меня получилось в итоге.

aiuGxjqfms.png

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

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

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

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

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

touch selfish/templates/macros/_main.html

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

{% 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 %}

Импортирую в шаблоне profile.html созданный macro.

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

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

  {% 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 %}

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

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

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

.checkbox {
  margin: 0;
}

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

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

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

Возвращаюсь в браузер, вхожу администратором и открываю профиль любого пользователя вводом соответствующего url-адреса в адресной строке браузера.

EEtS1TtKpI.png

На данный момент полученная форма не имеет продолжения в серверной логике и, в сущности, является бутафорией, но это очень легко исправить. Чтобы определить серверную логику для изменения набора разрешений пользователя, мне понадобится ещё один служебный объект. Открываю в редакторе файл 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}

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

...
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-жетоном, запросы, не имеющие соответствующих данных, будут прерваны обработчиком соответствующей ошибки. Более того, при обработке запроса дополнительно проверяются разрешения текущего пользователя, таким образом, без соответствующей сессии набор разрешений изменить не получится.

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

m4vUtYmaSa.png

s4WhgcdlwI.png

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

6. Настраиваем поведение профиля

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

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

touch selfish/static/js/scroll_panel.js

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

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

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

touch selfish/static/js/show_hide.js

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

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

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

touch selfish/static/js/show_datetime.js

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

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

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

touch selfish/static/js/top_flashed.js

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

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

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

touch selfish/static/js/main/profile.js

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

{% block scripts %}
  {{ super() }}
  {% assets filters='jsmin', output='generic/js/main/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 %}

Открываю файл profile.js, этот файл будет определять конечное поведение страницы профиля и будет использовать функции из только что созданных файлов, добавленных в асеты шаблона в блоке scripts. Кроме этого, в этом файле мне необходимо определить дополнительные вспомогательные функции, которые будут использоваться только в этом сценарии. Определяю их.

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

});

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

ddlV0eej21.png

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

zGJaGIyZYg.png

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

itQLAzAU53.png

F7yxloKFNW.png

Осталась одна маленькая деталь, мне хочется, чтобы при входе в профиль пользователя блок разрешений находился в свёрнутом состоянии. В шаблоне profile.html нахожу нужный тег и дописываю этому тегу ещё один класс - to-be-hidden.

nlfICmaZGb.png

Возвращаюсь в браузер и обновляю страницу профиля.

cIbOx9b2ur.png

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

7. Подводим итоги

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

Текущее состояние кода проекта как всегда можно увидеть в моём профиле на gitlab.com. В случае необходимости, вопросы мне можно задать в комментариях ниже, я с удовольствием на них отвечу.

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

Комментарии: