Add journaling for the election and auth app #37
13 changed files with 281 additions and 15 deletions
|
@ -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"
|
||||
|
|
|
@ -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:])
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
# #############################################################################
|
||||
|
|
|
@ -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())
|
||||
|
|
54
shared/migrations/0001_initial.py
Normal file
54
shared/migrations/0001_initial.py
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
0
shared/migrations/__init__.py
Normal file
0
shared/migrations/__init__.py
Normal file
23
shared/mixins.py
Normal file
23
shared/mixins.py
Normal 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
28
shared/models.py
Normal 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"]
|
|
@ -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 %}
|
||||
|
|
53
shared/templates/auth/journal.html
Normal file
53
shared/templates/auth/journal.html
Normal 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 %}
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue