backoffice: add history/ paths to list snapshots (#4960)

This commit is contained in:
Frédéric Péters 2020-08-10 20:29:52 +02:00
parent 8384cedeec
commit b415ed66de
11 changed files with 278 additions and 5 deletions

View File

@ -63,6 +63,63 @@ def test_snapshot_basics(pub):
assert '>testcard2<' in snapshot.serialization
def test_snapshot_instance(pub):
formdef = FormDef()
formdef.name = 'testform'
formdef.fields = []
formdef.store()
carddef = CardDef()
carddef.name = 'testcard'
carddef.fields = []
carddef.store()
# remove existing snapshots as they may be duplicated if table_name was
# generated in a different second.
pub.snapshot_class.wipe()
carddef.name = 'testcard2'
carddef.store()
for i in range(10):
formdef.name = 'testform %s' % i
formdef.store()
assert pub.snapshot_class.count() == 11
snapshots = pub.snapshot_class.select_object_history(formdef)
assert len(snapshots) == 10
for i in range(10):
assert snapshots[i].instance.name == 'testform %s' % (9 - i)
snapshots = pub.snapshot_class.select_object_history(carddef)
assert len(snapshots) == 1
def test_snapshot_user(pub):
user = pub.user_class()
user.name = 'User Name'
user.email = 'foo@localhost'
user.store()
carddef = CardDef()
carddef.name = 'testcard'
carddef.fields = []
carddef.store()
snapshot = pub.snapshot_class.select_object_history(carddef)[0]
assert snapshot.user is None
snapshot.user_id = user.id
snapshot.store()
snapshot = pub.snapshot_class.select_object_history(carddef)[0]
assert str(snapshot.user) == 'User Name'
snapshot.user_id = 'nope'
snapshot.store()
snapshot = pub.snapshot_class.select_object_history(carddef)[0]
assert str(snapshot.user) == 'unknown user'
def test_form_snapshot_comments(pub):
create_superuser(pub)
create_role()
@ -87,3 +144,21 @@ def test_form_snapshot_comments(pub):
resp.forms[0]['type'] = 'string'
resp = resp.forms[0].submit().follow()
assert pub.snapshot_class.select(order_by='-timestamp')[0].comment == 'New field "foobar"'
def test_form_snapshot_history(pub):
formdef = FormDef()
formdef.name = 'testform'
formdef.fields = []
formdef.store()
for i in range(5):
formdef.name = 'testform %s' % i
formdef.store()
create_superuser(pub)
create_role()
app = login(get_app(pub))
resp = app.get('/backoffice/forms/%s/history/' % formdef.id)
assert [x.attrib['class'] for x in resp.pyquery.find('ul.snapshots-list li')] == [
'new-day', 'collapsed', 'collapsed', 'collapsed', 'collapsed', 'collapsed']

View File

@ -28,6 +28,7 @@ from wcs.qommon.backoffice.menu import html_top
from wcs.admin.fields import FieldDefPage, FieldsDirectory
from wcs.admin import utils
from wcs.backoffice.snapshots import SnapshotsDirectory
class BlockFieldDefPage(FieldDefPage):
@ -39,7 +40,8 @@ class BlockFieldDefPage(FieldDefPage):
class BlockDirectory(FieldsDirectory):
_q_exports = ['', 'update_order', 'new', 'delete', 'export', 'settings']
_q_exports = ['', 'update_order', 'new', 'delete', 'export', 'settings',
('history', 'snapshots_dir'),]
field_def_page_class = BlockFieldDefPage
blacklisted_types = ['page', 'table', 'table-select', 'tablerows', 'ranked-items', 'blocks']
support_import = False
@ -48,6 +50,7 @@ class BlockDirectory(FieldsDirectory):
def __init__(self, section, *args, **kwargs):
self.section = section
super().__init__(*args, **kwargs)
self.snapshots_dir = SnapshotsDirectory(self.objectdef)
def _q_traverse(self, path):
get_response().breadcrumb.append(('%s/' % self.objectdef.id, self.objectdef.name))

View File

@ -33,6 +33,7 @@ from wcs.qommon.backoffice.menu import html_top
from wcs.data_sources import (NamedDataSource, DataSourceSelectionWidget,
get_structured_items)
from wcs.formdef import FormDef, get_formdefs_of_all_kinds
from wcs.backoffice.snapshots import SnapshotsDirectory
class NamedDataSourceUI(object):
@ -155,7 +156,8 @@ class NamedDataSourceUI(object):
class NamedDataSourcePage(Directory):
_q_exports = ['', 'edit', 'delete', 'export']
_q_exports = ['', 'edit', 'delete', 'export',
('history', 'snapshots_dir'),]
do_not_call_in_templates = True
def __init__(self, component):
@ -165,6 +167,7 @@ class NamedDataSourcePage(Directory):
raise errors.TraversalError()
self.datasource_ui = NamedDataSourceUI(self.datasource)
get_response().breadcrumb.append((component + '/', self.datasource.name))
self.snapshots_dir = SnapshotsDirectory(self.datasource)
def _q_index(self):
html_top('datasources', title=self.datasource.name)

View File

@ -51,6 +51,8 @@ from .categories import CategoriesDirectory
from .data_sources import NamedDataSourcesDirectory
from .logged_errors import LoggedErrorsDirectory
from wcs.backoffice.snapshots import SnapshotsDirectory
def get_categories():
t = sorted([(misc.simplify(x.name), x.id, x.name, x.id) for x in Category.select()])
@ -365,7 +367,8 @@ class FormDefPage(Directory):
'overwrite', 'qrcode', 'information',
('public-url', 'public_url'),
('backoffice-submission-roles', 'backoffice_submission_roles'),
('logged-errors', 'logged_errors_dir'),]
('logged-errors', 'logged_errors_dir'),
('history', 'snapshots_dir'),]
formdef_class = FormDef
formdef_export_prefix = 'form'
@ -394,6 +397,7 @@ class FormDefPage(Directory):
self.role.html_top = self.html_top
self.options = OptionsDirectory(self.formdef)
self.logged_errors_dir = LoggedErrorsDirectory(parent_dir=self, formdef_id=self.formdef.id)
self.snapshots_dir = SnapshotsDirectory(self.formdef)
def html_top(self, title):
return html_top('forms', title)

View File

@ -49,6 +49,7 @@ from .fields import FieldDefPage, FieldsDirectory
from .data_sources import NamedDataSourcesDirectory
from .logged_errors import LoggedErrorsDirectory
from .mail_templates import MailTemplatesDirectory
from wcs.backoffice.snapshots import SnapshotsDirectory
from wcs.backoffice.studio import StudioDirectory
@ -1369,6 +1370,7 @@ class WorkflowPage(Directory):
('functions', 'functions_dir'), ('global-actions', 'global_actions_dir'),
('criticality-levels', 'criticality_levels_dir'),
('logged-errors', 'logged_errors_dir'),
('history', 'snapshots_dir'),
]
def __init__(self, component, html_top):
@ -1388,6 +1390,7 @@ class WorkflowPage(Directory):
self.global_actions_dir = GlobalActionsDirectory(self.workflow, html_top)
self.criticality_levels_dir = CriticalityLevelsDirectory(self.workflow)
self.logged_errors_dir = LoggedErrorsDirectory(parent_dir=self, workflow_id=self.workflow.id)
self.snapshots_dir = SnapshotsDirectory(self.workflow)
get_response().breadcrumb.append((component + '/', self.workflow.name))
def _q_index(self):

View File

@ -25,6 +25,7 @@ from wcs.qommon import misc
from wcs.qommon.form import *
from wcs.qommon.backoffice.menu import html_top
from wcs.wscalls import NamedWsCall, WsCallRequestWidget
from wcs.backoffice.snapshots import SnapshotsDirectory
class NamedWsCallUI(object):
@ -95,7 +96,8 @@ class NamedWsCallUI(object):
class NamedWsCallPage(Directory):
_q_exports = ['', 'edit', 'delete', 'export']
_q_exports = ['', 'edit', 'delete', 'export',
('history', 'snapshots_dir'),]
def __init__(self, component):
try:
@ -104,6 +106,7 @@ class NamedWsCallPage(Directory):
raise errors.TraversalError()
self.wscall_ui = NamedWsCallUI(self.wscall)
get_response().breadcrumb.append((component + '/', self.wscall.name))
self.snapshots_dir = SnapshotsDirectory(self.wscall)
def _q_index(self):
html_top('wscalls', title=self.wscall.name)

View File

@ -0,0 +1,81 @@
# w.c.s. - web application for online forms
# Copyright (C) 2005-2020 Entr'ouvert
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/licenses/>.
from quixote import get_publisher, get_response
from quixote.directory import Directory
from wcs.qommon import _, errors, misc, template
from wcs.qommon.backoffice.menu import html_top
from wcs.qommon.storage import Equal
class SnapshotsDirectory(Directory):
_q_exports = ['']
do_not_call_in_templates = True
def __init__(self, instance):
self.obj = instance
self.object_type = instance.xml_root_node
self.object_id = instance.id
def _q_traverse(self, path):
get_response().breadcrumb.append(('history/', _('History')))
return super()._q_traverse(path)
def _q_index(self):
html_top('', _('History'))
return template.QommonTemplateResponse(
templates=['wcs/backoffice/snapshots.html'],
context={'view': self})
def snapshots(self):
current_date = None
snapshots = get_publisher().snapshot_class.select_object_history(self.obj)[1:]
day_snapshot = None
for i, snapshot in enumerate(snapshots):
if snapshot.timestamp.date() != current_date:
current_date = snapshot.timestamp.date()
snapshot.new_day = True
snapshot.day_other_count = 0
day_snapshot = snapshot
else:
day_snapshot.day_other_count += 1
return snapshots
def _q_lookup(self, component):
snapshots = get_publisher().snapshot_class.select_object_history(self.obj,
clause=[Equal('id', component)])
if not snapshots:
raise errors.TraversalError()
return SnapshotDirectory(self.obj, snapshots[0])
class SnapshotDirectory(Directory):
_q_exports = ['']
def __init__(self, instance, snapshot):
self.obj = instance
self.snapshot = snapshot
def _q_traverse(self, path):
get_response().breadcrumb.append((
'%s/' % self.snapshot.id,
misc.localstrftime(self.snapshot.timestamp))
)
return super()._q_traverse(path)
def _q_index(self):
return ''

View File

@ -1937,3 +1937,11 @@ div.mail-body {
#sidebar-custom-views .active {
font-weight: bold;
}
ul.snapshots-list .collapsed {
display: none;
}
ul.snapshots-list .new-day, ul.snapshots-list .has-label {
display: block;
}

View File

@ -18,7 +18,14 @@ import xml.etree.ElementTree as ET
from django.utils.timezone import now
from quixote import get_session
from quixote import get_publisher, get_session
from wcs.qommon import _
class UnknownUser:
def __str__(self):
return _('unknown user')
class Snapshot:
@ -31,6 +38,10 @@ class Snapshot:
serialization = None
label = None # (named snapshot)
# cache
_instance = None
_user = None
@classmethod
def snap(cls, instance, comment):
obj = cls()
@ -45,3 +56,33 @@ class Snapshot:
if latest is None or obj.serialization != latest.serialization:
# save snapshot if there are changes
obj.store()
def get_object_class(self):
from wcs.blocks import BlockDef
from wcs.carddef import CardDef
from wcs.data_sources import NamedDataSource
from wcs.formdef import FormDef
from wcs.workflows import Workflow
from wcs.wscalls import NamedWsCall
for klass in (BlockDef, CardDef, NamedDataSource, FormDef, Workflow, NamedWsCall):
if klass.xml_root_node == self.object_type:
return klass
raise KeyError('no class for object type: %s' % self.object_type)
@property
def instance(self):
if self._instance is None:
tree = ET.fromstring(self.serialization)
self._instance = self.get_object_class().import_from_xml_tree(tree, include_id=True)
return self._instance
@property
def user(self):
if not self.user_id:
return None
if self._user is None:
try:
self._user = get_publisher().user_class.get(self.user_id)
except KeyError:
self._user = UnknownUser()
return self._user

View File

@ -2404,6 +2404,13 @@ class Snapshot(SqlMixin, wcs.snapshots.Snapshot):
conn.commit()
cur.close()
@classmethod
def select_object_history(cls, obj, clause=None):
return cls.select([
Equal('object_type', obj.xml_root_node),
Equal('object_id', obj.id)] + (clause or []),
order_by='-timestamp')
@classmethod
def _row2ob(cls, row, **kwargs):
o = cls()

View File

@ -0,0 +1,45 @@
{% load i18n %}
{% block body %}
<div id="appbar">
<h2>{% trans "History" %}</h2>
</div>
{% with snapshots=view.snapshots %}
{% if snapshots %}
<div class="section">
<ul class="objects-list snapshots-list">
{% for snapshot in snapshots %}
<li data-day="{{ snapshot.timestamp|date:"Y-m-d" }}" class="{% if snapshot.new_day %}new-day{% elif snapshot.label %}has-label{% else %}collapsed{% endif %}"
>{{ snapshot.timestamp }}{% if snapshot.label %} <strong>{{ snapshot.label }}</strong>{% elif snapshot.comment %}, {{ snapshot.comment }}{% endif %}
{% if snapshot.user_id %}({{ snapshot.user }}){% endif %}
{% if snapshot.new_day and snapshot.day_other_count %}(<a class="reveal"
href="#day-{{ snapshot.timestamp|date:"Y-m-d"}}">
{% blocktrans trimmed count counter=snapshot.day_other_count %}
1 other this day
{% plural %}
{{ counter }} others this day{% endblocktrans %}
</a>){% endif %}
<a href="{{snapshot.id}}/view/">{% trans "View" %}</a>
</li>
{% endfor %}
</ul>
</div>
{% else %}
<div class="infonotice"><p>{% trans "No changes history" %}</p></div>
{% endif %}
{% endwith %}
<script>
$(function() {
$('li.new-day a.reveal').on('click', function() {
var day = $(this).parents('li.new-day').data('day');
$('.snapshots-list li[data-day="' + day + '"]').toggleClass('collapsed');
return false;
});
});
</script>
{% endblock %}