Compare commits

..

8 Commits

Author SHA1 Message Date
Lauréline Guérin d459378ccc
export_import: missing component in bundle (#88068)
gitea/chrono/pipeline/head There was a failure building this commit Details
2024-03-14 15:20:18 +01:00
Lauréline Guérin da77fb2b48
export_import: unknown component_type in urls (#88068) 2024-03-14 15:20:18 +01:00
Lauréline Guérin 9537977a80
export_import: invalid bundle (#88068) 2024-03-14 15:20:18 +01:00
Lauréline Guérin d465105fbc
manager: get snapshots to compare from application version (#87653)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-07 16:44:44 +01:00
Lauréline Guérin ccc65e1963
export_import: redirect to compare view if compare in GET params (#87653) 2024-03-07 16:44:44 +01:00
Lauréline Guérin 5a52fa7911
export_import: bundle-check endpoint (#87653) 2024-03-07 16:44:44 +01:00
Lauréline Guérin da123eaf41
export_import: snapshots on application import (#87653) 2024-03-07 16:44:44 +01:00
Lauréline Guérin 024b34b34f
agendas: object history and compare (#87316)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-07 16:44:19 +01:00
27 changed files with 1575 additions and 4 deletions

View File

@ -19,6 +19,7 @@ import json
import tarfile
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
@ -42,6 +43,14 @@ klasses_translation = {
}
klasses_translation_reverse = {v: k for k, v in klasses_translation.items()}
compare_urls = {
'agendas': 'chrono-manager-agenda-history-compare',
'categories': 'chrono-manager-category-history-compare',
'events_types': 'chrono-manager-events-type-history-compare',
'resources': 'chrono-manager-resource-history-compare',
'unavailability_calendars': 'chrono-manager-unavailability-calendar-history-compare',
}
def get_klass_from_component_type(component_type):
try:
@ -191,6 +200,27 @@ component_dependencies = ComponentDependencies.as_view()
def component_redirect(request, component_type, slug):
klass = get_klass_from_component_type(component_type)
component = get_object_or_404(klass, slug=slug)
if component_type not in klasses or component_type == 'roles':
raise Http404
if (
'compare' in request.GET
and request.GET.get('application')
and request.GET.get('version1')
and request.GET.get('version2')
):
component_type = klasses_translation.get(component_type, component_type)
return redirect(
'%s?version1=%s&version2=%s&application=%s'
% (
reverse(compare_urls[component_type], args=[component.pk]),
request.GET['version1'],
request.GET['version2'],
request.GET['application'],
)
)
if klass == Agenda:
return redirect(reverse('chrono-manager-agenda-view', kwargs={'pk': component.pk}))
if klass == Category:
@ -201,6 +231,7 @@ def component_redirect(request, component_type, slug):
return redirect(reverse('chrono-manager-resource-view', kwargs={'pk': component.pk}))
if klass == UnavailabilityCalendar:
return redirect(reverse('chrono-manager-unavailability-calendar-view', kwargs={'pk': component.pk}))
raise Http404
@ -208,7 +239,113 @@ class BundleCheck(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def put(self, request, *args, **kwargs):
return Response({'err': 0, 'data': {}})
tar_io = io.BytesIO(request.read())
try:
with tarfile.open(fileobj=tar_io) as tar:
try:
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
except KeyError:
raise APIError(_('Invalid tar file, missing manifest'))
application_slug = manifest.get('slug')
application_version = manifest.get('version_number')
if not application_slug or not application_version:
return Response({'data': {}})
differences = []
unknown_elements = []
no_history_elements = []
legacy_elements = []
content_types = ContentType.objects.get_for_models(
*[v for k, v in klasses.items() if k != 'roles']
)
for element in manifest.get('elements'):
component_type = element['type']
if component_type not in klasses or component_type == 'roles':
continue
klass = klasses[component_type]
component_type = klasses_translation.get(component_type, component_type)
try:
component = klass.objects.get(slug=element['slug'])
except klass.DoesNotExist:
unknown_elements.append(
{
'type': component_type,
'slug': element['slug'],
}
)
continue
elements_qs = ApplicationElement.objects.filter(
application__slug=application_slug,
content_type=content_types[klass],
object_id=component.pk,
)
if not elements_qs.exists():
# object exists, but not linked to the application
legacy_elements.append(
{
'type': component.application_component_type,
'slug': str(component.slug),
# information needed here, Relation objects may not exist yet in hobo
'text': component.label,
'url': reverse(
'api-export-import-component-redirect',
kwargs={
'slug': str(component.slug),
'component_type': component.application_component_type,
},
),
}
)
continue
snapshot_for_app = (
klass.get_snapshot_model()
.objects.filter(
instance=component,
application_slug=application_slug,
application_version=application_version,
)
.order_by('timestamp')
.last()
)
if not snapshot_for_app:
# no snapshot for this bundle
no_history_elements.append(
{
'type': element['type'],
'slug': element['slug'],
}
)
continue
last_snapshot = (
klass.get_snapshot_model().objects.filter(instance=component).latest('timestamp')
)
if snapshot_for_app.pk != last_snapshot.pk:
differences.append(
{
'type': element['type'],
'slug': element['slug'],
'url': '%s%s?version1=%s&version2=%s'
% (
request.build_absolute_uri('/')[:-1],
reverse(compare_urls[component_type], args=[component.pk]),
snapshot_for_app.pk,
last_snapshot.pk,
),
}
)
except tarfile.TarError:
raise APIError(_('Invalid tar file'))
return Response(
{
'data': {
'differences': differences,
'unknown_elements': unknown_elements,
'no_history_elements': no_history_elements,
'legacy_elements': legacy_elements,
}
}
)
bundle_check = BundleCheck.as_view()
@ -283,6 +420,11 @@ class BundleImport(GenericAPIView):
self.application, existing_component
)
self.application_elements.add(element.content_object)
if self.install is True:
existing_component.take_snapshot(
comment=_('Application (%s)') % self.application,
application=self.application,
)
def unlink_obsolete_objects(self):
known_elements = ApplicationElement.objects.filter(application=self.application)

View File

@ -40,7 +40,7 @@ class WithSnapshotMixin:
return cls._meta.get_field('snapshot').related_model
def take_snapshot(self, *args, **kwargs):
self.get_snapshot_model().take(self, *args, **kwargs)
return self.get_snapshot_model().take(self, *args, **kwargs)
class AbstractSnapshot(models.Model):
@ -74,6 +74,7 @@ class AbstractSnapshot(models.Model):
snapshot.application_slug = application.slug
snapshot.application_version = application.version_number
snapshot.save()
return snapshot
def get_instance(self):
try:
@ -85,6 +86,54 @@ class AbstractSnapshot(models.Model):
def load_instance(self, json_instance, snapshot=None):
return self.get_instance_model().import_json(json_instance, snapshot=snapshot)[1]
def load_history(self):
if self.instance is None:
self._history = []
return
history = type(self).objects.filter(instance=self.instance)
self._history = [s.id for s in history]
@property
def previous(self):
if not hasattr(self, '_history'):
self.load_history()
try:
idx = self._history.index(self.id)
except ValueError:
return None
if idx == 0:
return None
return self._history[idx - 1]
@property
def next(self):
if not hasattr(self, '_history'):
self.load_history()
try:
idx = self._history.index(self.id)
except ValueError:
return None
try:
return self._history[idx + 1]
except IndexError:
return None
@property
def first(self):
if not hasattr(self, '_history'):
self.load_history()
return self._history[0]
@property
def last(self):
if not hasattr(self, '_history'):
self.load_history()
return self._history[-1]
class AgendaSnapshot(AbstractSnapshot):
instance = models.ForeignKey(

View File

@ -0,0 +1,150 @@
# chrono - agendas system
# Copyright (C) 2016-2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import difflib
import json
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from chrono.utils.timezone import localtime
class InstanceWithSnapshotHistoryView(ListView):
def get_queryset(self):
self.instance = get_object_or_404(self.model.get_instance_model(), pk=self.kwargs['pk'])
return self.instance.instance_snapshots.all().select_related('user')
def get_context_data(self, **kwargs):
kwargs[self.instance_context_key] = self.instance
kwargs['object'] = self.instance
current_date = None
context = super().get_context_data(**kwargs)
day_snapshot = None
for snapshot in context['object_list']:
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 context
class InstanceWithSnapshotHistoryCompareView(DetailView):
def get_snapshots_from_application(self):
version1 = self.request.GET.get('version1')
version2 = self.request.GET.get('version2')
if not version1 or not version2:
raise Http404
snapshot_for_app1 = (
self.model.get_snapshot_model()
.objects.filter(
instance=self.object,
application_slug=self.request.GET['application'],
application_version=self.request.GET['version1'],
)
.order_by('timestamp')
.last()
)
snapshot_for_app2 = (
self.model.get_snapshot_model()
.objects.filter(
instance=self.object,
application_slug=self.request.GET['application'],
application_version=self.request.GET['version2'],
)
.order_by('timestamp')
.last()
)
return snapshot_for_app1, snapshot_for_app2
def get_snapshots(self):
if 'application' in self.request.GET:
return self.get_snapshots_from_application()
id1 = self.request.GET.get('version1')
id2 = self.request.GET.get('version2')
if not id1 or not id2:
raise Http404
snapshot1 = get_object_or_404(self.model.get_snapshot_model(), pk=id1, instance=self.object)
snapshot2 = get_object_or_404(self.model.get_snapshot_model(), pk=id2, instance=self.object)
return snapshot1, snapshot2
def get_context_data(self, **kwargs):
kwargs[self.instance_context_key] = self.object
mode = self.request.GET.get('mode') or 'json'
if mode not in ['json']:
raise Http404
snapshot1, snapshot2 = self.get_snapshots()
if not snapshot1 or not snapshot2:
return redirect(reverse(self.history_view, args=[self.object.pk]))
if snapshot1.timestamp > snapshot2.timestamp:
snapshot1, snapshot2 = snapshot2, snapshot1
kwargs['mode'] = mode
kwargs['snapshot1'] = snapshot1
kwargs['snapshot2'] = snapshot2
kwargs['fromdesc'] = self.get_snapshot_desc(snapshot1)
kwargs['todesc'] = self.get_snapshot_desc(snapshot2)
kwargs.update(getattr(self, 'get_compare_%s_context' % mode)(snapshot1, snapshot2))
return super().get_context_data(**kwargs)
def get(self, request, *args, **kwargs):
self.object = self.get_object()
context = self.get_context_data(object=self.object)
if isinstance(context, HttpResponseRedirect):
return context
return self.render_to_response(context)
def get_compare_json_context(self, snapshot1, snapshot2):
s1 = json.dumps(snapshot1.serialization, sort_keys=True, indent=2)
s2 = json.dumps(snapshot2.serialization, sort_keys=True, indent=2)
diff_serialization = difflib.HtmlDiff(wrapcolumn=160).make_table(
fromlines=s1.splitlines(True),
tolines=s2.splitlines(True),
)
return {
'diff_serialization': diff_serialization,
}
def get_snapshot_desc(self, snapshot):
label_or_comment = ''
if snapshot.label:
label_or_comment = snapshot.label
elif snapshot.comment:
label_or_comment = snapshot.comment
if snapshot.application_version:
label_or_comment += ' (%s)' % _('Version %s') % snapshot.application_version
return '{name} ({pk}) - {label_or_comment} ({user}{timestamp})'.format(
name=_('Snapshot'),
pk=snapshot.id,
label_or_comment=label_or_comment,
user='%s ' % snapshot.user if snapshot.user_id else '',
timestamp=date_format(localtime(snapshot.timestamp), format='DATETIME_FORMAT'),
)

View File

@ -956,3 +956,54 @@ a.button.button-paragraph {
.application-logo, .application-icon {
vertical-align: middle;
}
.snapshots-list .collapsed {
display: none;
}
p.snapshot-description {
font-size: 80%;
margin: 0;
}
table.diff {
background: white;
border: 1px solid #f3f3f3;
border-collapse: collapse;
width: 100%;
colgroup, thead, tbody, td {
border: 1px solid #f3f3f3;
}
tbody tr:nth-child(even) {
background: #fdfdfd;
}
th, td {
max-width: 30vw;
/* it will not actually limit width as the table is set to
* expand to 100% but it will prevent one side getting wider
*/
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
.diff_header {
background: #f7f7f7;
}
td.diff_header {
text-align: right;
padding-right: 10px;
color: #606060;
}
.diff_next {
display: none;
}
.diff_add {
background-color: #aaffaa;
}
.diff_chg {
background-color: #ffff77;
}
.diff_sub {
background-color: #ffaaaa;
}
}

View File

@ -0,0 +1,20 @@
<p class="snapshot-description">{{ fromdesc|safe }} ➔ {{ todesc|safe }}</p>
<div class="diff">
{% if mode == 'json' %}
{{ diff_serialization|safe }}
{% else %}
<div class="{{ tab_class_names }}">
<div class="pk-tabs--tab-list" role="tablist">
{% for tab in tabs %}{{ tab|safe }}{% endfor %}
{{ tab_list|safe }}
</div>
<div class="pk-tabs--container">
{% for attrs, panel in panels %}
<div{% for k, v in attrs.items %} {{ k }}="{{ v }}"{% endfor %}>
{{ panel|safe }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>

View File

@ -0,0 +1,63 @@
{% load i18n %}
<div>
<form action="{{ compare_url }}" method="get">
{% if object_list|length > 1 %}
<p><button>{% trans "Show differences" %}</button></p>
{% endif %}
<table class="main">
<thead>
<th>{% trans 'Identifier' %}</th>
<th>{% trans 'Compare' %}</th>
<th>{% trans 'Date' %}</th>
<th>{% trans 'Description' %}</th>
<th>{% trans 'User' %}</th>
<th>{% trans 'Actions' %}</th>
</thead>
<tbody class="snapshots-list">
{% for snapshot in object_list %}
<tr data-day="{{ snapshot.timestamp|date:"Y-m-d" }}" class="{% if snapshot.new_day %}new-day{% else %}collapsed{% endif %}">
<td><span class="counter">#{{ snapshot.pk }}</span></td>
<td>
{% if object_list|length > 1 %}
{% if not forloop.last %}<input type="radio" name="version1" value="{{ snapshot.pk }}" {% if forloop.first %}checked="checked"{% endif %} />{% else %}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{% endif %}
{% if not forloop.first %}<input type="radio" name="version2" value="{{ snapshot.pk }}" {% if forloop.counter == 2 %}checked="checked"{% endif %}/>{% else %}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{% endif %}
{% endif %}
</td>
<td>
{{ snapshot.timestamp }}
{% if snapshot.new_day and snapshot.day_other_count %} — <a class="reveal" href="#day-{{ snapshot.timestamp|date:"Y-m-d"}}">
{% if snapshot.day_other_count >= 50 %}<strong>{% endif %}
{% blocktrans trimmed count counter=snapshot.day_other_count %}
1 other this day
{% plural %}
{{ counter }} others
{% endblocktrans %}
{% endif %}
</td>
<td>
{% if snapshot.label %}
<strong>{{ snapshot.label }}</strong>
{% elif snapshot.comment %}
{{ snapshot.comment }}
{% endif %}
{% if snapshot.application_version %}({% blocktrans with version=snapshot.application_version %}Version {{ version }}{% endblocktrans %}){% endif %}
</td>
<td>{% if snapshot.user %} {{ snapshot.user.get_full_name }}{% endif %}</td>
<td>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
<script>
$(function() {
$('tr.new-day a.reveal').on('click', function() {
var day = $(this).parents('tr.new-day').data('day');
$('.snapshots-list tr[data-day="' + day + '"]:not(.new-day)').toggleClass('collapsed');
return false;
});
});
</script>

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_agenda_settings.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Agenda history' %} - {{ agenda }}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-agenda-history' pk=agenda.pk %}">{% trans "History" %}</a>
{% endblock %}
{% block content %}
{% url 'chrono-manager-agenda-history-compare' pk=agenda.pk as compare_url %}
{% include 'chrono/includes/snapshot_history_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "chrono/manager_agenda_history.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-agenda-history-compare' pk=agenda.pk %}">{% trans "Compare snapshots" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
{% endblock %}

View File

@ -120,6 +120,10 @@
<a class="button button-paragraph" rel="popup" class="action-duplicate" href="{% url 'chrono-manager-agenda-duplicate' pk=object.pk %}">{% trans 'Duplicate' %}</a>
{% endif %}
{% if show_history %}
<h3>{% trans "Navigation" %}</h3>
<a class="button button-paragraph" href="{% url 'chrono-manager-agenda-history' pk=agenda.pk %}">{% trans 'History' %}</a>
{% endif %}
{% block agenda-extra-navigation-actions %}{% endblock %}
{% url 'chrono-manager-homepage' as object_list_url %}

View File

@ -20,6 +20,11 @@
{% else %}
<h2>{% trans "New Category" %}</h2>
{% endif %}
{% if show_history and category %}
<span class="actions">
<a href="{% url 'chrono-manager-category-history' pk=category.pk %}">{% trans 'History' %}</a>
</span>
{% endif %}
{% endblock %}
{% block content %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_category_form.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Category history' %} - {{ category }}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-category-history' pk=category.pk %}">{% trans "History" %}</a>
{% endblock %}
{% block content %}
{% url 'chrono-manager-category-history-compare' pk=category.pk as compare_url %}
{% include 'chrono/includes/snapshot_history_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "chrono/manager_category_history.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-category-history-compare' pk=category.pk %}">{% trans "Compare snapshots" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
{% endblock %}

View File

@ -8,7 +8,9 @@
{% block agenda-extra-navigation-actions %}
{% with lingo_url=object.get_lingo_url %}{% if lingo_url %}
<h3>{% trans "Navigation" %}</h3>
{% if not show_history %}
<h3>{% trans "Navigation" %}</h3>
{% endif %}
<a class="button button-paragraph" href="{{ lingo_url }}">{% trans 'Pricing' context 'pricing' %}</a>
{% endif %}{% endwith %}
{% endblock %}

View File

@ -20,6 +20,11 @@
{% else %}
<h2>{% trans "New events type" %}</h2>
{% endif %}
{% if show_history and object.pk %}
<span class="actions">
<a href="{% url 'chrono-manager-events-type-history' pk=object.pk %}">{% trans 'History' %}</a>
</span>
{% endif %}
{% endblock %}
{% block content %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_events_type_form.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Events type history' %} - {{ events_type }}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-events-type-history' pk=events_type.pk %}">{% trans "History" %}</a>
{% endblock %}
{% block content %}
{% url 'chrono-manager-events-type-history-compare' pk=events_type.pk as compare_url %}
{% include 'chrono/includes/snapshot_history_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "chrono/manager_events_type_history.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-events-type-history-compare' pk=events_type.pk %}">{% trans "Compare snapshots" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
{% endblock %}

View File

@ -63,6 +63,11 @@
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-resource-edit' pk=resource.pk %}">{% trans 'Edit' %}</a>
{% endif %}
{% if show_history %}
<h3>{% trans "Navigation" %}</h3>
<a class="button button-paragraph" href="{% url 'chrono-manager-resource-history' pk=resource.pk %}">{% trans 'History' %}</a>
{% endif %}
{% url 'chrono-manager-resource-list' as object_list_url %}
{% include 'chrono/includes/application_detail_fragment.html' %}
</aside>

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_resource_detail.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Resource history' %} - {{ resource }}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-resource-history' pk=resource.pk %}">{% trans "History" %}</a>
{% endblock %}
{% block content %}
{% url 'chrono-manager-resource-history-compare' pk=resource.pk as compare_url %}
{% include 'chrono/includes/snapshot_history_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "chrono/manager_resource_history.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-resource-history-compare' pk=resource.pk %}">{% trans "Compare snapshots" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_unavailability_calendar_settings.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'UnavailabilityCalendarSnapshot calendar history' %} - {{ unavailability_calendar }}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-unavailability-calendar-history' pk=unavailability_calendar.pk %}">{% trans "History" %}</a>
{% endblock %}
{% block content %}
{% url 'chrono-manager-unavailability-calendar-history-compare' pk=unavailability_calendar.pk as compare_url %}
{% include 'chrono/includes/snapshot_history_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "chrono/manager_unavailability_calendar_history.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-unavailability-calendar-history-compare' pk=unavailability_calendar.pk %}">{% trans "Compare snapshots" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
{% endblock %}

View File

@ -54,6 +54,11 @@
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-unavailability-calendar-delete' pk=unavailability_calendar.id %}">{% trans 'Delete' %}</a>
{% endif %}
{% if show_history %}
<h3>{% trans "Navigation" %}</h3>
<a class="button button-paragraph" href="{% url 'chrono-manager-unavailability-calendar-history' pk=unavailability_calendar.pk %}">{% trans 'History' %}</a>
{% endif %}
{% url 'chrono-manager-unavailability-calendar-list' as object_list_url %}
{% include 'chrono/includes/application_detail_fragment.html' %}
</aside>

View File

@ -65,6 +65,16 @@ urlpatterns = [
views.unavailability_calendar_import_unavailabilities,
name='chrono-manager-unavailability-calendar-import-unavailabilities',
),
path(
'unavailability-calendar/<int:pk>/history/',
views.unavailability_calendar_history,
name='chrono-manager-unavailability-calendar-history',
),
path(
'unavailability-calendar/<int:pk>/history/compare/',
views.unavailability_calendar_history_compare,
name='chrono-manager-unavailability-calendar-history-compare',
),
path('resources/', views.resource_list, name='chrono-manager-resource-list'),
path('resource/add/', views.resource_add, name='chrono-manager-resource-add'),
path('resource/<int:pk>/', views.resource_view, name='chrono-manager-resource-view'),
@ -90,10 +100,22 @@ urlpatterns = [
),
path('resource/<int:pk>/edit/', views.resource_edit, name='chrono-manager-resource-edit'),
path('resource/<int:pk>/delete/', views.resource_delete, name='chrono-manager-resource-delete'),
path('resource/<int:pk>/history/', views.resource_history, name='chrono-manager-resource-history'),
path(
'resource/<int:pk>/history/compare/',
views.resource_history_compare,
name='chrono-manager-resource-history-compare',
),
path('categories/', views.category_list, name='chrono-manager-category-list'),
path('category/add/', views.category_add, name='chrono-manager-category-add'),
path('category/<int:pk>/edit/', views.category_edit, name='chrono-manager-category-edit'),
path('category/<int:pk>/delete/', views.category_delete, name='chrono-manager-category-delete'),
path('category/<int:pk>/history/', views.category_history, name='chrono-manager-category-history'),
path(
'category/<int:pk>/history/compare/',
views.category_history_compare,
name='chrono-manager-category-history-compare',
),
path('events-types/', views.events_type_list, name='chrono-manager-events-type-list'),
path('events-type/add/', views.events_type_add, name='chrono-manager-events-type-add'),
path('events-type/<int:pk>/edit/', views.events_type_edit, name='chrono-manager-events-type-edit'),
@ -102,6 +124,14 @@ urlpatterns = [
views.events_type_delete,
name='chrono-manager-events-type-delete',
),
path(
'events-type/<int:pk>/history/', views.events_type_history, name='chrono-manager-events-type-history'
),
path(
'events-type/<int:pk>/history/compare/',
views.events_type_history_compare,
name='chrono-manager-events-type-history-compare',
),
path('agendas/add/', views.agenda_add, name='chrono-manager-agenda-add'),
path('agendas/import/', views.agendas_import, name='chrono-manager-agendas-import'),
path('agendas/export/', views.agendas_export, name='chrono-manager-agendas-export'),
@ -449,6 +479,12 @@ urlpatterns = [
views.agenda_import_events_sample_csv,
name='chrono-manager-sample-events-csv',
),
path('agendas/<int:pk>/history/', views.agenda_history, name='chrono-manager-agenda-history'),
path(
'agendas/<int:pk>/history/compare/',
views.agenda_history_compare,
name='chrono-manager-agenda-history-compare',
),
path(
'shared-custody/settings/',
views.shared_custody_settings,

View File

@ -92,6 +92,14 @@ from chrono.agendas.models import (
VirtualMember,
)
from chrono.apps.export_import.models import Application
from chrono.apps.snapshot.models import (
AgendaSnapshot,
CategorySnapshot,
EventsTypeSnapshot,
ResourceSnapshot,
UnavailabilityCalendarSnapshot,
)
from chrono.apps.snapshot.views import InstanceWithSnapshotHistoryCompareView, InstanceWithSnapshotHistoryView
from chrono.utils.date import get_weekday_index
from chrono.utils.timezone import localtime, make_aware, make_naive, now
@ -288,6 +296,7 @@ class ResourceDetailView(DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['resource'] = self.object
context['show_history'] = settings.SNAPSHOTS_ENABLED
return context
@ -791,6 +800,35 @@ class ResourceDeleteView(DeleteView):
resource_delete = ResourceDeleteView.as_view()
class ResourceHistoryView(InstanceWithSnapshotHistoryView):
template_name = 'chrono/manager_resource_history.html'
model = ResourceSnapshot
instance_context_key = 'resource'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
resource_history = ResourceHistoryView.as_view()
class ResourceHistoryCompareView(InstanceWithSnapshotHistoryCompareView):
template_name = 'chrono/manager_resource_history_compare.html'
model = Resource
instance_context_key = 'resource'
history_view = 'chrono-manager-resource-history'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
resource_history_compare = ResourceHistoryCompareView.as_view()
class CategoryListView(WithApplicationsMixin, ListView):
template_name = 'chrono/manager_category_list.html'
model = Category
@ -852,6 +890,10 @@ class CategoryEditView(UpdateView):
self.object.take_snapshot(request=self.request)
return response
def get_context_data(self, **kwargs):
kwargs['show_history'] = settings.SNAPSHOTS_ENABLED
return super().get_context_data(**kwargs)
category_edit = CategoryEditView.as_view()
@ -876,6 +918,35 @@ class CategoryDeleteView(DeleteView):
category_delete = CategoryDeleteView.as_view()
class CategoryHistoryView(InstanceWithSnapshotHistoryView):
template_name = 'chrono/manager_category_history.html'
model = CategorySnapshot
instance_context_key = 'category'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
category_history = CategoryHistoryView.as_view()
class CategoryHistoryCompareView(InstanceWithSnapshotHistoryCompareView):
template_name = 'chrono/manager_category_history_compare.html'
model = Category
instance_context_key = 'category'
history_view = 'chrono-manager-category-history'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
category_history_compare = CategoryHistoryCompareView.as_view()
class EventsTypeListView(WithApplicationsMixin, ListView):
template_name = 'chrono/manager_events_type_list.html'
model = EventsType
@ -934,6 +1005,7 @@ class EventsTypeEditView(UpdateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['show_history'] = settings.SNAPSHOTS_ENABLED
data = None
if self.request.method == 'POST':
data = self.request.POST
@ -1020,6 +1092,35 @@ class EventsTypeDeleteView(DeleteView):
events_type_delete = EventsTypeDeleteView.as_view()
class EventsTypeHistoryView(InstanceWithSnapshotHistoryView):
template_name = 'chrono/manager_events_type_history.html'
model = EventsTypeSnapshot
instance_context_key = 'events_type'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
events_type_history = EventsTypeHistoryView.as_view()
class EventsTypeHistoryCompareView(InstanceWithSnapshotHistoryCompareView):
template_name = 'chrono/manager_events_type_history_compare.html'
model = EventsType
instance_context_key = 'events_type'
history_view = 'chrono-manager-events-type-history'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
events_type_history_compare = EventsTypeHistoryCompareView.as_view()
class AgendaAddView(CreateView):
template_name = 'chrono/manager_agenda_add_form.html'
model = Agenda
@ -2430,6 +2531,7 @@ class AgendaSettings(ManagedAgendaMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['show_history'] = settings.SNAPSHOTS_ENABLED
if self.agenda.accept_meetings():
context['meeting_types'] = self.object.iter_meetingtypes()
if self.agenda.kind == 'virtual':
@ -4052,6 +4154,35 @@ class TimePeriodExceptionSourceRefreshView(ManagedTimePeriodExceptionMixin, Deta
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view()
class AgendaHistoryView(InstanceWithSnapshotHistoryView):
template_name = 'chrono/manager_agenda_history.html'
model = AgendaSnapshot
instance_context_key = 'agenda'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
agenda_history = AgendaHistoryView.as_view()
class AgendaHistoryCompareView(InstanceWithSnapshotHistoryCompareView):
template_name = 'chrono/manager_agenda_history_compare.html'
model = Agenda
instance_context_key = 'agenda'
history_view = 'chrono-manager-agenda-history'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
agenda_history_compare = AgendaHistoryCompareView.as_view()
class BookingCancelView(ViewableAgendaMixin, UpdateView):
template_name = 'chrono/manager_confirm_booking_cancellation.html'
model = Booking
@ -4560,6 +4691,7 @@ class UnavailabilityCalendarSettings(ManagedUnavailabilityCalendarMixin, DetailV
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['unavailability_calendar'] = self.object
context['show_history'] = settings.SNAPSHOTS_ENABLED
return context
@ -4669,6 +4801,35 @@ class UnavailabilityCalendarImportUnavailabilitiesView(ManagedUnavailabilityCale
unavailability_calendar_import_unavailabilities = UnavailabilityCalendarImportUnavailabilitiesView.as_view()
class UnavailabilityCalendarHistoryView(InstanceWithSnapshotHistoryView):
template_name = 'chrono/manager_unavailability_calendar_history.html'
model = UnavailabilityCalendarSnapshot
instance_context_key = 'unavailability_calendar'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
unavailability_calendar_history = UnavailabilityCalendarHistoryView.as_view()
class UnavailabilityCalendarHistoryCompareView(InstanceWithSnapshotHistoryCompareView):
template_name = 'chrono/manager_unavailability_calendar_history_compare.html'
model = UnavailabilityCalendar
instance_context_key = 'unavailability_calendar'
history_view = 'chrono-manager-unavailability-calendar-history'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
unavailability_calendar_history_compare = UnavailabilityCalendarHistoryCompareView.as_view()
class SharedCustodyAgendaMixin:
agenda = None
tab_anchor = None

View File

@ -206,6 +206,7 @@ REST_FRAMEWORK = {'EXCEPTION_HANDLER': 'chrono.api.utils.exception_handler'}
SHARED_CUSTODY_ENABLED = False
PARTIAL_BOOKINGS_ENABLED = False
SNAPSHOTS_ENABLED = False
CHRONO_ANTS_HUB_URL = None

View File

@ -9,6 +9,7 @@ from django.contrib.contenttypes.models import ContentType
from chrono.agendas.models import Agenda, Category, Desk, EventsType, Resource, UnavailabilityCalendar
from chrono.apps.export_import.models import Application, ApplicationElement
from chrono.apps.snapshot.models import AgendaSnapshot
pytestmark = pytest.mark.django_db
@ -374,22 +375,57 @@ def test_redirect(app, user):
redirect_url = f'/api/export-import/agendas/{agenda.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == f'/manage/agendas/{agenda.pk}/'
resp = app.get(redirect_url + '?compare', status=302)
assert resp.location == f'/manage/agendas/{agenda.pk}/'
resp = app.get(redirect_url + '?compare&version1=bar&version2=bar&application=foo', status=302)
assert (
resp.location
== f'/manage/agendas/{agenda.pk}/history/compare/?version1=bar&version2=bar&application=foo'
)
redirect_url = f'/api/export-import/agendas_categories/{category.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == '/manage/categories/'
resp = app.get(redirect_url + '?compare', status=302)
assert resp.location == '/manage/categories/'
resp = app.get(redirect_url + '?compare&version1=bar&version2=bar&application=foo', status=302)
assert (
resp.location
== f'/manage/category/{category.pk}/history/compare/?version1=bar&version2=bar&application=foo'
)
redirect_url = f'/api/export-import/resources/{resource.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == f'/manage/resource/{resource.pk}/'
resp = app.get(redirect_url + '?compare', status=302)
assert resp.location == f'/manage/resource/{resource.pk}/'
resp = app.get(redirect_url + '?compare&version1=bar&version2=bar&application=foo', status=302)
assert (
resp.location
== f'/manage/resource/{resource.pk}/history/compare/?version1=bar&version2=bar&application=foo'
)
redirect_url = f'/api/export-import/events_types/{events_type.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == '/manage/events-types/'
resp = app.get(redirect_url + '?compare', status=302)
assert resp.location == '/manage/events-types/'
resp = app.get(redirect_url + '?compare&version1=bar&version2=bar&application=foo', status=302)
assert (
resp.location
== f'/manage/events-type/{events_type.pk}/history/compare/?version1=bar&version2=bar&application=foo'
)
redirect_url = f'/api/export-import/unavailability_calendars/{unavailability_calendar.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == f'/manage/unavailability-calendar/{unavailability_calendar.pk}/'
resp = app.get(redirect_url + '?compare', status=302)
assert resp.location == f'/manage/unavailability-calendar/{unavailability_calendar.pk}/'
resp = app.get(redirect_url + '?compare&version1=bar&version2=bar&application=foo', status=302)
assert (
resp.location
== f'/manage/unavailability-calendar/{unavailability_calendar.pk}/history/compare/?version1=bar&version2=bar&application=foo'
)
# unknown component type
app.get('/api/export-import/unknown/foo/redirect/', status=404)
@ -509,6 +545,12 @@ def test_bundle_import(app, user):
assert application.editable is False
assert application.visible is True
assert ApplicationElement.objects.count() == 8
for model in [Agenda, Category, EventsType, Resource, UnavailabilityCalendar]:
for instance in model.objects.all():
last_snapshot = model.get_snapshot_model().objects.filter(instance=instance).latest('pk')
assert last_snapshot.comment == 'Application (Test)'
assert last_snapshot.application_slug == 'test'
assert last_snapshot.application_version == '42.0'
# check editable flag is kept on install
application.editable = True
@ -538,6 +580,12 @@ def test_bundle_import(app, user):
).exists()
is False
)
for model in [Agenda, Category, EventsType, Resource, UnavailabilityCalendar]:
for instance in model.objects.all():
last_snapshot = model.get_snapshot_model().objects.filter(instance=instance).latest('pk')
assert last_snapshot.comment == 'Application (Test)'
assert last_snapshot.application_slug == 'test'
assert last_snapshot.application_version == '42.1'
# bad file format
resp = app.put('/api/export-import/bundle-import/', b'garbage')
@ -699,4 +747,283 @@ def test_bundle_unlink(app, user, bundle):
def test_bundle_check(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
assert app.put('/api/export-import/bundle-check/').json == {'err': 0, 'data': {}}
bundles = []
for version_number in ['42.0', '42.1']:
bundles.append(create_bundle(app, user, version_number=version_number))
Agenda.objects.all().delete()
Category.objects.all().delete()
Resource.objects.all().delete()
EventsType.objects.all().delete()
UnavailabilityCalendar.objects.all().delete()
incomplete_bundles = []
for manifest_json in [{'slug': 'test'}, {'version_number': '1.0'}]:
tar_io = io.BytesIO()
with tarfile.open(mode='w', fileobj=tar_io) as tar:
manifest_fd = io.BytesIO(json.dumps(manifest_json, indent=2).encode())
tarinfo = tarfile.TarInfo('manifest.json')
tarinfo.size = len(manifest_fd.getvalue())
tar.addfile(tarinfo, fileobj=manifest_fd)
incomplete_bundles.append(tar_io.getvalue())
# incorrect bundles, missing information
resp = app.put('/api/export-import/bundle-check/', incomplete_bundles[0])
assert resp.json == {'data': {}}
resp = app.put('/api/export-import/bundle-check/', incomplete_bundles[1])
assert resp.json == {'data': {}}
# not yet imported
resp = app.put('/api/export-import/bundle-check/', bundles[0])
assert resp.json == {
'data': {
'differences': [],
'no_history_elements': [],
'unknown_elements': [
{'slug': 'rdv', 'type': 'agendas'},
{'slug': 'foo', 'type': 'categories'},
{'slug': 'foo', 'type': 'resources'},
{'slug': 'foo', 'type': 'unavailability_calendars'},
{'slug': 'evt', 'type': 'agendas'},
{'slug': 'foo', 'type': 'events_types'},
{'slug': 'virt', 'type': 'agendas'},
{'slug': 'sub', 'type': 'agendas'},
],
'legacy_elements': [],
}
}
# import bundle
resp = app.put('/api/export-import/bundle-import/', bundles[0])
assert Application.objects.count() == 1
assert ApplicationElement.objects.count() == 8
# remove application links
Application.objects.all().delete()
resp = app.put('/api/export-import/bundle-check/', bundles[0])
assert resp.json == {
'data': {
'differences': [],
'no_history_elements': [],
'unknown_elements': [],
'legacy_elements': [
{
'slug': 'rdv',
'text': 'Rdv',
'type': 'agendas',
'url': '/api/export-import/agendas/rdv/redirect/',
},
{
'slug': 'foo',
'text': 'Foo',
'type': 'agendas_categories',
'url': '/api/export-import/agendas_categories/foo/redirect/',
},
{
'slug': 'foo',
'text': 'Foo',
'type': 'resources',
'url': '/api/export-import/resources/foo/redirect/',
},
{
'slug': 'foo',
'text': 'Foo',
'type': 'unavailability_calendars',
'url': '/api/export-import/unavailability_calendars/foo/redirect/',
},
{
'slug': 'evt',
'text': 'Evt',
'type': 'agendas',
'url': '/api/export-import/agendas/evt/redirect/',
},
{
'slug': 'foo',
'text': 'Foo',
'type': 'events_types',
'url': '/api/export-import/events_types/foo/redirect/',
},
{
'slug': 'virt',
'text': 'Virt',
'type': 'agendas',
'url': '/api/export-import/agendas/virt/redirect/',
},
{
'slug': 'sub',
'text': 'Sub',
'type': 'agendas',
'url': '/api/export-import/agendas/sub/redirect/',
},
],
}
}
# import bundle again, recreate links
resp = app.put('/api/export-import/bundle-import/', bundles[0])
assert Application.objects.count() == 1
assert ApplicationElement.objects.count() == 8
# no changes since last import
resp = app.put('/api/export-import/bundle-check/', bundles[0])
assert resp.json == {
'data': {
'differences': [],
'unknown_elements': [],
'no_history_elements': [],
'legacy_elements': [],
}
}
# add local changes
snapshots = {}
for model in [Agenda, Category, EventsType, Resource, UnavailabilityCalendar]:
for instance in model.objects.all():
old_snapshot = model.get_snapshot_model().objects.filter(instance=instance).latest('pk')
instance.take_snapshot(comment='local changes')
new_snapshot = model.get_snapshot_model().objects.filter(instance=instance).latest('pk')
assert new_snapshot.pk > old_snapshot.pk
snapshots[f'{instance.application_component_type}:{instance.slug}'] = (
instance.pk,
old_snapshot.pk,
new_snapshot.pk,
)
# and check
resp = app.put('/api/export-import/bundle-check/', bundles[0])
assert resp.json == {
'data': {
'differences': [
{
'slug': 'rdv',
'type': 'agendas',
'url': 'http://testserver/manage/agendas/%s/history/compare/?version1=%s&version2=%s'
% (
snapshots['agendas:rdv'][0],
snapshots['agendas:rdv'][1],
snapshots['agendas:rdv'][2],
),
},
{
'slug': 'foo',
'type': 'agendas_categories',
'url': 'http://testserver/manage/category/%s/history/compare/?version1=%s&version2=%s'
% (
snapshots['agendas_categories:foo'][0],
snapshots['agendas_categories:foo'][1],
snapshots['agendas_categories:foo'][2],
),
},
{
'slug': 'foo',
'type': 'resources',
'url': 'http://testserver/manage/resource/%s/history/compare/?version1=%s&version2=%s'
% (
snapshots['resources:foo'][0],
snapshots['resources:foo'][1],
snapshots['resources:foo'][2],
),
},
{
'slug': 'foo',
'type': 'unavailability_calendars',
'url': 'http://testserver/manage/unavailability-calendar/%s/history/compare/?version1=%s&version2=%s'
% (
snapshots['unavailability_calendars:foo'][0],
snapshots['unavailability_calendars:foo'][1],
snapshots['unavailability_calendars:foo'][2],
),
},
{
'slug': 'evt',
'type': 'agendas',
'url': 'http://testserver/manage/agendas/%s/history/compare/?version1=%s&version2=%s'
% (
snapshots['agendas:evt'][0],
snapshots['agendas:evt'][1],
snapshots['agendas:evt'][2],
),
},
{
'slug': 'foo',
'type': 'events_types',
'url': 'http://testserver/manage/events-type/%s/history/compare/?version1=%s&version2=%s'
% (
snapshots['events_types:foo'][0],
snapshots['events_types:foo'][1],
snapshots['events_types:foo'][2],
),
},
{
'slug': 'virt',
'type': 'agendas',
'url': 'http://testserver/manage/agendas/%s/history/compare/?version1=%s&version2=%s'
% (
snapshots['agendas:virt'][0],
snapshots['agendas:virt'][1],
snapshots['agendas:virt'][2],
),
},
{
'slug': 'sub',
'type': 'agendas',
'url': 'http://testserver/manage/agendas/%s/history/compare/?version1=%s&version2=%s'
% (
snapshots['agendas:sub'][0],
snapshots['agendas:sub'][1],
snapshots['agendas:sub'][2],
),
},
],
'unknown_elements': [],
'no_history_elements': [],
'legacy_elements': [],
}
}
# update bundle
resp = app.put('/api/export-import/bundle-import/', bundles[1])
# and check
resp = app.put('/api/export-import/bundle-check/', bundles[1])
assert resp.json == {
'data': {
'differences': [],
'unknown_elements': [],
'no_history_elements': [],
'legacy_elements': [],
}
}
# snapshots without application info
AgendaSnapshot.objects.update(application_slug=None, application_version=None)
resp = app.put('/api/export-import/bundle-check/', bundles[1])
assert resp.json == {
'data': {
'differences': [],
'unknown_elements': [],
'no_history_elements': [
{'slug': 'rdv', 'type': 'agendas'},
{'slug': 'evt', 'type': 'agendas'},
{'slug': 'virt', 'type': 'agendas'},
{'slug': 'sub', 'type': 'agendas'},
],
'legacy_elements': [],
}
}
# bad file format
resp = app.put('/api/export-import/bundle-check/', b'garbage')
assert resp.json['err']
assert resp.json['err_desc'] == 'Invalid tar file'
# missing manifest
tar_io = io.BytesIO()
with tarfile.open(mode='w', fileobj=tar_io) as tar:
foo_fd = io.BytesIO(json.dumps({'foo': 'bar'}, indent=2).encode())
tarinfo = tarfile.TarInfo('foo.json')
tarinfo.size = len(foo_fd.getvalue())
tar.addfile(tarinfo, fileobj=foo_fd)
resp = app.put('/api/export-import/bundle-check/', tar_io.getvalue())
assert resp.json['err']
assert resp.json['err_desc'] == 'Invalid tar file, missing manifest'

View File

@ -0,0 +1,375 @@
import datetime
import pytest
from django.utils.timezone import now
from chrono.agendas.models import Agenda, Category, Desk, Event, EventsType, Resource, UnavailabilityCalendar
from chrono.apps.snapshot.models import (
AgendaSnapshot,
CategorySnapshot,
EventsTypeSnapshot,
ResourceSnapshot,
UnavailabilityCalendarSnapshot,
)
from tests.utils import login
pytestmark = pytest.mark.django_db
def test_agenda_history(settings, app, admin_user):
agenda = Agenda.objects.create(slug='foo', label='Foo')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
snapshot1 = agenda.take_snapshot()
Event.objects.create(
agenda=agenda,
places=1,
start_datetime=now() - datetime.timedelta(days=60),
)
agenda.description = 'Foo Bar'
agenda.save()
snapshot2 = agenda.take_snapshot()
snapshot2.application_slug = 'foobar'
snapshot2.application_version = '42.0'
snapshot2.save()
assert AgendaSnapshot.objects.count() == 2
app = login(app)
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
assert 'History' not in resp
settings.SNAPSHOTS_ENABLED = True
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
resp = resp.click('History')
assert [x.attrib['class'] for x in resp.pyquery.find('.snapshots-list tr')] == [
'new-day',
'collapsed',
]
assert '(Version 42.0)' in resp.pyquery('tr:nth-child(1)').text()
resp = app.get(
'/manage/agendas/%s/history/compare/?version1=%s&version2=%s'
% (agenda.pk, snapshot1.pk, snapshot2.pk)
)
assert 'Snapshot (%s)' % (snapshot1.pk) in resp
assert 'Snapshot (%s) - (Version 42.0)' % (snapshot2.pk) in resp
assert resp.text.count('diff_sub') == 1
assert resp.text.count('diff_add') == 16
assert resp.text.count('diff_chg') == 0
resp = app.get(
'/manage/agendas/%s/history/compare/?version1=%s&version2=%s'
% (agenda.pk, snapshot2.pk, snapshot1.pk)
)
assert 'Snapshot (%s)' % (snapshot1.pk) in resp
assert 'Snapshot (%s) - (Version 42.0)' % (snapshot2.pk) in resp
assert resp.text.count('diff_sub') == 1
assert resp.text.count('diff_add') == 16
assert resp.text.count('diff_chg') == 0
# check compare on application version number
snapshot1.application_slug = 'foobar'
snapshot1.application_version = '41.0'
snapshot1.save()
# application not found
resp = app.get(
'/manage/agendas/%s/history/compare/?application=foobaz&version1=41.0&version2=42.0' % agenda.pk
)
assert resp.location == '/manage/agendas/%s/history/' % agenda.pk
# version1 not found
resp = app.get(
'/manage/agendas/%s/history/compare/?application=foobar&version1=40.0&version2=42.0' % agenda.pk
)
assert resp.location == '/manage/agendas/%s/history/' % agenda.pk
# version2 not found
resp = app.get(
'/manage/agendas/%s/history/compare/?application=foobar&version1=41.0&version2=43.0' % agenda.pk
)
assert resp.location == '/manage/agendas/%s/history/' % agenda.pk
# ok
resp = app.get(
'/manage/agendas/%s/history/compare/?application=foobar&version1=41.0&version2=42.0' % agenda.pk
)
assert 'Snapshot (%s) - (Version 41.0)' % snapshot1.pk in resp
assert 'Snapshot (%s) - (Version 42.0)' % snapshot2.pk in resp
def test_category_history(settings, app, admin_user):
category = Category.objects.create(slug='foo', label='Foo')
snapshot1 = category.take_snapshot()
category.label = 'Bar'
category.save()
snapshot2 = category.take_snapshot()
snapshot2.application_slug = 'foobar'
snapshot2.application_version = '42.0'
snapshot2.save()
assert CategorySnapshot.objects.count() == 2
app = login(app)
resp = app.get('/manage/category/%s/edit/' % category.pk)
assert 'History' not in resp
settings.SNAPSHOTS_ENABLED = True
resp = app.get('/manage/category/%s/edit/' % category.pk)
resp = resp.click('History')
assert [x.attrib['class'] for x in resp.pyquery.find('.snapshots-list tr')] == [
'new-day',
'collapsed',
]
assert '(Version 42.0)' in resp.pyquery('tr:nth-child(1)').text()
resp = app.get(
'/manage/category/%s/history/compare/?version1=%s&version2=%s'
% (category.pk, snapshot1.pk, snapshot2.pk)
)
assert 'Snapshot (%s)' % (snapshot1.pk) in resp
assert 'Snapshot (%s) - (Version 42.0)' % (snapshot2.pk) in resp
assert resp.text.count('diff_sub') == 0
assert resp.text.count('diff_add') == 0
assert resp.text.count('diff_chg') == 2
resp = app.get(
'/manage/category/%s/history/compare/?version1=%s&version2=%s'
% (category.pk, snapshot2.pk, snapshot1.pk)
)
assert 'Snapshot (%s)' % (snapshot1.pk) in resp
assert 'Snapshot (%s) - (Version 42.0)' % (snapshot2.pk) in resp
assert resp.text.count('diff_sub') == 0
assert resp.text.count('diff_add') == 0
assert resp.text.count('diff_chg') == 2
# check compare on application version number
snapshot1.application_slug = 'foobar'
snapshot1.application_version = '41.0'
snapshot1.save()
# application not found
resp = app.get(
'/manage/category/%s/history/compare/?application=foobaz&version1=41.0&version2=42.0' % category.pk
)
assert resp.location == '/manage/category/%s/history/' % category.pk
# version1 not found
resp = app.get(
'/manage/category/%s/history/compare/?application=foobar&version1=40.0&version2=42.0' % category.pk
)
assert resp.location == '/manage/category/%s/history/' % category.pk
# version2 not found
resp = app.get(
'/manage/category/%s/history/compare/?application=foobar&version1=41.0&version2=43.0' % category.pk
)
assert resp.location == '/manage/category/%s/history/' % category.pk
# ok
resp = app.get(
'/manage/category/%s/history/compare/?application=foobar&version1=41.0&version2=42.0' % category.pk
)
assert 'Snapshot (%s) - (Version 41.0)' % snapshot1.pk in resp
assert 'Snapshot (%s) - (Version 42.0)' % snapshot2.pk in resp
def test_events_type_history(settings, app, admin_user):
events_type = EventsType.objects.create(slug='foo', label='Foo')
snapshot1 = events_type.take_snapshot()
events_type.label = 'Bar'
events_type.save()
snapshot2 = events_type.take_snapshot()
snapshot2.application_slug = 'foobar'
snapshot2.application_version = '42.0'
snapshot2.save()
assert EventsTypeSnapshot.objects.count() == 2
app = login(app)
resp = app.get('/manage/events-type/%s/edit/' % events_type.pk)
assert 'History' not in resp
settings.SNAPSHOTS_ENABLED = True
resp = app.get('/manage/events-type/%s/edit/' % events_type.pk)
resp = resp.click('History')
assert [x.attrib['class'] for x in resp.pyquery.find('.snapshots-list tr')] == [
'new-day',
'collapsed',
]
assert '(Version 42.0)' in resp.pyquery('tr:nth-child(1)').text()
resp = app.get(
'/manage/events-type/%s/history/compare/?version1=%s&version2=%s'
% (events_type.pk, snapshot1.pk, snapshot2.pk)
)
assert 'Snapshot (%s)' % (snapshot1.pk) in resp
assert 'Snapshot (%s) - (Version 42.0)' % (snapshot2.pk) in resp
assert resp.text.count('diff_sub') == 0
assert resp.text.count('diff_add') == 0
assert resp.text.count('diff_chg') == 2
resp = app.get(
'/manage/events-type/%s/history/compare/?version1=%s&version2=%s'
% (events_type.pk, snapshot2.pk, snapshot1.pk)
)
assert 'Snapshot (%s)' % (snapshot1.pk) in resp
assert 'Snapshot (%s) - (Version 42.0)' % (snapshot2.pk) in resp
assert resp.text.count('diff_sub') == 0
assert resp.text.count('diff_add') == 0
assert resp.text.count('diff_chg') == 2
# check compare on application version number
snapshot1.application_slug = 'foobar'
snapshot1.application_version = '41.0'
snapshot1.save()
# application not found
resp = app.get(
'/manage/events-type/%s/history/compare/?application=foobaz&version1=41.0&version2=42.0'
% events_type.pk
)
assert resp.location == '/manage/events-type/%s/history/' % events_type.pk
# version1 not found
resp = app.get(
'/manage/events-type/%s/history/compare/?application=foobar&version1=40.0&version2=42.0'
% events_type.pk
)
assert resp.location == '/manage/events-type/%s/history/' % events_type.pk
# version2 not found
resp = app.get(
'/manage/events-type/%s/history/compare/?application=foobar&version1=41.0&version2=43.0'
% events_type.pk
)
assert resp.location == '/manage/events-type/%s/history/' % events_type.pk
# ok
resp = app.get(
'/manage/events-type/%s/history/compare/?application=foobar&version1=41.0&version2=42.0'
% events_type.pk
)
assert 'Snapshot (%s) - (Version 41.0)' % snapshot1.pk in resp
assert 'Snapshot (%s) - (Version 42.0)' % snapshot2.pk in resp
def test_resource_history(settings, app, admin_user):
resource = Resource.objects.create(slug='foo', label='Foo')
snapshot1 = resource.take_snapshot()
resource.label = 'Bar'
resource.save()
snapshot2 = resource.take_snapshot()
snapshot2.application_slug = 'foobar'
snapshot2.application_version = '42.0'
snapshot2.save()
assert ResourceSnapshot.objects.count() == 2
app = login(app)
resp = app.get('/manage/resource/%s/' % resource.pk)
assert 'History' not in resp
settings.SNAPSHOTS_ENABLED = True
resp = app.get('/manage/resource/%s/' % resource.pk)
resp = resp.click('History')
assert [x.attrib['class'] for x in resp.pyquery.find('.snapshots-list tr')] == [
'new-day',
'collapsed',
]
assert '(Version 42.0)' in resp.pyquery('tr:nth-child(1)').text()
resp = app.get(
'/manage/resource/%s/history/compare/?version1=%s&version2=%s'
% (resource.pk, snapshot1.pk, snapshot2.pk)
)
assert 'Snapshot (%s)' % (snapshot1.pk) in resp
assert 'Snapshot (%s) - (Version 42.0)' % (snapshot2.pk) in resp
assert resp.text.count('diff_sub') == 0
assert resp.text.count('diff_add') == 0
assert resp.text.count('diff_chg') == 2
resp = app.get(
'/manage/resource/%s/history/compare/?version1=%s&version2=%s'
% (resource.pk, snapshot2.pk, snapshot1.pk)
)
assert 'Snapshot (%s)' % (snapshot1.pk) in resp
assert 'Snapshot (%s) - (Version 42.0)' % (snapshot2.pk) in resp
assert resp.text.count('diff_sub') == 0
assert resp.text.count('diff_add') == 0
assert resp.text.count('diff_chg') == 2
# check compare on application version number
snapshot1.application_slug = 'foobar'
snapshot1.application_version = '41.0'
snapshot1.save()
# application not found
resp = app.get(
'/manage/resource/%s/history/compare/?application=foobaz&version1=41.0&version2=42.0' % resource.pk
)
assert resp.location == '/manage/resource/%s/history/' % resource.pk
# version1 not found
resp = app.get(
'/manage/resource/%s/history/compare/?application=foobar&version1=40.0&version2=42.0' % resource.pk
)
assert resp.location == '/manage/resource/%s/history/' % resource.pk
# version2 not found
resp = app.get(
'/manage/resource/%s/history/compare/?application=foobar&version1=41.0&version2=43.0' % resource.pk
)
assert resp.location == '/manage/resource/%s/history/' % resource.pk
# ok
resp = app.get(
'/manage/resource/%s/history/compare/?application=foobar&version1=41.0&version2=42.0' % resource.pk
)
assert 'Snapshot (%s) - (Version 41.0)' % snapshot1.pk in resp
assert 'Snapshot (%s) - (Version 42.0)' % snapshot2.pk in resp
def test_unavailability_calendar_history(settings, app, admin_user):
unavailability_calendar = UnavailabilityCalendar.objects.create(slug='foo', label='Foo')
snapshot1 = unavailability_calendar.take_snapshot()
unavailability_calendar.label = 'Bar'
unavailability_calendar.save()
snapshot2 = unavailability_calendar.take_snapshot()
snapshot2.application_slug = 'foobar'
snapshot2.application_version = '42.0'
snapshot2.save()
assert UnavailabilityCalendarSnapshot.objects.count() == 2
app = login(app)
resp = app.get('/manage/unavailability-calendar/%s/settings' % unavailability_calendar.pk)
assert 'History' not in resp
settings.SNAPSHOTS_ENABLED = True
resp = app.get('/manage/unavailability-calendar/%s/settings' % unavailability_calendar.pk)
resp = resp.click('History')
assert [x.attrib['class'] for x in resp.pyquery.find('.snapshots-list tr')] == [
'new-day',
'collapsed',
]
assert '(Version 42.0)' in resp.pyquery('tr:nth-child(1)').text()
resp = app.get(
'/manage/unavailability-calendar/%s/history/compare/?version1=%s&version2=%s'
% (unavailability_calendar.pk, snapshot1.pk, snapshot2.pk)
)
assert 'Snapshot (%s)' % (snapshot1.pk) in resp
assert 'Snapshot (%s) - (Version 42.0)' % (snapshot2.pk) in resp
assert resp.text.count('diff_sub') == 0
assert resp.text.count('diff_add') == 0
assert resp.text.count('diff_chg') == 2
resp = app.get(
'/manage/unavailability-calendar/%s/history/compare/?version1=%s&version2=%s'
% (unavailability_calendar.pk, snapshot2.pk, snapshot1.pk)
)
assert 'Snapshot (%s)' % (snapshot1.pk) in resp
assert 'Snapshot (%s) - (Version 42.0)' % (snapshot2.pk) in resp
assert resp.text.count('diff_sub') == 0
assert resp.text.count('diff_add') == 0
assert resp.text.count('diff_chg') == 2
# check compare on application version number
snapshot1.application_slug = 'foobar'
snapshot1.application_version = '41.0'
snapshot1.save()
# application not found
resp = app.get(
'/manage/unavailability-calendar/%s/history/compare/?application=foobaz&version1=41.0&version2=42.0'
% unavailability_calendar.pk
)
assert resp.location == '/manage/unavailability-calendar/%s/history/' % unavailability_calendar.pk
# version1 not found
resp = app.get(
'/manage/unavailability-calendar/%s/history/compare/?application=foobar&version1=40.0&version2=42.0'
% unavailability_calendar.pk
)
assert resp.location == '/manage/unavailability-calendar/%s/history/' % unavailability_calendar.pk
# version2 not found
resp = app.get(
'/manage/unavailability-calendar/%s/history/compare/?application=foobar&version1=41.0&version2=43.0'
% unavailability_calendar.pk
)
assert resp.location == '/manage/unavailability-calendar/%s/history/' % unavailability_calendar.pk
# ok
resp = app.get(
'/manage/unavailability-calendar/%s/history/compare/?application=foobar&version1=41.0&version2=42.0'
% unavailability_calendar.pk
)
assert 'Snapshot (%s) - (Version 41.0)' % snapshot1.pk in resp
assert 'Snapshot (%s) - (Version 42.0)' % snapshot2.pk in resp