general: add journal app (#86632)
gitea/chrono/pipeline/head There was a failure building this commit Details

This commit is contained in:
Frédéric Péters 2024-03-26 09:52:47 +01:00
parent 7c91b91d89
commit 125f1bc797
19 changed files with 829 additions and 18 deletions

View File

@ -9,6 +9,7 @@ recursive-include chrono/api/templates *.html
recursive-include chrono/agendas/templates *.html *.txt
recursive-include chrono/manager/templates *.html *.txt
recursive-include chrono/apps/ants_hub/templates *.html
recursive-include chrono/apps/journal/templates *.html
# sql (migrations)
recursive-include chrono/agendas/sql *.sql

View File

@ -78,6 +78,7 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext, pgettext_lazy
from chrono.apps.export_import.models import WithApplicationMixin
from chrono.apps.journal.utils import audit
from chrono.apps.snapshot.models import (
AgendaSnapshot,
CategorySnapshot,
@ -2276,6 +2277,12 @@ class Event(WithInspectMixin, models.Model):
return self.label
return date_format(localtime(self.start_datetime), format='DATETIME_FORMAT')
def get_time_label(self):
date_str = date_format(localtime(self.start_datetime), format='SHORT_DATETIME_FORMAT')
if self.label:
return f'{self.label} ({date_str})'
return date_str
@functional.cached_property
def cancellation_status(self):
if self.cancelled:
@ -3142,9 +3149,15 @@ class Booking(models.Model):
del self.user_check
return super().refresh_from_db(*args, **kwargs)
def cancel(self, trigger_callback=False):
def cancel(self, trigger_callback=False, request=None):
timestamp = now()
with transaction.atomic():
audit(
'booking:cancel',
request=request,
agenda=self.event.agenda,
extra_data={'booking': self, 'event': event},
)
self.secondary_booking_set.update(cancellation_datetime=timestamp)
self.cancellation_datetime = timestamp
self.save()
@ -3164,15 +3177,26 @@ class Booking(models.Model):
self.secondary_booking_set.update(in_waiting_list=True)
self.save()
def reset_user_was_present(self):
def reset_user_was_present(self, request=None):
with transaction.atomic():
audit(
'check:reset',
request=request,
agenda=self.event.agenda,
extra_data={
'event': self.event,
'user_name': self.user_name,
},
)
if self.user_check:
self.user_check.delete()
self.user_check = None
self.event.checked = False
self.event.save(update_fields=['checked'])
def mark_user_absence(self, check_type_slug=None, check_type_label=None, start_time=None, end_time=None):
def mark_user_absence(
self, check_type_slug=None, check_type_label=None, start_time=None, end_time=None, request=None
):
if not self.user_check:
self.user_check = BookingCheck(booking=self)
self.user_check.presence = False
@ -3183,12 +3207,23 @@ class Booking(models.Model):
self.cancellation_datetime = None
with transaction.atomic():
audit(
'check:absence',
request=request,
agenda=self.event.agenda,
extra_data={
'event': self.event,
'user_name': self.user_name,
},
)
self.user_check.save()
self.secondary_booking_set.update(cancellation_datetime=None)
self.save()
self.event.set_is_checked()
def mark_user_presence(self, check_type_slug=None, check_type_label=None, start_time=None, end_time=None):
def mark_user_presence(
self, check_type_slug=None, check_type_label=None, start_time=None, end_time=None, request=None
):
if not self.user_check:
self.user_check = BookingCheck(booking=self)
self.user_check.presence = True
@ -3199,6 +3234,15 @@ class Booking(models.Model):
self.cancellation_datetime = None
with transaction.atomic():
audit(
'check:presence',
request=request,
agenda=self.event.agenda,
extra_data={
'event': self.event,
'user_name': self.user_name,
},
)
self.user_check.save()
self.secondary_booking_set.update(cancellation_datetime=None)
self.save()

View File

@ -57,6 +57,7 @@ from chrono.agendas.models import (
)
from chrono.api import serializers
from chrono.api.utils import APIError, APIErrorBadRequest, Response
from chrono.apps.journal.utils import audit
from chrono.utils.publik_urls import translate_to_publik_url
from chrono.utils.timezone import localtime, make_aware, now
@ -1318,7 +1319,7 @@ class EventsAgendaFillslot(APIView):
if to_cancel_booking:
cancelled_booking_id = to_cancel_booking.pk
to_cancel_booking.cancel()
to_cancel_booking.cancel(request=request)
# now we have a list of events, book them.
primary_booking = None
@ -1331,6 +1332,17 @@ class EventsAgendaFillslot(APIView):
in_waiting_list=in_waiting_list,
)
new_booking.save()
audit(
'booking:create',
request=request,
agenda=event.agenda,
extra_data={
'booking': new_booking,
'event': event,
'primary_booking_id': primary_booking.id if primary_booking else None,
},
)
if lock_code and not confirm_after_lock:
Lease.objects.create(
booking=new_booking,
@ -1555,7 +1567,7 @@ class MeetingsAgendaFillslot(APIView):
).delete()
if to_cancel_booking:
cancelled_booking_id = to_cancel_booking.pk
to_cancel_booking.cancel()
to_cancel_booking.cancel(request=request)
# book event
event.save()
@ -1568,6 +1580,16 @@ class MeetingsAgendaFillslot(APIView):
color=color,
)
booking.save()
audit(
'booking:create',
request=request,
agenda=event.agenda,
extra_data={
'booking': booking,
'event': event,
},
)
if lock_code and not confirm_after_lock:
Lease.objects.create(
booking=booking,
@ -2466,6 +2488,17 @@ class MultipleAgendasEventsCheckLock(APIView):
for event in events:
event.async_refresh_booking_computed_times()
for event in events:
audit(
'check:lock' if check_locked else 'check:unlock',
request=request,
agenda=event.agenda,
extra_data={
'event': str(event),
'event_id': event.id,
},
)
return Response({'err': 0})
@ -2495,6 +2528,17 @@ class MultipleAgendasEventsInvoiced(APIView):
)
events.update(invoiced=invoiced)
for event in events:
audit(
'invoice:mark' if invoiced else 'invoice:unmark',
request=request,
agenda=event.agenda,
extra_data={
'event': str(event),
'event_id': event.id,
},
)
return Response({'err': 0})
@ -2932,7 +2976,7 @@ class BookingAPI(APIView):
if self.booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
self.booking.cancel()
self.booking.cancel(request=request)
response = {'err': 0, 'booking_id': self.booking.pk}
return Response(response)
@ -2956,7 +3000,7 @@ class CancelBooking(APIView):
raise APIError(N_('already cancelled'))
if booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
booking.cancel()
booking.cancel(request=request)
response = {'err': 0, 'booking_id': booking.id}
return Response(response)
@ -2985,6 +3029,17 @@ class AcceptBooking(APIView):
raise APIError(N_('booking is not in waiting list'), err=3)
booking.accept()
event = booking.event
audit(
'booking:accept',
request=request,
agenda=event.agenda,
extra_data={
'booking_id': booking.id,
'event': str(event),
'event_id': event.id,
},
)
response = {
'err': 0,
'booking_id': booking.pk,
@ -3016,6 +3071,18 @@ class SuspendBooking(APIView):
if booking.in_waiting_list:
raise APIError(N_('booking is already in waiting list'), err=3)
booking.suspend()
event = booking.event
audit(
'booking:suspend',
request=request,
agenda=event.agenda,
extra_data={
'booking_id': booking.id,
'event': str(event),
'event_id': event.id,
},
)
response = {'err': 0, 'booking_id': booking.pk}
return Response(response)
@ -3354,6 +3421,15 @@ class EventCheck(APIView):
if not event.checked:
event.checked = True
event.save(update_fields=['checked'])
audit(
'check:mark',
request=request,
agenda=event.agenda,
extra_data={
'event': str(event),
'event_id': event.id,
},
)
event.async_notify_checked()
response = {
'err': 0,

View File

View File

@ -0,0 +1,56 @@
# 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 datetime
import django_filters
from django.forms.widgets import DateInput
from django.utils.translation import gettext_lazy as _
from chrono.agendas.models import Agenda
from .models import AuditEntry
class DateWidget(DateInput):
input_type = 'date'
def __init__(self, *args, **kwargs):
kwargs['format'] = '%Y-%m-%d'
super().__init__(*args, **kwargs)
class DayFilter(django_filters.DateFilter):
def filter(self, qs, value):
if value:
qs = qs.filter(timestamp__gte=value, timestamp__lt=value + datetime.timedelta(days=1))
return qs
class JournalFilterSet(django_filters.FilterSet):
timestamp = DayFilter(widget=DateWidget())
agenda = django_filters.ModelChoiceFilter(queryset=Agenda.objects.all())
action_type = django_filters.ChoiceFilter(
choices=(
('booking', _('Booking')),
('check', _('Checking')),
('invoice', _('Invoicing')),
)
)
class Meta:
model = AuditEntry
fields = []

View File

@ -0,0 +1,6 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = []
operations = []

View File

@ -0,0 +1,52 @@
# Generated by Django 3.2.16 on 2024-04-23 11:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('agendas', '0171_snapshot_models'),
('journal', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AuditEntry',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Date')),
('action_type', models.CharField(max_length=100, verbose_name='Action type')),
('action_code', models.CharField(max_length=100, verbose_name='Action code')),
('extra_data', models.JSONField(blank=True, default=dict)),
(
'agenda',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='audit_entries',
to='agendas.agenda',
),
),
(
'user',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
verbose_name='User',
),
),
],
options={
'ordering': ('-timestamp',),
},
),
]

View File

@ -0,0 +1,57 @@
# 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/>.
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
MESSAGES = {
'booking:accept': _('acceptation of booking (%(booking_id)s) in event "%(event)s"'),
'booking:cancel': _('cancellation of booking (%(booking_id)s) in event "%(event)s"'),
'booking:create': _('created booking (%(booking_id)s) for event %(event)s'),
'booking:suspend': _('suspension of booking (%(booking_id)s) in event "%(event)s"'),
'check:mark': _('marked event %(event)s as checked'),
'check:mark-unchecked-absent': _('marked unchecked users as absent in %(event)s'),
'check:reset': _('reset check of %(user_name)s in %(event)s'),
'check:lock': _('marked event %(event)s as locked for checks'),
'check:unlock': _('unmarked event %(event)s as locked for checks'),
'check:absence': _('marked absence of %(user_name)s in %(event)s'),
'check:presence': _('marked presence of %(user_name)s in %(event)s'),
'invoice:mark': _('marked event %(event)s as invoiced'),
'invoice:unmark': _('unmarked event %(event)s as invoiced'),
}
class AuditEntry(models.Model):
timestamp = models.DateTimeField(verbose_name=_('Date'), auto_now_add=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, verbose_name=_('User'), on_delete=models.SET_NULL, null=True
)
action_type = models.CharField(verbose_name=_('Action type'), max_length=100)
action_code = models.CharField(verbose_name=_('Action code'), max_length=100)
agenda = models.ForeignKey(
'agendas.Agenda', on_delete=models.SET_NULL, null=True, related_name='audit_entries'
)
extra_data = models.JSONField(blank=True, default=dict)
class Meta:
ordering = ('-timestamp',)
def get_action_text(self):
try:
return MESSAGES[f'{self.action_type}:{self.action_code}'] % self.extra_data
except KeyError:
return _('Unknown entry (%s:%s)') % (self.action_type, self.action_code)

View File

@ -0,0 +1,49 @@
{% extends "chrono/manager_base.html" %}
{% load gadjo i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-audit-journal' %}">{% trans "Audit journal" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Audit journal" %}</h2>
{% endblock %}
{% block content %}
<table class="main">
<thead>
<tr>
<th>{% trans "Date" %}</th>
<th>{% trans "User" %}</th>
<th>{% trans "Agenda" %}</th>
<th>{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for line in object_list %}
<tr>
<td>{{ line.timestamp }}</td>
<td>{{ line.user.get_full_name }}</td>
<td>{{ line.agenda }}</td>
<td>{{ line.get_action_text }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "gadjo/pagination.html" %}
{% endblock %}
{% block sidebar %}
<aside id="sidebar">
<h3>{% trans "Search" %}</h3>
<form action=".">
{{ filter.form|with_template }}
<div class="buttons">
<button>{% trans "Search" %}</button>
</div>
</form>
</aside>
{% endblock %}

View File

@ -0,0 +1,24 @@
# 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/>.
from django.urls import path
from . import views
urlpatterns = [
path('', views.journal_home, name='chrono-manager-audit-journal'),
]

View File

@ -0,0 +1,35 @@
# 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/>.
from .models import AuditEntry
def audit(action, request=None, user=None, agenda=None, extra_data=None):
action_type, action_code = action.split(':', 1)
extra_data = extra_data or {}
if 'event' in extra_data:
extra_data['event_id'] = extra_data['event'].id
extra_data['event'] = extra_data['event'].get_time_label()
if 'booking' in extra_data:
extra_data['booking_id'] = extra_data['booking'].id
del extra_data['booking']
return AuditEntry.objects.create(
user=request.user if request else user,
action_type=action_type,
action_code=action_code,
agenda=agenda,
extra_data=extra_data,
)

View File

@ -0,0 +1,42 @@
# 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/>.
from django.core.exceptions import PermissionDenied
from django.views.generic import ListView
from .forms import JournalFilterSet
class JournalHomeView(ListView):
template_name = 'chrono/journal/home.html'
paginate_by = 10
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
self.filterset = JournalFilterSet(self.request.GET)
return self.filterset.qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['filter'] = self.filterset
return context
journal_home = JournalHomeView.as_view()

View File

@ -71,6 +71,9 @@
<a class="button button-paragraph" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'ANTS Hub' %}</a>
{% endif %}
{% endif %}
{% if user.is_staff and audit_journal_enabled %}
<a class="button button-paragraph" href="{% url 'chrono-manager-audit-journal' %}">{% trans 'Audit journal' %}</a>
{% endif %}
{% endif %}
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Agendas outside applications') %}

View File

@ -568,4 +568,5 @@ urlpatterns = [
),
re_path(r'^menu.json$', views.menu_json),
path('ants/', include('chrono.apps.ants_hub.urls')),
path('journal/', include('chrono.apps.journal.urls')),
]

View File

@ -92,6 +92,7 @@ from chrono.agendas.models import (
VirtualMember,
)
from chrono.apps.export_import.models import Application
from chrono.apps.journal.utils import audit
from chrono.apps.snapshot.models import (
AgendaSnapshot,
CategorySnapshot,
@ -221,6 +222,7 @@ class HomepageView(WithApplicationsMixin, ListView):
context['has_access_to_unavailability_calendars'] = self.has_access_to_unavailability_calendars()
context['shared_custody_enabled'] = settings.SHARED_CUSTODY_ENABLED
context['ants_hub_enabled'] = bool(settings.CHRONO_ANTS_HUB_URL)
context['audit_journal_enabled'] = settings.AUDIT_JOURNAL_ENABLED
context['with_sidebar'] = True
return self.with_applications_context_data(context)
@ -3334,9 +3336,19 @@ class EventPresenceView(EventCheckMixin, ViewableAgendaMixin, FormView):
booking_check = BookingCheck(booking=booking, presence=True, **qs_kwargs)
booking_checks_to_create.append(booking_check)
BookingCheck.objects.filter(booking__in=bookings).update(presence=True, **qs_kwargs)
BookingCheck.objects.bulk_create(booking_checks_to_create)
self.event.set_is_checked()
with transaction.atomic():
audit(
'check:mark-unchecked-present',
request=request,
agenda=self.event.agenda,
extra_data={
'event': self.event,
'check_type_slug': qs_kwargs['type_slug'],
},
)
BookingCheck.objects.filter(booking__in=bookings).update(presence=True, **qs_kwargs)
BookingCheck.objects.bulk_create(booking_checks_to_create)
self.event.set_is_checked()
return self.response(request)
@ -3365,9 +3377,20 @@ class EventAbsenceView(EventCheckMixin, ViewableAgendaMixin, FormView):
booking_check = BookingCheck(booking=booking, presence=False, **qs_kwargs)
booking_checks_to_create.append(booking_check)
BookingCheck.objects.filter(booking__in=bookings).update(presence=False, **qs_kwargs)
BookingCheck.objects.bulk_create(booking_checks_to_create)
self.event.set_is_checked()
with transaction.atomic():
audit(
'check:mark-unchecked-absent',
request=request,
agenda=self.event.agenda,
extra_data={
'event': self.event,
'check_type_slug': qs_kwargs['type_slug'],
},
)
BookingCheck.objects.filter(booking__in=bookings).update(presence=False, **qs_kwargs)
BookingCheck.objects.bulk_create(booking_checks_to_create)
self.event.set_is_checked()
return self.response(request)
@ -3379,6 +3402,14 @@ class EventCheckedView(EventCheckMixin, ViewableAgendaMixin, View):
if not self.event.checked:
self.event.checked = True
self.event.save(update_fields=['checked'])
audit(
'check:mark',
request=request,
agenda=self.agenda,
extra_data={
'event': self.event,
},
)
self.event.async_notify_checked()
return self.response(request)
@ -4271,7 +4302,7 @@ class BookingCancelView(ViewableAgendaMixin, UpdateView):
def form_valid(self, form):
trigger_callback = not form.cleaned_data['disable_trigger']
try:
self.booking.cancel(trigger_callback)
self.booking.cancel(trigger_callback, request=self.request)
except requests.RequestException:
form.add_error(None, _('There has been an error sending cancellation notification to form.'))
form.add_error(None, _('Check this box if you are sure you want to proceed anyway.'))
@ -4356,6 +4387,7 @@ class PresenceViewMixin:
booking.mark_user_presence(
check_type_slug=check_type.slug if check_type else None,
check_type_label=check_type.label if check_type else None,
request=request,
)
return self.response(request, booking)
@ -4372,6 +4404,7 @@ class AbsenceViewMixin:
booking.mark_user_absence(
check_type_slug=check_type.slug if check_type else None,
check_type_label=check_type.label if check_type else None,
request=request,
)
return self.response(request, booking)
@ -4393,7 +4426,7 @@ booking_absence = BookingAbsenceView.as_view()
class BookingResetView(ViewableAgendaMixin, BookingCheckMixin, FormView):
def post(self, request, *args, **kwargs):
booking = self.get_booking(**kwargs)
booking.reset_user_was_present()
booking.reset_user_was_present(request=request)
return self.response(request, booking)

View File

@ -62,6 +62,7 @@ INSTALLED_APPS = (
'chrono.manager',
'chrono.apps.ants_hub',
'chrono.apps.export_import',
'chrono.apps.journal',
'chrono.apps.snapshot',
)
@ -207,6 +208,7 @@ REST_FRAMEWORK = {'EXCEPTION_HANDLER': 'chrono.api.utils.exception_handler'}
SHARED_CUSTODY_ENABLED = False
PARTIAL_BOOKINGS_ENABLED = False
SNAPSHOTS_ENABLED = False
AUDIT_JOURNAL_ENABLED = False
CHRONO_ANTS_HUB_URL = None

View File

@ -1248,7 +1248,7 @@ def test_agenda_meeting_api_multiple_desk(app, user):
with CaptureQueriesContext(connection) as ctx:
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.pk, event_id))
assert len(ctx.captured_queries) == 18
assert len(ctx.captured_queries) == 19
assert resp_booking.json['datetime'] == localtime(Booking.objects.last().event.start_datetime).strftime(
'%Y-%m-%d %H:%M:%S'
@ -1283,7 +1283,7 @@ def test_agenda_meeting_api_multiple_desk(app, user):
booking_url = event_data['api']['fillslot_url']
with CaptureQueriesContext(connection) as ctx:
app.post(booking_url)
assert len(ctx.captured_queries) == 17
assert len(ctx.captured_queries) == 18
with CaptureQueriesContext(connection) as ctx:
app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)

View File

@ -0,0 +1,330 @@
import datetime
import pytest
from pyquery import PyQuery
from chrono.agendas.models import Agenda, Booking, Event
from chrono.apps.journal.models import AuditEntry
from chrono.apps.journal.utils import audit
from chrono.utils.timezone import make_aware
from tests.utils import login
pytestmark = pytest.mark.django_db
def test_journal_permission(app, admin_user, manager_user):
app = login(app, username='manager', password='manager')
app.get('/manage/journal/', status=403)
app = login(app)
app.get('/manage/journal/', status=200)
def test_journal_feature_flag(app, admin_user, settings):
app = login(app)
assert settings.AUDIT_JOURNAL_ENABLED is False
resp = app.get('/manage/')
assert 'Audit journal' not in resp.text
settings.AUDIT_JOURNAL_ENABLED = True
resp = app.get('/manage/')
assert 'Audit journal' in resp.text
def test_journal_browse(app, admin_user, manager_user, settings):
settings.AUDIT_JOURNAL_ENABLED = True
admin_user.first_name = 'Admin'
admin_user.save()
manager_user.first_name = 'Manager'
manager_user.save()
# some audit events
agendas = [
Agenda.objects.create(label='Foo', kind='events'),
Agenda.objects.create(label='Bar', kind='events'),
Agenda.objects.create(label='Baz', kind='events'),
]
event = Event.objects.create(
start_datetime=make_aware(datetime.datetime(2024, 1, 2, 3, 4)), places=20, agenda=agendas[0]
)
booking = Booking.objects.create(event=event)
event2 = Event.objects.create(
start_datetime=make_aware(datetime.datetime(2024, 1, 2, 3, 4)),
places=20,
label='foobar',
agenda=agendas[0],
)
for i in range(20):
user = admin_user if i % 3 else manager_user
agenda = agendas[i % 3]
entry = audit(
'booking:cancel',
user=user,
agenda=agenda,
extra_data={'booking': booking, 'event': event},
)
entry.timestamp = make_aware(
datetime.datetime(2024, 1, 1) + datetime.timedelta(days=i, hours=i, minutes=i)
)
entry.save()
entry = audit(
'check:absence', user=user, agenda=agenda, extra_data={'user_name': 'User', 'event': event2}
)
entry.timestamp = make_aware(
datetime.datetime(2024, 1, 2) + datetime.timedelta(days=i, hours=i, minutes=i)
)
entry.save()
app = login(app)
resp = app.get('/manage/journal/')
assert [[x.text for x in PyQuery(x).find('td')] for x in resp.pyquery('tbody tr')] == [
[
'Jan. 21, 2024, 7:19 p.m.',
'Admin',
'Bar',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
[
'Jan. 20, 2024, 7:19 p.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 20, 2024, 6:18 p.m.',
'Manager',
'Foo',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
[
'Jan. 19, 2024, 6:18 p.m.',
'Manager',
'Foo',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 19, 2024, 5:17 p.m.',
'Admin',
'Baz',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
[
'Jan. 18, 2024, 5:17 p.m.',
'Admin',
'Baz',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 18, 2024, 4:16 p.m.',
'Admin',
'Bar',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
[
'Jan. 17, 2024, 4:16 p.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 17, 2024, 3:15 p.m.',
'Manager',
'Foo',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
[
'Jan. 16, 2024, 3:15 p.m.',
'Manager',
'Foo',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
]
resp = resp.click('2') # pagination
assert [[x.text for x in PyQuery(x).find('td')] for x in resp.pyquery('tbody tr')] == [
[
'Jan. 16, 2024, 2:14 p.m.',
'Admin',
'Baz',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
[
'Jan. 15, 2024, 2:14 p.m.',
'Admin',
'Baz',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 15, 2024, 1:13 p.m.',
'Admin',
'Bar',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
[
'Jan. 14, 2024, 1:13 p.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 14, 2024, 12:12 p.m.',
'Manager',
'Foo',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
[
'Jan. 13, 2024, 12:12 p.m.',
'Manager',
'Foo',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 13, 2024, 11:11 a.m.',
'Admin',
'Baz',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
[
'Jan. 12, 2024, 11:11 a.m.',
'Admin',
'Baz',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 12, 2024, 10:10 a.m.',
'Admin',
'Bar',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
[
'Jan. 11, 2024, 10:10 a.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
]
# filters
assert resp.form['timestamp'].attrs == {'type': 'date'}
resp.form['timestamp'].value = '2024-01-19'
resp = resp.form.submit()
assert [[x.text for x in PyQuery(x).find('td')] for x in resp.pyquery('tbody tr')] == [
[
'Jan. 19, 2024, 6:18 p.m.',
'Manager',
'Foo',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 19, 2024, 5:17 p.m.',
'Admin',
'Baz',
'marked absence of User in foobar (01/02/2024 3:04 a.m.)',
],
]
assert resp.form['timestamp'].value == '2024-01-19'
resp.form['agenda'].value = agendas[0].id
resp = resp.form.submit()
assert [[x.text for x in PyQuery(x).find('td')] for x in resp.pyquery('tbody tr')] == [
[
'Jan. 19, 2024, 6:18 p.m.',
'Manager',
'Foo',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
]
]
resp.form['agenda'].value = agendas[1].id
resp = resp.form.submit()
assert [[x.text for x in PyQuery(x).find('td')] for x in resp.pyquery('tbody tr')] == []
resp.form['timestamp'].value = ''
resp.form['action_type'].value = 'booking'
resp = resp.form.submit()
assert [[x.text for x in PyQuery(x).find('td')] for x in resp.pyquery('tbody tr')] == [
[
'Jan. 20, 2024, 7:19 p.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 17, 2024, 4:16 p.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 14, 2024, 1:13 p.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 11, 2024, 10:10 a.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 8, 2024, 7:07 a.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 5, 2024, 4:04 a.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
[
'Jan. 2, 2024, 1:01 a.m.',
'Admin',
'Bar',
'cancellation of booking (1) in event "01/02/2024 3:04 a.m."',
],
]
def test_journal_browse_invalid_or_unknown_event(app, admin_user, settings):
settings.AUDIT_JOURNAL_ENABLED = True
admin_user.first_name = 'Admin'
admin_user.save()
AuditEntry.objects.all().delete()
agenda = Agenda.objects.create(label='Foo', kind='events')
event = Event.objects.create(
start_datetime=make_aware(datetime.datetime(2024, 1, 2, 3, 4)), places=20, agenda=agenda
)
entry = audit(
'booking:cancel',
user=admin_user,
agenda=agenda,
extra_data={'event': event}, # missing booking_id
)
entry.timestamp = make_aware(datetime.datetime(2024, 1, 1))
entry.save()
resp = login(app).get('/manage/journal/')
assert [[x.text for x in PyQuery(x).find('td')] for x in resp.pyquery('tbody tr')] == [
['Jan. 1, 2024, midnight', 'Admin', 'Foo', 'Unknown entry (booking:cancel)']
]
AuditEntry.objects.all().delete()
entry = audit(
'foo:bar',
user=admin_user,
agenda=agenda,
)
entry.timestamp = make_aware(datetime.datetime(2024, 1, 1))
entry.save()
resp = login(app).get('/manage/journal/')
assert [[x.text for x in PyQuery(x).find('td')] for x in resp.pyquery('tbody tr')] == [
['Jan. 1, 2024, midnight', 'Admin', 'Foo', 'Unknown entry (foo:bar)']
]