xversion.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. from crispy_forms.utils import TEMPLATE_PACK
  2. from django.contrib.contenttypes.fields import GenericRelation
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.core.exceptions import PermissionDenied
  5. from django.db import models
  6. from django.db.models.query import QuerySet
  7. from django.forms.models import model_to_dict
  8. from django.http import HttpResponseRedirect
  9. from django.shortcuts import get_object_or_404
  10. from django.template.response import TemplateResponse
  11. from django.utils import six
  12. from django.utils.encoding import force_text, smart_text
  13. from django.utils.safestring import mark_safe
  14. from django.utils.text import capfirst
  15. from django.utils.translation import ugettext as _
  16. from xadmin.layout import Field, render_field
  17. from xadmin.plugins.inline import Inline
  18. from xadmin.plugins.actions import BaseActionView
  19. from xadmin.plugins.inline import InlineModelAdmin
  20. from xadmin.sites import site
  21. from xadmin.util import unquote, quote, model_format_dict, is_related_field2
  22. from xadmin.views import BaseAdminPlugin, ModelAdminView, CreateAdminView, UpdateAdminView, DetailAdminView, ModelFormAdminView, DeleteAdminView, ListAdminView
  23. from xadmin.views.base import csrf_protect_m, filter_hook
  24. from xadmin.views.detail import DetailAdminUtil
  25. from reversion.models import Revision, Version
  26. from reversion.revisions import is_active, register, is_registered, set_comment, create_revision, set_user
  27. from contextlib import contextmanager
  28. from functools import partial
  29. def _autoregister(admin, model, follow=None):
  30. """Registers a model with reversion, if required."""
  31. if model._meta.proxy:
  32. raise RegistrationError("Proxy models cannot be used with django-reversion, register the parent class instead")
  33. if not is_registered(model):
  34. follow = follow or []
  35. for parent_cls, field in model._meta.parents.items():
  36. follow.append(field.name)
  37. _autoregister(admin, parent_cls)
  38. register(model, follow=follow, format=admin.reversion_format)
  39. def _register_model(admin, model):
  40. if not hasattr(admin, 'reversion_format'):
  41. admin.reversion_format = 'json'
  42. if not is_registered(model):
  43. inline_fields = []
  44. for inline in getattr(admin, 'inlines', []):
  45. inline_model = inline.model
  46. if getattr(inline, 'generic_inline', False):
  47. ct_field = getattr(inline, 'ct_field', 'content_type')
  48. ct_fk_field = getattr(inline, 'ct_fk_field', 'object_id')
  49. for field in model._meta.many_to_many:
  50. if isinstance(field, GenericRelation) \
  51. and field.rel.to == inline_model \
  52. and field.object_id_field_name == ct_fk_field \
  53. and field.content_type_field_name == ct_field:
  54. inline_fields.append(field.name)
  55. _autoregister(admin, inline_model)
  56. else:
  57. fk_name = getattr(inline, 'fk_name', None)
  58. if not fk_name:
  59. for field in inline_model._meta.fields:
  60. if isinstance(field, (models.ForeignKey, models.OneToOneField)) and issubclass(model, field.remote_field.model):
  61. fk_name = field.name
  62. _autoregister(admin, inline_model, follow=[fk_name])
  63. if not inline_model._meta.get_field(fk_name).remote_field.is_hidden():
  64. accessor = inline_model._meta.get_field(fk_name).remote_field.get_accessor_name()
  65. inline_fields.append(accessor)
  66. _autoregister(admin, model, inline_fields)
  67. def register_models(admin_site=None):
  68. if admin_site is None:
  69. admin_site = site
  70. for model, admin in admin_site._registry.items():
  71. if getattr(admin, 'reversion_enable', False):
  72. _register_model(admin, model)
  73. @contextmanager
  74. def do_create_revision(request):
  75. with create_revision():
  76. set_user(request.user)
  77. yield
  78. class ReversionPlugin(BaseAdminPlugin):
  79. # The serialization format to use when registering models with reversion.
  80. reversion_format = "json"
  81. # Whether to ignore duplicate revision data.
  82. ignore_duplicate_revisions = False
  83. reversion_enable = False
  84. def init_request(self, *args, **kwargs):
  85. return self.reversion_enable
  86. def do_post(self, __):
  87. def _method():
  88. self.revision_context_manager.set_user(self.user)
  89. comment = ''
  90. admin_view = self.admin_view
  91. if isinstance(admin_view, CreateAdminView):
  92. comment = _(u"Initial version.")
  93. elif isinstance(admin_view, UpdateAdminView):
  94. comment = _(u"Change version.")
  95. elif isinstance(admin_view, RevisionView):
  96. comment = _(u"Revert version.")
  97. elif isinstance(admin_view, RecoverView):
  98. comment = _(u"Rercover version.")
  99. elif isinstance(admin_view, DeleteAdminView):
  100. comment = _(u"Deleted %(verbose_name)s.") % {
  101. "verbose_name": self.opts.verbose_name}
  102. self.revision_context_manager.set_comment(comment)
  103. return __()
  104. return _method
  105. def post(self, __, request, *args, **kwargs):
  106. with do_create_revision(request):
  107. return __()
  108. # Block Views
  109. def block_top_toolbar(self, context, nodes):
  110. recoverlist_url = self.admin_view.model_admin_url('recoverlist')
  111. nodes.append(mark_safe('<div class="btn-group"><a class="btn btn-default btn-sm" href="%s"><i class="fa fa-trash-o"></i> %s</a></div>' % (recoverlist_url, _(u"Recover"))))
  112. def block_nav_toggles(self, context, nodes):
  113. obj = getattr(
  114. self.admin_view, 'org_obj', getattr(self.admin_view, 'obj', None))
  115. if obj:
  116. revisionlist_url = self.admin_view.model_admin_url(
  117. 'revisionlist', quote(obj.pk))
  118. nodes.append(mark_safe('<a href="%s" class="navbar-toggle pull-right"><i class="fa fa-calendar"></i></a>' % revisionlist_url))
  119. def block_nav_btns(self, context, nodes):
  120. obj = getattr(
  121. self.admin_view, 'org_obj', getattr(self.admin_view, 'obj', None))
  122. if obj:
  123. revisionlist_url = self.admin_view.model_admin_url(
  124. 'revisionlist', quote(obj.pk))
  125. nodes.append(mark_safe('<a href="%s" class="btn btn-default"><i class="fa fa-calendar"></i> <span>%s</span></a>' % (revisionlist_url, _(u'History'))))
  126. # action revision
  127. class ActionRevisionPlugin(BaseAdminPlugin):
  128. reversion_enable = False
  129. def init_request(self, *args, **kwargs):
  130. return self.reversion_enable
  131. def do_action(self, __, queryset):
  132. with do_create_revision(self.request):
  133. return __()
  134. class BaseReversionView(ModelAdminView):
  135. # The serialization format to use when registering models with reversion.
  136. reversion_format = "json"
  137. # Whether to ignore duplicate revision data.
  138. ignore_duplicate_revisions = False
  139. # If True, then the default ordering of object_history and recover lists will be reversed.
  140. history_latest_first = False
  141. reversion_enable = False
  142. def init_request(self, *args, **kwargs):
  143. if not self.has_change_permission() and not self.has_add_permission():
  144. raise PermissionDenied
  145. def _order_version_queryset(self, queryset):
  146. """Applies the correct ordering to the given version queryset."""
  147. if self.history_latest_first:
  148. return queryset.order_by("-pk")
  149. return queryset.order_by("pk")
  150. class RecoverListView(BaseReversionView):
  151. recover_list_template = None
  152. def get_context(self):
  153. context = super(RecoverListView, self).get_context()
  154. opts = self.opts
  155. deleted = self._order_version_queryset(Version.objects.get_deleted(self.model))
  156. context.update({
  157. "opts": opts,
  158. "app_label": opts.app_label,
  159. "model_name": capfirst(opts.verbose_name),
  160. "title": _("Recover deleted %(name)s") % {"name": force_text(opts.verbose_name_plural)},
  161. "deleted": deleted,
  162. "changelist_url": self.model_admin_url("changelist"),
  163. })
  164. return context
  165. @csrf_protect_m
  166. def get(self, request, *args, **kwargs):
  167. context = self.get_context()
  168. return TemplateResponse(
  169. request, self.recover_list_template or self.get_template_list(
  170. "views/recover_list.html"),
  171. context)
  172. class RevisionListView(BaseReversionView):
  173. object_history_template = None
  174. revision_diff_template = None
  175. def _reversion_order_version_queryset(self, queryset):
  176. """Applies the correct ordering to the given version queryset."""
  177. if not self.history_latest_first:
  178. queryset = queryset.order_by("pk")
  179. return queryset
  180. def get_context(self):
  181. context = super(RevisionListView, self).get_context()
  182. opts = self.opts
  183. action_list = [
  184. {
  185. "revision": version.revision,
  186. "url": self.model_admin_url('revision', quote(version.object_id), version.id),
  187. "version": version
  188. }
  189. for version
  190. in self._reversion_order_version_queryset(Version.objects.get_for_object_reference(
  191. self.model,
  192. self.obj.pk,
  193. ).select_related("revision__user"))
  194. ]
  195. context.update({
  196. 'title': _('Change history: %s') % force_text(self.obj),
  197. 'action_list': action_list,
  198. 'model_name': capfirst(force_text(opts.verbose_name_plural)),
  199. 'object': self.obj,
  200. 'app_label': opts.app_label,
  201. "changelist_url": self.model_admin_url("changelist"),
  202. "update_url": self.model_admin_url("change", self.obj.pk),
  203. 'opts': opts,
  204. })
  205. return context
  206. def get(self, request, object_id, *args, **kwargs):
  207. object_id = unquote(object_id)
  208. self.obj = self.get_object(object_id)
  209. if not self.has_change_permission(self.obj):
  210. raise PermissionDenied
  211. return self.get_response()
  212. def get_response(self):
  213. context = self.get_context()
  214. return TemplateResponse(self.request, self.object_history_template or
  215. self.get_template_list('views/model_history.html'), context)
  216. def get_version_object(self, version):
  217. obj_version = version._object_version
  218. obj = obj_version.object
  219. obj._state.db = self.obj._state.db
  220. for field_name, pks in obj_version.m2m_data.items():
  221. f = self.opts.get_field(field_name)
  222. if f.rel and isinstance(f.rel, models.ManyToManyRel):
  223. setattr(obj, f.name, f.rel.to._default_manager.get_query_set(
  224. ).filter(pk__in=pks).all())
  225. detail = self.get_model_view(DetailAdminUtil, self.model, obj)
  226. return obj, detail
  227. def post(self, request, object_id, *args, **kwargs):
  228. object_id = unquote(object_id)
  229. self.obj = self.get_object(object_id)
  230. if not self.has_change_permission(self.obj):
  231. raise PermissionDenied
  232. params = self.request.POST
  233. if 'version_a' not in params or 'version_b' not in params:
  234. self.message_user(_("Must select two versions."), 'error')
  235. return self.get_response()
  236. version_a_id = params['version_a']
  237. version_b_id = params['version_b']
  238. if version_a_id == version_b_id:
  239. self.message_user(
  240. _("Please select two different versions."), 'error')
  241. return self.get_response()
  242. version_a = get_object_or_404(Version, pk=version_a_id)
  243. version_b = get_object_or_404(Version, pk=version_b_id)
  244. diffs = []
  245. obj_a, detail_a = self.get_version_object(version_a)
  246. obj_b, detail_b = self.get_version_object(version_b)
  247. for f in (self.opts.fields + self.opts.many_to_many):
  248. if is_related_field2(f):
  249. label = f.opts.verbose_name
  250. else:
  251. label = f.verbose_name
  252. value_a = f.value_from_object(obj_a)
  253. value_b = f.value_from_object(obj_b)
  254. is_diff = value_a != value_b
  255. if type(value_a) in (list, tuple) and type(value_b) in (list, tuple) \
  256. and len(value_a) == len(value_b) and is_diff:
  257. is_diff = False
  258. for i in xrange(len(value_a)):
  259. if value_a[i] != value_a[i]:
  260. is_diff = True
  261. break
  262. if type(value_a) is QuerySet and type(value_b) is QuerySet:
  263. is_diff = list(value_a) != list(value_b)
  264. diffs.append((label, detail_a.get_field_result(
  265. f.name).val, detail_b.get_field_result(f.name).val, is_diff))
  266. context = super(RevisionListView, self).get_context()
  267. context.update({
  268. 'object': self.obj,
  269. 'opts': self.opts,
  270. 'version_a': version_a,
  271. 'version_b': version_b,
  272. 'revision_a_url': self.model_admin_url('revision', quote(version_a.object_id), version_a.id),
  273. 'revision_b_url': self.model_admin_url('revision', quote(version_b.object_id), version_b.id),
  274. 'diffs': diffs
  275. })
  276. return TemplateResponse(
  277. self.request, self.revision_diff_template or self.get_template_list('views/revision_diff.html'),
  278. context)
  279. @filter_hook
  280. def get_media(self):
  281. return super(RevisionListView, self).get_media() + self.vendor('xadmin.plugin.revision.js', 'xadmin.form.css')
  282. class BaseRevisionView(ModelFormAdminView):
  283. @filter_hook
  284. def get_revision(self):
  285. return self.version.field_dict
  286. @filter_hook
  287. def get_form_datas(self):
  288. datas = {"instance": self.org_obj, "initial": self.get_revision()}
  289. if self.request_method == 'post':
  290. datas.update(
  291. {'data': self.request.POST, 'files': self.request.FILES})
  292. return datas
  293. @filter_hook
  294. def get_context(self):
  295. context = super(BaseRevisionView, self).get_context()
  296. context.update({
  297. 'object': self.org_obj
  298. })
  299. return context
  300. @filter_hook
  301. def get_media(self):
  302. return super(BaseRevisionView, self).get_media() + self.vendor('xadmin.plugin.revision.js')
  303. class DiffField(Field):
  304. def render(self, form, form_style, context, template_pack=TEMPLATE_PACK, **kwargs):
  305. html = ''
  306. for field in self.fields:
  307. html += ('<div class="diff_field" rel="tooltip"><textarea class="org-data" style="display:none;">%s</textarea>%s</div>' %
  308. (_('Current: %s') % self.attrs.pop('orgdata', ''), render_field(field, form, form_style, context, template_pack=template_pack, attrs=self.attrs)))
  309. return html
  310. class RevisionView(BaseRevisionView):
  311. revision_form_template = None
  312. def init_request(self, object_id, version_id):
  313. self.detail = self.get_model_view(
  314. DetailAdminView, self.model, object_id)
  315. self.org_obj = self.detail.obj
  316. self.version = get_object_or_404(
  317. Version, pk=version_id, object_id=smart_text(self.org_obj.pk))
  318. self.prepare_form()
  319. def get_form_helper(self):
  320. helper = super(RevisionView, self).get_form_helper()
  321. diff_fields = {}
  322. version_data = self.version.field_dict
  323. for f in self.opts.fields:
  324. fvalue = f.value_from_object(self.org_obj)
  325. vvalue = version_data.get(f.name, None)
  326. if fvalue is None and vvalue == '':
  327. vvalue = None
  328. if is_related_field2(f):
  329. vvalue = version_data.get(f.name + '_' + f.rel.get_related_field().name, None)
  330. if fvalue != vvalue:
  331. diff_fields[f.name] = self.detail.get_field_result(f.name).val
  332. for k, v in diff_fields.items():
  333. helper[k].wrap(DiffField, orgdata=v)
  334. return helper
  335. @filter_hook
  336. def get_context(self):
  337. context = super(RevisionView, self).get_context()
  338. context["title"] = _(
  339. "Revert %s") % force_text(self.model._meta.verbose_name)
  340. return context
  341. @filter_hook
  342. def get_response(self):
  343. context = self.get_context()
  344. context.update(self.kwargs or {})
  345. form_template = self.revision_form_template
  346. return TemplateResponse(
  347. self.request, form_template or self.get_template_list(
  348. 'views/revision_form.html'),
  349. context)
  350. @filter_hook
  351. def post_response(self):
  352. self.message_user(_('The %(model)s "%(name)s" was reverted successfully. You may edit it again below.') %
  353. {"model": force_text(self.opts.verbose_name), "name": smart_text(self.new_obj)}, 'success')
  354. return HttpResponseRedirect(self.model_admin_url('change', self.new_obj.pk))
  355. class RecoverView(BaseRevisionView):
  356. recover_form_template = None
  357. def init_request(self, version_id):
  358. if not self.has_change_permission() and not self.has_add_permission():
  359. raise PermissionDenied
  360. self.version = get_object_or_404(Version, pk=version_id)
  361. self.org_obj = self.version._object_version.object
  362. self.prepare_form()
  363. @filter_hook
  364. def get_context(self):
  365. context = super(RecoverView, self).get_context()
  366. context["title"] = _("Recover %s") % self.version.object_repr
  367. return context
  368. @filter_hook
  369. def get_response(self):
  370. context = self.get_context()
  371. context.update(self.kwargs or {})
  372. form_template = self.recover_form_template
  373. return TemplateResponse(
  374. self.request, form_template or self.get_template_list(
  375. 'views/recover_form.html'),
  376. context)
  377. @filter_hook
  378. def post_response(self):
  379. self.message_user(_('The %(model)s "%(name)s" was recovered successfully. You may edit it again below.') %
  380. {"model": force_text(self.opts.verbose_name), "name": smart_text(self.new_obj)}, 'success')
  381. return HttpResponseRedirect(self.model_admin_url('change', self.new_obj.pk))
  382. class InlineDiffField(Field):
  383. def render(self, form, form_style, context, template_pack=TEMPLATE_PACK, **kwargs):
  384. html = ''
  385. instance = form.instance
  386. if not instance.pk:
  387. return super(InlineDiffField, self).render(form, form_style, context)
  388. initial = form.initial
  389. opts = instance._meta
  390. detail = form.detail
  391. for field in self.fields:
  392. f = opts.get_field(field)
  393. f_html = render_field(field, form, form_style, context,
  394. template_pack=template_pack, attrs=self.attrs)
  395. if f.value_from_object(instance) != initial.get(field, None):
  396. current_val = detail.get_field_result(f.name).val
  397. html += ('<div class="diff_field" rel="tooltip"><textarea class="org-data" style="display:none;">%s</textarea>%s</div>'
  398. % (_('Current: %s') % current_val, f_html))
  399. else:
  400. html += f_html
  401. return html
  402. # inline hack plugin
  403. class InlineRevisionPlugin(BaseAdminPlugin):
  404. def get_related_versions(self, obj, version, formset):
  405. """Retreives all the related Version objects for the given FormSet."""
  406. object_id = obj.pk
  407. # Get the fk name.
  408. try:
  409. fk_name = formset.fk.name + '_' + formset.fk.rel.get_related_field().name
  410. except AttributeError:
  411. # This is a GenericInlineFormset, or similar.
  412. fk_name = formset.ct_fk_field.name
  413. # Look up the revision data.
  414. revision_versions = version.revision.version_set.all()
  415. related_versions = dict([(related_version.object_id, related_version)
  416. for related_version in revision_versions
  417. if ContentType.objects.get_for_id(related_version.content_type_id).model_class() == formset.model
  418. and smart_text(related_version.field_dict[fk_name]) == smart_text(object_id)])
  419. return related_versions
  420. def _hack_inline_formset_initial(self, revision_view, formset):
  421. """Hacks the given formset to contain the correct initial data."""
  422. # Now we hack it to push in the data from the revision!
  423. initial = []
  424. related_versions = self.get_related_versions(
  425. revision_view.org_obj, revision_view.version, formset)
  426. formset.related_versions = related_versions
  427. for related_obj in formset.queryset:
  428. if smart_text(related_obj.pk) in related_versions:
  429. initial.append(
  430. related_versions.pop(smart_text(related_obj.pk)).field_dict)
  431. else:
  432. initial_data = model_to_dict(related_obj)
  433. initial_data["DELETE"] = True
  434. initial.append(initial_data)
  435. for related_version in related_versions.values():
  436. initial_row = related_version.field_dict
  437. pk_name = ContentType.objects.get_for_id(
  438. related_version.content_type_id).model_class()._meta.pk.name
  439. del initial_row[pk_name]
  440. initial.append(initial_row)
  441. # Reconstruct the forms with the new revision data.
  442. formset.initial = initial
  443. formset.forms = [formset._construct_form(
  444. n) for n in xrange(len(initial))]
  445. # Hack the formset to force a save of everything.
  446. def get_changed_data(form):
  447. return [field.name for field in form.fields]
  448. for form in formset.forms:
  449. form.has_changed = lambda: True
  450. form._get_changed_data = partial(get_changed_data, form=form)
  451. def total_form_count_hack(count):
  452. return lambda: count
  453. formset.total_form_count = total_form_count_hack(len(initial))
  454. if self.request.method == 'GET' and formset.helper and formset.helper.layout:
  455. helper = formset.helper
  456. cls_str = str if six.PY3 else basestring
  457. helper.filter(cls_str).wrap(InlineDiffField)
  458. fake_admin_class = type(str('%s%sFakeAdmin' % (self.opts.app_label, self.opts.model_name)), (object, ), {'model': self.model})
  459. for form in formset.forms:
  460. instance = form.instance
  461. if instance.pk:
  462. form.detail = self.get_view(
  463. DetailAdminUtil, fake_admin_class, instance)
  464. def instance_form(self, formset, **kwargs):
  465. admin_view = self.admin_view.admin_view
  466. if hasattr(admin_view, 'version') and hasattr(admin_view, 'org_obj'):
  467. self._hack_inline_formset_initial(admin_view, formset)
  468. return formset
  469. class VersionInline(object):
  470. model = Version
  471. extra = 0
  472. style = 'accordion'
  473. class ReversionAdmin(object):
  474. model_icon = 'fa fa-exchange'
  475. list_display = ('__str__', 'date_created', 'user', 'comment')
  476. list_display_links = ('__str__',)
  477. list_filter = ('date_created', 'user')
  478. inlines = [VersionInline]
  479. site.register(Revision, ReversionAdmin)
  480. site.register_modelview(
  481. r'^recover/$', RecoverListView, name='%s_%s_recoverlist')
  482. site.register_modelview(
  483. r'^recover/([^/]+)/$', RecoverView, name='%s_%s_recover')
  484. site.register_modelview(
  485. r'^([^/]+)/revision/$', RevisionListView, name='%s_%s_revisionlist')
  486. site.register_modelview(
  487. r'^([^/]+)/revision/([^/]+)/$', RevisionView, name='%s_%s_revision')
  488. site.register_plugin(ReversionPlugin, ListAdminView)
  489. site.register_plugin(ReversionPlugin, ModelFormAdminView)
  490. site.register_plugin(ReversionPlugin, DeleteAdminView)
  491. site.register_plugin(InlineRevisionPlugin, InlineModelAdmin)
  492. site.register_plugin(ActionRevisionPlugin, BaseActionView)