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) # Tuples (committee name, description)
('Committee', 'Management Committee'), ('Committee', 'Management Committee'),
] ]
BUDGET_ENABLE_VOTING = True
TICKETING_FEE_PROPORTION = 0.0175 # Previous default was (1-1/1.01884) TICKETING_FEE_PROPORTION = 0.0175 # Previous default was (1-1/1.01884)
TICKETING_FEE_FIXED = 0.30 # Previous default was 0.8133/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/ # Download from http://bsb.apca.com.au/
BSB_FILE_PATH = 'sstreasury/BSBDirectoryMay20-290.csv' 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 # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [

View File

@ -218,7 +218,7 @@
{% elif item.action == import('sstreasury.models').BudgetAction.EDIT.value %} {% 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> <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 %} {% 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 %} {% 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> <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 %} {% endif %}
@ -228,10 +228,103 @@
</div> </div>
</div> </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 %} {% endif %}
{% endfor %} {% endfor %}
</div> </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 %} {% if claims is not none %}
<h2>Reimbursement claims</h2> <h2>Reimbursement claims</h2>

View File

@ -17,6 +17,7 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.conf import settings
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from jsonfield import JSONField from jsonfield import JSONField
@ -219,6 +220,25 @@ class BudgetRevision(models.Model):
if self.state != BudgetState.APPROVED.value: if self.state != BudgetState.APPROVED.value:
return False return False
return True 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): class ClaimState(DescriptionEnum):
DRAFT = 10, 'Draft' DRAFT = 10, 'Draft'

View File

@ -259,7 +259,7 @@ def budget_view(request, budget, revision):
if not revision.can_view(request.user): if not revision.can_view(request.user):
raise PermissionDenied 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) history.sort(key=lambda x: x.time, reverse=True)
if revision.state == models.BudgetState.APPROVED.value and 'revision' not in request.GET: 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(): with transaction.atomic():
revision.update_state(request.user, models.BudgetState.CANCELLED) 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})) return redirect(reverse('budget_view', kwargs={'id': budget.id}))
@login_required @login_required