Automatically approve budget when reached threshold for approval

This commit is contained in:
Yingtong Li 2023-05-01 18:47:56 +10:00
parent 7f6bf1b07f
commit 490faa8147
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
5 changed files with 106 additions and 80 deletions

View File

@ -34,8 +34,8 @@ PROMO_LOGO_LINK = 'https://example.com'
PROMO_GROUPS_MANDATORY = ['All Years'] PROMO_GROUPS_MANDATORY = ['All Years']
AVAILABLE_APPROVERS = [ AVAILABLE_APPROVERS = [
# Tuples (committee name, description) # Tuples (committee name, (description, votes required to approve))
('Committee', 'Management Committee'), ('Committee', ('Management Committee', 10)),
] ]
BUDGET_ENABLE_VOTING = True BUDGET_ENABLE_VOTING = True

View File

@ -84,7 +84,7 @@
<div class="eleven wide column" style="padding-left: 0; padding-right: 0;"> <div class="eleven wide column" style="padding-left: 0; padding-right: 0;">
<select class="ui dropdown" name="approver"> <select class="ui dropdown" name="approver">
{% for approver in settings.AVAILABLE_APPROVERS %} {% for approver in settings.AVAILABLE_APPROVERS %}
<option value="{{ approver[0] }}"{% if approver[0] == revision.approver %} selected{% endif %}>{{ approver[1] }}</option> <option value="{{ approver[0] }}"{% if approver[0] == revision.approver %} selected{% endif %}>{{ approver[1][0] }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>

View File

@ -115,7 +115,7 @@
</tr> </tr>
<tr> <tr>
<td>Responsible committee</td> <td>Responsible committee</td>
<td>{{ dict(settings.AVAILABLE_APPROVERS)[revision.approver] }}</td> <td>{{ dict(settings.AVAILABLE_APPROVERS)[revision.approver][0] }}</td>
</tr> </tr>
<tr> <tr>
<td>Comments</td> <td>Comments</td>
@ -212,15 +212,16 @@
</div> </div>
<div class="content"> <div class="content">
<div class="summary"> <div class="summary">
<i class="user circle icon"></i>
{% if item.action == import('sstreasury.models').BudgetAction.CREATE.value %} {% if item.action == import('sstreasury.models').BudgetAction.CREATE.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> created the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a> <i class="user circle icon"></i> <a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> created the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% 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> <i class="user circle icon"></i> <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="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a> <i class="user circle icon"></i> <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>
{% elif item.action == import('sstreasury.models').BudgetAction.AUTO_APPROVE.value %}
System 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> <i class="user circle icon"></i> <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 %}
<div class="date"> <div class="date">
{{ localtime(item.time) }} {{ localtime(item.time) }}
@ -253,78 +254,82 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
{% if revision.state == import('sstreasury.models').BudgetState.ENDORSED.value %}
<h2>Committee voting</h2>
{% if revision.state == import('sstreasury.models').BudgetState.ENDORSED.value %} <p>{{ dict(settings.AVAILABLE_APPROVERS)[revision.approver][1] }} votes in favour are required for approval.</p>
<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 %}
<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 is_latest and 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 is_latest and 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 is_latest and 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 is_latest %}
{% if claims is not none %} {% if claims is not none %}
<h2>Reimbursement claims</h2> <h2>Reimbursement claims</h2>

View File

@ -57,6 +57,7 @@ class BudgetAction(DescriptionEnum):
CREATE = 5, 'Created' CREATE = 5, 'Created'
EDIT = 10, 'Edited' EDIT = 10, 'Edited'
UPDATE_STATE = 20, 'Updated state' UPDATE_STATE = 20, 'Updated state'
AUTO_APPROVE = 30, 'Automatically approved'
class BudgetRevision(models.Model): class BudgetRevision(models.Model):
budget = models.ForeignKey(Budget, on_delete=models.CASCADE) budget = models.ForeignKey(Budget, on_delete=models.CASCADE)

View File

@ -508,7 +508,27 @@ def budget_action(request, budget, revision):
vote.vote_type = vote_type.value vote.vote_type = vote_type.value
vote.save() vote.save()
# TODO: Check for vote threshold # Check if threshold for automatic approval is reached
votes_in_favour = revision.budgetvote_set.filter(is_current=True, vote_type=models.BudgetVoteType.IN_FAVOUR.value).count()
if votes_in_favour >= dict(settings.AVAILABLE_APPROVERS)[revision.approver][1]:
# Automatically approve
revision.copy()
revision.time = timezone.now()
revision.state = models.BudgetState.APPROVED.value
revision.action = models.BudgetAction.AUTO_APPROVE.value
revision.save()
# Send emails
users_to_email = set()
for user in revision.contributors.all():
users_to_email.add(user.email)
for user in User.objects.filter(groups__name=revision.approver):
users_to_email.add(user.email)
emailer = Emailer()
for email in users_to_email:
emailer.send_mail([email], 'Budget approved: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_approved.md', {'revision': revision})
return redirect(reverse('budget_view', kwargs={'id': budget.id})) return redirect(reverse('budget_view', kwargs={'id': budget.id}))