Revenue and economy revamp (#285)

* rework economy stuff, add revenue model, unfinished code!

* part 2 of economy overhaul. add views for dealing with revenue. rework expense views.
This commit is contained in:
Thomas Steen Rasmussen 2018-11-20 17:12:32 +01:00 committed by GitHub
parent a8051783cb
commit a057bd6464
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1332 additions and 416 deletions

View file

@ -47,12 +47,5 @@
</tbody>
</table>
</div>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -6,19 +6,18 @@
{% include 'includes/expense_detail_panel.html' %}
{% if expense.approved != None %}
<div class="alert alert-info">This expense has already been approved/rejected.</div>
<div class="lead">Economy Team notes for this expense:</div>
<div class="lead">{{ expense.notes }}</div>
{% else %}
{% if expense.approved == None %}
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
{% bootstrap_button "<i class='fas fa-check'></i> Approve Expense" button_type="submit" button_class="btn-success" name="approve" %}
{% bootstrap_button "<i class='fas fa-times'></i> Reject Expense" button_type="submit" button_class="btn-danger" name="reject" %}
<a href="{% url 'backoffice:expense_manage_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
<a href="{% url 'backoffice:expense_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endif %}
<br>
<a href="{% url 'backoffice:expense_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Back to Expense List</a>
{% endblock content %}

View file

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% load staticfiles %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %}
<h2>Manage Expenses for {{ camp.title }}</h2>
{% if unapproved_expenses %}
<div class="lead">
This table shows unapproved expenses for {{ camp.title }}.
</div>
{% include 'includes/expense_list_panel.html' with expense_list=unapproved_expenses %}
<hr>
{% endif %}
<div class="lead">
This table shows all approved expenses for {{ camp.title }}.
</div>
{% include 'includes/expense_list_panel.html' %}
{% endblock content %}

View file

@ -1,91 +0,0 @@
{% extends 'base.html' %}
{% load staticfiles %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %}
<h2>Manage Expenses for {{ camp.title }}</h2>
{% if unapproved_expenses %}
<div class="lead">
This table shows unapproved expenses for {{ camp.title }}.
</div>
<table class="table table-hover">
<thead>
<tr>
<th>User</th>
<th>Description</th>
<th>Amount</th>
<th>Paid by</th>
<th>Approved?</th>
<th>Reimbursement?</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for expense in unapproved_expenses %}
<tr>
<td>{{ expense.user }}</td>
<td>{{ expense.description }}</td>
<td>{{ expense.amount }} DKK</td>
<td>{% if expense.paid_by_bornhack %}BornHack{% else %}{{ expense.user }}{% endif %}</td>
<td>{{ expense.approval_status }}</td>
<td>{% if expense.reimbursement %}<a href="{% url 'backoffice:reimbursement_detail' camp_slug=camp.slug pk=expense.reimbursement.pk %}" class="btn btn-primary">Details</a>{% else %}N/A{% endif %}</td>
<td>
<a class="btn btn-primary" href="{% url "backoffice:expense_detail" camp_slug=camp.slug pk=expense.pk %}">Details</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<hr>
<div class="lead">
This table shows all expenses for {{ camp.title }}.
</div>
<br>
<table class="table table-hover">
<thead>
<tr>
<th>User</th>
<th>Description</th>
<th>Amount</th>
<th>Paid by</th>
<th>Approved?</th>
<th>Reimbursement?</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for expense in expense_list %}
<tr>
<td>{{ expense.user }}</td>
<td>{{ expense.description }}</td>
<td>{{ expense.amount }} DKK</td>
<td>{% if expense.paid_by_bornhack %}BornHack{% else %}{{ expense.user }}{% endif %}</td>
<td>{{ expense.approval_status }}</td>
<td>{% if expense.reimbursement %}<a href="{% url 'backoffice:reimbursement_detail' camp_slug=camp.slug pk=expense.reimbursement.pk %}" class="btn btn-primary">Details</a>{% else %}N/A{% endif %}</td>
<td>
<a class="btn btn-primary" href="{% url "backoffice:expense_detail" camp_slug=camp.slug pk=expense.pk %}">Details</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -64,13 +64,17 @@
{% if perms.camps.economyteam_permission %}
<h3>Economy Team</h3>
<a href="{% url 'backoffice:expense_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Expenses</h4>
<p class="list-group-item-text">Use this view to see and approve/reject expenses.</p>
</a>
<a href="{% url 'backoffice:reimbursement_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Reimbursements</h4>
<p class="list-group-item-text">Use this view to view and create reimbursements</p>
<p class="list-group-item-text">Use this view to view and create reimbursements for approved expenses.</p>
</a>
<a href="{% url 'backoffice:expense_manage_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Expenses</h4>
<p class="list-group-item-text">Use this view to see and approve/reject expenses</p>
<a href="{% url 'backoffice:revenue_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Revenues</h4>
<p class="list-group-item-text">Use this view to see and approve/reject revenues.</p>
</a>
{% endif %}
</div>

View file

@ -79,11 +79,5 @@
</table>
{% endif %}
</div>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -36,9 +36,4 @@
</table>
</div>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -42,10 +42,4 @@
</tbody>
</table>
</div>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -42,10 +42,4 @@
</tbody>
</table>
</div>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -43,12 +43,5 @@
</tbody>
</table>
</div>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<h3>Delete Reimbursement for User {{ reimbursement_user }}</h3>
<p>The total amount for this reimbursement is <b>{{ reimbursement.amount }} DKK</b></p>
<p class="lead">Really delete this reimbursement?</p>
<form method="POST">
{% csrf_token %}
{% bootstrap_button "<i class='fas fa-times'></i> Yes, Delete it" button_type="submit" button_class="btn-danger" name="Delete" %}
<a href="{% url 'backoffice:reimbursement_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endblock content %}

View file

@ -2,11 +2,13 @@
{% load bootstrap3 %}
{% block content %}
<h3>Manage Reimbursement</h3>
<h3>Reimbursement Details</h3>
{% include 'includes/reimbursement_detail_panel.html' %}
<a class="btn btn-success" href="{% url "backoffice:reimbursement_list" camp_slug=camp.slug %}">Back to reimbursement list</a>
<a class="btn btn-primary" href="{% url "backoffice:reimbursement_update" camp_slug=camp.slug pk=reimbursement.pk %}"><i class="fas fa-edit"></i> Update</a>
<a class="btn btn-danger" href="{% url "backoffice:reimbursement_delete" camp_slug=camp.slug pk=reimbursement.pk %}"><i class="fas fa-times"></i> Delete</a>
<a class="btn btn-primary" href="{% url "backoffice:reimbursement_list" camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Back to reimbursement list</a>
{% endblock content %}

View file

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<h3>Update Reimbursement for User {{ reimbursement_user }}</h3>
<p class="lead">The total amount for this reimbursement is <b>{{ reimbursement.amount }} DKK</b></p>
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
{% bootstrap_button "<i class='fas fa-check'></i> Update" button_type="submit" button_class="btn-success" name="Update" %}
<a href="{% url 'backoffice:reimbursement_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endblock content %}

View file

@ -1,55 +0,0 @@
{% extends 'base.html' %}
{% load staticfiles %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %}
<div class="row">
<h2>Reimbursements for {{ camp.title }}</h2>
</div>
<br>
<div class="row">
<table class="table table-hover">
<thead>
<tr>
<th>Camp</th>
<th>Created</th>
<th>Reimbursement User</th>
<th>Economy Team Notes</th>
<th>Amount</th>
<th>Paid</th>
<th>Expenses</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for reim in reimbursement_list %}
<tr>
<td>{{ reim.camp }}</td>
<td>{{ reim.created }} by {{ reim.user }}</td>
<td>{{ reim.reimbursement_user }}</td>
<td>{{ reim.notes|default:"N/A" }}</td>
<td>{{ reim.amount }} DKK</td>
<td>{{ reim.paid }}</td>
<td>{% for expense in reim.expenses.all %}{% if not expense.paid_by_bornhack %}{{ expense.pk }} - {{ expense.amount }} DKK - {{ expense.description }}<br>{% endif %}{% endfor %}</td>
<td>
<a class="btn btn-primary" href="{% url "backoffice:reimbursement_detail" camp_slug=camp.slug pk=reim.pk %}">Details</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a class="btn btn-success" href="{% url "backoffice:reimbursement_create_userselect" camp_slug=camp.slug %}">Create New Reimbursement</a>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% load staticfiles %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %}
<h2>Reimbursements for {{ camp.title }}</h2>
{% include 'includes/reimbursement_list_panel.html' %}
<a class="btn btn-success" href="{% url "backoffice:reimbursement_create_userselect" camp_slug=camp.slug %}"><i class="fas fa-plus"></i> Create New Reimbursement</a>
{% endblock content %}

View file

@ -0,0 +1,22 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<h3>Manage Revenue</h3>
{% include 'includes/revenue_detail_panel.html' %}
{% if revenue.approved == None %}
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
{% bootstrap_button "<i class='fas fa-check'></i> Approve Revenue" button_type="submit" button_class="btn-success" name="approve" %}
{% bootstrap_button "<i class='fas fa-times'></i> Reject Revenue" button_type="submit" button_class="btn-danger" name="reject" %}
<a href="{% url 'backoffice:revenue_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endif %}
<br>
<a href="{% url 'backoffice:revenue_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Back to Revenue List</a>
{% endblock content %}

View file

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% load staticfiles %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %}
<h2>Manage Revenues for {{ camp.title }}</h2>
{% if unapproved_revenues %}
<div class="lead">
This table shows unapproved revenues for {{ camp.title }}.
</div>
{% include 'includes/revenue_list_panel.html' with revenue_list=unapproved_revenues %}
<hr>
{% endif %}
<div class="lead">
This table shows all approved revenues for {{ camp.title }}.
</div>
{% include 'includes/revenue_list_panel.html' %}
{% endblock content %}

View file

@ -47,12 +47,5 @@
</tbody>
</table>
</div>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -35,10 +35,4 @@
</tbody>
</table>
</div>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -6,24 +6,64 @@ app_name = 'backoffice'
urlpatterns = [
path('', BackofficeIndexView.as_view(), name='index'),
# infodesk
path('product_handout/', ProductHandoutView.as_view(), name='product_handout'),
path('badge_handout/', BadgeHandoutView.as_view(), name='badge_handout'),
path('ticket_checkin/', TicketCheckinView.as_view(), name='ticket_checkin'),
# public names
path('public_credit_names/', ApproveNamesView.as_view(), name='public_credit_names'),
# merchandise orders
path('merchandise_orders/', MerchandiseOrdersView.as_view(), name='merchandise_orders'),
path('merchandise_to_order/', MerchandiseToOrderView.as_view(), name='merchandise_to_order'),
# village orders
path('village_orders/', VillageOrdersView.as_view(), name='village_orders'),
path('village_to_order/', VillageToOrderView.as_view(), name='village_to_order'),
# manage proposals
path('manage_proposals/', include([
path('', ManageProposalsView.as_view(), name='manage_proposals'),
path('speakers/<uuid:pk>/', SpeakerProposalManageView.as_view(), name='speakerproposal_manage'),
path('events/<uuid:pk>/', EventProposalManageView.as_view(), name='eventproposal_manage'),
])),
path('village_orders/', VillageOrdersView.as_view(), name='village_orders'),
path('village_to_order/', VillageToOrderView.as_view(), name='village_to_order'),
path('economy/expenses/', ExpenseManageListView.as_view(), name='expense_manage_list'),
path('economy/expenses/<uuid:pk>/', ExpenseManageDetailView.as_view(), name='expense_detail'),
path('economy/reimbursements/', ReimbursementListView.as_view(), name='reimbursement_list'),
path('economy/reimbursements/<uuid:pk>/', ReimbursementDetailView.as_view(), name='reimbursement_detail'),
path('economy/reimbursements/create/', ReimbursementCreateUserSelectView.as_view(), name='reimbursement_create_userselect'),
path('economy/reimbursements/create/<int:user_id>/', ReimbursementCreateView.as_view(), name='reimbursement_create'),
# economy
path('economy/',
include([
# expenses
path('expenses/',
include([
path('', ExpenseListView.as_view(), name='expense_list'),
path('<uuid:pk>/', ExpenseDetailView.as_view(), name='expense_detail'),
]),
),
# revenues
path('revenues/',
include([
path('', RevenueListView.as_view(), name='revenue_list'),
path('<uuid:pk>/', RevenueDetailView.as_view(), name='revenue_detail'),
]),
),
# reimbursements
path('reimbursements/',
include([
path('', ReimbursementListView.as_view(), name='reimbursement_list'),
path('<uuid:pk>/',
include([
path('', ReimbursementDetailView.as_view(), name='reimbursement_detail'),
path('update/', ReimbursementUpdateView.as_view(), name='reimbursement_update'),
path('delete/', ReimbursementDeleteView.as_view(), name='reimbursement_delete'),
]),
),
path('create/', ReimbursementCreateUserSelectView.as_view(), name='reimbursement_create_userselect'),
path('create/<int:user_id>/', ReimbursementCreateView.as_view(), name='reimbursement_create'),
]),
),
]),
),
]

View file

@ -4,7 +4,7 @@ from itertools import chain
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.contrib.auth.models import User
from django.views.generic import TemplateView, ListView, DetailView
from django.views.generic.edit import CreateView, UpdateView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.shortcuts import redirect, get_object_or_404
from django.urls import reverse
from django.contrib import messages
@ -18,7 +18,7 @@ from shop.models import OrderProductRelation
from tickets.models import ShopTicket, SponsorTicket, DiscountTicket
from profiles.models import Profile
from program.models import SpeakerProposal, EventProposal
from economy.models import Expense, Reimbursement
from economy.models import Expense, Reimbursement, Revenue
from utils.mixins import RaisePermissionRequiredMixin
from teams.models import Team
from .mixins import *
@ -98,7 +98,7 @@ class ManageProposalsView(CampViewMixin, ContentTeamPermissionMixin, ListView):
return context
class ProposalManageView(CampViewMixin, ContentTeamPermissionMixin, UpdateView):
class ProposalManageBaseView(CampViewMixin, ContentTeamPermissionMixin, UpdateView):
"""
This class contains the shared logic between SpeakerProposalManageView and EventProposalManageView
"""
@ -120,7 +120,7 @@ class ProposalManageView(CampViewMixin, ContentTeamPermissionMixin, UpdateView):
return redirect(reverse('backoffice:manage_proposals', kwargs={'camp_slug': self.camp.slug}))
class SpeakerProposalManageView(ProposalManageView):
class SpeakerProposalManageView(ProposalManageBaseView):
"""
This view allows an admin to approve/reject SpeakerProposals
"""
@ -128,7 +128,7 @@ class SpeakerProposalManageView(ProposalManageView):
template_name = "manage_speakerproposal.html"
class EventProposalManageView(ProposalManageView):
class EventProposalManageView(ProposalManageBaseView):
"""
This view allows an admin to approve/reject EventProposals
"""
@ -228,13 +228,16 @@ class VillageToOrderView(CampViewMixin, OrgaTeamPermissionMixin, TemplateView):
return context
class ExpenseManageListView(CampViewMixin, EconomyTeamPermissionMixin, ListView):
################################
########### EXPENSES ###########
class ExpenseListView(CampViewMixin, EconomyTeamPermissionMixin, ListView):
model = Expense
template_name = 'expense_manage_list.html'
template_name = 'expense_list_backoffice.html'
def get_queryset(self, **kwargs):
"""
Exclude unapproved expenses
Exclude unapproved expenses, they are shown seperately
"""
queryset = super().get_queryset(**kwargs)
return queryset.exclude(approved__isnull=True)
@ -247,9 +250,10 @@ class ExpenseManageListView(CampViewMixin, EconomyTeamPermissionMixin, ListView)
context['unapproved_expenses'] = Expense.objects.filter(camp=self.camp, approved__isnull=True)
return context
class ExpenseManageDetailView(CampViewMixin, EconomyTeamPermissionMixin, UpdateView):
class ExpenseDetailView(CampViewMixin, EconomyTeamPermissionMixin, UpdateView):
model = Expense
template_name = 'expense_manage_detail.html'
template_name = 'expense_detail_backoffice.html'
fields = ['notes']
def form_valid(self, form):
@ -265,12 +269,15 @@ class ExpenseManageDetailView(CampViewMixin, EconomyTeamPermissionMixin, UpdateV
expense.reject(self.request)
else:
messages.error(self.request, "Unknown submit action")
return redirect(reverse('backoffice:expense_manage_list', kwargs={'camp_slug': self.camp.slug}))
return redirect(reverse('backoffice:expense_list', kwargs={'camp_slug': self.camp.slug}))
######################################
########### REIMBURSEMENTS ###########
class ReimbursementListView(CampViewMixin, EconomyTeamPermissionMixin, ListView):
model = Reimbursement
template_name = 'reimbursement_list.html'
template_name = 'reimbursement_list_backoffice.html'
class ReimbursementDetailView(CampViewMixin, EconomyTeamPermissionMixin, DetailView):
@ -373,3 +380,68 @@ class ReimbursementCreateView(CampViewMixin, EconomyTeamPermissionMixin, CreateV
messages.success(self.request, "Reimbursement %s has been created" % reimbursement.pk)
return redirect(reverse('backoffice:reimbursement_detail', kwargs={'camp_slug': self.camp.slug, 'pk': reimbursement.pk}))
class ReimbursementUpdateView(CampViewMixin, EconomyTeamPermissionMixin, UpdateView):
model = Reimbursement
template_name = 'reimbursement_form.html'
fields = ['notes', 'paid']
def get_success_url(self):
return reverse('backoffice:reimbursement_detail', kwargs={'camp_slug': self.camp.slug, 'pk': self.get_object().pk})
class ReimbursementDeleteView(CampViewMixin, EconomyTeamPermissionMixin, DeleteView):
model = Reimbursement
template_name = 'reimbursement_delete.html'
fields = ['notes', 'paid']
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if self.get_object().paid:
messages.error(request, "This reimbursement has already been paid so it cannot be deleted")
return redirect(reverse('backoffice:reimbursement_list', kwargs={'camp_slug': self.camp.slug}))
return response
################################
########### REVENUES ###########
class RevenueListView(CampViewMixin, EconomyTeamPermissionMixin, ListView):
model = Revenue
template_name = 'revenue_list_backoffice.html'
def get_queryset(self, **kwargs):
"""
Exclude unapproved revenues, they are shown seperately
"""
queryset = super().get_queryset(**kwargs)
return queryset.exclude(approved__isnull=True)
def get_context_data(self, **kwargs):
"""
Include unapproved revenues seperately
"""
context = super().get_context_data(**kwargs)
context['unapproved_revenues'] = Revenue.objects.filter(camp=self.camp, approved__isnull=True)
return context
class RevenueDetailView(CampViewMixin, EconomyTeamPermissionMixin, UpdateView):
model = Revenue
template_name = 'revenue_detail_backoffice.html'
fields = ['notes']
def form_valid(self, form):
"""
We have two submit buttons in this form, Approve and Reject
"""
revenue = form.save()
if 'approve' in form.data:
# approve button was pressed
revenue.approve(self.request)
elif 'reject' in form.data:
# reject button was pressed
revenue.reject(self.request)
else:
messages.error(self.request, "Unknown submit action")
return redirect(reverse('backoffice:revenue_list', kwargs={'camp_slug': self.camp.slug}))

View file

@ -0,0 +1,17 @@
# Generated by Django 2.0.4 on 2018-09-17 15:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('camps', '0031_auto_20180830_0014'),
]
operations = [
migrations.AlterModelOptions(
name='permission',
options={'default_permissions': (), 'managed': False, 'permissions': (('backoffice_permission', 'BackOffice access'), ('orgateam_permission', 'Orga Team permissions set'), ('infoteam_permission', 'Info Team permissions set'), ('economyteam_permission', 'Economy Team permissions set'), ('contentteam_permission', 'Content Team permissions set'), ('expense_create_permission', 'Expense Create permission'), ('revenue_create_permission', 'Revenue Create permission'))},
),
]

View file

@ -25,6 +25,7 @@ class Permission(models.Model):
("economyteam_permission", "Economy Team permissions set"),
("contentteam_permission", "Content Team permissions set"),
("expense_create_permission", "Expense Create permission"),
("revenue_create_permission", "Revenue Create permission"),
)

View file

@ -1,7 +1,9 @@
from django.contrib import admin
from .models import Expense, Reimbursement
from .models import Expense, Reimbursement, Revenue
### expenses
def approve_expenses(modeladmin, request, queryset):
for expense in queryset.all():
expense.approve(request)
@ -22,11 +24,34 @@ class ExpenseAdmin(admin.ModelAdmin):
actions = [approve_expenses, reject_expenses]
### revenues
def approve_revenues(modeladmin, request, queryset):
for revenue in queryset.all():
revenue.approve(request)
approve_revenues.short_description = "Approve Revenues"
def reject_revenues(modeladmin, request, queryset):
for revenue in queryset.all():
revenue.reject(request)
reject_revenues.short_description = "Reject Revenues"
@admin.register(Revenue)
class RevenueAdmin(admin.ModelAdmin):
list_filter = ['camp', 'responsible_team', 'approved', 'user']
list_display = ['user', 'description', 'amount', 'camp', 'responsible_team', 'approved']
search_fields = ['description', 'amount', 'user']
actions = [approve_revenues, reject_revenues]
### reimbursements
@admin.register(Reimbursement)
class ReimbursementAdmin(admin.ModelAdmin):
def get_amount(self, obj):
return obj.amount
list_filter = ['camp', 'user', 'reimbursement_user', 'paid']
list_display = ['camp', 'user', 'reimbursement_user', 'paid', 'notes', 'get_amount']
search_fields = ['user__username', 'reimbursement_user__username', 'notes']

View file

@ -4,14 +4,15 @@ from django.conf import settings
from utils.email import add_outgoing_email
# expense emails
def send_accountingsystem_email(expense):
def send_accountingsystem_expense_email(expense):
"""
Sends an email to the accountingsystem with the invoice as an attachment,
and with the expense uuid and description in email subject
"""
add_outgoing_email(
"emails/accountingsystem_email.txt",
"emails/accountingsystem_expense_email.txt",
formatdict=dict(expense=expense),
subject="Expense %s for %s" % (expense.pk, expense.camp.title),
to_recipients=[settings.ACCOUNTINGSYSTEM_EMAIL],
@ -43,3 +44,43 @@ def send_expense_rejected_email(expense):
to_recipients=[expense.user.emailaddress_set.get(primary=True).email],
)
# revenue emails
def send_accountingsystem_revenue_email(revenue):
"""
Sends an email to the accountingsystem with the invoice as an attachment,
and with the revenue uuid and description in email subject
"""
add_outgoing_email(
"emails/accountingsystem_revenue_email.txt",
formatdict=dict(revenue=revenue),
subject="Revenue %s for %s" % (revenue.pk, revenue.camp.title),
to_recipients=[settings.ACCOUNTINGSYSTEM_EMAIL],
attachment=revenue.invoice.read(),
attachment_filename=os.path.basename(revenue.invoice.file.name),
)
def send_revenue_approved_email(revenue):
"""
Sends a revenue-approved email to the user who created the revenue
"""
add_outgoing_email(
"emails/revenue_approved_email.txt",
formatdict=dict(revenue=revenue),
subject="Your revenue for %s has been approved." % revenue.camp.title,
to_recipients=[revenue.user.emailaddress_set.get(primary=True).email],
)
def send_revenue_rejected_email(revenue):
"""
Sends an revenue-rejected email to the user who created the revenue
"""
add_outgoing_email(
"emails/revenue_rejected_email.txt",
formatdict=dict(revenue=revenue),
subject="Your revenue for %s has been rejected." % revenue.camp.title,
to_recipients=[revenue.user.emailaddress_set.get(primary=True).email],
)

View file

