diff --git a/elections/mixins.py b/elections/mixins.py index 2b243bb..fdd7dab 100644 --- a/elections/mixins.py +++ b/elections/mixins.py @@ -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" diff --git a/elections/models.py b/elections/models.py index 4d1bab8..565f821 100644 --- a/elections/models.py +++ b/elections/models.py @@ -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:]) diff --git a/elections/views.py b/elections/views.py index e8f9635..8d921c3 100644 --- a/elections/views.py +++ b/elections/views.py @@ -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) diff --git a/shared/auth/urls.py b/shared/auth/urls.py index 2cf428e..59d85a5 100644 --- a/shared/auth/urls.py +++ b/shared/auth/urls.py @@ -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"), ] diff --git a/shared/auth/views.py b/shared/auth/views.py index b63f001..7d3f172 100644 --- a/shared/auth/views.py +++ b/shared/auth/views.py @@ -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 # ############################################################################# diff --git a/shared/json/mixins.py b/shared/json/mixins.py index 2b920c8..9413f72 100644 --- a/shared/json/mixins.py +++ b/shared/json/mixins.py @@ -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()) diff --git a/shared/migrations/0001_initial.py b/shared/migrations/0001_initial.py new file mode 100644 index 0000000..35a960d --- /dev/null +++ b/shared/migrations/0001_initial.py @@ -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, + ), + ), + ], + ), + ] diff --git a/shared/migrations/__init__.py b/shared/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/mixins.py b/shared/mixins.py new file mode 100644 index 0000000..ecc9429 --- /dev/null +++ b/shared/mixins.py @@ -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) diff --git a/shared/models.py b/shared/models.py new file mode 100644 index 0000000..de9ae5a --- /dev/null +++ b/shared/models.py @@ -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"] diff --git a/shared/templates/auth/admin-panel.html b/shared/templates/auth/admin-panel.html index 1c92e68..f34613c 100644 --- a/shared/templates/auth/admin-panel.html +++ b/shared/templates/auth/admin-panel.html @@ -35,4 +35,22 @@ +
{% trans "Niveau" %} | +{% trans "Message" %} | +{% trans "Origine" %} | +{% trans "Heure" %} | ++ |
---|---|---|---|---|
+ | {{ e.message }} | +{% if e.user %}{{ e.user }}{% endif %} | +{{ e.timestamp }} | ++ + + + + | +
{{ e.data }} | +