Система версионности контента

Модель Django ORM

Версии контента различного типа (например, записи стены сообщества и записис стены пользователя) хранятся в отдельных моделях Django ORM. Кроме того такой подход позволяет описать связь с оригенальным объектом через стандартный ForeignKey, а не писать собственную программную реализацию для универсальной модели. Соответственно нам сперва необходимо описать новую модель для нашего типа контента. Для примера рассмотрим страницу академии. Версионность реализована в приложении versioj, поэтму все программные элементы новой версионности будем описывать в нём. Идем в versioj/models.py и описываем новую модель версий, использую абстрактный класс VersioLingvoAbstracta:

class VersioAkademioPagxo(VersioLingvoAbstracta):
    posedanto = models.ForeignKey('akademio.AkademioPagxo', verbose_name=_('Originala akademio pagxo'),
                                blank=False, null=False, on_delete=models.CASCADE)

    class Meta:
        # имя таблицы в БД
        db_table = 'versioj_akademioj_pagxoj'
        # индексируемые поля
        index_together = (
            ('posedanto', 'id', 'lingvo'),
        )
        # название объекта модели
        verbose_name = _('Versio akademio pagxo')
        # название объекта модели во множественном числе
        verbose_name_plural = _('Versioj akademioj pagxoj')
        # права на модель
        permissions = (
            ('povas_vidi_akademio_pagxo_version', _('Povas vidi akademion pagxon version')),
            ('povas_forigi_akademio_pagxo_version', _('Povas forigi akademion pagxon version')),
            ('povas_restarigi_akademio_pagxo_version', _('Povas restarigi akademion pagxon version')),
        )

    # реализуем автоинвремент поля id для конкретного родительского объекта
    def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
        if self.id is None or not self.id:
            model = self._meta.model
            next_id = model.objects.filter(
                posedanto=self.posedanto, lingvo=self.lingvo
            ).aggregate(models.Max('id'))['id__max']

            next_id = 1 if next_id is None else next_id + 1

            super().__setattr__('id', next_id)

        super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)

Все основные поля уже описаны в абстрактной модели, так что наша задача обозначить только одно поле posedanto, в данном случае оно ссылается на объект модели akademio.AkademioPagxo. Кроме того в метаклассе задаём необходимые атрибуты: db_table, index_together (всегда одинаков для всех моделей), verbose_name, verbose_name_plural, permissions. Переопределяем метод save, он тоже одинаков для всех моделей, так что его можно просто скопировать. После описания новой модели нам нужно сгенерировать новую мутацию при помощи стандартной команды оболочки python3 manage.py makemigrations versioj, а после принять миграцию python3 manage.py migrate. На этом работа с моделью завершена.

Описание узла схемы QGraphQL API

Для новой модели версионности на необходимо описать узел для API, делается это в versioj/api/schema.py. Тут всё стандартно и в соответствии со статьёй Реализация API Код описания нашё модели:

class VersioAkademioPagxoNode(SiriusoAuthNode, SiriusoObjectId, DjangoObjectType):
    permission_classes = (AllowAny,)

    valoro = graphene.String()
    priskribo = graphene.String()

    class Meta:
        model = VersioAkademioPagxo
        filter_fields = {
            'uuid': ['exact', 'in', 'not_in'],
            'autoro__id': ['exact', 'in'],
            'posedanto__uuid': ['exact', 'in'],
            'lingvo__kodo': ['exact', 'in'],
        }
        interfaces = (graphene.relay.Node,)

    def resolve_valoro(self, info):
        return self.valoro['teksto']

Добавляем новое поле в узел схемы Академии

Теперь нам нужно сделать доступными версии объекта в его узле схемы GraphQL API. Для страницы академии мы должны добавить в узел akamio.api.schema.AkademioPagxoNode поле versioj и метод его разрешения:

class AkademioPagxoNode(SiriusoAuthNode, SiriusoObjectId, DjangoObjectType):
    ...

    versioj = SiriusoFilterConnectionField(VersioAkademioPagxoNode, description=_('Версии страницы'))

    def resolve_versioj(self, info, **kwargs):
        user = info.context.user
        model = VersioAkademioPagxo

        perm_name = 'versioj.povas_vidi_akademion_pagxon_version'
        lingvo = lingvo_kodo_normaligo(get_lang_kodo(info.context))

        if user.has_perm(perm_name, self):
            return model.objects.filter(posedanto=self, lingvo__kodo=lingvo)

        if ((user.is_authenticated and perms.has_registrita_perm(perm_name))
                or (not user.is_authenticated and perms.has_neregistrita_perm(perm_name))):
            return model.objects.filter(posedanto=self, lingvo__kodo=lingvo)

        return model.objects.none()

Код метода resolve_versioj универсален, и его можно скопировать, внеся минимальные изменения в части названия модели версионности и названия права доступа, в данном случае model = VersioAkademioPagxo и perm_name = 'versioj.povas_vidi_akademion_pagxon_version'. Так как versioj подключается через фильтр-коннектор SiriusoFilterConnectionField, то по идее можно не проверять права в методе resolve_versioj, а описать в модели нашей версионности versioj.VersioAkademioPagxo статический метод _get_perm_cond в соответствии со статьёй Система прав доступа

Автоматическое создание версии объекта

Чтобы система автоматически создавала версию текущего состояния объекта, система версионности использует стандартный механизм сигналов Django. Это значит, что нам нужно добавить callback-функцию для обработки всех сохранений оригенальной модели. Это делается в versioj/signals.py добавлением функции-обработчика с соответствующим декоратором @receiver:

[@receiver](profile/receiver)(pre_save, sender=AkademioPagxo)
def savi_version_akademio_pagxo(instance, **kwargs):
    # Если объект только создан
    if kwargs.get('created', False) and len(instance.teksto['lingvo'].keys()):
        for lingvo_kodo in instance.teksto['lingvo'].keys():
            lingvo = get_infolingvo_by_code(lingvo_kodo)

            if lingvo:
                with transaction.atomic():
                    VersioAkademioPagxo.objects.select_for_update(of=('self',)).filter(
                        posedanto=instance,
                        lingvo=lingvo,
                        aktiva=True,
                    ).update(aktiva=False)

                VersioAkademioPagxo.objects.create(
                    autoro=instance.autoro,
                    posedanto=instance,
                    lingvo=lingvo,
                    aktiva=True,
                    valoro={
                        'teksto': get_enhavo(instance.teksto, lingvo_kodo, empty_values=True)[0],
                        'priskribo': get_priskribo(instance.teksto, lingvo_kodo, empty_values=True)[0]
                    }
                )
    elif instance.forigo:
        # если объект удаляется, то удаляем все его версии для экономии места
        VersioAkademioPagxo.objects.filter(posedanto=instance).delete()
    elif 'teksto' in (kwargs.get('update_fields') or dict()) or 'priskribo' in (kwargs.get('update_fields')):
        # если произошло изменение объекта и изменение коснулось поля 'teksto' или 'priskribo'
        try:
            # находим оригенальный объект до изменения
            aktuala = AkademioPagxo.objects.get(uuid=instance.uuid)

            # сохраняем версию для всех языковых кодов поля teksto
            for lingvo_kodo in instance.teksto['lingvo'].keys():
                if lingvo_kodo not in aktuala.teksto['lingvo']:
                    lingvo = get_infolingvo_by_code(lingvo_kodo)

                    if lingvo:
                        with transaction.atomic():
                            VersioAkademioPagxo.objects.select_for_update(of=('self',)).filter(
                                posedanto=instance,
                                lingvo=lingvo,
                                aktiva=True,
                            ).update(aktiva=False)

                        VersioAkademioPagxo.objects.create(
                            autoro=instance.autoro,
                            posedanto=instance,
                            lingvo=lingvo,
                            aktiva=True,
                            valoro={
                                'teksto': get_enhavo(instance.teksto, lingvo_kodo, empty_values=True)[0],
                                'priskribo': get_priskribo(instance.teksto, lingvo_kodo, empty_values=True)[0]
                            }
                        )
                elif get_enhavo(instance.teksto, lingvo_kodo)[0] != get_enhavo(aktuala.teksto, lingvo_kodo)[0]:
                    lingvo = get_infolingvo_by_code(lingvo_kodo)

                    if lingvo:
                        with transaction.atomic():
                            VersioAkademioPagxo.objects.select_for_update(of=('self',)).filter(
                                posedanto=instance,
                                lingvo=lingvo,
                                aktiva=True,
                            ).update(aktiva=False)

                        VersioAkademioPagxo.objects.create(
                            autoro=instance.autoro,
                            posedanto=instance,
                            lingvo=lingvo,
                            aktiva=True,
                            valoro={
                                'teksto': get_enhavo(instance.teksto, lingvo_kodo, empty_values=True)[0],
                                'priskribo': get_priskribo(instance.teksto, lingvo_kodo, empty_values=True)[0]
                            }
                        )

        except AkademioPagxo.DoesNotExist:
            pass

