Add journaling for the election and auth app #37

Closed
thubrecht wants to merge 2 commits from thubrecht/logger into master
13 changed files with 281 additions and 15 deletions

View file

@ -4,10 +4,12 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from shared.mixins import LogMixin
from .models import Election, Option, Question from .models import Election, Option, Question
class AdminOnlyMixin(PermissionRequiredMixin): class AdminOnlyMixin(LogMixin, PermissionRequiredMixin):
"""Restreint l'accès aux admins""" """Restreint l'accès aux admins"""
permission_required = "elections.election_admin" permission_required = "elections.election_admin"

View file

@ -30,7 +30,7 @@ from .utils import (
# ############################################################################# # #############################################################################
class Election(models.Model): class Election(Serializer, models.Model):
name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255)) name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255))
short_name = models.SlugField(_("nom bref"), unique=True) short_name = models.SlugField(_("nom bref"), unique=True)
description = TranslatedFieldWithFallback( description = TranslatedFieldWithFallback(
@ -80,6 +80,16 @@ class Election(models.Model):
_("date de publication"), null=True, default=None _("date de publication"), null=True, default=None
) )
serializable_fields = [
"name_fr",
"name_en",
"description_fr",
"description_en",
"start_date",
"end_date",
"restricted",
]
class Meta: class Meta:
permissions = [ permissions = [
("election_admin", _("Peut administrer des élections")), ("election_admin", _("Peut administrer des élections")),
@ -228,7 +238,7 @@ class Duel(models.Model):
# ############################################################################# # #############################################################################
class User(AbstractUser): class User(Serializer, AbstractUser):
election = models.ForeignKey( election = models.ForeignKey(
Election, Election,
related_name="registered_voters", related_name="registered_voters",
@ -239,6 +249,8 @@ class User(AbstractUser):
full_name = models.CharField(_("Nom et Prénom"), max_length=150, blank=True) full_name = models.CharField(_("Nom et Prénom"), max_length=150, blank=True)
has_valid_email = models.BooleanField(_("email valide"), null=True, default=None) has_valid_email = models.BooleanField(_("email valide"), null=True, default=None)
serializable_fields = ["username", "email", "is_staff"]
@property @property
def base_username(self): def base_username(self):
return "__".join(self.username.split("__")[1:]) return "__".join(self.username.split("__")[1:])

View file

@ -68,6 +68,8 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
)[:50] )[:50]
# TODO: Change this if we modify the user model # TODO: Change this if we modify the user model
form.instance.created_by = self.request.user form.instance.created_by = self.request.user
self.log_info("Election created", data={"election": form.instance.get_data()})
return super().form_valid(form) return super().form_valid(form)
@ -80,11 +82,16 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView):
# On ne peut supprimer que les élections n'ayant pas eu de vote et dont # On ne peut supprimer que les élections n'ayant pas eu de vote et dont
# le mail d'annonce n'a pas été fait # le mail d'annonce n'a pas été fait
if obj.voters.exists() or obj.send_election_mail: if obj.voters.exists() or obj.send_election_mail:
self.log_warn("Cannot delete election")
raise Http404 raise Http404
return obj return obj
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.get_object().delete() obj = self.get_object()
self.log_info("Election deleted", data={"election": obj.get_data()})
obj.delete()
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -118,6 +125,11 @@ class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView):
self.election = self.get_object() self.election = self.get_object()
self.election.visible = True self.election.visible = True
self.election.save() self.election.save()
self.log_info(
"Election set to visible", data={"election": self.election.get_data()}
)
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -131,9 +143,13 @@ class ExportVotersView(CreatorOnlyMixin, View):
response["Content-Disposition"] = "attachment; filename=voters.csv" response["Content-Disposition"] = "attachment; filename=voters.csv"
writer.writerow(["Nom", "login"]) writer.writerow(["Nom", "login"])
for v in self.get_object().voters.all(): obj = self.get_object()
for v in obj.voters.all():
writer.writerow([v.full_name, v.base_username]) writer.writerow([v.full_name, v.base_username])
self.log_info("Voters exported", data={"election": obj.get_data()})
return response return response
@ -168,6 +184,9 @@ class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormVi
# existant déjà pour ne pas avoir de doublons # existant déjà pour ne pas avoir de doublons
self.object.registered_voters.all().delete() self.object.registered_voters.all().delete()
create_users(self.object, form.cleaned_data["csv_file"]) create_users(self.object, form.cleaned_data["csv_file"])
self.log_info("Voters imported", data={"election": self.object.get_data()})
return super().form_valid(form) return super().form_valid(form)
@ -205,6 +224,11 @@ class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView
body=form.cleaned_data["message"], body=form.cleaned_data["message"],
reply_to=self.request.user.email, reply_to=self.request.user.email,
) )
self.log_info(
"Started sending e-mails", data={"election": self.object.get_data()}
)
return super().form_valid(form) return super().form_valid(form)
@ -216,7 +240,7 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = super().get_form(form_class) form = super().get_form(form_class)
if self.object.sent_mail: if self.object.sent_mail or self.object.sent_mail is None:
form.fields["restricted"].disabled = True form.fields["restricted"].disabled = True
return form return form
@ -228,6 +252,9 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
# pré-enregistré·e·s # pré-enregistré·e·s
if not form.cleaned_data["restricted"]: if not form.cleaned_data["restricted"]:
self.object.registered_voters.all().delete() self.object.registered_voters.all().delete()
self.log_info("Updated election", data={"election": self.object.get_data()})
return super().form_valid(form) return super().form_valid(form)
@ -264,6 +291,12 @@ class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
# On marque les questions comme non votées # On marque les questions comme non votées
self.voter.cast_elections.remove(election) self.voter.cast_elections.remove(election)
self.voter.cast_questions.remove(*list(election.questions.all())) self.voter.cast_questions.remove(*list(election.questions.all()))
self.log_warn(
"Vote deleted",
data={"election": election.get_data(), "voter": self.voter.get_data()},
)
return self.render_to_json(action="delete") return self.render_to_json(action="delete")
@ -288,6 +321,9 @@ class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView):
election.tallied = True election.tallied = True
election.time_tallied = timezone.now() election.time_tallied = timezone.now()
election.save() election.save()
self.log_info("Election tallied", data={"election": election.get_data()})
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -308,6 +344,14 @@ class ElectionChangePublicationView(ClosedElectionMixin, BackgroundUpdateView):
) )
self.election.save() self.election.save()
self.log_info(
"Election published"
if self.election.results_public
else "Election unpublished",
data={"election": self.election.get_data()},
)
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -318,11 +362,15 @@ class DownloadResultsView(CreatorOnlyMixin, View):
return super().get_queryset().filter(tallied=True) return super().get_queryset().filter(tallied=True)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
content = "\n".join([q.results for q in self.get_object().questions.all()]) obj = self.get_object()
content = "\n".join([q.results for q in obj.questions.all()])
response = HttpResponse(content, content_type="text/plain") response = HttpResponse(content, content_type="text/plain")
response["Content-Disposition"] = "attachment; filename=results.txt" response["Content-Disposition"] = "attachment; filename=results.txt"
self.log_info("Results downloaded", data={"election": obj.get_data()})
return response return response
@ -335,6 +383,9 @@ class ElectionArchiveView(ClosedElectionMixin, BackgroundUpdateView):
election = self.get_object() election = self.get_object()
election.archived = True election.archived = True
election.save() election.save()
self.log_info("Election archived", data={"election": election.get_data()})
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)

