В нашем Django-приложении необходимо было разработать отчет (расчет) бонусов.
Отчет должен иметь вложенную структуру с подведением итогов по пользователям, подразделениям и по всей компании. Схематично его логику можно представить:

print total
for department in departments:
    print department.total
    for user in department.users:
        print user.total
        for row in user.rows:
            print row.data

У этого отчета было два осложняющих момента:

  1. В роли "row" могли выступать разные модели (и располагаться вперемежку), что не позволяет использовать итераторы по QuerySet'ам.
  2. Время построение отчета. Сбор данных занимает существенное время (несколько секунд). Данные в отчете могут меняться. Говоря на чистоту, это не статический отчет, а инструмент для контроля и корректировки начисленных бонусов в виде отчета. Но данные меняются не очень часто, скажем на каждые 100 просмотров придется одно изменение, после которого нужно перестроить отчет. Т.е. данные можно кэшировать.

Структура из вложенных словарей отлично решает обе задачи: в них можно сложить все требуемые скаляры (числа, строки, даты), сериализовать и сложить в кэш.

Структура данных для отчета приобрела вид (упрощена):

{
    'total': {
        'income': 1234,
        'bonus': 123,
        'expense': 1234,
        'penalty': 123
    },
    'departments': {
        '{dept_id}': {
            'department': {
                'title': 'Mega Department'
            }
            'total': {
                'income': 1234,
                'bonus': 123,
                'expense': 1234,
                'penalty': 123
            },
            'users': {
                '{user_id}': {
                    'user': {
                        'name': 'John Smith'
                    },
                    'total': {
                        'income': 1234,
                        'bonus': 123,
                        'expense': 1234,
                        'penalty': 123
                    },
                    'rows': {
                        '{sale_id}': {        //  Одна модель
                            'type': 'sale'
                            'base_income': 1234,
                            'bonus': 123,
                            'comment': 'some description'
                        },
                        '{expense_id}': {     //  Другая модель !!!
                            'type': 'expense'
                            'expense': 1234,
                            'penalty': 123,
                            'comment': 'some description'
                        },
                        ...
                    }
                },
                ...
            }
        },
        ...
    }
}

И вот тут-то я столкнулся с проблемой, что заполнение такой структуры из словарей не столь удобно, как мне того хотелось. Проверка словарей на наличие ключей или использование setdefatult(key, {}) превращает код в нечитабельную кашу.

Эта структура чем-то напоминает XML. И мне бы хотелось использовать что-то подобное тому, как строятся XPath-выражения для адресации узлов XML-дерева:

/departments/{dept_id}/users/{user_id}/rows/{row_id}/base_income

или на языке Python что-то вида:

data.departments.{dept_id}.users.{user_id}.rows.{row_id}.base_income

Учтывая, что {dept_id} и прочие другие {id} — целые числа, то я разрешил себе использование квадратных скобок: [].

data.departments[{dept_id}].users[{user_id}].rows[{row_id}].base_income

Собственно мне нужен был такой класс, который бы вел себя в основном, как словарь, но при этом:

  1. доступ к атрибутам можно было делать без квадратных скобочек
  2. автоматически создавались отсутствующие аттрибуты

Так появился ElasticDict

В итоге


Код по подготовке данных выглядит приблизительно так:

data = ElasticDict()
for sale in Sale.objects.filter(...).prefetch_related(...):
    data.departments[sale.user.department.pk].users[sale.user.pk].rows[sale.pk] = {'base_income': sale.amount, 'bonus': sale.calc_bonus()}

# или в другой форме, кому как больше нравится
for expense in Expense.objects.filter(...).prefetch_related(...):
    data.departments[sale.user.department.pk].users[sale.user.pk].rows[expense.pk].base_expense = expense.amount
    data.departments[sale.user.department.pk].users[sale.user.pk].rows[expense.pk].penalty = expense.calc_penalty()

Код в шаблоне так:

{{ data.total }}
{% for dept_id, department in data.departments.items %}
    {{ department.total }}
    {% for user_id, user in department.users.items %}
        {{ user.total }}
        {% for row_id, row in user.rows.items %}:
            {{ row.data }}
        {% endfor %}
    {% endfor %}
{% endfor %}

Заключение


Надо отметить, что ElasticDict() это подкласс обычного dict()'а, т.е. в нем доступно все то, что и в обычном словаре. В тот момент, когда потребуется "зафиксировать" структуру (снова захотим получать KeyError'ы при обращении к несуществующим ключам), экземпляр ElasticDict можно экспортировать в обчный dict(). Делается рекурсивный обход ElasticDict(), где все экземпляры этого класа заменяются на обычные словари. Есть и обратное преобразование — на вход подаем словарь, на выходе получаем ElasticDict также с рекурсивным обходом.

