Как стать автором
Обновить

Когда встроенного MVC не хватает

Время на прочтение8 мин
Количество просмотров9.5K
Одним из главных преимуществ фреймворков является их предопределённая архитектура. Открываешь незнакомый проект и сразу знаешь, где и как искать код связи с БД, или HTML, или схему url. Кроме того, она позволяет разработчику не задумываться над структурой хранения кода и при этом быть уверенным, что проект будет выглядеть более менее адекватно. Но хочу рассказать о случае, когда реализация MVC в Django, а именно распределение логики по файлам models, forms, views, templates оказалась неудобной и какую на её основе построили альтернативу.

Встала у нас задача сделать движок для статистической отчетности на Django. Мы создали селекторы для получения данных из Oracle и виджеты для отображения этих данных в виде таблиц или графиков (с помощью HighChart). Но это всё чисто технологические решения, без особой магии. Если появятся интересующиеся, расскажу в отдельном посте. А сейчас хотелось бы обратить внимание на более необычную часть проекта. На предоставление составителям отчетов удобного способа эти отчеты составлять.

Здесь есть несколько моментов:
  1. С одной стороны, составители отчетов находятся с нами в одном отделе, то есть внутренности проекта им показывать в принципе можно. С другой стороны, они хорошо владеют SQL, чуть-чуть HTML и совсем никак Python’ом, и уж тем более не Django.
    Значит, нужно по возможности избавить их от нагрузки на мозг в виде освоения архитектуры фреймворка. Кроме того, нужно поместить их творчество в песочницу, чтобы никакие ошибки не влияли на работоспособность системы в целом.
  2. На каждой странице должно размещаться несколько отчетов в достаточно произвольном виде. Страниц очень много и они обычно никак не связаны между собой (ну разве что источниками в БД)
    Если распихать логику одного отчета по разным файлам, то получим огромные файлы, по которым нужно искать отчёт по кусочкам.


    А надо бы иметь возможность открыть «нечто» и увидеть перед собой всю логику построения отчета от и до.

  3. Нужна возможность оперативной правки отчета без перезагрузки Django.
  4. Желательно обеспечить возможность совместной работы и отслеживания изменений в отчетах.

Был вариант хранить настройки отчётов в базе. Но отслеживать изменения гораздо легче в системе управления версиями, чем в БД. К тому же, заранее было понятно, что движок будет развиваться, а менять схему данных, пожалуй, самое болезненное для любой системы.
Значит, файлы. Которые движок будет читать и что-то на их основе выполнять. Формат предполагался разный. И JSON, и ini, и выдумать какой-то свой. XML был отметён сразу, как трудночитаемый. Но в один из вечеров меня осенило – а чем сам Python плох? Настройка выглядит ничем не сложней, даже для человека незнакомого с языком совсем (разве что, две первые строки покажутся ему магическими):
# -*- coding: utf-8 -*-
from statistics import OracleSelect, Chart, Table

select_traf = OracleSelect('user/password@DB',
                           """select DAY, NSS_TRAF, BSS_TRAF
                              from DAY_TRAFFIC
                              where DAY >= trunc(sysdate,'dd')-32""")

chart_traf = Chart(selector=select_traf,
                   x_column='DAY',
                   y_columns=[('NSS_TRAF', u'NSS траффик'),
                              ('BSS_TRAF', u'BSS траффик')])

table_traf = Table(selector=select_traf,
                   columns=['DAY', 'NSS_TRAF',  'BSS_TRAF'])

template = """
{{ chart_traf }}
{{ table_traf }}
"""
На самом деле, для виджетов Chart и Table опций гораздо больше, но я не вижу смысла в демонстрационном коде перечислять их все.

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

Чтение(выполнение) настроечного файла

Вот как в самом простом виде выглядит «интерпретатор» настроечных файлов.
import os
from django.template import RequestContext, Template
from django.http import HttpResponse, Http404
from settings import PROJECT_ROOT # корневая папка проекта, вычисляется из переменной __file__ в файле settings.py