View file

@ -14,5 +14,6 @@ urlpatterns = [
"permissions", views.PermissionManagementView.as_view(), name="auth.permissions" "permissions", views.PermissionManagementView.as_view(), name="auth.permissions"
), ),
path("accounts", views.AccountListView.as_view(), name="auth.accounts"), path("accounts", views.AccountListView.as_view(), name="auth.accounts"),
path("journal", views.JournalView.as_view(), name="auth.journal"),
path("admins", views.AdminAccountsView.as_view(), name="auth.admins"), path("admins", views.AdminAccountsView.as_view(), name="auth.admins"),
] ]

View file

@ -8,6 +8,9 @@ from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, FormView, ListView, TemplateView from django.views.generic import CreateView, FormView, ListView, TemplateView
from shared.mixins import LogMixin
from shared.models import Event
from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm
from .utils import generate_password from .utils import generate_password
@ -19,7 +22,7 @@ User = get_user_model()
# ############################################################################# # #############################################################################
class StaffMemberMixin(UserPassesTestMixin): class StaffMemberMixin(LogMixin, UserPassesTestMixin):
""" """
Mixin permettant de restreindre l'accès aux membres `staff`, si la personne Mixin permettant de restreindre l'accès aux membres `staff`, si la personne
n'est pas connectée, renvoie sur la page d'authentification n'est pas connectée, renvoie sur la page d'authentification
@ -71,6 +74,10 @@ class CreatePwdAccount(StaffMemberMixin, SuccessMessageMixin, CreateView):
# On enregistre un mot de passe aléatoire # On enregistre un mot de passe aléatoire
form.instance.password = make_password(generate_password(32)) form.instance.password = make_password(generate_password(32))
self.log_info(
"Password account created", data={"user": form.instance.get_data()}
)
return super().form_valid(form) return super().form_valid(form)
@ -155,9 +162,22 @@ class PermissionManagementView(StaffMemberMixin, SuccessMessageMixin, FormView):
faq_perm.user_set.remove(user) faq_perm.user_set.remove(user)
user.save() user.save()
self.log_info("Permissions changed", data={"user": user.get_data()})
return super().form_valid(form) return super().form_valid(form)
# #############################################################################
# Log history
# #############################################################################
class JournalView(StaffMemberMixin, ListView):
queryset = Event.objects.select_related("user")
template_name = "auth/journal.html"
# ############################################################################# # #############################################################################
# List of special accounts # List of special accounts
# ############################################################################# # #############################################################################

