Basic reimbursement claim data model and interface
This commit is contained in:
parent
9ff988d2e9
commit
f3287e127a
@ -33,7 +33,7 @@
|
||||
<div class="item">
|
||||
Reimbursements
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
168
sstreasury/jinja2/sstreasury/claim_edit.html
Normal file
168
sstreasury/jinja2/sstreasury/claim_edit.html
Normal 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 %}
|
74
sstreasury/jinja2/sstreasury/claim_list.html
Normal file
74
sstreasury/jinja2/sstreasury/claim_list.html
Normal 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 %}
|
170
sstreasury/jinja2/sstreasury/claim_view.html
Normal file
170
sstreasury/jinja2/sstreasury/claim_view.html
Normal 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 %}
|
@ -21,6 +21,13 @@ from jsonfield import JSONField
|
||||
|
||||
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):
|
||||
pass
|
||||
|
||||
@ -33,7 +40,7 @@ class BudgetComment(models.Model):
|
||||
class Meta:
|
||||
ordering = ['id']
|
||||
|
||||
class BudgetState(Enum):
|
||||
class BudgetState(DescriptionEnum):
|
||||
DRAFT = 10, 'Draft'
|
||||
RESUBMIT = 20, 'Returned for redrafting'
|
||||
AWAIT_REVIEW = 30, 'Awaiting Treasury review'
|
||||
@ -41,23 +48,11 @@ class BudgetState(Enum):
|
||||
APPROVED = 50, 'Approved'
|
||||
#CANCELLED = 60, 'Cancelled'
|
||||
|
||||
def __new__(cls, value, description):
|
||||
obj = object.__new__(cls)
|
||||
obj._value_ = value
|
||||
obj.description = description
|
||||
return obj
|
||||
|
||||
class BudgetAction(Enum):
|
||||
class BudgetAction(DescriptionEnum):
|
||||
CREATE = 5, 'Created'
|
||||
EDIT = 10, 'Edited'
|
||||
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):
|
||||
budget = models.ForeignKey(Budget, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=100)
|
||||
@ -85,5 +80,67 @@ class BudgetRevision(models.Model):
|
||||
self.save()
|
||||
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:
|
||||
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])
|
||||
|
@ -135,7 +135,7 @@ function makeGrid() {
|
||||
height: editing ? '20em' : 'auto',
|
||||
inserting: 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,
|
||||
fields: f,
|
||||
onItemUpdated: recalcExpTotal,
|
||||
|
88
sstreasury/static/sstreasury/claim.js
Normal file
88
sstreasury/static/sstreasury/claim.js
Normal 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,
|
||||
});
|
||||
}
|
@ -25,5 +25,8 @@ urlpatterns = [
|
||||
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/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'),
|
||||
]
|
||||
|
@ -52,20 +52,20 @@ def budget_list(request):
|
||||
group = budgets_action
|
||||
elif request.user.groups.filter(name='Secretary').exists() and state == models.BudgetState.ENDORSED:
|
||||
group = budgets_action
|
||||
else:
|
||||
if request.user in revision.contributors.all():
|
||||
if state in [models.BudgetState.DRAFT, models.BudgetState.RESUBMIT]:
|
||||
group = budgets_action
|
||||
elif state in [models.BudgetState.AWAIT_REVIEW, models.BudgetState.ENDORSED]:
|
||||
group = budgets_open
|
||||
else:
|
||||
group = budgets_closed
|
||||
elif request.user.groups.filter(name='Committee').exists() and state == models.BudgetState.ENDORSED:
|
||||
group = budgets_action
|
||||
elif request.user == revision.author or request.user in revision.contributors.all():
|
||||
if state in [models.BudgetState.DRAFT, models.BudgetState.RESUBMIT]:
|
||||
group = budgets_action
|
||||
elif state in [models.BudgetState.AWAIT_REVIEW, models.BudgetState.ENDORSED]:
|
||||
group = budgets_open
|
||||
else:
|
||||
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:
|
||||
group = budgets_closed
|
||||
else:
|
||||
group = budgets_open
|
||||
group = budgets_closed
|
||||
elif request.user.groups.filter(name='Treasury').exists() or request.user.groups.filter(name='Secretary').exists():
|
||||
if state == models.BudgetState.APPROVED:
|
||||
group = budgets_closed
|
||||
else:
|
||||
group = budgets_open
|
||||
|
||||
if group is not None:
|
||||
group.append(revision)
|
||||
@ -85,9 +85,8 @@ def budget_view(request, id):
|
||||
else:
|
||||
revision = budget.budgetrevision_set.reverse()[0]
|
||||
|
||||
if request.user not in revision.contributors.all():
|
||||
if not request.user.groups.filter(name='Treasury').exists():
|
||||
raise PermissionDenied
|
||||
if not revision.can_view(request.user):
|
||||
raise PermissionDenied
|
||||
|
||||
history = list(itertools.chain(budget.budgetrevision_set.all(), revision.budget.budgetcomment_set.all()))
|
||||
history.sort(key=lambda x: x.time, reverse=True)
|
||||
@ -107,9 +106,8 @@ def budget_print(request, id):
|
||||
else:
|
||||
revision = budget.budgetrevision_set.reverse()[0]
|
||||
|
||||
if request.user not in revision.contributors.all():
|
||||
if not request.user.groups.filter(name='Treasury').exists():
|
||||
raise PermissionDenied
|
||||
if not revision.can_view(request.user):
|
||||
raise PermissionDenied
|
||||
|
||||
return render(request, 'sstreasury/budget_print.html', {
|
||||
'revision': revision,
|
||||
@ -183,9 +181,8 @@ def uses_budget(viewfunc):
|
||||
def budget_viewable(viewfunc):
|
||||
@functools.wraps(viewfunc)
|
||||
def func(request, budget, revision):
|
||||
if request.user not in revision.contributors.all():
|
||||
if not request.user.groups.filter(name='Treasury').exists():
|
||||
raise PermissionDenied
|
||||
if not revision.can_view(request.user):
|
||||
raise PermissionDenied
|
||||
|
||||
return viewfunc(request, budget, revision)
|
||||
return func
|
||||
@ -200,7 +197,7 @@ def budget_editable(viewfunc):
|
||||
if not request.user.groups.filter(name='Treasury').exists():
|
||||
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():
|
||||
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})
|
||||
|
||||
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
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user