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/
+{{ 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
+Deleted Elections
+
+
+{% if elections_page.has_previous %} +previous {{limit}} +{% endif %} + +Elections {{elections_page.start_index}} - {{elections_page.end_index}} (of {{total_elections}}) + +{% if elections_page.has_next %} +next {{limit}} +{% endif %} +
+ +{% if elections %} +{% for election in elections %} +
+{{election.name}} by {{election.admin.pretty_name}}
+Deleted at: {{election.deleted_at|utc_time}}
+{{election.num_voters}} voters / {{election.num_cast_votes}} cast votes
+
No deleted elections found.
+{% endif %} + +{% endblock %} diff --git a/helios/tests.py b/helios/tests.py index 4fcba20c7..71f9b60ce 100644 --- a/helios/tests.py +++ b/helios/tests.py @@ -202,6 +202,45 @@ def test_archive(self): self.election.archived_at = None self.assertFalse(self.election.is_archived) + def test_soft_delete(self): + # Test that soft delete sets the flags correctly + self.assertFalse(self.election.is_deleted) + self.assertIsNone(self.election.deleted_at) + + # Soft delete the election + self.election.soft_delete() + self.assertTrue(self.election.is_deleted) + self.assertIsNotNone(self.election.deleted_at) + + # Verify it's logged + log_entries = self.election.get_log().all() + self.assertTrue(any('deleted' in log.log.lower() for log in log_entries)) + + # Test that deleted elections are excluded from default queries + elections = models.Election.objects.filter(uuid=self.election.uuid) + self.assertEqual(len(elections), 0) + + # But can still be found with objects_with_deleted + election = models.Election.objects_with_deleted.get(uuid=self.election.uuid) + self.assertEqual(election, self.election) + + # Test that get_by_uuid respects the default manager (excludes deleted) + election = models.Election.get_by_uuid(self.election.uuid) + self.assertIsNone(election) + + # But get_by_uuid with include_deleted=True should work + election = models.Election.get_by_uuid(self.election.uuid, include_deleted=True) + self.assertEqual(election, self.election) + + # Test undelete + self.election.undelete() + self.assertFalse(self.election.is_deleted) + self.assertIsNone(self.election.deleted_at) + + # Should be visible in default queries again + elections = models.Election.objects.filter(uuid=self.election.uuid) + self.assertEqual(len(elections), 1) + def test_voter_registration(self): # before adding a voter voters = models.Voter.get_by_election(self.election) @@ -1228,6 +1267,78 @@ def test_voters_clear_blocked_when_frozen(self): self.assertStatusCode(response, 403) +class ElectionDeleteViewTests(WebTest): + fixtures = ['users.json', 'election.json'] + allow_database_queries = True + + def setUp(self): + self.election = models.Election.objects.all()[0] + self.user = auth_models.User.objects.get(user_id='ben@adida.net', user_type='google') + + def setup_login(self, from_scratch=False): + if from_scratch: + self.client.get("/") + session = self.client.session + session['user'] = {'type': self.user.user_type, 'user_id': self.user.user_id} + session.save() + + def test_delete_with_post(self): + """Test soft deleting an election via POST""" + self.setup_login(from_scratch=True) + + # Verify election is not deleted initially + self.assertFalse(self.election.is_deleted) + + # POST to delete endpoint + response = self.client.post( + "/helios/elections/%s/delete" % self.election.uuid, + {"delete_p": "1", "csrf_token": self.client.session.get("csrf_token", "")} + ) + self.assertRedirects(response) + + # Election should be soft deleted + election = models.Election.objects_with_deleted.get(uuid=self.election.uuid) + self.assertTrue(election.is_deleted) + self.assertIsNotNone(election.deleted_at) + + # Should not appear in default queries + elections = models.Election.objects.filter(uuid=self.election.uuid) + self.assertEqual(len(elections), 0) + + def test_delete_requires_admin(self): + """Test that only election admins can delete""" + # Don't log in - should get permission denied + response = self.client.post( + "/helios/elections/%s/delete" % self.election.uuid, + {"delete_p": "1", "csrf_token": "fake"} + ) + self.assertStatusCode(response, 403) + + # Election should not be deleted + election = models.Election.objects_with_deleted.get(uuid=self.election.uuid) + self.assertFalse(election.is_deleted) + + def test_deleted_election_not_accessible_to_non_admins(self): + """Test that deleted elections return 404 for non-admin users""" + # Soft delete the election + self.election.soft_delete() + + # Try to access as non-admin (not logged in) + response = self.client.get("/helios/elections/%s/view" % self.election.uuid) + self.assertStatusCode(response, 404) + + def test_deleted_election_not_accessible_to_election_admins(self): + """Test that deleted elections return 404 even for election admins""" + self.setup_login(from_scratch=True) + + # Soft delete the election + self.election.soft_delete() + + # Election admin should not be able to access it + response = self.client.get("/helios/elections/%s/view" % self.election.uuid) + self.assertStatusCode(response, 404) + + class EmailOptOutTests(TestCase): fixtures = ['users.json'] allow_database_queries = True @@ -1792,6 +1903,30 @@ def test_get_by_user_as_admin_no_duplicates(self): # Should only appear once due to distinct() self.assertEqual(elections.count(self.election), 1) + def test_get_by_user_as_admin_excludes_deleted(self): + """Test that soft-deleted elections don't appear in get_by_user_as_admin""" + # Verify election appears initially + elections = models.Election.get_by_user_as_admin(self.admin) + self.assertIn(self.election, elections) + + # Soft delete the election + self.election.soft_delete() + + # Should no longer appear in admin list + elections = models.Election.get_by_user_as_admin(self.admin) + self.assertNotIn(self.election, elections) + + # Same for additional admins + self.election.undelete() # Restore first + self.election.admins.add(self.other_user) + elections = models.Election.get_by_user_as_admin(self.other_user) + self.assertIn(self.election, elections) + + # Delete and verify other_user also doesn't see it + self.election.soft_delete() + elections = models.Election.get_by_user_as_admin(self.other_user) + self.assertNotIn(self.election, elections) + class ElectionMultipleAdminsSecurityTests(TestCase): """Test multiple administrators authorization checks""" diff --git a/helios/views.py b/helios/views.py index 4150eecf2..1141ac5de 100644 --- a/helios/views.py +++ b/helios/views.py @@ -17,6 +17,7 @@ from django.db import transaction, IntegrityError from django.http import HttpResponse, Http404, HttpResponseRedirect, HttpResponseForbidden, HttpResponseBadRequest from django.urls import reverse +from django.views.decorators.http import require_http_methods import helios_auth.url_names as helios_auth_urls from helios import utils, VOTERS_EMAIL, VOTERS_UPLOAD, url_names @@ -1142,18 +1143,32 @@ def one_election_set_featured(request, election): @election_admin() def one_election_archive(request, election): - + archive_p = request.GET.get('archive_p', True) - + if bool(int(archive_p)): election.archived_at = datetime.datetime.utcnow() else: election.archived_at = None - + election.save() return HttpResponseRedirect(settings.SECURE_URL_HOST + reverse(url_names.election.ELECTION_VIEW, args=[election.uuid])) - + +@election_admin() +@require_http_methods(["POST"]) +def one_election_delete(request, election): + """ + Soft delete an election. The election will be hidden from all users except site admins. + Requires POST request with CSRF protection. + """ + check_csrf(request) + + election.soft_delete() + + # After deletion, redirect to admin's election list since the election is now invisible + return HttpResponseRedirect(settings.SECURE_URL_HOST + reverse(url_names.ELECTIONS_ADMINISTERED)) + @election_admin() def one_election_copy(request, election): # FIXME: make this a POST and CSRF protect it