def dynamic_page(request, report_path):
    ctx_dict = {}

    execfile(os.path.join(PROJECT_ROOT, 'reports', report_path + '.py'), ctx_dict)

    templ_header = '{% extends "base.html" %}{% block content %}'
    templ_footer = '{% endblock %}'
    template = Template(templ_header + ctx_dict['template'] + templ_footer)

    context = RequestContext(request)
    context.autoescape = False
    context.update(ctx_dict)

    return HttpResponse(template.render(context))

Выполняем с помощью execfile настроечный файл. Все переменные созданные в скрипте будут находиться в словаре ctx_dict. Берём содержимое переменной template и составляем полноценный шаблон, в который передаём стандартный RequestContext и свежесозданный контекст из того же скрипта.
В urls.py добавляем
(r'^reports/(?P<report_path>.+)$', 'statistics.views.dynamic_page'),

Передача контекста в отчет и из отчета

Передача произвольного словаря в качестве пространства имён для исполняемого скрипта открывает интересные возможности.
Например, нам понадобилось в настроечном файле обращаться к get-параметрам запроса. Для этого нужно просто изменить ctx_dict перед тем как передать его в execfile
def dynamic_page(request, report_path):
    ctx_dict = {'get': request.GET.get}
    ...

Теперь в настроечном файле безо всяких импортов будет доступна функция get, которая достаёт значение нужного параметра из текущего request’а. Собственно, импорты тут бы и не помогли, поскольку request каждый раз новый.
В то же время, понадобилась и пост-обработка полученных из настроечного файла данных. Например, появилась необходимость присвоить каждому графику html-id в соответствии с его именем. Это нужно для того, чтобы в javascript напечаталось то же имя, что и в питоне (для взаимодействия графиков друг с другом). Конечно, можно это решить ещё одним параметром в Chart, но не очень кошерно постоянно писать что-то в стиле
chart_name = Chart(select, x_col, config, ..., html_id='chart_name')

Лучше уж не напрягать пользователей движка его внутренностями, а назначать нужные id автоматически, уже после формирования ctx_dict в execfile.
    ...
    execfile(os.path.join(PROJECT_ROOT, 'reports', report_path + '.py'), ctx_dict)

    for (name, obj) in ctx_dict.items():
        if isinstance(obj, (Chart, Table)):
            obj.html_id = name
    ...

Есть ещё один интересный момент с ctx_dict. Так как все его значения попадают в контекст шаблона, они переписывают одноименные, переданные из RequestContext. Например, если какой-то контекстный процессор вычисляет значение 'TITLE' для помещения его в заголовок страницы, то вы можете в своём настроечном файле вычислить своё и оно будет выводиться вместо существующего
bs = get('bs')
if bs is not None:
    TITLE = u'Трафик на БС %s' % bs

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

Масштабирование до других url и базовых шаблонов

В конце концов дошло до того, что на Портале понадобилось размещать несколько разделов со статистикой. Разумеется, они были немного по разному оформлены и требовали немного разной логики, ну и нам самим было удобно хранить группы отчетов по отдельности.
Значит dynamic_page должен стать из простой вьюхи генератором вьюх. Что и было сделано.
import os
from django.template import RequestContext, Template
from django.http import HttpResponse, Http404
from settings import PROJECT_ROOT
from functools import partial

def get_param(request, key=None, default=None, as_list=False):
    if key:
        if as_list:
            return request.GET.getlist(key)
        else:
            return request.GET.get(key, default)
    else:
        return request.GET.lists()

