8
0
Fork 0
mirror of https://gitlab2.federez.net/re2o/re2o synced 2024-11-24 12:23:11 +00:00
re2o/logs/views.py

576 lines
23 KiB
Python
Raw Permalink Normal View History

2020-11-23 16:06:37 +00:00
# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il
2017-01-15 23:01:18 +00:00
# se veut agnostique au réseau considéré, de manière à être installable en
# quelques clics.
#
# Copyright © 2018 Gabriel Détraz
# Copyright © 2018 Lara Kermarec
# Copyright © 2018 Augustin Lemesle
# Copyright © 2018 Hugo Levy-Falk
2017-01-15 23:01:18 +00:00
#
# 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, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
2016-10-31 16:27:27 +00:00
# App de gestion des statistiques pour re2o
# Gabriel Détraz
# Gplv2
2020-05-30 09:08:13 +00:00
"""logs.views
Views of logs and general statistics.
2017-10-14 02:17:42 +00:00
2020-05-30 09:08:13 +00:00
The general indew view displays a list of the last actions, sorted by
importance, with date and user formatted.
2017-10-14 02:17:42 +00:00
2020-05-30 09:08:13 +00:00
stats_logs returns all the logs.
2017-10-14 02:17:42 +00:00
2020-05-30 09:08:13 +00:00
The other views are related to specific topics, with statistics for number of
objects for per model, number of actions per user etc.
2017-10-14 02:17:42 +00:00
"""
from __future__ import unicode_literals
2021-02-10 10:06:09 +00:00
from django.apps import apps
2016-10-31 16:27:27 +00:00
from django.contrib import messages
2017-12-28 15:32:48 +00:00
from django.contrib.auth.decorators import login_required
2018-07-19 10:43:19 +00:00
from django.db.models import Count
2021-02-10 10:06:09 +00:00
from django.http import Http404
from django.shortcuts import redirect, render
from django.urls import reverse
2018-07-19 10:43:19 +00:00
from django.utils.translation import ugettext as _
2021-02-10 10:06:09 +00:00
from reversion.models import ContentType, Revision, Version
2016-10-31 16:27:27 +00:00
2021-02-10 10:06:09 +00:00
from cotisations.models import (Article, Banque, Cotisation, Facture, Paiement,
Vente)
from machines.models import (SOA, Domain, Extension, Interface, IpList, IpType,
Machine, MachineType, Mx, Nas, Ns,
OuverturePortList, Service, Vlan)
from preferences.models import GeneralOption
2021-02-10 10:06:09 +00:00
from re2o.acl import (acl_error_message, can_edit_history, can_view,
can_view_all, can_view_app)
from re2o.base import SortTable, re2o_paginator
from re2o.utils import (all_active_assigned_interfaces_count,
all_active_interfaces_count, all_adherent, all_baned,
all_has_access, all_whitelisted)
2017-10-14 02:17:42 +00:00
from re2o.views import form
2021-02-10 10:06:09 +00:00
from topologie.models import (AccessPoint, ConstructorSwitch, ModelSwitch,
Port, Room, Stack, Switch)
from users.models import (Adherent, Ban, Club, ListRight, ListShell, School,
ServiceUser, User, Whitelist)
2020-04-22 16:17:06 +00:00
from .acl import can_view as can_view_logs
2021-02-10 10:06:09 +00:00
from .forms import ActionsSearchForm, MachineHistorySearchForm
from .models import (ActionsSearch, MachineHistorySearch, RevisionAction,
get_history_class)
2016-10-31 16:27:27 +00:00
@login_required
@can_view_app("logs")
2016-10-31 16:27:27 +00:00
def index(request):
"""View used to display summary of events about users."""
pagination_number = GeneralOption.get_cached_value("pagination_number")
# The types of content kept for display
content_type_filter = ["ban", "whitelist", "vente", "interface", "user"]
# Select only wanted versions
2017-10-14 02:17:42 +00:00
versions = Version.objects.filter(
content_type__in=ContentType.objects.filter(model__in=content_type_filter)
).select_related("revision")
versions = SortTable.sort(
versions, request.GET.get("col"), request.GET.get("order"), SortTable.LOGS_INDEX
)
versions = re2o_paginator(request, versions, pagination_number)
# Force to have a list instead of QuerySet
versions.count(0)
# Items to remove later because invalid
to_remove = []
# Parse every item (max = pagination_number)
2017-10-14 02:17:42 +00:00
for i in range(len(versions.object_list)):
if versions.object_list[i].object:
version = versions.object_list[i]
versions.object_list[i] = {
"rev_id": version.revision.id,
"comment": version.revision.comment,
"datetime": version.revision.date_created,
"username": version.revision.user.get_username()
if version.revision.user
else "?",
"user_id": version.revision.user_id,
"version": version,
}
2017-10-14 02:17:42 +00:00
else:
to_remove.insert(0, i)
# Remove all tagged invalid items
2017-10-14 02:17:42 +00:00
for i in to_remove:
versions.object_list.pop(i)
return render(request, "logs/index.html", {"versions_list": versions})
2017-10-14 02:17:42 +00:00
@login_required
2017-12-28 15:32:48 +00:00
@can_view_all(GeneralOption)
def stats_logs(request):
"""View used to do an advanced search through the logs."""
2020-04-24 13:37:05 +00:00
actions_form = ActionsSearchForm(request.GET or None)
if actions_form.is_valid():
actions = ActionsSearch()
revisions = actions.get(actions_form.cleaned_data)
revisions = SortTable.sort(
revisions,
request.GET.get("col"),
request.GET.get("order"),
SortTable.LOGS_STATS_LOGS,
)
pagination_number = GeneralOption.get_cached_value("pagination_number")
revisions = re2o_paginator(request, revisions, pagination_number)
2020-04-24 16:45:37 +00:00
# Only do this now so it's not applied to objects which aren't displayed
# It can take a bit of time because it has to compute the diff of each version
revisions.object_list = [RevisionAction(r) for r in revisions.object_list]
2020-04-24 13:37:05 +00:00
return render(request, "logs/stats_logs.html", {"revisions_list": revisions})
2020-04-30 19:55:44 +00:00
return render(
request, "logs/search_stats_logs.html", {"actions_form": actions_form}
)
2017-10-14 02:17:42 +00:00
@login_required
2017-12-28 15:32:48 +00:00
@can_edit_history
def revert_action(request, revision_id):
"""View used to revert actions."""
try:
revision = Revision.objects.get(id=revision_id)
except Revision.DoesNotExist:
2018-08-05 16:57:36 +00:00
messages.error(request, _("Nonexistent revision."))
if request.method == "POST":
revision.revert()
2018-08-05 16:57:36 +00:00
messages.success(request, _("The action was deleted."))
return redirect(reverse("logs:index"))
return form(
{"objet": revision, "objet_name": revision.__class__.__name__},
"logs/delete.html",
request,
)
2017-10-14 02:17:42 +00:00
2017-05-26 02:32:45 +00:00
@login_required
@can_view_all(IpList, Interface, User)
2017-05-26 02:32:45 +00:00
def stats_general(request):
"""View used to display general statistics about users (activated,
disabled, archived etc.) and IP addresses (ranges, number of assigned
addresses etc.).
"""
2017-10-14 02:17:42 +00:00
ip_dict = dict()
for ip_range in IpType.objects.select_related("vlan").all():
2017-05-26 02:32:45 +00:00
all_ip = IpList.objects.filter(ip_type=ip_range)
used_ip = Interface.objects.filter(ipv4__in=all_ip).count()
active_ip = (
all_active_assigned_interfaces_count()
.filter(ipv4__in=IpList.objects.filter(ip_type=ip_range))
.count()
)
ip_dict[ip_range] = [
ip_range,
ip_range.vlan,
all_ip.count(),
used_ip,
active_ip,
all_ip.count() - used_ip,
]
_all_adherent = all_adherent(including_asso=False)
_all_has_access = all_has_access(including_asso=False)
2017-10-27 00:44:19 +00:00
_all_baned = all_baned()
_all_whitelisted = all_whitelisted()
_all_active_interfaces_count = all_active_interfaces_count()
_all_active_assigned_interfaces_count = all_active_assigned_interfaces_count()
2017-05-26 02:32:45 +00:00
stats = [
[ # First set of data (about users)
[ # Headers
2018-08-05 16:57:36 +00:00
_("Category"),
_("Number of users (members and clubs)"),
_("Number of members"),
_("Number of clubs"),
2018-04-13 20:37:04 +00:00
],
{ # Data
"active_users": [
2018-08-05 16:57:36 +00:00
_("Activated users"),
2018-04-13 20:37:04 +00:00
User.objects.filter(state=User.STATE_ACTIVE).count(),
(Adherent.objects.filter(state=Adherent.STATE_ACTIVE).count()),
Club.objects.filter(state=Club.STATE_ACTIVE).count(),
2018-04-13 20:37:04 +00:00
],
"inactive_users": [
2018-08-05 16:57:36 +00:00
_("Disabled users"),
2018-04-13 20:37:04 +00:00
User.objects.filter(state=User.STATE_DISABLED).count(),
(Adherent.objects.filter(state=Adherent.STATE_DISABLED).count()),
Club.objects.filter(state=Club.STATE_DISABLED).count(),
2018-04-13 20:37:04 +00:00
],
"archive_users": [
2018-08-05 16:57:36 +00:00
_("Archived users"),
2018-04-13 20:37:04 +00:00
User.objects.filter(state=User.STATE_ARCHIVE).count(),
(Adherent.objects.filter(state=Adherent.STATE_ARCHIVE).count()),
Club.objects.filter(state=Club.STATE_ARCHIVE).count(),
2019-03-17 02:00:28 +00:00
],
"full_archive_users": [
2019-11-16 14:03:08 +00:00
_("Fully archived users"),
2019-03-17 02:00:28 +00:00
User.objects.filter(state=User.STATE_FULL_ARCHIVE).count(),
(
Adherent.objects.filter(
state=Adherent.STATE_FULL_ARCHIVE
).count()
),
Club.objects.filter(state=Club.STATE_FULL_ARCHIVE).count(),
2018-04-13 20:37:04 +00:00
],
"not_active_users": [
_("Not yet active users"),
User.objects.filter(state=User.STATE_NOT_YET_ACTIVE).count(),
(
Adherent.objects.filter(
state=Adherent.STATE_NOT_YET_ACTIVE
).count()
),
Club.objects.filter(state=Club.STATE_NOT_YET_ACTIVE).count(),
],
"adherent_users": [
2018-08-05 16:57:36 +00:00
_("Contributing members"),
2018-04-13 20:37:04 +00:00
_all_adherent.count(),
_all_adherent.exclude(adherent__isnull=True).count(),
_all_adherent.exclude(club__isnull=True).count(),
2018-04-13 20:37:04 +00:00
],
"connexion_users": [
2018-08-05 16:57:36 +00:00
_("Users benefiting from a connection"),
2018-04-13 20:37:04 +00:00
_all_has_access.count(),
_all_has_access.exclude(adherent__isnull=True).count(),
_all_has_access.exclude(club__isnull=True).count(),
2018-04-13 20:37:04 +00:00
],
"ban_users": [
2018-08-05 16:57:36 +00:00
_("Banned users"),
2018-04-13 20:37:04 +00:00
_all_baned.count(),
_all_baned.exclude(adherent__isnull=True).count(),
_all_baned.exclude(club__isnull=True).count(),
2018-04-13 20:37:04 +00:00
],
"whitelisted_user": [
2018-08-05 16:57:36 +00:00
_("Users benefiting from a free connection"),
2018-04-13 20:37:04 +00:00
_all_whitelisted.count(),
_all_whitelisted.exclude(adherent__isnull=True).count(),
_all_whitelisted.exclude(club__isnull=True).count(),
2018-04-13 20:37:04 +00:00
],
2020-04-17 23:36:24 +00:00
"email_state_verified_users": [
2020-04-17 23:40:01 +00:00
_("Users with a confirmed email"),
2020-04-17 23:36:24 +00:00
User.objects.filter(email_state=User.EMAIL_STATE_VERIFIED).count(),
2020-04-30 19:55:44 +00:00
Adherent.objects.filter(
email_state=User.EMAIL_STATE_VERIFIED
).count(),
2020-04-17 23:36:24 +00:00
Club.objects.filter(email_state=User.EMAIL_STATE_VERIFIED).count(),
],
"email_state_unverified_users": [
2020-04-17 23:40:01 +00:00
_("Users with an unconfirmed email"),
2020-04-30 19:55:44 +00:00
User.objects.filter(
email_state=User.EMAIL_STATE_UNVERIFIED
).count(),
Adherent.objects.filter(
email_state=User.EMAIL_STATE_UNVERIFIED
).count(),
Club.objects.filter(
email_state=User.EMAIL_STATE_UNVERIFIED
).count(),
2020-04-17 23:36:24 +00:00
],
"email_state_pending_users": [
_("Users pending email confirmation"),
User.objects.filter(email_state=User.EMAIL_STATE_PENDING).count(),
2020-04-30 19:55:44 +00:00
Adherent.objects.filter(
email_state=User.EMAIL_STATE_PENDING
).count(),
2020-04-17 23:36:24 +00:00
Club.objects.filter(email_state=User.EMAIL_STATE_PENDING).count(),
],
"actives_interfaces": [
2018-08-05 16:57:36 +00:00
_("Active interfaces (with access to the network)"),
2018-04-13 20:37:04 +00:00
_all_active_interfaces_count.count(),
(
_all_active_interfaces_count.exclude(
machine__user__adherent__isnull=True
).count()
),
(
_all_active_interfaces_count.exclude(
machine__user__club__isnull=True
).count()
),
2018-04-13 20:37:04 +00:00
],
"actives_assigned_interfaces": [
2018-08-05 16:57:36 +00:00
_("Active interfaces assigned IPv4"),
2018-04-13 20:37:04 +00:00
_all_active_assigned_interfaces_count.count(),
(
_all_active_assigned_interfaces_count.exclude(
machine__user__adherent__isnull=True
).count()
),
(
_all_active_assigned_interfaces_count.exclude(
machine__user__club__isnull=True
).count()
),
],
},
2018-04-13 20:37:04 +00:00
],
[ # Second set of data (about ip adresses)
[ # Headers
2018-08-05 16:57:36 +00:00
_("IP range"),
_("VLAN"),
_("Total number of IP addresses"),
_("Number of assigned IP addresses"),
_("Number of IP address assigned to an activated machine"),
2019-11-16 14:03:08 +00:00
_("Number of unassigned IP addresses"),
2018-04-13 20:37:04 +00:00
],
ip_dict, # Data already prepared
],
2018-04-13 20:37:04 +00:00
]
return render(request, "logs/stats_general.html", {"stats_list": stats})
2017-05-26 02:32:45 +00:00
@login_required
@can_view_app("users", "cotisations", "machines", "topologie")
def stats_models(request):
"""View used to display general statistics about the number of objects
stored in the database, for each model.
"""
stats = {
2019-11-16 14:03:08 +00:00
_("Users (members and clubs)"): {
"users": [User._meta.verbose_name, User.objects.count()],
"adherents": [Adherent._meta.verbose_name, Adherent.objects.count()],
"clubs": [Club._meta.verbose_name, Club.objects.count()],
"serviceuser": [
ServiceUser._meta.verbose_name,
ServiceUser.objects.count(),
],
"school": [School._meta.verbose_name, School.objects.count()],
"listright": [ListRight._meta.verbose_name, ListRight.objects.count()],
"listshell": [ListShell._meta.verbose_name, ListShell.objects.count()],
"ban": [Ban._meta.verbose_name, Ban.objects.count()],
"whitelist": [Whitelist._meta.verbose_name, Whitelist.objects.count()],
2017-10-14 02:17:42 +00:00
},
2019-11-16 14:03:08 +00:00
Cotisation._meta.verbose_name_plural.title(): {
"factures": [Facture._meta.verbose_name, Facture.objects.count()],
"vente": [Vente._meta.verbose_name, Vente.objects.count()],
"cotisation": [Cotisation._meta.verbose_name, Cotisation.objects.count()],
"article": [Article._meta.verbose_name, Article.objects.count()],
"banque": [Banque._meta.verbose_name, Banque.objects.count()],
2017-10-14 02:17:42 +00:00
},
2019-11-16 14:03:08 +00:00
Machine._meta.verbose_name_plural.title(): {
"machine": [Machine._meta.verbose_name, Machine.objects.count()],
"typemachine": [
MachineType._meta.verbose_name,
MachineType.objects.count(),
],
"typeip": [IpType._meta.verbose_name, IpType.objects.count()],
"extension": [Extension._meta.verbose_name, Extension.objects.count()],
"interface": [Interface._meta.verbose_name, Interface.objects.count()],
"alias": [
Domain._meta.verbose_name,
Domain.objects.exclude(cname=None).count(),
],
"iplist": [IpList._meta.verbose_name, IpList.objects.count()],
"service": [Service._meta.verbose_name, Service.objects.count()],
"ouvertureportlist": [
2018-08-05 16:57:36 +00:00
OuverturePortList._meta.verbose_name,
OuverturePortList.objects.count(),
2017-10-27 00:44:19 +00:00
],
"vlan": [Vlan._meta.verbose_name, Vlan.objects.count()],
"SOA": [SOA._meta.verbose_name, SOA.objects.count()],
"Mx": [Mx._meta.verbose_name, Mx.objects.count()],
"Ns": [Ns._meta.verbose_name, Ns.objects.count()],
"nas": [Nas._meta.verbose_name, Nas.objects.count()],
2017-10-14 02:17:42 +00:00
},
2018-08-05 16:57:36 +00:00
_("Topology"): {
"switch": [Switch._meta.verbose_name, Switch.objects.count()],
"bornes": [AccessPoint._meta.verbose_name, AccessPoint.objects.count()],
"port": [Port._meta.verbose_name, Port.objects.count()],
"chambre": [Room._meta.verbose_name, Room.objects.count()],
"stack": [Stack._meta.verbose_name, Stack.objects.count()],
"modelswitch": [
2018-08-05 16:57:36 +00:00
ModelSwitch._meta.verbose_name,
ModelSwitch.objects.count(),
2017-10-27 00:44:19 +00:00
],
"constructorswitch": [
2018-08-05 16:57:36 +00:00
ConstructorSwitch._meta.verbose_name,
ConstructorSwitch.objects.count(),
2017-10-27 00:44:19 +00:00
],
2017-10-14 02:17:42 +00:00
},
_("Actions performed"): {
"revision": [_("Number of actions"), Revision.objects.count()]
2017-10-14 02:17:42 +00:00
},
}
return render(request, "logs/stats_models.html", {"stats_list": stats})
2017-10-14 02:17:42 +00:00
2016-11-01 02:11:32 +00:00
@login_required
@can_view_app("users")
2016-11-01 02:11:32 +00:00
def stats_users(request):
"""View used to display statistics aggregated by user (number of machines,
bans, whitelists, rights etc.).
"""
2016-11-01 02:11:32 +00:00
stats = {
2019-11-16 14:03:08 +00:00
User._meta.verbose_name: {
2020-04-30 19:55:44 +00:00
Machine._meta.verbose_name_plural: User.objects.annotate(
num=Count("machine")
).order_by("-num")[:10],
Facture._meta.verbose_name_plural: User.objects.annotate(
num=Count("facture")
).order_by("-num")[:10],
Ban._meta.verbose_name_plural: User.objects.annotate(
num=Count("ban")
).order_by("-num")[:10],
Whitelist._meta.verbose_name_plural: User.objects.annotate(
num=Count("whitelist")
).order_by("-num")[:10],
2019-11-16 14:03:08 +00:00
_("rights"): User.objects.annotate(num=Count("groups")).order_by("-num")[
:10
],
2017-10-14 02:17:42 +00:00
},
2019-11-16 14:03:08 +00:00
School._meta.verbose_name: {
2020-04-30 19:55:44 +00:00
User._meta.verbose_name_plural: School.objects.annotate(
num=Count("user")
).order_by("-num")[:10]
2017-10-14 02:17:42 +00:00
},
2019-11-16 14:03:08 +00:00
Paiement._meta.verbose_name: {
2020-04-30 19:55:44 +00:00
User._meta.verbose_name_plural: Paiement.objects.annotate(
num=Count("facture")
).order_by("-num")[:10]
2017-10-14 02:17:42 +00:00
},
2019-11-16 14:03:08 +00:00
Banque._meta.verbose_name: {
2020-04-30 19:55:44 +00:00
User._meta.verbose_name_plural: Banque.objects.annotate(
num=Count("facture")
).order_by("-num")[:10]
2017-10-14 02:17:42 +00:00
},
2016-11-01 02:11:32 +00:00
}
return render(request, "logs/stats_users.html", {"stats_list": stats})
2017-10-14 02:17:42 +00:00
2016-11-01 02:11:32 +00:00
@login_required
@can_view_app("users")
2016-11-01 02:11:32 +00:00
def stats_actions(request):
"""View used to display the number of actions, aggregated by user."""
2016-11-01 02:11:32 +00:00
stats = {
2019-11-16 14:03:08 +00:00
User._meta.verbose_name: {
_("actions"): User.objects.annotate(num=Count("revision")).order_by("-num")[
:40
]
}
2016-11-01 02:11:32 +00:00
}
return render(request, "logs/stats_users.html", {"stats_list": stats})
2020-04-22 16:17:06 +00:00
@login_required
@can_view_app("users")
2020-04-22 16:32:56 +00:00
def stats_search_machine_history(request):
"""View used to display the history of machines with the given IP or MAC
address.
"""
history_form = MachineHistorySearchForm(request.GET or None)
2020-04-22 16:17:06 +00:00
if history_form.is_valid():
history = MachineHistorySearch()
events = history.get(
2020-04-30 19:55:44 +00:00
history_form.cleaned_data.get("q", ""), history_form.cleaned_data
)
max_result = GeneralOption.get_cached_value("pagination_number")
2020-04-30 19:55:44 +00:00
events = re2o_paginator(request, events, max_result)
2021-02-10 10:06:09 +00:00
return render(
request,
"logs/machine_history.html",
{"events": events},
)
2020-04-30 19:55:44 +00:00
return render(
request, "logs/search_machine_history.html", {"history_form": history_form}
)
2020-04-22 16:17:06 +00:00
def get_history_object(request, model, object_name, object_id):
2020-04-23 16:06:21 +00:00
"""Get the objet of type model with the given object_id
Handles permissions and DoesNotExist errors
"""
try:
2020-04-30 19:55:44 +00:00
instance = model.get_instance(object_id)
2020-04-23 16:06:21 +00:00
except model.DoesNotExist:
instance = None
if instance is None:
authorized, msg, permissions = can_view_logs(request.user)
else:
authorized, msg, permissions = instance.can_view(request.user)
msg = acl_error_message(msg, permissions)
if not authorized:
2020-04-23 16:06:21 +00:00
messages.error(
request, msg or _("You don't have the right to access this menu.")
)
2020-04-30 19:55:44 +00:00
return (
False,
redirect(reverse("users:profil", kwargs={"userid": str(request.user.id)})),
2020-04-23 16:06:21 +00:00
)
return True, instance
@login_required
def history(request, application, object_name, object_id):
"""Render history for a model.
The model is determined using the `HISTORY_BIND` dictionnary if none is
found, raises a Http404. The view checks if the user is allowed to see the
history using the `can_view` method of the model, or the generic
`can_view_app("logs")` for deleted objects (see `get_history_object`).
Args:
request: The request sent by the user.
application: Name of the application.
object_name: Name of the model.
object_id: Id of the object you want to acces history.
Returns:
The rendered page of history if access is granted, else the user is
redirected to their profile page, with an error message.
Raises:
Http404: This kind of models doesn't have history.
"""
try:
model = apps.get_model(application, object_name)
except LookupError:
2018-07-19 10:43:19 +00:00
raise Http404(_("No model found."))
2020-04-23 16:06:21 +00:00
authorized, instance = get_history_object(request, model, object_name, object_id)
2020-08-28 19:16:44 +00:00
if not authorized:
2020-04-23 16:06:21 +00:00
return instance
history = get_history_class(model)
events = history.get(int(object_id), model)
# Events is None if object wasn't found
if events is None:
messages.error(request, _("Nonexistent entry."))
return redirect(
reverse("users:profil", kwargs={"userid": str(request.user.id)})
)
# Generate the pagination with the objects
max_result = GeneralOption.get_cached_value("pagination_number")
events = re2o_paginator(request, events, max_result)
# Add a default title in case the object was deleted
title = instance or "{} ({})".format(history.name, _("Deleted"))
return render(
request,
2020-04-25 10:45:36 +00:00
"re2o/history.html",
{"title": title, "events": events, "related_history": history.related},
)