@ -1,18 +1,14 @@
import os, magic, copy
from django import forms
from .models import Expense
from .models import Expense, Revenue
class ExpenseCreateForm(forms.ModelForm):
class CleanInvoiceForm(forms.ModelForm):
"""
We have to define this form explicitly because we want our ImageField to accept PDF files as well as images,
and we cannot change the clean_* methods with an autogenerated form from inside views.py
"""
class Meta:
model = Expense
fields = ['description', 'amount', 'invoice', 'paid_by_bornhack', 'responsible_team']
invoice = forms.FileField()
def clean_invoice(self):
@ -36,3 +32,27 @@ class ExpenseCreateForm(forms.ModelForm):
# this is either a valid image, or has mimetype application/pdf, all good
return uploaded_file
class ExpenseCreateForm(CleanInvoiceForm):
class Meta:
model = Expense
fields = ['description', 'amount', 'invoice', 'paid_by_bornhack', 'responsible_team']
class ExpenseUpdateForm(forms.ModelForm):
class Meta:
model = Expense
fields = ['description', 'amount', 'paid_by_bornhack', 'responsible_team']
class RevenueCreateForm(CleanInvoiceForm):
class Meta:
model = Revenue
fields = ['description', 'amount', 'invoice', 'responsible_team']
class RevenueUpdateForm(forms.ModelForm):
class Meta:
model = Revenue
fields = ['description', 'amount', 'responsible_team']

View file

