diff --git a/helios/election_url_names.py b/helios/election_url_names.py index bb24fc837..0700de268 100644 --- a/helios/election_url_names.py +++ b/helios/election_url_names.py @@ -5,6 +5,7 @@ ELECTION_SCHEDULE="election@schedule" ELECTION_EXTEND="election@extend" ELECTION_ARCHIVE="election@archive" +ELECTION_DELETE="election@delete" ELECTION_COPY="election@copy" ELECTION_BADGE="election@badge" diff --git a/helios/election_urls.py b/helios/election_urls.py index f97601e41..db01c871e 100644 --- a/helios/election_urls.py +++ b/helios/election_urls.py @@ -21,6 +21,7 @@ path('/schedule', views.one_election_schedule, name=names.ELECTION_SCHEDULE), path('/extend', views.one_election_extend, name=names.ELECTION_EXTEND), path('/archive', views.one_election_archive, name=names.ELECTION_ARCHIVE), + path('/delete', views.one_election_delete, name=names.ELECTION_DELETE), path('/copy', views.one_election_copy, name=names.ELECTION_COPY), # badge diff --git a/helios/migrations/0008_add_election_soft_delete.py b/helios/migrations/0008_add_election_soft_delete.py new file mode 100644 index 000000000..1c4db7a1f --- /dev/null +++ b/helios/migrations/0008_add_election_soft_delete.py @@ -0,0 +1,18 @@ +# Generated manually for soft delete functionality + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helios', '0007_add_election_admins'), + ] + + operations = [ + migrations.AddField( + model_name='election', + name='deleted_at', + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/helios/models.py b/helios/models.py index 19eb866d3..c8cde856a 100644 --- a/helios/models.py +++ b/helios/models.py @@ -31,6 +31,14 @@ class HeliosModel(models.Model, datatypes.LDObjectContainer): class Meta: abstract = True +class ElectionManager(models.Manager): + """ + Custom manager that filters out soft-deleted elections by default. + Use Election.objects_with_deleted.all() to include deleted elections. + """ + def get_queryset(self): + return super().get_queryset().filter(deleted_at__isnull=True) + class Election(HeliosModel): admin = models.ForeignKey(User, on_delete=models.CASCADE) @@ -99,6 +107,9 @@ class Election(HeliosModel): frozen_at = models.DateTimeField(auto_now_add=False, default=None, null=True) archived_at = models.DateTimeField(auto_now_add=False, default=None, null=True) + # soft delete timestamp - null means not deleted, non-null means deleted at that time + deleted_at = models.DateTimeField(auto_now_add=False, default=None, null=True) + # dates for the election steps, as scheduled # these are always UTC registration_starts_at = models.DateTimeField(auto_now_add=False, default=None, null=True) @@ -146,6 +157,10 @@ class Election(HeliosModel): # downloadable election info election_info_url = models.CharField(max_length=300, null=True) + # Custom managers + objects = ElectionManager() # default manager excludes deleted elections + objects_with_deleted = models.Manager() # includes all elections + class Meta: app_label = 'helios' @@ -211,6 +226,10 @@ def encrypted_tally_hash(self): def is_archived(self): return self.archived_at is not None + @property + def is_deleted(self): + return self.deleted_at is not None + @property def description_bleached(self): return bleach.clean(self.description, @@ -256,16 +275,18 @@ def get_by_user_as_voter(cls, user, archived_p=None, limit=None): return query @classmethod - def get_by_uuid(cls, uuid): + def get_by_uuid(cls, uuid, include_deleted=False): try: - return cls.objects.select_related().get(uuid=uuid) + manager = cls.objects_with_deleted if include_deleted else cls.objects + return manager.select_related().get(uuid=uuid) except cls.DoesNotExist: return None @classmethod - def get_by_short_name(cls, short_name): + def get_by_short_name(cls, short_name, include_deleted=False): try: - return cls.objects.get(short_name=short_name) + manager = cls.objects_with_deleted if include_deleted else cls.objects + return manager.get(short_name=short_name) except cls.DoesNotExist: return None @@ -601,6 +622,23 @@ def freeze(self): self.save() + def soft_delete(self): + """ + Soft delete the election by setting deleted_at timestamp. + The election will be hidden from default queries. + """ + self.deleted_at = datetime.datetime.utcnow() + self.append_log(ElectionLog.DELETED) + self.save() + + def undelete(self): + """ + Restore a soft-deleted election by clearing deleted_at. + """ + self.deleted_at = None + self.append_log(ElectionLog.UNDELETED) + self.save() + def generate_trustee(self, params): """ generate a trustee including the secret key, @@ -741,6 +779,8 @@ class ElectionLog(models.Model): FROZEN = "frozen" VOTER_FILE_ADDED = "voter file added" DECRYPTIONS_COMBINED = "decryptions combined" + DELETED = "deleted" + UNDELETED = "undeleted" election = models.ForeignKey(Election, on_delete=models.CASCADE) log = models.CharField(max_length=500) diff --git a/helios/security.py b/helios/security.py index fe373b54e..d9223d4d6 100644 --- a/helios/security.py +++ b/helios/security.py @@ -105,9 +105,10 @@ def get_election_by_uuid(uuid): # frozen - is the election frozen # newvoters - does the election accept new voters def election_view(**checks): - + def election_view_decorator(func): def election_view_wrapper(request, election_uuid=None, *args, **kw): + # Get election (excludes deleted by default) election = get_election_by_uuid(election_uuid) if not election: @@ -124,11 +125,11 @@ def election_view_wrapper(request, election_uuid=None, *args, **kw): return HttpResponseRedirect("%s?%s" % (reverse(password_voter_login, args=[election.uuid]), urllib.parse.urlencode({ 'return_url' : return_url }))) - + return func(request, election, *args, **kw) return update_wrapper(election_view_wrapper, func) - + return election_view_decorator def user_can_admin_election(user, election): @@ -167,35 +168,43 @@ def api_client_can_admin_election(api_client, election): # frozen - is the election frozen # newvoters - does the election accept new voters def election_admin(**checks): - + def election_admin_decorator(func): def election_admin_wrapper(request, election_uuid=None, *args, **kw): + # Get election (excludes deleted) election = get_election_by_uuid(election_uuid) + if not election: + raise Http404 + user = get_user(request) if not user_can_admin_election(user, election): raise PermissionDenied() - + # do checks do_election_checks(election, checks) - + return func(request, election, *args, **kw) return update_wrapper(election_admin_wrapper, func) - + return election_admin_decorator def trustee_check(func): def trustee_check_wrapper(request, election_uuid, trustee_uuid, *args, **kwargs): + # Get election (excludes deleted) election = get_election_by_uuid(election_uuid) - + + if not election: + raise Http404 + trustee = Trustee.get_by_election_and_uuid(election, trustee_uuid) - + if trustee == get_logged_in_trustee(request): return func(request, election, trustee, *args, **kwargs) else: raise PermissionDenied() - + return update_wrapper(trustee_check_wrapper, func) def can_create_election(request): diff --git a/helios/stats_url_names.py b/helios/stats_url_names.py index 8f01ae120..82f7833f9 100644 --- a/helios/stats_url_names.py +++ b/helios/stats_url_names.py @@ -4,3 +4,5 @@ STATS_ELECTIONS_PROBLEMS="stats@elections-problems" STATS_RECENT_VOTES="stats@recent-votes" STATS_USER_SEARCH="stats@user-search" +STATS_DELETED_ELECTIONS="stats@deleted-elections" +STATS_UNDELETE_ELECTION="stats@undelete-election" diff --git a/helios/stats_urls.py b/helios/stats_urls.py index 853f5a0f0..f6633bbe6 100644 --- a/helios/stats_urls.py +++ b/helios/stats_urls.py @@ -6,7 +6,7 @@ from django.urls import path -from helios.stats_views import (home, force_queue, elections, recent_problem_elections, recent_votes, user_search) +from helios.stats_views import (home, force_queue, elections, recent_problem_elections, recent_votes, user_search, deleted_elections, undelete_election) import helios.stats_url_names as names urlpatterns = [ @@ -16,4 +16,6 @@ path('problem-elections', recent_problem_elections, name=names.STATS_ELECTIONS_PROBLEMS), path('recent-votes', recent_votes, name=names.STATS_RECENT_VOTES), path('user-search', user_search, name=names.STATS_USER_SEARCH), + path('deleted-elections', deleted_elections, name=names.STATS_DELETED_ELECTIONS), + path('deleted-elections//undelete', undelete_election, name=names.STATS_UNDELETE_ELECTION), ] diff --git a/helios/stats_views.py b/helios/stats_views.py index 15ba2b7b3..0902bea46 100644 --- a/helios/stats_views.py +++ b/helios/stats_views.py @@ -8,11 +8,12 @@ from django.urls import reverse from django.db.models import Max, Count from django.http import HttpResponseRedirect +from django.views.decorators.http import require_http_methods from helios import tasks, url_names from helios.models import CastVote, Election from helios_auth.models import User -from helios_auth.security import get_user +from helios_auth.security import get_user, check_csrf from .security import PermissionDenied from .view_utils import render_template @@ -109,3 +110,46 @@ def user_search(request): 'q': q, 'users_with_elections': users_with_elections }) + +def deleted_elections(request): + user = require_admin(request) + + page = int(request.GET.get('page', 1)) + limit = int(request.GET.get('limit', 25)) + q = request.GET.get('q','') + + # Get deleted elections, searching by name if query provided + elections_query = Election.objects_with_deleted.filter(deleted_at__isnull=False) + if q: + elections_query = elections_query.filter(name__icontains=q) + + elections = elections_query.order_by('-deleted_at') + elections_paginator = Paginator(elections, limit) + elections_page = elections_paginator.page(page) + + total_elections = elections_paginator.count + + return render_template(request, "stats_deleted_elections", { + 'elections': elections_page.object_list, + 'elections_page': elections_page, + 'limit': limit, + 'total_elections': total_elections, + 'q': q + }) + +@require_http_methods(["POST"]) +def undelete_election(request, election_uuid): + user = require_admin(request) + check_csrf(request) + + # Get the deleted election + election = Election.objects_with_deleted.get(uuid=election_uuid) + + if not election or not election.is_deleted: + raise PermissionDenied() + + # Undelete it + election.undelete() + + # Redirect back to deleted elections list + return HttpResponseRedirect(reverse(url_names.stats.STATS_DELETED_ELECTIONS)) diff --git a/helios/templates/election_view.html b/helios/templates/election_view.html index ed8ae69e5..0cd3e28d7 100644 --- a/helios/templates/election_view.html +++ b/helios/templates/election_view.html @@ -16,6 +16,13 @@

{{ election.name }} {% endif %} {% if admin_p %}  {% if election.is_archived %}unarchive it{% else %}archive it{% endif %} +{% if election.is_archived %} +
+ + + +
+{% endif %} copy {% endif %}
diff --git a/helios/templates/stats.html b/helios/templates/stats.html index 5ef6ffba4..b1dbba1ce 100644 --- a/helios/templates/stats.html +++ b/helios/templates/stats.html @@ -6,6 +6,7 @@

Admin