diff --git a/selfserv/settings.example.py b/selfserv/settings.example.py index 61cf2be..902036e 100644 --- a/selfserv/settings.example.py +++ b/selfserv/settings.example.py @@ -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 = [ diff --git a/sstreasury/jinja2/sstreasury/budget_view.html b/sstreasury/jinja2/sstreasury/budget_view.html index 9abb4ff..f378f21 100644 --- a/sstreasury/jinja2/sstreasury/budget_view.html +++ b/sstreasury/jinja2/sstreasury/budget_view.html @@ -218,7 +218,7 @@ {% elif item.action == import('sstreasury.models').BudgetAction.EDIT.value %} {{ item.author.first_name }} {{ item.author.last_name }} edited the budget (view) {% elif item.action == import('sstreasury.models').BudgetAction.UPDATE_STATE.value %} - {{ item.author.first_name }} {{ item.author.last_name }} changed the state to: {{ item.get_state_display() }} + {{ item.author.first_name }} {{ item.author.last_name }} changed the state to: {{ item.get_state_display() }} (view) {% else %} {{ item.author.first_name }} {{ item.author.last_name }} modified the budget (view) {% endif %} @@ -228,10 +228,103 @@ + {% elif item.__class__.__name__ == 'BudgetVote' %} +
+
+ +
+
+
+ + {{ item.voter.first_name }} {{ item.voter.last_name }} + {% 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 %} +
+ {{ localtime(item.time) }} +
+
+
+
{% endif %} {% endfor %} + {% if revision.state == import('sstreasury.models').BudgetState.ENDORSED.value %} +

Committee voting

+ +
+ +
+
+
+
In favour ({{ revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value).count() }})
+ {% if revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value).exists() %} +
+
    + {% for vote in revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value) %} +
  • {{ vote.voter.first_name }} {{ vote.voter.last_name }}
  • + {% endfor %} +
+
+ {% endif %} +
+ {% if revision.can_vote(request.user) %} + + {% endif %} +
+
+
+
+
+
Against ({{ revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.AGAINST.value).count() }})
+ {% if revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.AGAINST.value).exists() %} +
+
    + {% for vote in revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.AGAINST.value) %} +
  • {{ vote.voter.first_name }} {{ vote.voter.last_name }}
  • + {% endfor %} +
+
+ {% endif %} +
+ {% if revision.can_vote(request.user) %} + + {% endif %} +
+
+
+
+
+
Abstentions ({{ revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.ABSTAIN.value).count() }})
+ {% if revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.ABSTAIN.value).exists() %} +
+
    + {% for vote in revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.ABSTAIN.value) %} +
  • {{ vote.voter.first_name }} {{ vote.voter.last_name }}
  • + {% endfor %} +
+
+ {% endif %} +
+ {% if revision.can_vote(request.user) %} + + {% endif %} +
+
+
+ {% endif %} + {% if claims is not none %}

Reimbursement claims

diff --git a/sstreasury/models.py b/sstreasury/models.py index 33b499b..8b7a5ef 100644 --- a/sstreasury/models.py +++ b/sstreasury/models.py @@ -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 @@ -219,6 +220,25 @@ class BudgetRevision(models.Model): if self.state != BudgetState.APPROVED.value: 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' diff --git a/sstreasury/views.py b/sstreasury/views.py index fbef3fb..e6b3e92 100644 --- a/sstreasury/views.py +++ b/sstreasury/views.py @@ -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