Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions helios/election_url_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions helios/election_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions helios/migrations/0008_add_election_soft_delete.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
48 changes: 44 additions & 4 deletions helios/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 19 additions & 10 deletions helios/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions helios/stats_url_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 3 additions & 1 deletion helios/stats_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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/<str:election_uuid>/undelete', undelete_election, name=names.STATS_UNDELETE_ELECTION),
]
46 changes: 45 additions & 1 deletion helios/stats_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))
7 changes: 7 additions & 0 deletions helios/templates/election_view.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ <h3 class="title">{{ election.name }}
{% endif %}
{% if admin_p %}
&nbsp;{% if election.is_archived %}<a class="small button" href="{% url "election@archive" election_uuid=election.uuid %}?archive_p=0">unarchive it</a>{% else %}<a class="small button" href="{% url "election@archive" election_uuid=election.uuid %}?archive_p=1">archive it</a>{% endif %}
{% if election.is_archived %}
<form method="post" action="{% url "election@delete" election_uuid=election.uuid %}" style="display:inline;" onsubmit="return confirm('Are you sure you want to delete this election? Once deleted, only site administrators can restore it.');">
<input type="hidden" name="csrf_token" value="{{csrf_token}}" />
<input type="hidden" name="delete_p" value="1" />
<button type="submit" class="small button">delete it</button>
</form>
{% endif %}
<a class="small button" onclick="return window.confirm('Are you sure you want to copy this election?');" href="{% url "election@copy" election_uuid=election.uuid %}">copy</a>
{% endif %}
<br />
Expand Down
1 change: 1 addition & 0 deletions helios/templates/stats.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ <h1>Admin</h1>

<ul>
<li> <a href="{% url "stats@elections" %}">elections</a></li>
<li> <a href="{% url "stats@deleted-elections" %}">deleted elections</a></li>
<li> <a href="{% url "stats@user-search" %}">user search</a></li>
<li> <a href="{% url "stats@recent-votes" %}">recent votes</a></li>
<li> <a href="{% url "stats@elections-problems" %}">recent problem elections</a></li>
Expand Down
44 changes: 44 additions & 0 deletions helios/templates/stats_deleted_elections.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{% extends TEMPLATE_BASE %}
{% load timezone_tags %}
{% block title %}Deleted Elections{% endblock %}

{% block content %}
<h1>Deleted Elections</h1>

<p>
<form method="get" action="{% url "stats@deleted-elections" %}">
<b>search</b>: <input type="text" name="q" value="{{q}}"/>
<input class="small button" type="submit" value="search" /> <a class="small button" href="?">clear search</a>
</form>
</p>


<p>
{% if elections_page.has_previous %}
<a href="?page={{elections_page.previous_page_number}}&limit={{limit}}&q={{q|urlencode}}">previous {{limit}}</a> &nbsp;&nbsp;
{% endif %}

Elections {{elections_page.start_index}} - {{elections_page.end_index}} (of {{total_elections}})&nbsp;&nbsp;

{% if elections_page.has_next %}
<a href="?page={{elections_page.next_page_number}}&limit={{limit}}&q={{q|urlencode}}">next {{limit}}</a> &nbsp;&nbsp;
{% endif %}
</p>

{% if elections %}
{% for election in elections %}
<p>
<b>{{election.name}}</b> by <a href="mailto:{{election.admin.info.email}}">{{election.admin.pretty_name}}</a><br />
Deleted at: {{election.deleted_at|utc_time}}<br />
{{election.num_voters}} voters / {{election.num_cast_votes}} cast votes<br />
<form method="post" action="{% url "stats@undelete-election" election.uuid %}" style="display:inline;" onsubmit="return confirm('Are you sure you want to restore this election? It will become visible to its administrators again.');">
<input type="hidden" name="csrf_token" value="{{csrf_token}}" />
<button type="submit" class="small button">undelete</button>
</form>
</p>
{% endfor %}
{% else %}
<p>No deleted elections found.</p>
{% endif %}

{% endblock %}
Loading