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

от автора

Одним из главных преимуществ фреймворков является их предопределённая архитектура. Открываешь незнакомый проект и сразу знаешь, где и как искать код связи с БД, или 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])      # Выполнение 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'), 

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

ссылка на оригинал статьи http://habrahabr.ru/post/185180/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *