Basic reimbursement claim data model and interface

This commit is contained in:
Yingtong Li 2019-12-28 19:11:22 +11:00
parent 9ff988d2e9
commit f3287e127a
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
9 changed files with 689 additions and 39 deletions

View File

@ -33,7 +33,7 @@
<div class="item"> <div class="item">
Reimbursements Reimbursements
<div class="menu"> <div class="menu">
<a class="item">Your reimbursement claims</a> <a class="{% if request.resolver_match.url_name == 'claim_list' %}active {% endif %}item" href="{{ url('claim_list') }}">Your reimbursement claims</a>
<a class="item">Create new claim</a> <a class="item">Create new claim</a>
</div> </div>
</div> </div>

View File

@ -0,0 +1,168 @@
{% extends 'sstreasury/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}{% if request.resolver_match.url_name == 'claim_new' %}New{% else %}Edit{% endif %} reimbursement claim{% endblock %}
{% block maincontent %}
<h1>{% if request.resolver_match.url_name == 'claim_new' %}New{% else %}Edit{% endif %} reimbursement claim</h1>
<form class="ui form" method="POST">
<div class="ui disabled inline grid field">
<label class="three wide column">ID</label>
<input class="eleven wide column" type="text" name="id" value="{{ claim.id if claim.id != None else '' }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Purpose</label>
<input class="eleven wide column" type="text" name="purpose" value="{{ claim.purpose }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Expenditure date</label>
<div class="eleven wide column">
<div class="ui calendar" id="cal_date">
<div class="ui input left icon grid">
<i class="calendar icon" style="z-index: 999;"></i>
<input class="twelve wide column" type="text" name="date" value="{{ claim.date or '' }}">
</div>
</div>
</div>
</div>
<div class="ui required inline grid field">
<label class="three wide column">Claimee</label>
<div class="eleven wide column">
<div class="ui list">
<div class="item">
<i class="user circle icon"></i>
<div class="content">
<a href="mailto:{{ claim.author.email }}">
{% if claim.author.first_name %}
{{ claim.author.first_name }} {{ claim.author.last_name }}
{% else %}
{{ claim.author.email }}
{% endif %}
</a>
</div>
</div>
</div>
</div>
</div>
<div class="ui divider"></div>
<div class="ui inline grid field">
<label class="three wide column">Comments</label>
<textarea class="eleven wide column" rows="2" name="comments">{{ claim.comments }}</textarea>
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">Items</label>
<div class="eleven wide column"></div>
</div>
<div id="items_grid"></div>
<input type="hidden" name="items" id="items_input">
<div class="ui divider"></div>
<div class="ui error message"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input class="ui primary button" type="submit" name='submit' value="Save">
<input class="ui button" type="submit" name='submit' value="Save and continue editing">
{% if request.resolver_match.url_name == 'claim_edit' %}
<input class="ui right floated red button" type="submit" name='submit' value="Delete" onclick="return confirm('Are you sure you want to delete this reimbursement claim? This action is IRREVERSIBLE.');">
{% endif %}
</form>
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui-calendar@0.0.8/dist/calendar.min.css" integrity="sha256-KCHiPtYk/vfF5/6lDXpz5r5FuIYchVdai0fepwGft80=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.css" integrity="sha256-a/jNbtm7jpeKiXCShJ8YC+eNL9Abh7CBiYXHgaofUVs=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid-theme.min.css" integrity="sha256-0rD7ZUV4NLK6VtGhEim14ZUZGC45Kcikjdcr4N03ddA=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dragula@3.7.2/dist/dragula.min.css" integrity="sha256-iVhQxXOykHeL03K08zkxBGxDCLCuzRGGiTYf2FL6mLY=" crossorigin="anonymous">
{% endblock %}
{% block script %}
{{ super() }}
<script src="https://cdn.jsdelivr.net/npm/semantic-ui-calendar@0.0.8/dist/calendar.min.js" integrity="sha256-Pnz4CK94A8tUiYWCfg/Ko25YZrHqOKeMS4JDXVTcVA0=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.js" integrity="sha256-lzjMTpg04xOdI+MJdjBst98bVI6qHToLyVodu3EywFU=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dragula@3.7.2/dist/dragula.min.js" integrity="sha256-ug4bHfqHFAj2B5MESRxbLd3R3wdVMQzug2KHZqFEmFI=" crossorigin="anonymous"></script>
<script src="{{ static('sstreasury/claim.js') }}"></script>
<script>
function leftpad(n) {
if (n < 10)
return '0' + n;
return '' + n;
}
$('#cal_date').calendar({
type: 'date',
formatter: {
date: function(date, settings) {
return date.getFullYear() + '-' + leftpad(date.getMonth() + 1) + '-' + leftpad(date.getDate());
}
}
});
$('.ui.form').form({
on: 'blur',
keyboardShortcuts: false,
fields: {
purpose: 'empty',
date: 'empty'
},
onSuccess: function(event, fields) {
var items_data = [];
$('#items_grid .jsgrid-grid-body tr:not(.totalrow):not(.jsgrid-nodata-row)').each(function(i, el) {
var row = $(el).data('JSGridItem');
items_data.push({
'Description': row['Description'],
'Unit price': row['Unit price\n(incl GST)'],
'Units': row['Units'],
'GST-free': row['GST-free'],
});
});
$('#items_input').val(JSON.stringify(items_data));
}
});
// Interferes with jsGrid
$('.ui.form').on('keyup keypress', function(e) {
var keyCode = e.keyCode || e.which;
if (keyCode === 13) {
e.preventDefault();
return false;
}
});
var items_data = JSON.parse({{ import('json').dumps(import('json').dumps(claim.items))|safe }});
for (var row of items_data) {
row['Unit price\n(incl GST)'] = row['Unit price'];
}
var editing = true;
makeGrid();
dragula([document.querySelector('#items_grid tbody')], {
accepts: function (el, target, source, sibling) {
return sibling !== null && !sibling.classList.contains('totalrow');
},
invalid: function (el, handle) {
return el.classList.contains('totalrow');
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,74 @@
{% extends 'sstreasury/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}Your reimbursement claims{% endblock %}
{% macro listclaims(claims) %}
<table class="ui selectable celled table">
<thead>
<tr>
<th class="twelve wide">Name</th>
<th class="four wide">Status</th>
<th class="one wide">View</th>
</tr>
</thead>
<tbody>
{% for claim in claims %}
<tr>
<td class="selectable"><a href="{{ url('claim_view', kwargs={'id': claim.id}) }}">{{ claim.purpose }}</a></td>
<td class="selectable"><a href="{{ url('claim_view', kwargs={'id': claim.id}) }}">{{ claim.get_state_display() }}</a></td>
<td>
<a href="{{ url('claim_view', kwargs={'id': claim.id}) }}" class="ui tiny primary icon button"><i class="eye icon"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %}
{% block maincontent %}
<h1>Your reimbursement claims</h1>
{% if not claims_action and not claims_open and not claims_closed %}
<p>You have no reimbursement claims to view. To create a claim, click <a href="{{ url('claim_new') }}">Create new claim</a>.</p>
{% endif %}
{% if claims_action %}
<h2>Claims requiring action</h2>
{{ listclaims(claims_action) }}
{% endif %}
{% if claims_open %}
<h2>Open claims</h2>
{{ listclaims(claims_open) }}
{% endif %}
{% if claims_closed %}
<h2>Closed claims</h2>
{{ listclaims(claims_closed) }}
{% endif %}
{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}

View File

@ -0,0 +1,170 @@
{% extends 'sstreasury/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}{{ claim.purpose }}{% endblock %}
{% block maincontent %}
<h1>{{ claim.purpose }}</h1>
<form class="ui form" action="{# TODO #}" method="POST">
<span class="ui header">Status: {{ claim.get_state_display() }}</span>
{# TODO #}
</form>
<table class="ui mydefinition table">
<tbody>
<tr>
<td class="two wide">ID</td>
<td class="fourteen wide">{{ claim.id }}</td>
</tr>
<tr>
<td>Purpose</td>
<td>{{ claim.purpose }}</td>
</tr>
<tr>
<td>Expenditure date</td>
<td>{{ claim.date }}</td>
</tr>
<tr>
<td>Claimee</td>
<td>
<div class="ui list">
<div class="item">
<i class="user circle icon"></i>
<div class="content">
<a href="mailto:{{ claim.author.email }}">
{% if claim.author.first_name %}
{{ claim.author.first_name }} {{ claim.author.last_name }}
{% else %}
{{ claim.author.email }}
{% endif %}
</a>
</div>
</div>
</div>
</td>
</tr>
<tr>
<td>Comments</td>
<td>{{ claim.comments }}</td>
</tr>
<tr>
<td>Items</td>
<td>
<div id="items_grid"></div>
</td>
</tr>
</tbody>
</table>
{# TODO #}
<div class="ui feed">
{% for item in history %}
{% if item.__class__.__name__ == 'ClaimComment' %}
<div class="event">
<div class="label">
<i class="comment alternate outline icon"></i>
</div>
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> commented
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
<div class="extra text">
{{ item.content|markdown }}
</div>
</div>
</div>
{% elif item.__class__.__name__ == 'ClaimHistory' %}
<div class="event">
<div class="label">
<i class="edit icon"></i>
</div>
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
{% if item.action == import('sstreasury.models').ClaimAction.CREATE.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> created the claim
{% elif item.action == import('sstreasury.models').ClaimAction.EDIT.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> edited the claim
{% elif item.action == import('sstreasury.models').ClaimAction.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() }}
{% else %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> modified the claim
{% endif %}
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.css" integrity="sha256-a/jNbtm7jpeKiXCShJ8YC+eNL9Abh7CBiYXHgaofUVs=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid-theme.min.css" integrity="sha256-0rD7ZUV4NLK6VtGhEim14ZUZGC45Kcikjdcr4N03ddA=" crossorigin="anonymous">
<style>
/* Fix the CSS */
.ui.mydefinition.table > tbody > tr > td:first-child:not(.ignored) {
background: rgba(0,0,0,.03);
font-weight: 700;
color: rgba(0,0,0,.95);
}
.jsgrid-align-right, .jsgrid-align-right input, .jsgrid-align-right select, .jsgrid-align-right textarea {
text-align: right !important;
}
.jsgrid-cell {
padding: .5em !important;
}
.jsgrid-header-row .jsgrid-header-cell {
text-align: center !important;
}
</style>
{% endblock %}
{% block script %}
{{ super() }}
<script src="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.js" integrity="sha256-lzjMTpg04xOdI+MJdjBst98bVI6qHToLyVodu3EywFU=" crossorigin="anonymous"></script>
<script src="{{ static('sstreasury/claim.js') }}"></script>
<script>
var items_data = JSON.parse({{ import('json').dumps(import('json').dumps(claim.items))|safe }});
for (var row of items_data) {
row['Unit price\n(incl GST)'] = row['Unit price'];
}
var editing = false;
makeGrid();
</script>
{% endblock %}

View File

@ -21,6 +21,13 @@ from jsonfield import JSONField
from enum import Enum from enum import Enum
class DescriptionEnum(Enum):
def __new__(cls, value, description):
obj = object.__new__(cls)
obj._value_ = value
obj.description = description
return obj
class Budget(models.Model): class Budget(models.Model):
pass pass
@ -33,7 +40,7 @@ class BudgetComment(models.Model):
class Meta: class Meta:
ordering = ['id'] ordering = ['id']
class BudgetState(Enum): class BudgetState(DescriptionEnum):
DRAFT = 10, 'Draft' DRAFT = 10, 'Draft'
RESUBMIT = 20, 'Returned for redrafting' RESUBMIT = 20, 'Returned for redrafting'
AWAIT_REVIEW = 30, 'Awaiting Treasury review' AWAIT_REVIEW = 30, 'Awaiting Treasury review'
@ -41,23 +48,11 @@ class BudgetState(Enum):
APPROVED = 50, 'Approved' APPROVED = 50, 'Approved'
#CANCELLED = 60, 'Cancelled' #CANCELLED = 60, 'Cancelled'
def __new__(cls, value, description): class BudgetAction(DescriptionEnum):
obj = object.__new__(cls)
obj._value_ = value
obj.description = description
return obj
class BudgetAction(Enum):
CREATE = 5, 'Created' CREATE = 5, 'Created'
EDIT = 10, 'Edited' EDIT = 10, 'Edited'
UPDATE_STATE = 20, 'Updated state' UPDATE_STATE = 20, 'Updated state'
def __new__(cls, value, description):
obj = object.__new__(cls)
obj._value_ = value
obj.description = description
return obj
class BudgetRevision(models.Model): class BudgetRevision(models.Model):
budget = models.ForeignKey(Budget, on_delete=models.CASCADE) budget = models.ForeignKey(Budget, on_delete=models.CASCADE)
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
@ -85,5 +80,67 @@ class BudgetRevision(models.Model):
self.save() self.save()
self.contributors.add(*contributors) self.contributors.add(*contributors)
def can_view(self, user):
if user == self.author:
return True
if user in self.contributors.all():
return True
if user.groups.filter(name='Treasury').exists():
return True
if (self.state == BudgetState.ENDORSED or self.state == BudgetState.APPROVED) and user.groups.filter(name='Committee').exists():
return True
return False
class Meta: class Meta:
ordering = ['id'] ordering = ['id']
class ClaimState(DescriptionEnum):
DRAFT = 10, 'Draft'
RESUBMIT = 20, 'Returned for redrafting'
AWAIT_REVIEW = 30, 'Awaiting Treasury approval'
APPROVED = 40, 'Approved by Treasury, awaiting payment'
PAID = 50, 'Paid'
class ClaimAction(DescriptionEnum):
CREATE = 5, 'Created'
EDIT = 10, 'Edited'
UPDATE_STATE = 20, 'Updated state'
class ReimbursementClaim(models.Model):
purpose = models.CharField(max_length=100)
date = models.DateField()
comments = models.TextField()
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
state = models.IntegerField(choices=[(v.value, v.description) for v in ClaimState])
items = JSONField(default=[])
def can_view(self, user):
if user == self.author:
return True
if user.groups.filter(name='Treasury').exists():
return True
return False
class ClaimReceipt(models.Model):
claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE)
uploaded_file = models.FileField()
class ClaimComment(models.Model):
claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE)
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
content = models.TextField()
class Meta:
ordering = ['id']
class ClaimHistory(models.Model):
claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE)
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
state = models.IntegerField(choices=[(v.value, v.description) for v in ClaimState])
time = models.DateTimeField()
action = models.IntegerField(choices=[(v.value, v.description) for v in ClaimAction])

View File

@ -135,7 +135,7 @@ function makeGrid() {
height: editing ? '20em' : 'auto', height: editing ? '20em' : 'auto',
inserting: editing, inserting: editing,
editing: editing, editing: editing,
noDataContent: editing ? 'No entries. Click the green plus icon at the top right to add a new row.' : 'No entries', noDataContent: editing ? 'No entries. Enter details above then click the green plus icon.' : 'No entries',
data: expense_data, data: expense_data,
fields: f, fields: f,
onItemUpdated: recalcExpTotal, onItemUpdated: recalcExpTotal,

View File

@ -0,0 +1,88 @@
/*
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
function recalcTotal(args) {
var total = 0;
var gst = 0;
for (var row of args.grid.data) {
total += row['Unit price\n(incl GST)'] * row['Units'];
if (!row['GST-free']) {
gst += (row['Unit price\n(incl GST)'] * row['Units']) / 11;
}
}
$(args.grid._body).find('.totalrow').remove();
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-style: italic;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Includes GST of:</td>').prop('colspan', args.grid.fields.length - (editing ? 2 : 1)));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + gst.toFixed(2)));
if (editing) {
totalrow.append($('<td class="jsgrid-cell"></td>'));
}
$(args.grid._body).find('tr:last').after(totalrow);
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-weight: bold;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Total:</td>').prop('colspan', args.grid.fields.length - (editing ? 2 : 1)));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + total.toFixed(2)));
if (editing) {
totalrow.append($('<td class="jsgrid-cell"></td>'));
}
$(args.grid._body).find('tr:last').after(totalrow);
}
// Allow floats
function FloatNumberField(config) {
jsGrid.NumberField.call(this, config);
}
FloatNumberField.prototype = new jsGrid.NumberField({
filterValue: function() {
return parseFloat(this.filterControl.val());
},
insertValue: function() {
return parseFloat(this.insertControl.val());
},
editValue: function() {
return parseFloat(this.editControl.val());
}
});
jsGrid.fields.float = FloatNumberField;
function makeGrid() {
f = [
{ name: 'Description', type: 'text', width: '50%', validate: 'required' },
{ name: 'Unit price\n(incl GST)', type: 'float', width: '12.5%', validate: 'required', itemTemplate: function(value, item) { return '$' + value.toFixed(2); } },
{ name: 'Units', type: 'float', width: '12.5%', validate: 'required' },
{ name: 'GST-free', type: 'checkbox', width: '5%' },
{ name: 'Total', align: 'right', width: '10%', itemTemplate: function(value, item) { return '$' + (item['Unit price\n(incl GST)'] * item['Units']).toFixed(2); } },
];
if (editing) {
f.push({ type: 'control', width: '10%', modeSwitchButton: false });
}
$('#items_grid').jsGrid({
width: '100%',
height: editing ? '20em' : 'auto',
inserting: editing,
editing: editing,
noDataContent: editing ? 'No entries. Enter details above then click the green plus icon.' : 'No entries',
data: items_data,
fields: f,
onItemUpdated: recalcTotal,
onRefreshed: recalcTotal,
});
}

View File

@ -25,5 +25,8 @@ urlpatterns = [
path('budgets/view/<int:id>/print', views.budget_print, name='budget_print'), path('budgets/view/<int:id>/print', views.budget_print, name='budget_print'),
path('budgets/edit/<int:id>', views.budget_edit, name='budget_edit'), path('budgets/edit/<int:id>', views.budget_edit, name='budget_edit'),
path('budgets/action/<int:id>', views.budget_action, name='budget_action'), path('budgets/action/<int:id>', views.budget_action, name='budget_action'),
path('claims/', views.claim_list, name='claim_list'),
path('claims/new/', views.claim_new, name='claim_new'),
path('claims/view/<int:id>', views.claim_view, name='claim_view'),
path('', views.index, name='treasury'), path('', views.index, name='treasury'),
] ]

View File

@ -52,16 +52,16 @@ def budget_list(request):
group = budgets_action group = budgets_action
elif request.user.groups.filter(name='Secretary').exists() and state == models.BudgetState.ENDORSED: elif request.user.groups.filter(name='Secretary').exists() and state == models.BudgetState.ENDORSED:
group = budgets_action group = budgets_action
else: elif request.user.groups.filter(name='Committee').exists() and state == models.BudgetState.ENDORSED:
if request.user in revision.contributors.all(): group = budgets_action
elif request.user == revision.author or request.user in revision.contributors.all():
if state in [models.BudgetState.DRAFT, models.BudgetState.RESUBMIT]: if state in [models.BudgetState.DRAFT, models.BudgetState.RESUBMIT]:
group = budgets_action group = budgets_action
elif state in [models.BudgetState.AWAIT_REVIEW, models.BudgetState.ENDORSED]: elif state in [models.BudgetState.AWAIT_REVIEW, models.BudgetState.ENDORSED]:
group = budgets_open group = budgets_open
else: else:
group = budgets_closed group = budgets_closed
else: elif request.user.groups.filter(name='Treasury').exists() or request.user.groups.filter(name='Secretary').exists():
if request.user in revision.contributors.all() or request.user.groups.filter(name='Treasury').exists() or request.user.groups.filter(name='Secretary').exists():
if state == models.BudgetState.APPROVED: if state == models.BudgetState.APPROVED:
group = budgets_closed group = budgets_closed
else: else:
@ -85,8 +85,7 @@ def budget_view(request, id):
else: else:
revision = budget.budgetrevision_set.reverse()[0] revision = budget.budgetrevision_set.reverse()[0]
if request.user not in revision.contributors.all(): if not revision.can_view(request.user):
if not request.user.groups.filter(name='Treasury').exists():
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(), revision.budget.budgetcomment_set.all()))
@ -107,8 +106,7 @@ def budget_print(request, id):
else: else:
revision = budget.budgetrevision_set.reverse()[0] revision = budget.budgetrevision_set.reverse()[0]
if request.user not in revision.contributors.all(): if not revision.can_view(request.user):
if not request.user.groups.filter(name='Treasury').exists():
raise PermissionDenied raise PermissionDenied
return render(request, 'sstreasury/budget_print.html', { return render(request, 'sstreasury/budget_print.html', {
@ -183,8 +181,7 @@ def uses_budget(viewfunc):
def budget_viewable(viewfunc): def budget_viewable(viewfunc):
@functools.wraps(viewfunc) @functools.wraps(viewfunc)
def func(request, budget, revision): def func(request, budget, revision):
if request.user not in revision.contributors.all(): if not revision.can_view(request.user):
if not request.user.groups.filter(name='Treasury').exists():
raise PermissionDenied raise PermissionDenied
return viewfunc(request, budget, revision) return viewfunc(request, budget, revision)
@ -200,7 +197,7 @@ def budget_editable(viewfunc):
if not request.user.groups.filter(name='Treasury').exists(): if not request.user.groups.filter(name='Treasury').exists():
raise PermissionDenied raise PermissionDenied
if request.user not in revision.contributors.all(): if request.user != revision.author and request.user not in revision.contributors.all():
if not request.user.groups.filter(name='Treasury').exists(): if not request.user.groups.filter(name='Treasury').exists():
raise PermissionDenied raise PermissionDenied
@ -360,3 +357,96 @@ def budget_action(request, budget, revision):
emailer.send_mail([user.email], 'Action required: Budget returned for re-drafting: {}'.format(revision.name), 'sstreasury/email/returned_committee.md', {'revision': revision}) emailer.send_mail([user.email], 'Action required: Budget returned for re-drafting: {}'.format(revision.name), 'sstreasury/email/returned_committee.md', {'revision': revision})
return redirect(reverse('budget_view', kwargs={'id': budget.id})) return redirect(reverse('budget_view', kwargs={'id': budget.id}))
@login_required
def claim_list(request):
claims_action = []
claims_open = []
claims_closed = []
for claim in models.ReimbursementClaim.objects.all():
state = models.ClaimState(claim.state)
group = None
if request.user.groups.filter(name='Treasury').exists() and state in [models.ClaimState.AWAIT_REVIEW, models.ClaimState.APPROVED]:
group = claims_action
elif request.user == claim.author:
if state in [models.ClaimState.DRAFT, models.ClaimState.RESUBMIT]:
group = claims_action
elif state in [models.ClaimState.AWAIT_REVIEW, models.ClaimState.APPROVED]:
group = claims_open
else:
group = claims_closed
elif request.user.groups.filter(name='Treasury').exists():
if state == models.ClaimState.APPROVED:
group = claims_closed
else:
group = claims_open
if group is not None:
group.append(claim)
return render(request, 'sstreasury/claim_list.html', {
'claims_action': claims_action,
'claims_open': claims_open,
'claims_closed': claims_closed
})
@login_required
def claim_view(request, id):
claim = models.ReimbursementClaim.objects.get(id=id)
if not claim.can_view(request.user):
raise PermissionDenied
history = list(itertools.chain(claim.claimhistory_set.all(), claim.claimcomment_set.all()))
history.sort(key=lambda x: x.time, reverse=True)
return render(request, 'sstreasury/claim_view.html', {
'claim': claim,
'history': history
})
def claim_from_form(claim, form):
claim.purpose = form['purpose']
claim.date = form['date'] if form['date'] else None
claim.comments = form['comments']
claim.state = models.ClaimState.DRAFT.value
claim.items = json.loads(form['items'])
claim.save()
return claim
@login_required
def claim_new(request):
if request.method == 'POST':
with transaction.atomic():
claim = models.ReimbursementClaim()
claim.author = request.user
claim.time = timezone.now()
#revision.action = models.BudgetAction.CREATE.value
claim = claim_from_form(claim, request.POST)
claim_history = models.ClaimHistory()
claim_history.claim = claim
claim_history.author = request.user
claim_history.state = claim.state
claim_history.time = timezone.now()
claim_history.action = models.ClaimAction.CREATE.value
claim_history.save()
if request.POST['submit'] == 'Save':
return redirect(reverse('claim_view', kwargs={'id': claim.id}))
else:
return redirect(reverse('claim_edit', kwargs={'id': claim.id}))
pass
else:
claim = models.ReimbursementClaim()
claim.author = request.user
return render(request, 'sstreasury/claim_edit.html', {
'claim': claim
})