Implement voting on budgets

This commit is contained in:
Yingtong Li 2023-05-01 18:28:24 +10:00
parent e098ed4f01
commit 5994cd38b6
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
4 changed files with 154 additions and 2 deletions

View File

@ -37,6 +37,7 @@ AVAILABLE_APPROVERS = [
# Tuples (committee name, description)
('Committee', 'Management Committee'),
]
BUDGET_ENABLE_VOTING = True
TICKETING_FEE_PROPORTION = 0.0175 # Previous default was (1-1/1.01884)
TICKETING_FEE_FIXED = 0.30 # Previous default was 0.8133/1.01884
@ -50,6 +51,11 @@ ABA_SRC_ACC = '00000000'
# Download from http://bsb.apca.com.au/
BSB_FILE_PATH = 'sstreasury/BSBDirectoryMay20-290.csv'
PRETIX_API_BASE = 'https://example.com'
PRETIX_API_TOKEN = 'abcdefg'
PRETIX_ORGANIZER = 'societyname'
PRETIX_START_YEAR = '2023' # Ignore events before this year
# Application definition
INSTALLED_APPS = [

View File

@ -218,7 +218,7 @@
{% elif item.action == import('sstreasury.models').BudgetAction.EDIT.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> edited the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% elif item.action == import('sstreasury.models').BudgetAction.UPDATE_STATE.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> changed the state to: {{ item.get_state_display() }}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> changed the state to: {{ item.get_state_display() }} <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% else %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> modified the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% endif %}
@ -228,10 +228,103 @@
</div>
</div>
</div>
{% elif item.__class__.__name__ == 'BudgetVote' %}
<div class="event">
<div class="label">
<i class="gavel icon"></i>
</div>
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
<a href="mailto:{{ item.voter.email }}">{{ item.voter.first_name }} {{ item.voter.last_name }}</a>
{% if item.vote_type == import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value %}
voted in favour of the budget
{% elif item.vote_type == import('sstreasury.models').BudgetVoteType.AGAINST.value %}
voted against the budget
{% elif item.vote_type == import('sstreasury.models').BudgetVoteType.ABSTAIN.value %}
abstained from voting
{% endif %}
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% if revision.state == import('sstreasury.models').BudgetState.ENDORSED.value %}
<h2>Committee voting</h2>
<form class="ui three column grid" action="{{ url('budget_action', kwargs={'id': revision.budget.id}) }}" method="POST">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<div class="column">
<div class="ui fluid card">
<div class="content">
<div class="header">In favour ({{ revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value).count() }})</div>
{% if revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value).exists() %}
<div class="description">
<ul style="margin-bottom:0">
{% for vote in revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value) %}
<li>{{ vote.voter.first_name }} {{ vote.voter.last_name }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if revision.can_vote(request.user) %}
<button class="ui bottom attached positive button" type="submit" name="action" value="VoteInFavour">
Vote in favour
</button>
{% endif %}
</div>
</div>
<div class="column">
<div class="ui fluid card">
<div class="content">
<div class="header">Against ({{ revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.AGAINST.value).count() }})</div>
{% if revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.AGAINST.value).exists() %}
<div class="description">
<ul style="margin-bottom:0">
{% for vote in revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.AGAINST.value) %}
<li>{{ vote.voter.first_name }} {{ vote.voter.last_name }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if revision.can_vote(request.user) %}
<button class="ui bottom attached negative button" type="submit" name="action" value="VoteAgainst">
Vote against
</button>
{% endif %}
</div>
</div>
<div class="column">
<div class="ui fluid card">
<div class="content">
<div class="header">Abstentions ({{ revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.ABSTAIN.value).count() }})</div>
{% if revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.ABSTAIN.value).exists() %}
<div class="description">
<ul style="margin-bottom:0">
{% for vote in revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.ABSTAIN.value) %}
<li>{{ vote.voter.first_name }} {{ vote.voter.last_name }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if revision.can_vote(request.user) %}
<button class="ui bottom attached secondary button" type="submit" name="action" value="VoteAbstain">
Abstain
</button>
{% endif %}
</div>
</div>
</form>
{% endif %}
{% if claims is not none %}
<h2>Reimbursement claims</h2>

View File

@ -17,6 +17,7 @@
from django.contrib.auth.models import User
from django.conf import settings
from django.db import models
from django.utils import timezone
from jsonfield import JSONField
@ -220,6 +221,25 @@ class BudgetRevision(models.Model):
return False
return True
def can_vote(self, user):
if not settings.BUDGET_ENABLE_VOTING:
return False
if self.state == BudgetState.ENDORSED.value and user.groups.filter(name=self.approver).exists():
return True
return False
class BudgetVoteType(DescriptionEnum):
IN_FAVOUR = 1, 'In favour'
AGAINST = -1, 'Against'
ABSTAIN = 0, 'Abstain'
class BudgetVote(models.Model):
revision = models.ForeignKey(BudgetRevision, on_delete=models.CASCADE)
voter = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
is_current = models.BooleanField()
vote_type = models.IntegerField(choices=[(v.value, v.description) for v in BudgetVoteType])
class ClaimState(DescriptionEnum):
DRAFT = 10, 'Draft'
WITHDRAWN = 15, 'Withdrawn'

View File

@ -259,7 +259,7 @@ def budget_view(request, budget, revision):
if not revision.can_view(request.user):
raise PermissionDenied
history = list(itertools.chain(budget.budgetrevision_set.all(), revision.budget.budgetcomment_set.all()))
history = list(itertools.chain(budget.budgetrevision_set.all(), budget.budgetcomment_set.all(), *[r.budgetvote_set.all() for r in budget.budgetrevision_set.all()]))
history.sort(key=lambda x: x.time, reverse=True)
if revision.state == models.BudgetState.APPROVED.value and 'revision' not in request.GET:
@ -477,6 +477,39 @@ def budget_action(request, budget, revision):
with transaction.atomic():
revision.update_state(request.user, models.BudgetState.CANCELLED)
if 'VoteInFavour' in actions or 'VoteAgainst' in actions or 'VoteAbstain' in actions:
if not revision.can_vote(request.user):
raise PermissionDenied
if 'VoteInFavour' in actions:
vote_type = models.BudgetVoteType.IN_FAVOUR
elif 'VoteAgainst' in actions:
vote_type = models.BudgetVoteType.AGAINST
elif 'VoteAbstain' in actions:
vote_type = models.BudgetVoteType.ABSTAIN
# Already exists?
if revision.budgetvote_set.filter(is_current=True, voter=request.user, vote_type=vote_type.value):
# No need to create new vote
pass
else:
with transaction.atomic():
# Invalidate any existing votes
for vote in revision.budgetvote_set.filter(is_current=True, voter=request.user):
vote.is_current = False
vote.save()
# Create a new vote
vote = models.BudgetVote()
vote.revision = revision
vote.voter = request.user
vote.time = timezone.now()
vote.is_current = True
vote.vote_type = vote_type.value
vote.save()
# TODO: Check for vote threshold
return redirect(reverse('budget_view', kwargs={'id': budget.id}))
@login_required