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.views.generic.detail import SingleObjectMixin
from shared.mixins import LogMixin
from .models import Election, Option, Question
class AdminOnlyMixin(PermissionRequiredMixin):
class AdminOnlyMixin(LogMixin, PermissionRequiredMixin):
"""Restreint l'accès aux admins"""
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))
short_name = models.SlugField(_("nom bref"), unique=True)
description = TranslatedFieldWithFallback(
@ -80,6 +80,16 @@ class Election(models.Model):
_("date de publication"), null=True, default=None
)
serializable_fields = [
"name_fr",
"name_en",
"description_fr",
"description_en",
"start_date",
"end_date",
"restricted",
]
class Meta:
permissions = [
("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,
related_name="registered_voters",
@ -239,6 +249,8 @@ class User(AbstractUser):
full_name = models.CharField(_("Nom et Prénom"), max_length=150, blank=True)
has_valid_email = models.BooleanField(_("email valide"), null=True, default=None)
serializable_fields = ["username", "email", "is_staff"]
@property
def base_username(self):
return "__".join(self.username.split("__")[1:])

View file

@ -68,6 +68,8 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
)[:50]
# TODO: Change this if we modify the user model
form.instance.created_by = self.request.user
self.log_info("Election created", data={"election": form.instance.get_data()})
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
# le mail d'annonce n'a pas été fait
if obj.voters.exists() or obj.send_election_mail:
self.log_warn("Cannot delete election")
raise Http404
return obj
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)
@ -118,6 +125,11 @@ class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView):
self.election = self.get_object()
self.election.visible = True
self.election.save()
self.log_info(
"Election set to visible", data={"election": self.election.get_data()}
)
return super().get(request, *args, **kwargs)
@ -131,9 +143,13 @@ class ExportVotersView(CreatorOnlyMixin, View):
response["Content-Disposition"] = "attachment; filename=voters.csv"
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])
self.log_info("Voters exported", data={"election": obj.get_data()})
return response
@ -168,6 +184,9 @@ class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormVi
# existant déjà pour ne pas avoir de doublons
self.object.registered_voters.all().delete()
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)
@ -205,6 +224,11 @@ class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView
body=form.cleaned_data["message"],
reply_to=self.request.user.email,
)
self.log_info(
"Started sending e-mails", data={"election": self.object.get_data()}
)
return super().form_valid(form)
@ -216,7 +240,7 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
def get_form(self, form_class=None):
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
return form
@ -228,6 +252,9 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
# pré-enregistré·e·s
if not form.cleaned_data["restricted"]:
self.object.registered_voters.all().delete()
self.log_info("Updated election", data={"election": self.object.get_data()})
return super().form_valid(form)
@ -264,6 +291,12 @@ class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
# On marque les questions comme non votées
self.voter.cast_elections.remove(election)
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")
@ -288,6 +321,9 @@ class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView):
election.tallied = True
election.time_tallied = timezone.now()
election.save()
self.log_info("Election tallied", data={"election": election.get_data()})
return super().get(request, *args, **kwargs)
@ -308,6 +344,14 @@ class ElectionChangePublicationView(ClosedElectionMixin, BackgroundUpdateView):
)
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)
@ -318,11 +362,15 @@ class DownloadResultsView(CreatorOnlyMixin, View):
return super().get_queryset().filter(tallied=True)
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["Content-Disposition"] = "attachment; filename=results.txt"
self.log_info("Results downloaded", data={"election": obj.get_data()})
return response
@ -335,6 +383,9 @@ class ElectionArchiveView(ClosedElectionMixin, BackgroundUpdateView):
election = self.get_object()
election.archived = True
election.save()
self.log_info("Election archived", data={"election": election.get_data()})
return super().get(request, *args, **kwargs)

View file

@ -14,5 +14,6 @@ urlpatterns = [
"permissions", views.PermissionManagementView.as_view(), name="auth.permissions"
),
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"),
]

View file

@ -8,6 +8,9 @@ from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
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 .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
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
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)
@ -155,9 +162,22 @@ class PermissionManagementView(StaffMemberMixin, SuccessMessageMixin, FormView):
faq_perm.user_set.remove(user)
user.save()
self.log_info("Permissions changed", data={"user": user.get_data()})
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
# #############################################################################

View file

@ -1,3 +1,4 @@
import datetime
import json
@ -7,15 +8,21 @@ class Serializer:
def get_serializable_fields(self):
return self.serializable_fields
def to_json(self):
def get_data(self):
data = {}
for field in self.get_serializable_fields():
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:
raise AttributeError(
"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 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 %}

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
def bulma_message_tag(tag):
if tag == "error":
return "danger"
return tag
return "danger" if tag == "error" else tag