class DynamicPage(object):
    "Генератор вьюх для динамического выполнения настроечных питонских файлов"

    # Создание view
    def __init__(self,
                 subpath, # Путь, от корня проекта, в котором нужно искать настроечные файлы
                 parent_template = "base.html",
                 load_tags = (), # список библиотек шаблонных тэгов
                 block_name = 'content',
                 pre_calc = lambda request, context: None, # заполнение контекста перед выполнением execfile
                 post_calc = lambda request, context: None): # обработка контекста после выполнения execfile
        self.templ_header = ('{% extends "' + parent_template + '" %}' +
                             DynamicPage.loading_tags(load_tags) +
                             DynamicPage.block_top(block_name))
        self.templ_footer = DynamicPage.block_foot(block_name)
        self.subpath = subpath
        self.pre_calc = pre_calc
        self.post_calc = post_calc

    @staticmethod
    def block_top(block_name):
        if block_name:
            return "{% block " + block_name + " %}"
        else:
            return ''

    @staticmethod
    def block_foot(block_name):
        if block_name:
            return "{% endblock %}"
        else:
            return ''

    @staticmethod
    def loading_tags(tags):
        return ''.join(['{% load ' + tag + ' %}' for tag in tags])

    @property
    def __name__(self):
        return self.__class__.__name__

    # Выполнение view
    def __call__(self, request, pagepath):
        ctx_dict = self.get_context(request, pagepath)

        if 'response' in ctx_dict and isinstance(ctx_dict['response'], HttpResponse):
            return ctx_dict['response'] # возможность возвращать напрямую response вместо обработки шаблона
            # Актуально для всякого рода экспортов, основывающихся на тех же вычислениях, что и html-страница
        else:
            template = Template(self.templ_header + ctx_dict['template'] + self.templ_footer)

            context = RequestContext(request)
            context.autoescape = False
            context.update(ctx_dict)

            return HttpResponse(template.render(context))

    def get_context(self, request, pagepath):
        fullpath = os.path.join(PROJECT_ROOT, self.subpath, pagepath + '.py')

        if not os.path.exists(fullpath):
            raise Http404

        ctx_dict = {'get': partial(get_param, request), 'request': request}

        self.pre_calc(request, ctx_dict)
        execfile(fullpath, ctx_dict)
        self.post_calc(request, ctx_dict)
        return ctx_dict

Это позволило создавать оболочки для разных разделов отчетности. Ими занимались программисты. Принципы же изготовления отчётов при этом не менялись.

Например, в одном случае понадобились упомянутые выше игры с html_id.
def add_html_id(request, context):
    for (name, obj) in context.items():
        if isinstance(obj, (Chart, Table)):
            obj.html_id = name

show_report = DynamicPage('stat_tech/pages',
                          parent_template='stat_tech/base.html',
                          load_tags=['adminmedia', 'jquery', 'chapters'],
                          post_calc=add_html_id)

В другом, заполнять из настроечного файла не один блок шаблона, а два.
show_weekly = DynamicPage('stat_weekly/pages',
                          parent_template = 'stat_weekly/base.html',
                          load_tags = ['chapters', ' employees'],
                          block_name=None)

В этом случае, блоки указываются в самом файле с отчётом
template = """
{% block chart %}
{{ costs_monthly }}
{{ costs_weekly }}
{% endblock %}
{% block responsible %}
{% employee vasily_pupkin %}, {% employee ivan_ivanov %}
{% endblock %}
"""

В третьем, узнавать подразделение, в котором работает текущий пользователь, чтобы на его основе определять, как и какие запросы выполнить, а так же, в каком виде показывать подменю.
def add_division(request, context):
    div = Division.get_by_user(request.user)
    context['DIVISION'] = div
    context['SUBMENU'] = calc_goal_submenu(request.path, div)

show_goal = DynamicPage('stat_goals/pages',
                        load_tags = ['chapters'], 
                        block_name='report',
                        parent_template = 'stat_goals/base.html',
                        pre_calc = add_division)

В urls все эти обертки добавляются как обычные вьюшки
    (r'^stat/(?P<pagepath>.+)$', 'stat_tech.views.show_report'),
    (r'^weeklyreport/(?P<pagepath>.+)$', 'stat_weekly.views.show_weekly'),
    (r'^goals/(?P<pagepath>.+)$', 'stat_goals.views.show_goal'),


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

UPD: По предложению magic4x, в DynamicPage добавлено свойство __name__, усиливающее мимикрию под функцию.
Теги:
Хабы:
Всего голосов 22: ↑20 и ↓2+18
Комментарии5

Публикации

Истории

Работа

Python разработчик
121 вакансия
Data Scientist
78 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань