WIP Reimbursement feature (#278)

* Almost done, need the send to economic part.

* Add a way to approve/reject an reimbursement and send mails accordingly.

* finish work on custom invoice address

* add textfield notes to Order for internal orga notes about the order

* Almost done, need the send to economic part.

* Add a way to approve/reject an reimbursement and send mails accordingly.

* economy commit of doom.. replace reimbursement app with an economy app, add Expense and Reimbursement models, add management of expenses and reimbursements to backoffice. Rework and cleanup permissions stuff, add Camp.Permissions pseudo model to hold all our non-model permissions. still experimental, expect rough edges, but basic functionality should work.
This commit is contained in:
Víðir Valberg Guðmundsson 2018-08-30 00:52:32 +02:00 committed by Thomas Steen Rasmussen
parent 2c1e5f12fe
commit b2fa1dc92c
41 changed files with 1204 additions and 46 deletions

30
src/backoffice/mixins.py Normal file
View file

@ -0,0 +1,30 @@
from utils.mixins import RaisePermissionRequiredMixin
class OrgaTeamPermissionMixin(RaisePermissionRequiredMixin):
"""
Permission mixin for views used by Orga Team
"""
permission_required = ("camps.backoffice_permission", "camps.orgateam_permission")
class EconomyTeamPermissionMixin(RaisePermissionRequiredMixin):
"""
Permission mixin for views used by Economy Team
"""
permission_required = ("camps.backoffice_permission", "camps.economyteam_permission")
class InfoTeamPermissionMixin(RaisePermissionRequiredMixin):
"""
Permission mixin for views used by Info Team/InfoDesk
"""
permission_required = ("camps.backoffice_permission", "camps.infoteam_permission")
class ContentTeamPermissionMixin(RaisePermissionRequiredMixin):
"""
Permission mixin for views used by Content Team
"""
permission_required = ("camps.backoffice_permission", "program.contentteam_permission")

View file

@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<h3>Manage Expense</h3>
{% 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 %}
<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>
</form>
{% endif %}
{% endblock content %}

View file

@ -0,0 +1,54 @@
{% 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>Expenses for {{ camp.title }}</h2>
<div class="lead">
This page shows all expenses for {{ camp.title }}.
</div>
</div>
<br>
<div class="row">
<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>{{ expense.reimbursement.pk }}</td>
<td>
<a class="btn btn-primary" href="{% url "backoffice:expense_manage_detail" camp_slug=camp.slug pk=expense.pk %}">Details</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
$(document).ready(function(){
$('.table').DataTable();
});
</script>
{% endblock content %}

View file

@ -14,7 +14,8 @@
<div class="row">
<p>
<div class="list-group">
{% if perms.camps.infodesk_permission %}
{% if perms.camps.infoteam_permission %}
<h3>Info Team</h3>
<a href="{% url 'backoffice:product_handout' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Hand Out Products</h4>
<p class="list-group-item-text">Use this view to mark products such as merchandise, cabins, fridges and so on as handed out.</p>
@ -29,14 +30,16 @@
</a>
{% endif %}
{% if perms.program.can_approve_proposals %}
{% if perms.camps.contentteam_permission %}
<h3>Content Team</h3>
<a href="{% url 'backoffice:manage_proposals' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Manage Proposals</h4>
<p class="list-group-item-text">Use this view to manage SpeakerProposals and EventProposals</p>
</a>
{% endif %}
{% if user.is_superuser %}
{% if perms.camps.orgateam_permission %}
<h3>Orga Team</h3>
<a href="{% url 'backoffice:public_credit_names' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Approve Public Credit Names</h4>
<p class="list-group-item-text">Use this view to check and approve users Public Credit Names</p>
@ -58,6 +61,18 @@
<p class="list-group-item-text">Use this view to generate a list of village gear that needs to be ordered</p>
</a>
{% endif %}
{% if perms.camps.economyteam_permission %}
<h3>Economy Team</h3>
<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>
</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>
{% endif %}
</div>
</div>

View file

@ -0,0 +1,44 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<h3>Create Reimbursement for User {{ user }}</h3>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">The following approved expenses will be covered by this reimbursement:</h3>
</div>
<div class="panel-body">
<table class="table">
<thead>
<tr>
<th>Description</th>
<th>Amount</th>
<th>Invoice</th>
<th>Responsible Team</th>
</tr>
</thead>
<tbody>
{% for expense in expenses %}
<tr>
<td>{{ expense.description }}</td>
<td>{{ expense.amount }}</td>
<td>{{ expense.invoice }}</td>
<td>{{ expense.responsible_team }} Team</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="lead">The total amount for this reimbursement will be <b>{{ total_amount.amount__sum }} DKK</b></p>
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
{% bootstrap_button "<i class='fas fa-check'></i> Approve" button_type="submit" button_class="btn-success" name="approve" %}
<a href="{% url 'backoffice:reimbursement_create_userselect' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endblock content %}

View file

@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% load commonmark %}
{% load static from staticfiles %}
{% load imageutils %}
{% block content %}
<div class="row">
<h2>Create Reimbursement - Select User</h2>
<div class="lead">
Start by selecting the user for whom you wish to create a reimbursement below:
</div>
</div>
<div class="row">
<div class="list-group">
{% for user in object_list %}
<a href="{% url 'backoffice:reimbursement_create' camp_slug=camp.slug user_id=user.id %}" class="list-group-item">
<h4 class="list-group-item-heading">{{ user.username }}</h4>
<p class="list-group-item-text">Create a reimbursement for user {{ user.username }}</p>
</a>
{% endfor %}
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<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>
{% endblock content %}

View file

@ -0,0 +1,55 @@
{% 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 by</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.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 %}{{ expense.pk }}<br>{% 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

@ -19,5 +19,11 @@ urlpatterns = [
])),
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_manage_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'),
]

View file

@ -1,37 +1,40 @@
import logging
import logging, os
from itertools import chain
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.views.generic import TemplateView, ListView
from django.views.generic.edit import UpdateView
from django.shortcuts import redirect
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.shortcuts import redirect, get_object_or_404
from django.urls import reverse
from django.contrib import messages
from django.utils import timezone
from django.db.models import Sum
from django.conf import settings
from django.core.files import File
from camps.mixins import CampViewMixin
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 utils.mixins import RaisePermissionRequiredMixin
from teams.models import Team
from .mixins import *
logger = logging.getLogger("bornhack.%s" % __name__)
class InfodeskMixin(CampViewMixin, PermissionRequiredMixin):
permission_required = ("camps.infodesk_permission")
class ContentTeamMixin(CampViewMixin, PermissionRequiredMixin):
permission_required = ("program.can_approve_proposals")
class BackofficeIndexView(InfodeskMixin, TemplateView):
class BackofficeIndexView(CampViewMixin, RaisePermissionRequiredMixin, TemplateView):
"""
The Backoffice index view only requires camps.backoffice_permission so we use RaisePermissionRequiredMixin directly
"""
permission_required = ("camps.backoffice_permission")
template_name = "index.html"
class ProductHandoutView(InfodeskMixin, ListView):
class ProductHandoutView(CampViewMixin, InfoTeamPermissionMixin, ListView):
template_name = "product_handout.html"
def get_queryset(self, **kwargs):
@ -43,7 +46,7 @@ class ProductHandoutView(InfodeskMixin, ListView):
).order_by('order')
class BadgeHandoutView(InfodeskMixin, ListView):
class BadgeHandoutView(CampViewMixin, InfoTeamPermissionMixin, ListView):
template_name = "badge_handout.html"
context_object_name = 'tickets'
@ -54,7 +57,7 @@ class BadgeHandoutView(InfodeskMixin, ListView):
return list(chain(shoptickets, sponsortickets, discounttickets))
class TicketCheckinView(InfodeskMixin, ListView):
class TicketCheckinView(CampViewMixin, InfoTeamPermissionMixin, ListView):
template_name = "ticket_checkin.html"
context_object_name = 'tickets'
@ -65,16 +68,7 @@ class TicketCheckinView(InfodeskMixin, ListView):
return list(chain(shoptickets, sponsortickets, discounttickets))
class BackofficeViewMixin(CampViewMixin, UserPassesTestMixin):
"""
Mixin used by all backoffice views. For now just uses CampViewMixin and StaffMemberRequiredMixin.
"""
def test_func(self):
return self.request.user.is_superuser
class ApproveNamesView(BackofficeViewMixin, ListView):
class ApproveNamesView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
template_name = "approve_public_credit_names.html"
context_object_name = 'profiles'
@ -82,7 +76,7 @@ class ApproveNamesView(BackofficeViewMixin, ListView):
return Profile.objects.filter(public_credit_name_approved=False).exclude(public_credit_name='')
class ManageProposalsView(ContentTeamMixin, ListView):
class ManageProposalsView(CampViewMixin, ContentTeamPermissionMixin, ListView):
"""
This view shows a list of pending SpeakerProposal and EventProposals.
"""
@ -104,7 +98,7 @@ class ManageProposalsView(ContentTeamMixin, ListView):
return context
class ProposalManageView(ContentTeamMixin, UpdateView):
class ProposalManageView(CampViewMixin, ContentTeamPermissionMixin, UpdateView):
"""
This class contains the shared logic between SpeakerProposalManageView and EventProposalManageView
"""
@ -142,7 +136,7 @@ class EventProposalManageView(ProposalManageView):
template_name = "manage_eventproposal.html"
class MerchandiseOrdersView(BackofficeViewMixin, ListView):
class MerchandiseOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
template_name = "orders_merchandise.html"
def get_queryset(self, **kwargs):
@ -159,7 +153,7 @@ class MerchandiseOrdersView(BackofficeViewMixin, ListView):
).order_by('order')
class MerchandiseToOrderView(BackofficeViewMixin, TemplateView):
class MerchandiseToOrderView(CampViewMixin, OrgaTeamPermissionMixin, TemplateView):
template_name = "merchandise_to_order.html"
def get_context_data(self, **kwargs):
@ -188,7 +182,7 @@ class MerchandiseToOrderView(BackofficeViewMixin, TemplateView):
return context
class VillageOrdersView(BackofficeViewMixin, ListView):
class VillageOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
template_name = "orders_village.html"
def get_queryset(self, **kwargs):
@ -205,7 +199,7 @@ class VillageOrdersView(BackofficeViewMixin, ListView):
).order_by('order')
class VillageToOrderView(BackofficeViewMixin, TemplateView):
class VillageToOrderView(CampViewMixin, OrgaTeamPermissionMixin, TemplateView):
template_name = "village_to_order.html"
def get_context_data(self, **kwargs):
@ -233,3 +227,134 @@ class VillageToOrderView(BackofficeViewMixin, TemplateView):
context['village'] = village_orders
return context
class ExpenseManageListView(CampViewMixin, EconomyTeamPermissionMixin, ListView):
model = Expense
template_name = 'expense_manage_list.html'
class ExpenseManageDetailView(CampViewMixin, EconomyTeamPermissionMixin, UpdateView):
model = Expense
template_name = 'expense_manage_detail.html'
fields = ['notes']
def form_valid(self, form):
"""
We have two submit buttons in this form, Approve and Reject
"""
expense = form.save()
if 'approve' in form.data:
# approve button was pressed
expense.approve()
elif 'reject' in form.data:
# reject button was pressed
expense.reject()
else:
messages.error(self.request, "Unknown submit action")
return redirect(reverse('backoffice:expense_manage_list', kwargs={'camp_slug': self.camp.slug}))
class ReimbursementListView(CampViewMixin, EconomyTeamPermissionMixin, ListView):
model = Reimbursement
template_name = 'reimbursement_list.html'
class ReimbursementDetailView(CampViewMixin, EconomyTeamPermissionMixin, DetailView):
model = Reimbursement
template_name = 'reimbursement_detail_backoffice.html'
class ReimbursementCreateUserSelectView(CampViewMixin, EconomyTeamPermissionMixin, ListView):
template_name = 'reimbursement_create_userselect.html'
def get_queryset(self):
queryset = User.objects.filter(
id__in=Expense.objects.filter(
camp=self.camp,
reimbursement__isnull=True,
paid_by_bornhack=False,
approved=True,
).values_list('user', flat=True).distinct()
)
return queryset
class ReimbursementCreateView(CampViewMixin, EconomyTeamPermissionMixin, CreateView):
model = Reimbursement
template_name = 'reimbursement_create.html'
fields = ['notes', 'paid']
def dispatch(self, request, *args, **kwargs):
""" Get the user from kwargs """
print("inside dispatch() with method %s" % request.method)
self.reimbursement_user = get_object_or_404(User, pk=kwargs['user_id'])
# get response now so we have self.camp available below
response = super().dispatch(request, *args, **kwargs)
# return the response
return response
def get(self, request, *args, **kwargs):
# does this user have any approved and un-reimbursed expenses?
if not self.reimbursement_user.expenses.filter(reimbursement__isnull=True, approved=True, paid_by_bornhack=False):
messages.error(request, "This user has no approved and unreimbursed expenses!")
return(redirect(reverse('backoffice:index', kwargs={'camp_slug': self.camp.slug})))
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['expenses'] = Expense.objects.filter(
user=self.reimbursement_user,
approved=True,
reimbursement__isnull=True,
paid_by_bornhack=False,
)
context['total_amount'] = context['expenses'].aggregate(Sum('amount'))
return context
def form_valid(self, form):
"""
Set user and camp for the Reimbursement before saving
"""
# get the expenses for this user
expenses = Expense.objects.filter(user=self.reimbursement_user, approved=True, reimbursement__isnull=True, paid_by_bornhack=False)
if not expenses:
messages.error(self.request, "No expenses found")
return redirect(reverse('backoffice:reimbursement_list', kwargs={'camp_slug': self.camp.slug}))
# get the Economy team for this camp
try:
economyteam = Team.objects.get(camp=self.camp, name=settings.ECONOMYTEAM_NAME)
except Team.DoesNotExist:
messages.error(self.request, "No economy team found")
return redirect(reverse('backoffice:reimbursement_list', kwargs={'camp_slug': self.camp.slug}))
# create reimbursement in database
reimbursement = form.save(commit=False)
reimbursement.reimbursement_user = self.reimbursement_user
reimbursement.user = self.request.user
reimbursement.camp = self.camp
reimbursement.save()
# add all expenses to reimbursement
for expense in expenses:
expense.reimbursement = reimbursement
expense.save()
# create expense for this reimbursement
expense = Expense()
expense.camp=self.camp
expense.user=self.request.user
expense.amount=reimbursement.amount
expense.description="Payment of reimbursement %s" % reimbursement.pk
expense.paid_by_bornhack=True
expense.responsible_team=economyteam
expense.approved=True
expense.reimbursement=reimbursement
expense.invoice.save("na.jpg", File(open(os.path.join(settings.DJANGO_BASE_PATH, "static_src/img/na.jpg"), "rb")))
expense.save()
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}))

View file

@ -89,3 +89,8 @@ CHANNEL_LAYERS = {
"CONFIG": {{ django_channels_config }}
},
}
ACCOUNTINGSYSTEM_EMAIL = "{{ django_accountingsystem_email }}"
ECONOMYTEAM_EMAIL = "{{ django_economyteam_email }}"
ECONOMYTEAM_NAME = "Economy"

View file

@ -76,3 +76,5 @@ CHANNEL_LAYERS = {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}
REIMBURSEMENT_MAIL = "reimbursement@example.com"

View file

@ -49,6 +49,7 @@ INSTALLED_APPS = [
'rideshare',
'tokens',
'feedback',
'economy',
'allauth',
'allauth.account',

View file

@ -202,6 +202,10 @@ urlpatterns = [
name='feedback'
),
path(
'economy/',
include('economy.urls', namespace='economy'),
),
])
)
]

View file

@ -0,0 +1,28 @@
# Generated by Django 2.0.4 on 2018-08-29 22:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('camps', '0030_camp_light_text'),
]
operations = [
migrations.CreateModel(
name='Permission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
options={
'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')),
'default_permissions': (),
'managed': False,
},
),
migrations.AlterModelOptions(
name='camp',
options={'ordering': ['-title'], 'verbose_name': 'Camp', 'verbose_name_plural': 'Camps'},
),
]

View file

@ -11,14 +11,28 @@ import logging
logger = logging.getLogger("bornhack.%s" % __name__)
class Permission(models.Model):
"""
An unmanaged field-less model which holds our non-model permissions (such as team permission sets)
"""
class Meta:
managed = False
default_permissions=()
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"),
)
class Camp(CreatedUpdatedModel, UUIDModel):
class Meta:
verbose_name = 'Camp'
verbose_name_plural = 'Camps'
ordering = ['-title']
permissions = (
("infodesk_permission", "Infodesk permission"),
)
title = models.CharField(
verbose_name='Title',

0
src/economy/__init__.py Normal file
View file

23
src/economy/admin.py Normal file
View file

@ -0,0 +1,23 @@
from django.contrib import admin
from .models import Expense, Reimbursement
def approve_expenses(modeladmin, request, queryset):
for expense in queryset.all():
expense.approve()
approve_expenses.short_description = "Approve Expenses"
def reject_expenses(modeladmin, request, queryset):
for expense in queryset.all():
expense.reject()
reject_expenses.short_description = "Reject Expenses"
@admin.register(Expense)
class ExpenseAdmin(admin.ModelAdmin):
list_filter = ['camp', 'responsible_team', 'approved', 'user', 'reimbursement']
list_display = ['user', 'description', 'amount', 'camp', 'responsible_team', 'approved', 'reimbursement']
search_fields = ['description', 'amount', 'user']
actions = [approve_expenses, reject_expenses]

5
src/economy/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class EconomyConfig(AppConfig):
name = 'economy'

View file

@ -0,0 +1,69 @@
# Generated by Django 2.0.4 on 2018-08-29 22:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('teams', '0049_auto_20180815_1119'),
('camps', '0031_auto_20180830_0014'),
]
operations = [
migrations.CreateModel(
name='Expense',
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 expense in DKK. Must match the amount on the invoice uploaded below.', max_digits=12)),
('description', models.CharField(help_text='A short description of this expense. Please keep it meningful as it helps the Economy team a lot when categorising expenses. 200 characters or fewer.', max_length=200)),
('paid_by_bornhack', models.BooleanField(default=True, help_text='Leave checked if this expense was paid by BornHack. Uncheck if you need a reimbursement for this expense.')),
('invoice', models.ImageField(help_text='The invoice for this expense. Please make sure the amount on the invoice matches the amount you entered above. All common image formats are accepted.', upload_to='expenses/')),
('approved', models.NullBooleanField(default=None, help_text='True if this expense 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 expense. Only visible to the Economy team and the submitting user.')),
('camp', models.ForeignKey(help_text='The camp to which this expense belongs', on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to='camps.Camp')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Reimbursement',
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)),
('notes', models.TextField(blank=True, help_text='Economy Team notes for this reimbursement. Only visible to the Economy team and the related user.')),
('paid', models.BooleanField(default=False, help_text='Check when this reimbursement has been paid to the user')),
('camp', models.ForeignKey(help_text='The camp to which this reimbursement belongs', on_delete=django.db.models.deletion.PROTECT, related_name='reimbursements', to='camps.Camp')),
('reimbursement_user', models.ForeignKey(help_text='The user this reimbursement belongs to.', on_delete=django.db.models.deletion.PROTECT, related_name='reimbursements', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(help_text='The user who created this reimbursement.', on_delete=django.db.models.deletion.PROTECT, related_name='created_reimbursements', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='expense',
name='reimbursement',
field=models.ForeignKey(blank=True, help_text='The reimbursement for this expense, if any. This is a dual-purpose field. If expense.paid_by_bornhack is true then expense.reimbursement references the reimbursement which this expense is created to cover. If expense.paid_by_bornhack is false then expense.reimbursement references the reimbursement which reimbursed this expense.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to='economy.Reimbursement'),
),
migrations.AddField(
model_name='expense',
name='responsible_team',
field=models.ForeignKey(help_text='The team to which this Expense belongs. A team responsible will need to approve the expense. When in doubt pick the Economy team.', on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to='teams.Team'),
),
migrations.AddField(
model_name='expense',
name='user',
field=models.ForeignKey(help_text='The user to which this expense belongs', on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to=settings.AUTH_USER_MODEL),
),
]

View file

28
src/economy/mixins.py Normal file
View file

@ -0,0 +1,28 @@
from django.http import HttpResponseRedirect, Http404
class ExpensePermissionMixin(object):
"""
This mixin checks if request.user submitted the Expense, 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 expense, and user is not in the economy team; fuckery is afoot, no thanks
raise Http404()
class ReimbursementPermissionMixin(object):
"""
This mixin checks if request.user owns the Reimbursement, 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 owns the reimbursement, and user is not in the economy team; fuckery is afoot, no thanks
raise Http404()

173
src/economy/models.py Normal file
View file

@ -0,0 +1,173 @@
import os
from django.db import models
from django.conf import settings
from django.db import models
from utils.email import add_outgoing_email
from utils.models import CampRelatedModel, UUIDModel
class Expense(CampRelatedModel, UUIDModel):
camp = models.ForeignKey(
'camps.Camp',
on_delete=models.PROTECT,
related_name='expenses',
help_text='The camp to which this expense belongs',
)
user = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
related_name='expenses',
help_text='The user to which this expense belongs',
)
amount = models.DecimalField(
decimal_places=2,
max_digits=12,
help_text='The amount of this expense in DKK. Must match the amount on the invoice uploaded below.',
)
description = models.CharField(
max_length=200,
help_text='A short description of this expense. Please keep it meningful as it helps the Economy team a lot when categorising expenses. 200 characters or fewer.',
)
paid_by_bornhack = models.BooleanField(
default=True,
help_text="Leave checked if this expense was paid by BornHack. Uncheck if you need a reimbursement for this expense.",
)
invoice = models.ImageField(
help_text='The invoice for this expense. Please make sure the amount on the invoice matches the amount you entered above. All common image formats are accepted.',
upload_to='expenses/',
)
responsible_team = models.ForeignKey(
'teams.Team',
on_delete=models.PROTECT,
related_name='expenses',
help_text='The team to which this Expense belongs. A team responsible will need to approve the expense. When in doubt pick the Economy team.'
)
approved = models.NullBooleanField(
default=None,
help_text='True if this expense has been approved by the responsible team. False if it has been rejected. Blank if noone has decided yet.'
)
reimbursement = models.ForeignKey(
'economy.Reimbursement',
on_delete=models.PROTECT,
related_name='expenses',
null=True,
blank=True,
help_text='The reimbursement for this expense, if any. This is a dual-purpose field. If expense.paid_by_bornhack is true then expense.reimbursement references the reimbursement which this expense is created to cover. If expense.paid_by_bornhack is false then expense.reimbursement references the reimbursement which reimbursed this expense.'
)
notes = models.TextField(
blank=True,
help_text='Economy Team notes for this expense. Only visible to the Economy team and the submitting user.'
)
@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):
"""
This method marks an expense as approved.
Approving an expense triggers an email to the economy system, and another email to the user who submitted the expense in the first place.
"""
self.approved = True
self.save()
# Add email for this expense which will be sent to the accounting software
add_outgoing_email(
"emails/accountingsystem_email.txt",
formatdict=dict(expense=self),
subject="Expense for %s" % self.camp.title,
to_recipients=[settings.ACCOUNTINGSYSTEM_EMAIL],
attachment=self.invoice.path,
attachment_filename=self.invoice.file.name,
)
# Add email which will be sent to the user who entered the expense
add_outgoing_email(
"emails/expense_approved_email.txt",
formatdict=dict(expense=self),
subject="Your expense for %s has been approved." % self.camp.title,
to_recipients=[self.user.emailaddress_set.get(primary=True).email],
)
def reject(self):
"""
This method marks an expense as not approved.
Not approving an expense triggers an email to the user who submitted the expense in the first place.
"""
self.approved = False
self.save()
# Add email which will be sent to the user who entered the expense
add_outgoing_email(
"emails/expense_rejected_email.txt",
formatdict=dict(expense=self),
subject="Your expense for %s has been rejected." % self.camp.title,
to_recipients=[self.user.emailaddress_set.get(primary=True).email],
)
class Reimbursement(CampRelatedModel, UUIDModel):
"""
A reimbursement covers one or more expenses.
"""
camp = models.ForeignKey(
'camps.Camp',
on_delete=models.PROTECT,
related_name='reimbursements',
help_text='The camp to which this reimbursement belongs',
)
user = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
related_name='created_reimbursements',
help_text='The user who created this reimbursement.'
)
reimbursement_user = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
related_name='reimbursements',
help_text='The user this reimbursement belongs to.'
)
notes = models.TextField(
blank=True,
help_text='Economy Team notes for this reimbursement. Only visible to the Economy team and the related user.'
)
paid = models.BooleanField(
default=False,
help_text="Check when this reimbursement has been paid to the user",
)
@property
def amount(self):
"""
The total amount for a reimbursement is calculated by adding up the amounts for all the related expenses
"""
amount = 0
for expense in self.expenses.all():
amount += expense.amount
return amount

View file

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

View file

@ -0,0 +1,12 @@
Hi,
Your expense {{ expense.pk }} for {{ expense.camp.title }} has been approved. The amount is DKK {{ expense.amount }} and description of the expense is:
{{ expense.description }}
{% if not expense.paid_by_bornhack %}The money will be transferred to your bank account with the next batch of reimbursements.{% else %}As this expense was paid for by BornHack no further action will be taken.{% endif %}
Have a nice day!
The {{ expense.camp.title }} Economy Team

View file

@ -0,0 +1,12 @@
Hi,
A new expense {{ expense.pk }} for {{ expense.responsible_team.name }} Team was just submitted by user {{ expense.user }}. The amount is DKK {{ expense.amount }} and description of the expense is:
{{ expense.description }}
{% if expense.paid_by_bornhack %}The expense was paid for by BornHack{% else %}The expense was paid for by the user "{{ expense.user }}" so it will need to be reimbursed after approval.{% endif %}
Have a nice day!
The {{ expense.camp.title }} Team

View file

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

View file

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

View file

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<h3>Create {{ 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>
</form>
{% endblock %}

View file

@ -0,0 +1,96 @@
{% 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 }} 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>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>
<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 %}
{% 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>
{% else %}
<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 %}{{ expense.pk }}<br>{% 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

@ -0,0 +1,45 @@
<div class="panel panel-default">
<div class="panel-heading">Expense Details for {{ expense.pk }}</div>
<div class="panel-body">
<table class="table">
<tr>
<th>Amount</th>
<td>{{ expense.amount }} DKK</td>
</tr>
<tr>
<th>Description</th>
<td>{{ expense.description }}</td>
</tr>
<tr>
<th>Paid by BornHack?</th>
<td>This expense was paid by {% if expense.paid_by_bornhack %}<b>BornHack</b>{% else %}<b>{{ expense.user }}</b>, and will be reimbursed when approved.{% endif %}</td>
</tr>
<tr>
<th>Filename</th>
<td>{{ expense.invoice }}</td>
</tr>
<tr>
<th>Approved?</th>
<td>{{ expense.approval_status }}</td>
</tr>
{% if not expense.paid_by_bornhack %}
<tr>
<th>Reimbursement?</th>
<td>{% if expense.reimbursement %}<a class="btn btn-primary" href="{% url 'economy:reimbursement_detail' camp_slug=camp.slug %}">{{ expense.reimbursement.pk }}</a>{% else %}N/A{% endif %}</td>
</tr>
{% endif %}
<tr>
<td>Invoice</td>
<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 %}" height=200></a><br>
Filename: {{ expense.invoice_filename }}
</td>
</tr>
<tr>
<th>Economy Team Notes</th>
<td>{{ expense.notes|default:"N/A" }}</td>
</tr>
</table>
</div>
</div>

View file

@ -0,0 +1,30 @@
<div class="panel panel-default">
<div class="panel-heading">Reimbursement Details for {{ reimbursement.pk }}</div>
<div class="panel-body">
<table class="table">
<tr>
<th>Amount</th>
<td>{{ reimbursement.amount }} DKK</td>
</tr>
<tr>
<th>Economy Team Notes</th>
<td>{{ reimbursement.notes|default:"N/A" }}</td>
</tr>
<tr>
<th>Total amount</th>
<td>{{ reimbursement.amount }} DKK</td>
</tr>
<tr>
<th>Paid</th>
<td>{{ reimbursement.paid }}</td>
</tr>
<tr>
<th>Created</th>
<td>{{ reimbursement.created }}</td>
</tr>
<tr>
<th>Expenses</th>
<td>{% for expense in reimbursement.expenses.all %}{% if not expense.paid_by_bornhack %}{{ expense.pk }} - {{ expense.amount }} DKK - {{ expense.description }}<br>{% endif %}{% endfor %}</td>
</table>
</div>
</div>

View file

@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<h3>Reimbursement Details</h3>
{% include 'includes/reimbursement_detail_panel.html' %}
{% endblock content %}

33
src/economy/urls.py Normal file
View file

@ -0,0 +1,33 @@
from django.urls import path, include
from .views import *
app_name = 'economy'
urlpatterns = [
path(
'expenses/',
ExpenseListView.as_view(),
name='expense_list'
),
path(
'expenses/add/',
ExpenseCreateView.as_view(),
name='expense_create'
),
path(
'expenses/<uuid:pk>/',
ExpenseDetailView.as_view(),
name='expense_detail'
),
path(
'expenses/<uuid:pk>/invoice/',
ExpenseInvoiceView.as_view(),
name='expense_invoice'
),
path(
'reimbursements/<uuid:pk>/',
ReimbursementDetailView.as_view(),
name='reimbursement_detail'
),
]

105
src/economy/views.py Normal file
View file

@ -0,0 +1,105 @@
import magic
from django.shortcuts import render
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.contrib.auth.mixins import PermissionRequiredMixin
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
class ExpenseListView(LoginRequiredMixin, CampViewMixin, ListView):
model = Expense
template_name = 'expense_list.html'
def get_queryset(self):
# 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(user=self.request.user)
return context
class ExpenseDetailView(CampViewMixin, ExpensePermissionMixin, DetailView):
model = Expense
template_name = 'expense_detail.html'
pk_url_kwarg = 'expense_uuid'
class ExpenseCreateView(CampViewMixin, RaisePermissionRequiredMixin, CreateView):
model = Expense
fields = ['description', 'amount', 'invoice', 'paid_by_bornhack', 'responsible_team']
template_name = 'expense_form.html'
permission_required = ("camps.expense_create_permission")
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):
# TODO: make sure this user has permission to create expenses
expense = form.save(commit=False)
expense.user = self.request.user
expense.camp = self.camp
expense.save()
# a message for the user
messages.success(
self.request,
"The expense has been saved. It is now awaiting approval by the economy team.",
)
# send an email to the economy team
add_outgoing_email(
"emails/expense_awaiting_approval_email.txt",
formatdict=dict(expense=expense),
subject="New %s expense for %s Team is awaiting approval" % (expense.camp.title, expense.responsible_team.name),
to_recipients=[settings.ECONOMYTEAM_EMAIL],
)
# return to the expense list page
return HttpResponseRedirect(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
Uses ExpensePermissionMixin to make sure the user is allowed to see the image
"""
model = Expense
def get(self, request, *args, **kwargs):
# get expense
expense = self.get_object()
# read invoice file
invoicedata = expense.invoice.read()
# find mimetype
mimetype = magic.from_buffer(invoicedata, mime=True)
# put the response together and return it
response = HttpResponse(content_type=mimetype)
response.write(invoicedata)
return response
class ReimbursementDetailView(CampViewMixin, ReimbursementPermissionMixin, DetailView):
model = Reimbursement
template_name = 'reimbursement_detail.html'

View file

@ -0,0 +1,17 @@
# Generated by Django 2.0.4 on 2018-08-27 17:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('program', '0070_auto_20180819_1729'),
]
operations = [
migrations.AlterModelOptions(
name='eventproposal',
options={},
),
]

View file

@ -308,12 +308,6 @@ class SpeakerProposal(UserSubmittedModel):
class EventProposal(UserSubmittedModel):
""" An event proposal """
class Meta:
permissions = (
("can_approve_proposals", "Can approve proposals"),
)
track = models.ForeignKey(
'program.EventTrack',
related_name='eventproposals',

View file

@ -27,6 +27,7 @@ irc3==0.9.8
oauthlib==2.0.1
olefile==0.44
psycopg2==2.7.5
python-magic==0.4.15
python3-openid==3.0.10
pytz==2016.10
qrcode==5.3

BIN
src/static_src/img/na.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View file

@ -5,11 +5,17 @@
<a class="btn {% menubuttonclass 'villages' %}" href="{% url 'village_list' camp_slug=camp.slug %}">Villages</a>
<a class="btn {% menubuttonclass 'sponsors' %}" href="{% url 'sponsors' camp_slug=camp.slug %}">Sponsors</a>
<a class="btn {% menubuttonclass 'teams' %}" href="{% url 'teams:list' camp_slug=camp.slug %}">Teams</a>
{% if request.user.is_authenticated %}
<a class="btn {% menubuttonclass 'rideshare' %}" href="{% url 'rideshare:list' camp_slug=camp.slug %}">Rideshare</a>
<a class="btn {% menubuttonclass 'feedback' %}" href="{% url 'feedback' camp_slug=camp.slug %}">Feedback</a>
{% endif %}
{% if request.user.is_staff or perms.camps.infodesk_permission %}
{% if perms.camps.expense_create_permission %}
<a class="btn {% menubuttonclass 'economy' %}" href="{% url 'economy:expense_list' camp_slug=camp.slug %}">Expenses</a>
{% endif %}
{% if perms.camps.backoffice_permission %}
<a class="btn {% menubuttonclass 'backoffice' %}" href="{% url 'backoffice:index' camp_slug=camp.slug %}">Backoffice</a>
{% endif %}

View file

@ -1,5 +1,6 @@
from django.contrib import messages
from django.http import HttpResponseForbidden
from django.contrib.auth.mixins import PermissionRequiredMixin
class StaffMemberRequiredMixin(object):
@ -15,3 +16,11 @@ class StaffMemberRequiredMixin(object):
# continue with the request
return super().dispatch(request, *args, **kwargs)
class RaisePermissionRequiredMixin(PermissionRequiredMixin):
"""
A subclass of PermissionRequiredMixin which raises an exception to return 403 rather than a redirect to the login page
We use this to avoid a redirect loop since our login page redirects back to the ?next= url when a user is logged in...
"""
raise_exception = True