View file

@ -1,3 +1,4 @@
import datetime
import json import json
@ -7,15 +8,21 @@ class Serializer:
def get_serializable_fields(self): def get_serializable_fields(self):
return self.serializable_fields return self.serializable_fields
def to_json(self): def get_data(self):
data = {} data = {}
for field in self.get_serializable_fields(): for field in self.get_serializable_fields():
if hasattr(self, field): if hasattr(self, field):
data.update({field: getattr(self, field)}) if isinstance(getattr(self, field), datetime.date):
data.update({field: getattr(self, field).isoformat()})
else:
data.update({field: getattr(self, field)})
else: else:
raise AttributeError( raise AttributeError(
"This object does not have a field named '{}'".format(field) "This object does not have a field named '{}'".format(field)
) )
return json.dumps(data) return data
def to_json(self):
return json.dumps(self.get_data())

View file

@ -0,0 +1,54 @@
# Generated by Django 3.2.11 on 2022-01-12 01:10
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),
]
operations = [
migrations.CreateModel(
name="Event",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("message", models.TextField(default="")),
(
"level",
models.CharField(
choices=[
("info", "INFO"),
("warning", "WARNING"),
("error", "ERROR"),
],
max_length=7,
),
),
("timestamp", models.DateTimeField(auto_now_add=True)),
("data", models.JSONField(default=dict)),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="events",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View file

23
shared/mixins.py Normal file
View file

@ -0,0 +1,23 @@
from .models import Event
# #############################################################################
# Fonctions pour la journalisation
# #############################################################################
class LogMixin:
"""Utility to log events related to the current user"""
def _log(self, message, level, data={}):
Event.objects.create(
message=message, level=level, user=self.request.user, data=data
)
def log_info(self, message, data={}):
self._log(message, "info", data=data)
def log_warn(self, message, data={}):
self._log(message, "warn", data=data)
def log_error(self, message, data={}):
self._log(message, "error", data=data)

28
shared/models.py Normal file
View file

@ -0,0 +1,28 @@
from django.contrib.auth import get_user_model
from django.db import models
from .utils import choices_length
User = get_user_model()
LOG_LEVELS = (
("info", "INFO"),
("warning", "WARNING"),
("error", "ERROR"),
)
class Event(models.Model):
message = models.TextField(default="")
level = models.CharField(choices=LOG_LEVELS, max_length=choices_length(LOG_LEVELS))
timestamp = models.DateTimeField(auto_now_add=True)
data = models.JSONField(default=dict)
user = models.ForeignKey(
User, related_name="events", on_delete=models.SET_NULL, null=True
)
class Meta:
ordering = ["-timestamp"]

View file

@ -35,4 +35,22 @@
</div> </div>
</div> </div>
<div class="tile is-ancestor">
<div class="tile is-parent">
<a class="tile is-child notification is-light px-0" href="{% url 'auth.journal' %}">
<div class="subtitle has-text-centered">
<span class="icon-text">
<span class="icon">
<i class="fas fa-server"></i>
</span>
<span class="ml-3">{% trans "Journal d'évènements" %}</span>
</span>
</div>
</a>
</div>
{# Placeholder #}
<div class="tile is-parent py-0"></div>
</div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,53 @@
{% extends "base.html" %}
{% load i18n bulma %}
{% block custom_js %}
<script>
_$('a[data-data]').forEach(b => b.addEventListener('click', () => {
_id(b.dataset.data).classList.toggle('is-hidden');
_$('i', b).forEach(i => i.classList.toggle('is-hidden'));
}));
</script>
{% endblock %}
{% block content %}
<h1 class="title">{% trans "Journal d'évènements" %}</h1>
<hr>
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>{% trans "Niveau" %}</th>
<th>{% trans "Message" %}</th>
<th>{% trans "Origine" %}</th>
<th>{% trans "Heure" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for e in object_list %}
<tr>
<td><span class="tag is-{{ e.level|bulma_message_tag }}">{{ e.get_level_display }}</span></td>
<td>{{ e.message }}</td>
<td>{% if e.user %}<i>{{ e.user }}</i>{% endif %}</td>
<td>{{ e.timestamp }}</td>
<td>
<a class="icon has-text-primary is-pulled-right" data-data="data-{{ e.pk }}">
<i class="fas fa-expand"></i>
<i class="fas fa-compress is-hidden"></i>
</a>
</td>
</tr>
<tr id="data-{{ e.pk }}" class="is-hidden">
<td colspan="5" class="is-fullwidth">{{ e.data }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -68,7 +68,4 @@ def bulmafy(field, css_class):
@register.filter @register.filter
def bulma_message_tag(tag): def bulma_message_tag(tag):
if tag == "error": return "danger" if tag == "error" else tag
return "danger"
return tag