Замечания/предложения приветствуются!

UPDATE из англоговорящей тусовки подсказали, что уже есть аналог addict. Думаю, тем, кто проголосовал "мне надо" следует переключиться на него, как на более стабильный (проверенный).
Оно вам надо?

Проголосовало 90 человек. Воздержалось 64 человека.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Комментарии (8)


  1. novoxudonoser
    28.02.2016 00:35

    Батенька да вы же гигант мысли, да это же просто гениальное изобретение, не знаю, как я мог жить без этого, ведь {}.get('key',None) так не удобен…

    Дамс дожили, написал велосипед в ~100 строках — выложил на хабр. Что дальше? Будем стандартную библиотек изобретать?


    1. Ruzin
      28.02.2016 00:47
      -1

      Этот класс не претендует на вселенское открытие или всепоглощающую сингулярность.

      Просто мне показалось, что вот так писать — это не по-питоновски:

      data = {}
      data.setdefault('departments', {}).setdefault(sale.user.department.pk, {}).setdefault('users', {}).setdefault('sale.user.pk', {}).setdefault('rows', {}).setdefault(sale.pk, {}) = {...}

      Хотелось вот так:

      data = ElasticDict()
      data.departments[sale.user.department.pk].users[sale.user.pk].rows[sale.pk] = {...}

      P.S. {}.get('key',None) — не то делает, что мне нужно.


      1. demonMHM
        28.02.2016 01:24
        +4

        Выглядит, конечно, диковато, но

        from collections import defaultdict
        
        data = defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(dict)))))
        data['departments'][sale.user.department.pk]['users']['sale.user.pk']['rows'][sale.pk] = {...}


        1. hellman
          28.02.2016 14:19
          +4

          Можно рекурсивно:

          from collections import defaultdict
          
          recdict = lambda: defaultdict(recdict)
          
          data = recdict()
          data["qwe"]["asd"] = 123


          1. Ruzin
            28.02.2016 15:54

            Спасибо за идеи, мне вот еще одну подкинули в issue:

            from collections import defaultdict
            
            class DotDict(defaultdict):
                def __getattr__(self, attr):
                    return self.__getitem__(attr)
            
                def __setattr__(self, attr, val):
                    return self.__setitem__(attr, val)
            
            def ElasticDict():
                return DotDict(ElasticDict)
            
            data = ElasticDict()
            data.divisions.sales.persons[123].name = 'Alex'
            print data.divisions.sales.persons[123].name

            И дали ссылку на более продвинутую реализацию моего подхода: addict


      1. novoxudonoser
        28.02.2016 03:11
        -3

        >P.S. {}.get('key',None) — не то делает, что мне нужно.

        в статье:
        >>1. доступ к атрибутам можно было делать без квадратных скобочек
        >> 2. автоматически создавались отсутствующие аттрибуты


  1. defuz
    28.02.2016 01:54
    +2

    Проверка словарей на наличие ключей или использование setdefatult(key, {}) превращает код в нечитабельную кашу.

    Окей, давайте сравним. Ваше решение:

    from elasticdict import ElasticDict
    
    data = ElasticDict()
    
    for sale in Sale.objects.filter(...).prefetch_related(...):
        data.departments[sale.user.department.pk].users[sale.user.pk].rows[sale.pk] = {
            'base_income': sale.amount, 
            'bonus': sale.calc_bonus()
        }

    Решение в лоб, без каких-либо зависимостей, даже без стандартного defaultdict:

    data = {'departaments': {}}
    
    for sale in Sale.objects.filter(...).prefetch_related(...):
        departament = data['deparaments'].setdefault(sale.user.department.pk, {'users': {}})
        user = departament['users'].setdefault(sale.user.pk, {'rows': {}})
        user['rows'][sale.pk] = {
            'base_income': sale.amount, 
            'bonus': sale.calc_bonus()
        }

    Вопрос автору: оно того стоило?


    1. Ruzin
      28.02.2016 15:51
      +1

      Согласен, ради конкретно такого примера не стоило бы.

      В моей задаче строчек для вставки в словарь сильно больше (30+), вдобавок, есть внутренняя логика if/else.
      Создание промежуточных переменных не добавляет читаемости.
      Я не настаиваю, что мой код абсолютно более читаемый, но найдутся те, кто со мной согласятся.
      Равно как найдутся те, кто скажет, что это отстой — и последних в 4 раза больше :)