Система версионности контента¶
Модель 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)
Мутация построена стандартно, общий алгоритм таков:
- Находим версию по переданному
uuid - Проверяем права на переключение версий
- Через поле
posedantoв найденной версии получаем доступ к оригенальной записи, меняем в ней соответствующие поля (в данном случаеtekstoиpriskriboс нужным языковым тегом). - Сохраняем оригенальную запись при помощи стандартной функции
save, но не указываем в ней параметра update_fields, иначе переключение на версию создаст ещё одну версию - Помечаем все версии как неактивные, а потом помечаем текущую версию активной.
Остаётся добавить нашу мутацию в список доступных мутаций приложения, для этого ниже в этом же файле mutation.py в класс VersioMutations добавляем поле мутации restarigi_akademio_versio:
class VersioMutations(graphene.ObjectType):
restarigi_versio = RestarigiVersion.Field()
restarigi_akademio_versio = RestarigiAkademioVersion.Field()
Тут следует заметить, что лучше придерживаться единого стиля наименования мутация, т.е. поле мутации должно начинаться с restarigi, а далее смысловое описание мутации. В нашем случае получилось именно restarigi_akademio_versio