@ -0,0 +1,40 @@
# Generated by Django 2.0.4 on 2018-09-16 13:27
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('shop', '0057_order_notes'),
('teams', '0049_auto_20180815_1119'),
('camps', '0031_auto_20180830_0014'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('economy', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Revenue',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('amount', models.DecimalField(decimal_places=2, help_text='The amount of this revenue in DKK. Must match the amount on the documentation uploaded below.', max_digits=12)),
('description', models.CharField(help_text='A short description of this revenue. Please keep it meningful as it helps the Economy team a lot when categorising revenue. 200 characters or fewer.', max_length=200)),
('invoice', models.ImageField(help_text='The invoice file for this revenue. Please make sure the amount on the invoice matches the amount you entered above. All common image formats are accepted, as well as PDF.', upload_to='revenues/')),
('approved', models.NullBooleanField(default=None, help_text='True if this Revenue has been approved by the responsible team. False if it has been rejected. Blank if noone has decided yet.')),
('notes', models.TextField(blank=True, help_text='Economy Team notes for this revenue. Only visible to the Economy team and the submitting user.')),
('camp', models.ForeignKey(help_text='The camp to which this revenue belongs', on_delete=django.db.models.deletion.PROTECT, related_name='revenues', to='camps.Camp')),
('invoice_fk', models.ForeignKey(help_text='The Invoice object to which this Revenue object relates. Can be None if this revenue does not have a related BornHack Invoice.', on_delete=django.db.models.deletion.PROTECT, related_name='revenues', to='shop.Invoice')),
('responsible_team', models.ForeignKey(help_text='The team to which this revenue belongs. When in doubt pick the Economy team.', on_delete=django.db.models.deletion.PROTECT, related_name='revenues', to='teams.Team')),
('user', models.ForeignKey(help_text='The user who submitted this revenue', on_delete=django.db.models.deletion.PROTECT, related_name='revenues', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 2.0.4 on 2018-09-17 17:33
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('economy', '0002_revenue'),
]
operations = [
migrations.AlterField(
model_name='revenue',
name='invoice_fk',
field=models.ForeignKey(blank=True, help_text='The Invoice object to which this Revenue object relates. Can be None if this revenue does not have a related BornHack Invoice.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='revenues', to='shop.Invoice'),
),
]

View file

@ -10,7 +10,20 @@ class ExpensePermissionMixin(object):
if obj.user == self.request.user or self.request.user.has_perm('camps.economyteam_permission'):
return obj
else:
# the current user is different from the user who submitted the expense, and user is not in the economy team; fuckery is afoot, no thanks
# the current user is different from the user who submitted the expense, and current user is not in the economy team; fuckery is afoot, no thanks
raise Http404()
class RevenuePermissionMixin(object):
"""
This mixin checks if request.user submitted the Revenue, or if request.user has camps.economyteam_permission
"""
def get_object(self, queryset=None):
obj = super().get_object(queryset=queryset)
if obj.user == self.request.user or self.request.user.has_perm('camps.economyteam_permission'):
return obj
else:
# the current user is different from the user who submitted the revenue, and current user is not in the economy team; fuckery is afoot, no thanks
raise Http404()
@ -20,9 +33,9 @@ class ReimbursementPermissionMixin(object):
"""
def get_object(self, queryset=None):
obj = super().get_object(queryset=queryset)
if obj.user == self.request.user or self.request.user.has_perm('camps.economyteam_permission'):
if obj.reimbursement_user == self.request.user or self.request.user.has_perm('camps.economyteam_permission'):
return obj
else:
# the current user is different from the user who owns the reimbursement, and user is not in the economy team; fuckery is afoot, no thanks
# the current user is different from the user who "owns" the reimbursement, and current user is not in the economy team; fuckery is afoot, no thanks
raise Http404()

View file

@ -9,6 +9,122 @@ from django.core.exceptions import ValidationError
from utils.models import CampRelatedModel, UUIDModel
from .email import *
class Revenue(CampRelatedModel, UUIDModel):
"""
The Revenue model represents any type of income for BornHack.
Most Revenue objects will have a FK to the Invoice model, but only if the revenue relates directly to an Invoice in our system.
Other Revenue objects (such as money returned from bottle deposits) will not have a related BornHack Invoice object.
"""
camp = models.ForeignKey(
'camps.Camp',
on_delete=models.PROTECT,
related_name='revenues',
help_text='The camp to which this revenue belongs',
)
user = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
related_name='revenues',
help_text='The user who submitted this revenue',
)
amount = models.DecimalField(
decimal_places=2,
max_digits=12,
help_text='The amount of this revenue in DKK. Must match the amount on the documentation uploaded below.',
)
description = models.CharField(
max_length=200,
help_text='A short description of this revenue. Please keep it meningful as it helps the Economy team a lot when categorising revenue. 200 characters or fewer.',
)
invoice = models.ImageField(
help_text='The invoice file for this revenue. Please make sure the amount on the invoice matches the amount you entered above. All common image formats are accepted, as well as PDF.',
upload_to='revenues/',
)
invoice_fk = models.ForeignKey(
'shop.Invoice',
on_delete=models.PROTECT,
related_name='revenues',
help_text='The Invoice object to which this Revenue object relates. Can be None if this revenue does not have a related BornHack Invoice.',
blank=True,
null=True,
)
responsible_team = models.ForeignKey(
'teams.Team',
on_delete=models.PROTECT,
related_name='revenues',
help_text='The team to which this revenue belongs. When in doubt pick the Economy team.'
)
approved = models.NullBooleanField(
default=None,
help_text='True if this Revenue has been approved by the responsible team. False if it has been rejected. Blank if noone has decided yet.'
)
notes = models.TextField(
blank=True,
help_text='Economy Team notes for this revenue. Only visible to the Economy team and the submitting user.'
)
def clean(self):
if self.amount < 0:
raise ValidationError('Amount of a Revenue object can not be negative')
@property
def invoice_filename(self):
return os.path.basename(self.invoice.file.name)
@property
def approval_status(self):
if self.approved == None:
return "Pending approval"
elif self.approved == True:
return "Approved"
else:
return "Rejected"
def approve(self, request):
"""
This method marks a revenue as approved.
Approving a revenue triggers an email to the economy system, and another email to the user who submitted the revenue
"""
if request.user == self.user:
messages.error(request, "You cannot approve your own revenues, aka. the anti-stein-bagger defense")
return
# mark as approved and save
self.approved = True
self.save()
# send email to economic for this revenue
send_accountingsystem_revenue_email(revenue=self)
# send email to the user
send_revenue_approved_email(revenue=self)
# message to the browser
messages.success(request, "Revenue %s approved" % self.pk)
def reject(self, request):
"""
This method marks a revenue as not approved.
Not approving a revenue triggers an email to the user who submitted the revenue in the first place.
"""
# mark as not approved and save
self.approved = False
self.save()
# send email to the user
send_revenue_rejected_email(revenue=self)
# message to the browser
messages.success(request, "Revenue %s rejected" % self.pk)
class Expense(CampRelatedModel, UUIDModel):
camp = models.ForeignKey(
@ -95,7 +211,7 @@ class Expense(CampRelatedModel, UUIDModel):
Approving an expense triggers an email to the economy system, and another email to the user who submitted the expense in the first place.
"""
if request.user == self.user:
messages.error(request, "You cannot approve your own expenses, aka. the anti-stein-bagger defence")
messages.error(request, "You cannot approve your own expenses, aka. the anti-stein-bagger defense")
return
# mark as approved and save
@ -103,7 +219,7 @@ class Expense(CampRelatedModel, UUIDModel):
self.save()
# send email to economic for this expense
send_accountingsystem_email(expense=self)
send_accountingsystem_expense_email(expense=self)
# send email to the user
send_expense_approved_email(expense=self)
@ -162,6 +278,13 @@ class Reimbursement(CampRelatedModel, UUIDModel):
help_text="Check when this reimbursement has been paid to the user",
)
@property
def covered_expenses(self):
"""
Returns a queryset of all expenses covered by this reimbursement. Does not include the expense that paid for the reimbursement.
"""
return self.expenses.filter(paid_by_bornhack=False)
@property
def amount(self):
"""
@ -172,3 +295,4 @@ class Reimbursement(CampRelatedModel, UUIDModel):
amount += expense.amount
return amount

View file

@ -0,0 +1,45 @@
{% extends 'base.html' %}
{% load staticfiles %}
{% block title %}
Economy | {{ block.super }}
{% endblock %}
<{% block content %}
<h3>Your {{ camp.title }} Economy Overview</h3>
<table class="table table-hover">
<thead>
<tr>
<th>What</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td><h4>Expenses</h4></td>
<td>You have <b>{{ expense_count }} expense{{ expense_count|pluralize }}</b> ({{ approved_expense_count }} approved, {{ rejected_expense_count }} rejected, and {{ unapproved_expense_count }} pending approval) for {{ camp.title }}, for a total of <b>{{ expense_total|default:"0" }} DKK</b>.</td>
<td>
<a href="{% url "economy:expense_list" camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-list"></i> List Expenses</a>
<a href="{% url "economy:expense_create" camp_slug=camp.slug %}" class="btn btn-success"><i class="fas fa-plus"></i> Create Expense</a>
</td>
</tr>
<tr>
<td><h4>Reimbursements</h4></td>
<td>You have <b>{{ reimbursement_count }} reimbursement{{ reimbursement_count|pluralize }}</b> ({{ paid_reimbursement_count }} paid, {{ unpaid_reimbursement_count }} pending payment) for {{ camp.title }}, for a total of <b>{{ reimbursement_total }} DKK</b>.</td>
<td>
<a href="{% url "economy:reimbursement_list" camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-list"></i> List Reimbursements</a>
</td>
</tr>
<tr>
<td><h4>Revenue</h4></td>
<td>You have <b>{{ revenue_count }} revenue{{ revenue_count|pluralize }}</b> ({{ approved_revenue_count }} approved, {{ rejected_revenue_count }} rejected, and {{ unapproved_revenue_count }} still pending approval) for {{ camp.title }}, for a total of <b>{{ revenue_total|default:"0" }} DKK</b>.</td>
<td>
<a href="{% url "economy:revenue_list" camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-list"></i> List Revenues</a>
<a href="{% url "economy:revenue_create" camp_slug=camp.slug %}" class="btn btn-success"><i class="fas fa-plus"></i> Create Revenue</a>
</td>
</tr>
</tbody>
</table>
{% endblock %}

View file

@ -0,0 +1,10 @@
New revenue for {{ revenue.camp }}
The attached receipt for revenue {{ revenue.pk }} has the following description:
{{ revenue.description }}
Greetings
The {{ revenue.camp.title }} Economy Team

View file

@ -0,0 +1,10 @@
Hi,
Your revenue {{ revenue.pk }} for {{ revenue.camp.title }} has been approved. The amount is DKK {{ revenue.amount }} and description of the revenue is:
{{ revenue.description }}
Have a nice day!
The {{ revenue.camp.title }} Economy Team

View file

@ -0,0 +1,10 @@
Hi,
A new revenue {{ revenue.pk }} for {{ revenue.responsible_team.name }} Team was just submitted by user {{ revenue.user }}. The amount is DKK {{ revenue.amount }} and description of the revenue is:
{{ revenue.description }}
Have a nice day!
The {{ revenue.camp.title }} Team

View file

@ -0,0 +1,12 @@
Hi,
Your revenue {{ revenue.pk }} for {{ revenue.camp.title }} has been rejected. The amount is DKK {{ revenue.amount }} and description of the revenue is:
{{ revenue.description }}
Please contact us for more info.
Have a nice day!
The {{ revenue.camp.title }} Economy Team

View file

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block title %}
Delete Expense | {{ block.super }}
{% endblock %}
{% block content %}
<h3>Really delete expense {{ expense.uuid }}?</h3>
{% include 'includes/expense_detail_panel.html' %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<button class="btn btn-danger" type="submit"><i class="fas fa-times"></i> Delete Expense</button>
<a href="{% url 'economy:expense_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endblock %}

View file

@ -1,11 +1,16 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block title %}
{% if object %}Update{% else %}Create{% endif %} Expense | {{ block.super }}
{% endblock %}
{% block content %}
<h3>Create {{ camp.title }} Expense</h3>
<h3>{% if object %}Update{% else %}Create{% endif %} {{ camp.title }} Expense</h3>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form form %}
<button class="btn btn-primary pull-right" type="submit">Save</button>
<button class="btn btn-success" type="submit"><i class="fas fa-plus"></i> Save</button>
<a href="{% url 'economy:expense_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endblock %}

View file

@ -13,38 +13,7 @@ Expenses | {{ block.super }}
<{% block content %}
<h3>Your {{ camp.title }} Expenses</h3>
{% if expense_list %}
<table class="table table-hover">
<thead>
<tr>
<th>Paid by</th>
<th>Amount</th>
<th>Description</th>
<th>Responsible Team</th>
<th>Approved</th>
<th>Reimbursement?</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for expense in expense_list %}
<tr>
<td>{% if expense.paid_by_bornhack %}BornHack{% else %}You ({{ expense.user }}){% endif %}</td>
<td>{{ expense.amount }} DKK</td>
<td>{{ expense.description }}</td>
<td>{{ expense.responsible_team.name }} Team</td>
<td>{{ expense.approval_status }}</td>
<td>{% if expense.reimbursement %}<a href="{% url 'economy:reimbursement_detail' camp_slug=camp.slug pk=expense.reimbursement.pk %}" class="btn btn-primary">Details</a>{% else %}N/A{% endif %}</td>
<td>
<a class="btn btn-primary" href="{% url 'economy:expense_detail' camp_slug=camp.slug pk=expense.uuid %}"><i class="fas fa-search"></i> Details</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<h4>No expenses found for BornHack 2018, you haven't submitted any yet!</h4>
{% endif %}
{% include 'includes/expense_list_panel.html' %}
{% if perms.camps.expense_create_permission %}
<a class="btn btn-primary" href="{% url 'economy:expense_create' camp_slug=camp.slug %}"><i class="fas fa-plus"></i> Create Expense</a>
@ -52,57 +21,4 @@ Expenses | {{ block.super }}
<div class="alert alert-danger"><p class="lead"><span class="text-error">You don't have permission to add expenses. Please ask someone from the Economy team to add the permission if you need it.</p></div>
{% endif %}
<hr>
<h3>Your {{ camp.title }} Reimbursements</h3>
{% if reimbursement_list %}
<table class="table table-hover">
<thead>
<tr>
<th>Camp</th>
<th>User</th>
<th>Economy Team Notes</th>
<th>Amount</th>
<th>Paid</th>
<th>Expenses</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for reim in reimbursement_list %}
<tr>
<td>{{ reim.camp }}</td>
<td>{{ reim.user }}</td>
<td>{{ reim.notes|default:"N/A" }}</td>
<td>{{ reim.amount }} DKK</td>
<td>{{ reim.paid }}</td>
<td>
{% for expense in reim.expenses.all %}
{% if not expense.paid_by_bornhack %}
{% if request.resolver_match.app_name == "backoffice" %}
<a href="{% url 'backoffice:expense_detail' camp_slug=camp.slug pk=expense.pk %}" class="btn btn-primary">Details</a> {{ expense.amount }} DKK - {{ expense.description }}<br>
{% else %}
<a href="{% url 'economy:expense_detail' camp_slug=camp.slug pk=expense.pk %}" class="btn btn-primary">Details</a> {{ expense.amount }} DKK - {{ expense.description }}<br>
{% endif %}
{% endif %}
{% endfor %}
</td>
<td>
<a class="btn btn-primary" href="{% url "economy:reimbursement_detail" camp_slug=camp.slug pk=reim.pk %}">Details</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<h4>No reimbursements found for BornHack 2018</h4>
{% endif %}
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock %}

View file

@ -25,17 +25,22 @@
{% if not expense.paid_by_bornhack %}
<tr>
<th>Reimbursement?</th>
<td>
{% if expense.reimbursement %}
{% if request.resolver_match.app_name == "backoffice" %}
<td>{% if expense.reimbursement %}<a class="btn btn-primary" target="_blank" href="{% url 'backoffice:reimbursement_detail' camp_slug=camp.slug pk=expense.reimbursement.pk %}">{{ expense.reimbursement.pk }}</a>{% else %}N/A{% endif %}</td>
<a class="btn btn-primary" href="{% url 'backoffice:reimbursement_detail' camp_slug=camp.slug pk=expense.reimbursement.pk %}">{{ expense.reimbursement.pk }}</a>
{% else %}
<td>{% if expense.reimbursement %}<a class="btn btn-primary" target="_blank" href="{% url 'economy:reimbursement_detail' camp_slug=camp.slug pk=expense.reimbursement.pk %}">{{ expense.reimbursement.pk }}</a>{% else %}N/A{% endif %}</td>
<a class="btn btn-primary" href="{% url 'economy:reimbursement_detail' camp_slug=camp.slug pk=expense.reimbursement.pk %}">{{ expense.reimbursement.pk }}</a>
{% endif %}
{% else %}
N/A
{% endif %}
</tr>
{% endif %}
<tr>
<th>Invoice</th>
<td>
<a href="{% url 'economy:expense_invoice' camp_slug=camp.slug pk=expense.pk %}"><img src="{% url 'economy:expense_invoice' camp_slug=camp.slug pk=expense.pk %}?preview" height=200></a><br>
<a href="{% url 'economy:expense_invoice' camp_slug=camp.slug pk=expense.pk %}" target="_blank"><img src="{% url 'economy:expense_invoice' camp_slug=camp.slug pk=expense.pk %}?preview" height=200></a><br>
Filename: {{ expense.invoice_filename }}
</td>
</tr>

View file

@ -0,0 +1,59 @@
{% if expense_list %}
<table class="table table-hover">
<thead>
<tr>
{% if not reimbursement %}
<th>Paid by</th>
{% endif %}
<th>Amount</th>
<th>Description</th>
<th>Responsible Team</th>
{% if not reimbursement %}
<th>Approved</th>
<th>Reimbursement</th>
{% endif %}
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for expense in expense_list %}
<tr>
{% if not reimbursement %}
<td>{% if expense.paid_by_bornhack %}BornHack{% else %}{{ expense.user }}{% endif %}</td>
{% endif %}
<td>{{ expense.amount }} DKK</td>
<td>{{ expense.description }}</td>
<td>{{ expense.responsible_team.name }} Team</td>
{% if not reimbursement %}
<td>{{ expense.approval_status }}</td>
<td>
{% if expense.reimbursement and not expense.paid_by_bornhack %}
{% if request.resolver_match.app_name == "backoffice" %}
<a href="{% url 'backoffice:reimbursement_detail' camp_slug=camp.slug pk=expense.reimbursement.pk %}" class="btn btn-primary"><i class="fas fa-search"></i> Details</a>
{% else %}
<a href="{% url 'economy:reimbursement_detail' camp_slug=camp.slug pk=expense.reimbursement.pk %}" class="btn btn-primary"><i class="fas fa-search"></i> Details</a>
{% endif %}
{% else %}
N/A
{% endif %}
</td>
{% endif %}
<td>
{% if request.resolver_match.app_name == "backoffice" %}
<a class="btn btn-primary" href="{% url 'backoffice:expense_detail' camp_slug=camp.slug pk=expense.uuid %}"><i class="fas fa-search"></i> Details</a>
{% else %}
<a class="btn btn-primary" href="{% url 'economy:expense_detail' camp_slug=camp.slug pk=expense.uuid %}"><i class="fas fa-search"></i> Details</a>
<a class="btn btn-primary" href="{% url 'economy:expense_update' camp_slug=camp.slug pk=expense.uuid %}"><i class="fas fa-edit"></i> Update</a>
<a class="btn btn-danger" href="{% url 'economy:expense_delete' camp_slug=camp.slug pk=expense.uuid %}"><i class="fas fa-times"></i> Delete</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<h4>No expenses found.</h4>
{% endif %}

View file

@ -25,15 +25,7 @@
<tr>
<th>Expenses covered by this Reimbursement</th>
<td>
{% for expense in reimbursement.expenses.all %}
{% if not expense.paid_by_bornhack %}
{% if request.resolver_match.app_name == "backoffice" %}
<a href="{% url 'backoffice:expense_detail' camp_slug=camp.slug pk=expense.pk %}" class="btn btn-primary">{{ expense.pk }}</a> {{ expense.amount }} DKK - {{ expense.description }}<br>
{% else %}
<a href="{% url 'economy:expense_detail' camp_slug=camp.slug pk=expense.pk %}" class="btn btn-primary">{{ expense.pk }}</a> {{ expense.amount }} DKK - {{ expense.description }}<br>
{% endif %}
{% endif %}
{% endfor %}
{% include 'includes/expense_list_panel.html' with expense_list=reimbursement.covered_expenses.all %}
</td>
</tr>
</table>

View file

@ -0,0 +1,39 @@
{% if reimbursement_list %}
<table class="table table-hover">
<thead>
<tr>
<th>Camp</th>
<th>User</th>
<th>Economy Team Notes</th>
<th>Amount</th>
<th>Paid</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for reim in reimbursement_list %}
<tr>
<td>{{ reim.camp }}</td>
<td>{{ reim.user }}</td>
<td>{{ reim.notes|default:"N/A" }}</td>
<td>{{ reim.amount }} DKK</td>
<td>{{ reim.paid }}</td>
<td>
{% if request.resolver_match.app_name == "backoffice" %}
<a class="btn btn-primary" href="{% url "backoffice:reimbursement_detail" camp_slug=camp.slug pk=reim.pk %}"><i class="fas fa-search"></i> Details</a>
<a class="btn btn-primary" href="{% url "backoffice:reimbursement_update" camp_slug=camp.slug pk=reim.pk %}"><i class="fas fa-edit"></i> Update</a>
{% if not reim.paid %}
<a class="btn btn-danger" href="{% url "backoffice:reimbursement_delete" camp_slug=camp.slug pk=reim.pk %}"><i class="fas fa-times"></i> Delete</a>
{% endif %}
{% else %}
<a class="btn btn-primary" href="{% url "economy:reimbursement_detail" camp_slug=camp.slug pk=reim.pk %}"><i class="fas fa-search"></i> Details</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<h4>No reimbursements found for {{ camp.title }}</h4>
{% endif %}

View file

@ -0,0 +1,35 @@
<div class="panel panel-default">
<div class="panel-heading">Revenue Details for {{ revenue.pk }}</div>
<div class="panel-body">
<table class="table">
<tr>
<th>Amount</th>
<td>{{ revenue.amount }} DKK</td>
</tr>
<tr>
<th>Description</th>
<td>{{ revenue.description }}</td>
</tr>
<tr>
<th>Filename</th>
<td>{{ revenue.invoice }}</td>
</tr>
<tr>
<th>Approved?</th>
<td>{{ revenue.approval_status }}</td>
</tr>
<tr>
<th>Invoice</th>
<td>
<a href="{% url 'economy:revenue_invoice' camp_slug=camp.slug pk=revenue.pk %}"><img src="{% url 'economy:revenue_invoice' camp_slug=camp.slug pk=revenue.pk %}?preview" height=200></a><br>
Filename: {{ revenue.invoice_filename }}
</td>
</tr>
<tr>
<th>Economy Team Notes</th>
<td>{{ revenue.notes|default:"N/A" }}</td>
</tr>
</table>
</div>
</div>

View file

@ -0,0 +1,35 @@
{% if revenue_list %}
<table class="table table-hover">
<thead>
<tr>
<th>Amaount</th>
<th>Description</th>
<th>Responsible Team</th>
<th>Approved</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for revenue in revenue_list %}
<tr>
<td>{{ revenue.amount }} DKK</td>
<td>{{ revenue.description }}</td>
<td>{{ revenue.responsible_team.name }} Team</td>
<td>{{ revenue.approval_status }}</td>
<td>
{% if request.resolver_match.app_name == "backoffice" %}
<a class="btn btn-primary" href="{% url 'backoffice:revenue_detail' camp_slug=camp.slug pk=revenue.uuid %}"><i class="fas fa-search"></i> Details</a>
{% else %}
<a class="btn btn-primary" href="{% url 'economy:revenue_detail' camp_slug=camp.slug pk=revenue.uuid %}"><i class="fas fa-search"></i> Details</a>
<a class="btn btn-primary" href="{% url 'economy:revenue_update' camp_slug=camp.slug pk=revenue.uuid %}"><i class="fas fa-edit"></i> Update</a>
<a class="btn btn-danger" href="{% url 'economy:revenue_delete' camp_slug=camp.slug pk=revenue.uuid %}"><i class="fas fa-times"></i> Delete</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<h4>No revenues found.</h4>
{% endif %}

View file

@ -1,6 +1,10 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block title %}
Reimbursement Details | {{ block.super }}
{% endblock %}
{% block content %}
<h3>Reimbursement Details</h3>

View file

@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% load staticfiles %}
{% block title %}
Expenses | {{ block.super }}
{% endblock %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
<{% block content %}
<h3>Your {{ camp.title }} Reimbursements</h3>
{% include 'includes/reimbursement_list_panel.html' %}
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block title %}
Delete Revenue | {{ block.super }}
{% endblock %}
{% block content %}
<h3>Really delete revenue {{ revenue.uuid }}?</h3>
{% include 'includes/revenue_detail_panel.html' %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<button class="btn btn-danger" type="submit"><i class="fas fa-times"></i> Delete Revenue</button>
<a href="{% url 'economy:revenue_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}
Revenue Details | {{ block.super }}
{% endblock %}
{% block content %}
<div class="row">
{% include 'includes/revenue_detail_panel.html' %}
</div>
<a class="btn btn-primary" href="{% url 'economy:revenue_list' camp_slug=camp.slug %}">Back to Revenue List</a>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block title %}
{% if object %}Update{% else %}Create{% endif %} Revenue | {{ block.super }}
{% endblock %}
{% block content %}
<h3>{% if object %}Update{% else %}Create{% endif %} {{ camp.title }} Revenue</h3>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form form %}
<button class="btn btn-primary" type="submit"><i class="fas fa-check"></i> Save</button>
<a href="{% url 'economy:revenue_list' camp_slug=camp.slug %}" class="btn btn-danger"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% load staticfiles %}
{% block title %}
Revenues | {{ block.super }}
{% endblock %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
<{% block content %}
<h3>Your {{ camp.title }} Revenues</h3>
{% include 'includes/revenue_list_panel.html' %}
{% if perms.camps.revenue_create_permission %}
<a class="btn btn-success" href="{% url 'economy:revenue_create' camp_slug=camp.slug %}"><i class="fas fa-plus"></i> Create Revenue</a>
{% else %}
<div class="alert alert-danger"><p class="lead"><span class="text-error">You don't have permission to add revenue. Please ask someone from the Economy team to add the permission if you need it.</p></div>
{% endif %}
{% endblock %}

View file

@ -4,30 +4,111 @@ from .views import *
app_name = 'economy'
urlpatterns = [
path(
'',
EconomyDashboardView.as_view(),
name='dashboard'
),
# expenses
path(
'expenses/',
include([
path(
'',
ExpenseListView.as_view(),
name='expense_list'
),
path(
'expenses/add/',
'add/',
ExpenseCreateView.as_view(),
name='expense_create'
),
path(
'expenses/<uuid:pk>/',
'<uuid:pk>/',
include([
path(
'',
ExpenseDetailView.as_view(),
name='expense_detail'
),
path(
'expenses/<uuid:pk>/invoice/',
'update/',
ExpenseUpdateView.as_view(),
name='expense_update'
),
path(
'delete/',
ExpenseDeleteView.as_view(),
name='expense_delete'
),
path(
'invoice/',
ExpenseInvoiceView.as_view(),
name='expense_invoice'
),
]),
),
]),
),
# reimbursements
path(
'reimbursements/<uuid:pk>/',
'reimbursements/',
include([
path(
'',
ReimbursementListView.as_view(),
name='reimbursement_list'
),
path(
'<uuid:pk>/',
ReimbursementDetailView.as_view(),
name='reimbursement_detail'
),
]),
),
# revenue
path(
'revenues/',
include([
path(
'',
RevenueListView.as_view(),
name='revenue_list'
),
path(
'add/',
RevenueCreateView.as_view(),
name='revenue_create'
),
path(
'<uuid:pk>/',
include([
path(
'',
RevenueDetailView.as_view(),
name='revenue_detail'
),
path(
'update/',
RevenueUpdateView.as_view(),
name='revenue_update'
),
path(
'delete/',
RevenueDeleteView.as_view(),
name='revenue_delete'
),
path(
'invoice/',
RevenueInvoiceView.as_view(),
name='revenue_invoice'
),
]),
),
]),
),
]

View file

@ -1,23 +1,61 @@
import os, magic
from django.shortcuts import render
from django.shortcuts import render, redirect
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect, HttpResponse, Http404
from django.urls import reverse
from django.views.generic import CreateView, ListView, DetailView
from django.views.generic import CreateView, ListView, DetailView, TemplateView, UpdateView, DeleteView
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Sum
from camps.mixins import CampViewMixin
from utils.email import add_outgoing_email
from utils.mixins import RaisePermissionRequiredMixin
from teams.models import Team
from .models import Expense, Reimbursement
from .mixins import ExpensePermissionMixin, ReimbursementPermissionMixin
from .forms import ExpenseCreateForm
from .models import *
from .mixins import *
from .forms import *
class EconomyDashboardView(LoginRequiredMixin, CampViewMixin, TemplateView):
template_name = 'dashboard.html'
def get_context_data(self, **kwargs):
"""
Add expenses, reimbursements and revenues to the context
"""
context = super().get_context_data(**kwargs)
# get reimbursement stats
context['reimbursement_count'] = Reimbursement.objects.filter(reimbursement_user=self.request.user).count()
context['unpaid_reimbursement_count'] = Reimbursement.objects.filter(reimbursement_user=self.request.user, paid=False).count()
context['paid_reimbursement_count'] = Reimbursement.objects.filter(reimbursement_user=self.request.user, paid=True).count()
reimbursement_total = 0
for reimbursement in Reimbursement.objects.filter(user=self.request.user):
reimbursement_total += reimbursement.amount
context['reimbursement_total'] = reimbursement_total
# get expense stats
context['expense_count'] = Expense.objects.filter(user=self.request.user).count()
context['unapproved_expense_count'] = Expense.objects.filter(user=self.request.user, approved__isnull=True).count()
context['approved_expense_count'] = Expense.objects.filter(user=self.request.user, approved=True).count()
context['rejected_expense_count'] = Expense.objects.filter(user=self.request.user, approved=False).count()
context['expense_total'] = Expense.objects.filter(user=self.request.user).aggregate(Sum('amount'))['amount__sum']
# get revenue stats
context['revenue_count'] = Revenue.objects.filter(user=self.request.user).count()
context['unapproved_revenue_count'] = Revenue.objects.filter(user=self.request.user, approved__isnull=True).count()
context['approved_revenue_count'] = Revenue.objects.filter(user=self.request.user, approved=True).count()
context['rejected_revenue_count'] = Revenue.objects.filter(user=self.request.user, approved=False).count()
context['revenue_total'] = Revenue.objects.filter(user=self.request.user).aggregate(Sum('amount'))['amount__sum']
return context
########### Expense related views ###############
class ExpenseListView(LoginRequiredMixin, CampViewMixin, ListView):
model = Expense
template_name = 'expense_list.html'
@ -26,14 +64,6 @@ class ExpenseListView(LoginRequiredMixin, CampViewMixin, ListView):
# only return Expenses belonging to the current user
return super().get_queryset().filter(user=self.request.user)
def get_context_data(self, **kwargs):
"""
Add reimbursements to the context
"""
context = super().get_context_data(**kwargs)
context['reimbursement_list'] = Reimbursement.objects.filter(reimbursement_user=self.request.user)
return context
class ExpenseDetailView(CampViewMixin, ExpensePermissionMixin, DetailView):
model = Expense
@ -55,7 +85,6 @@ class ExpenseCreateView(CampViewMixin, RaisePermissionRequiredMixin, CreateView)
return context
def form_valid(self, form):
# TODO: make sure this user has permission to create expenses
expense = form.save(commit=False)
expense.user = self.request.user
expense.camp = self.camp
@ -79,6 +108,50 @@ class ExpenseCreateView(CampViewMixin, RaisePermissionRequiredMixin, CreateView)
return HttpResponseRedirect(reverse('economy:expense_list', kwargs={'camp_slug': self.camp.slug}))
class ExpenseUpdateView(CampViewMixin, ExpensePermissionMixin, UpdateView):
model = Expense
template_name = 'expense_form.html'
form_class = ExpenseUpdateForm
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if self.get_object().approved:
messages.error(self.request, "This expense has already been approved, it cannot be updated")
return redirect(reverse('economy:expense_list', kwargs={'camp_slug': self.camp.slug}))
return response
def get_context_data(self, **kwargs):
"""
Do not show teams that are not part of the current camp in the dropdown
"""
context = super().get_context_data(**kwargs)
context['form'].fields['responsible_team'].queryset = Team.objects.filter(camp=self.camp)
return context
def get_success_url(self):
messages.success(self.request, "Expense %s has been updated" % self.get_object().pk)
return(reverse('economy:expense_list', kwargs={'camp_slug': self.camp.slug}))
class ExpenseDeleteView(CampViewMixin, ExpensePermissionMixin, UpdateView):
model = Expense
template_name = 'expense_delete.html'
fields = []
def form_valid(self, form):
expense = self.get_object()
if expense.approved:
messages.error(self.request, "This expense has already been approved, it cannot be deleted")
else:
message = "Expense %s has been deleted" % expense.pk
expense.delete()
messages.success(self.request, message)
return redirect(self.get_success_url())
def get_success_url(self):
return(reverse('economy:expense_list', kwargs={'camp_slug': self.camp.slug}))
class ExpenseInvoiceView(CampViewMixin, ExpensePermissionMixin, DetailView):
"""
This view returns the invoice for an Expense with the proper mimetype
@ -103,7 +176,142 @@ class ExpenseInvoiceView(CampViewMixin, ExpensePermissionMixin, DetailView):
return response
########### Reimbursement related views ###############
class ReimbursementListView(LoginRequiredMixin, CampViewMixin, ListView):
model = Reimbursement
template_name = 'reimbursement_list.html'
def get_queryset(self):
# only return Expenses belonging to the current user
return super().get_queryset().filter(reimbursement_user=self.request.user)
class ReimbursementDetailView(CampViewMixin, ReimbursementPermissionMixin, DetailView):
model = Reimbursement
template_name = 'reimbursement_detail.html'
########### Revenue related views ###############
class RevenueListView(LoginRequiredMixin, CampViewMixin, ListView):
model = Revenue
template_name = 'revenue_list.html'
def get_queryset(self):
# only return Revenues belonging to the current user
return super().get_queryset().filter(user=self.request.user)
class RevenueDetailView(CampViewMixin, RevenuePermissionMixin, DetailView):
model = Revenue
template_name = 'revenue_detail.html'
class RevenueCreateView(CampViewMixin, RaisePermissionRequiredMixin, CreateView):
model = Revenue
template_name = 'revenue_form.html'
permission_required = ("camps.revenue_create_permission")
form_class = RevenueCreateForm
def get_context_data(self, **kwargs):
"""
Do not show teams that are not part of the current camp in the dropdown
"""
context = super().get_context_data(**kwargs)
context['form'].fields['responsible_team'].queryset = Team.objects.filter(camp=self.camp)
return context
def form_valid(self, form):
revenue = form.save(commit=False)
revenue.user = self.request.user
revenue.camp = self.camp
revenue.save()
# a message for the user
messages.success(
self.request,
"The revenue has been saved. It is now awaiting approval by the economy team.",
)
# send an email to the economy team
add_outgoing_email(
"emails/revenue_awaiting_approval_email.txt",
formatdict=dict(revenue=revenue),
subject="New %s revenue for %s Team is awaiting approval" % (revenue.camp.title, revenue.responsible_team.name),
to_recipients=[settings.ECONOMYTEAM_EMAIL],
)
# return to the revenue list page
return HttpResponseRedirect(reverse('economy:revenue_list', kwargs={'camp_slug': self.camp.slug}))
class RevenueUpdateView(CampViewMixin, RevenuePermissionMixin, UpdateView):
model = Revenue
template_name = 'revenue_form.html'
form_class = RevenueUpdateForm
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if self.get_object().approved:
messages.error(self.request, "This revenue has already been approved, it cannot be updated")
return redirect(reverse('economy:revenue_list', kwargs={'camp_slug': self.camp.slug}))
return response
def get_context_data(self, **kwargs):
"""
Do not show teams that are not part of the current camp in the dropdown
"""
context = super().get_context_data(**kwargs)
context['form'].fields['responsible_team'].queryset = Team.objects.filter(camp=self.camp)
return context
def get_success_url(self):
messages.success(self.request, "Revenue %s has been updated" % self.get_object().pk)
return(reverse('economy:revenue_list', kwargs={'camp_slug': self.camp.slug}))
class RevenueDeleteView(CampViewMixin, RevenuePermissionMixin, UpdateView):
model = Revenue
template_name = 'revenue_delete.html'
fields = []
def form_valid(self, form):
revenue = self.get_object()
if revenue.approved:
messages.error(self.request, "This revenue has already been approved, it cannot be deleted")
else:
message = "Revenue %s has been deleted" % revenue.pk
revenue.delete()
messages.success(self.request, message)
return redirect(self.get_success_url())
def get_success_url(self):
return(reverse('economy:revenue_list', kwargs={'camp_slug': self.camp.slug}))
class RevenueInvoiceView(CampViewMixin, RevenuePermissionMixin, DetailView):
"""
This view returns a http response with the invoice for a Revenue object, with the proper mimetype
Uses RevenuePermissionMixin to make sure the user is allowed to see the file
"""
model = Revenue
def get(self, request, *args, **kwargs):
# get revenue
revenue = self.get_object()
# read invoice file
invoicedata = revenue.invoice.read()
# find mimetype
mimetype = magic.from_buffer(invoicedata, mime=True)
# check if we have a PDF, no preview if so, load a pdf icon instead
if mimetype=="application/pdf" and 'preview' in request.GET:
invoicedata = open(os.path.join(settings.DJANGO_BASE_PATH, "static_src/img/pdf.png"), "rb").read()
mimetype = magic.from_buffer(invoicedata, mime=True)
# put the response together and return it
response = HttpResponse(content_type=mimetype)
response.write(invoicedata)
return response

View file

@ -113,10 +113,11 @@
{% endblock %}
</div>
</footer>
<!-- Enable all bootstrap tooltips on the page, only works if the useragent has JS enabled -->
<!-- Enable all bootstrap tooltips and jquery datatables on the page, only works if the useragent has JS enabled -->
<script>
$(document).ready(function(){
$('[data-toggle="tooltip"]').tooltip();
$('.table').DataTable();
});
</script>
</body>

View file

@ -11,8 +11,8 @@
<a class="btn {% menubuttonclass 'feedback' %}" href="{% url 'feedback' camp_slug=camp.slug %}">Feedback</a>
{% endif %}
{% if perms.camps.expense_create_permission %}
<a class="btn {% menubuttonclass 'economy' %}" href="{% url 'economy:expense_list' camp_slug=camp.slug %}">Expenses</a>
{% if perms.camps.expense_create_permission or perms.camps.revenue_create_permission %}
<a class="btn {% menubuttonclass 'economy' %}" href="{% url 'economy:dashboard' camp_slug=camp.slug %}">Economy</a>
{% endif %}
{% if perms.camps.backoffice_permission %}