Код функции-обработчика универсален и потому можно копировать его, изменяя название функции и модели оригенальной модели и модели версионности (не забываем, что у декоратора тоже указывается модель оригинал @receiver(pre_save, sender=AkademioPagxo)). Если требуется обрабатывать и изменения других полей, то необходимо соответствующим образом изменить код функции. Скорее всего в будущем будет создана универсальная функция для отслеживания необходимых изменений в необходимых полях, но пока нужно использовать вышеописанный метод.

Мутация переключений между версиями GraphQL API

Модель версий определена, узел схемы и функция-обработчик тоже, остаётся только добавить возможность переключения между версиями. А для этого мы должны описать мутацию в versioj/api/mutations.py. Код достаточно логичен и прост:

class RestarigiAkademioVersion(graphene.Mutation):
    status = graphene.Boolean()
    message = graphene.String()
    errors = graphene.List(ErrorNode)
    # возвращаем узел страницы академии
    pagxo = graphene.Field(AkademioPagxoNode)

    class Arguments:
        versio_uuid = graphene.UUID(required=True)

    [@staticmethod](profile/staticmethod)
    def mutate(root, info, versio_uuid):
        status = False
        message = None
        errors = list()
        pagxo = None
        versio = None
        user = info.context.user

        if user.is_authenticated:
            with transaction.atomic():
                for model in (VersioAkademioPagxo,):
                    try:
                        # находим требуемую версию, на которую будем переключаться
                        versio = model.objects.select_for_update(of=('self',)).get(uuid=versio_uuid)
                    except model.DoesNotExist:
                        pass

                # если не запись версии не найдена, то выдаём ошибку
                if not versio:
                    errors.append(ErrorNode(
                        field='versio_uuid',
                        message=_('Versio ne trovita')
                    ))
                else:
                    pagxo = versio.posedanto
                    perm_name = 'versioj.povas_restarigi_akademio_pagxo_version'

                    # Проверяем наличие прав на переключение версий
                    if user.has_perm(perm_name, pagxo) or user.has_perm(perm_name):
                        # иобновляем поля полседнего изменения
                        pagxo.lasta_dato = timezone.now()
                        pagxo.lasta_autoro = user
                        # восстанавливаем значения нужных полей из версии
                        set_enhavo(pagxo.teksto, versio.valoro['teksto'], versio.lingvo.kodo)
                        set_priskribo(pagxo.teksto, versio.valoro['priskribo'], versio.lingvo.kodo)
                        # сохраняем изменения в оригенальном объекте
                        pagxo.save()

                        # помечаем все вресии, как неактивные
                        versio._meta.model.objects.filter(
                            posedanto=versio.posedanto,
                            lingvo=versio.lingvo,
                            aktiva=True
                        ).update(aktiva=False)
                        # помечаем требуемую версию, как активную
                        versio.aktiva = True
                        versio.save()

                        status = True
                        message = _('Pagxa versio restarigita')
                    else:
                        message = _('Ne sufiĉe da rajtoj')

            if len(errors):
                message = _('Nevalida argumentvaloroj')
        else:
            message = _('Postulas rajtigon')

        return RestarigiAkademioVersion(status=status, message=message, errors=errors, pagxo=pagxo)

Мутация построена стандартно, общий алгоритм таков:

  1. Находим версию по переданному uuid
  2. Проверяем права на переключение версий
  3. Через поле posedanto в найденной версии получаем доступ к оригенальной записи, меняем в ней соответствующие поля (в данном случае teksto и priskribo с нужным языковым тегом).
  4. Сохраняем оригенальную запись при помощи стандартной функции save, но не указываем в ней параметра update_fields, иначе переключение на версию создаст ещё одну версию
  5. Помечаем все версии как неактивные, а потом помечаем текущую версию активной.

Остаётся добавить нашу мутацию в список доступных мутаций приложения, для этого ниже в этом же файле mutation.py в класс VersioMutations добавляем поле мутации restarigi_akademio_versio:

class VersioMutations(graphene.ObjectType):
    restarigi_versio = RestarigiVersion.Field()
    restarigi_akademio_versio = RestarigiAkademioVersion.Field()

Тут следует заметить, что лучше придерживаться единого стиля наименования мутация, т.е. поле мутации должно начинаться с restarigi, а далее смысловое описание мутации. В нашем случае получилось именно restarigi_akademio_versio