From b2fa1dc92c5d18c09712bb6574a1de4a5d466b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Thu, 30 Aug 2018 00:52:32 +0200 Subject: [PATCH] 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. --- src/backoffice/mixins.py | 30 +++ .../templates/expense_manage_detail.html | 24 +++ .../templates/expense_manage_list.html | 54 +++++ src/backoffice/templates/index.html | 21 +- .../templates/reimbursement_create.html | 44 ++++ .../reimbursement_create_userselect.html | 26 +++ .../reimbursement_detail_backoffice.html | 12 ++ .../templates/reimbursement_list.html | 55 +++++ src/backoffice/urls.py | 6 + src/backoffice/views.py | 191 +++++++++++++++--- src/bornhack/environment_settings.py.dist | 5 + src/bornhack/environment_settings.py.dist.dev | 2 + src/bornhack/settings.py | 1 + src/bornhack/urls.py | 4 + .../migrations/0031_auto_20180830_0014.py | 28 +++ src/camps/models.py | 20 +- src/economy/__init__.py | 0 src/economy/admin.py | 23 +++ src/economy/apps.py | 5 + src/economy/migrations/0001_initial.py | 69 +++++++ src/economy/migrations/__init__.py | 0 src/economy/mixins.py | 28 +++ src/economy/models.py | 173 ++++++++++++++++ .../emails/accountingsystem_email.txt | 10 + .../emails/expense_approved_email.txt | 12 ++ .../expense_awaiting_approval_email.txt | 12 ++ .../emails/expense_rejected_email.txt | 12 ++ src/economy/templates/expense_detail.html | 12 ++ src/economy/templates/expense_form.html | 11 + src/economy/templates/expense_list.html | 96 +++++++++ .../includes/expense_detail_panel.html | 45 +++++ .../includes/reimbursement_detail_panel.html | 30 +++ .../templates/reimbursement_detail.html | 10 + src/economy/urls.py | 33 +++ src/economy/views.py | 105 ++++++++++ .../migrations/0071_auto_20180827_1958.py | 17 ++ src/program/models.py | 6 - src/requirements/production.txt | 1 + src/static_src/img/na.jpg | Bin 0 -> 4243 bytes src/templates/includes/menuitems.html | 8 +- src/utils/mixins.py | 9 + 41 files changed, 1204 insertions(+), 46 deletions(-) create mode 100644 src/backoffice/mixins.py create mode 100644 src/backoffice/templates/expense_manage_detail.html create mode 100644 src/backoffice/templates/expense_manage_list.html create mode 100644 src/backoffice/templates/reimbursement_create.html create mode 100644 src/backoffice/templates/reimbursement_create_userselect.html create mode 100644 src/backoffice/templates/reimbursement_detail_backoffice.html create mode 100644 src/backoffice/templates/reimbursement_list.html create mode 100644 src/camps/migrations/0031_auto_20180830_0014.py create mode 100644 src/economy/__init__.py create mode 100644 src/economy/admin.py create mode 100644 src/economy/apps.py create mode 100644 src/economy/migrations/0001_initial.py create mode 100644 src/economy/migrations/__init__.py create mode 100644 src/economy/mixins.py create mode 100644 src/economy/models.py create mode 100644 src/economy/templates/emails/accountingsystem_email.txt create mode 100644 src/economy/templates/emails/expense_approved_email.txt create mode 100644 src/economy/templates/emails/expense_awaiting_approval_email.txt create mode 100644 src/economy/templates/emails/expense_rejected_email.txt create mode 100644 src/economy/templates/expense_detail.html create mode 100644 src/economy/templates/expense_form.html create mode 100644 src/economy/templates/expense_list.html create mode 100644 src/economy/templates/includes/expense_detail_panel.html create mode 100644 src/economy/templates/includes/reimbursement_detail_panel.html create mode 100644 src/economy/templates/reimbursement_detail.html create mode 100644 src/economy/urls.py create mode 100644 src/economy/views.py create mode 100644 src/program/migrations/0071_auto_20180827_1958.py create mode 100644 src/static_src/img/na.jpg diff --git a/src/backoffice/mixins.py b/src/backoffice/mixins.py new file mode 100644 index 00000000..17361b74 --- /dev/null +++ b/src/backoffice/mixins.py @@ -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") + diff --git a/src/backoffice/templates/expense_manage_detail.html b/src/backoffice/templates/expense_manage_detail.html new file mode 100644 index 00000000..7cb8646a --- /dev/null +++ b/src/backoffice/templates/expense_manage_detail.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Manage Expense

+ +{% include 'includes/expense_detail_panel.html' %} + +{% if expense.approved != None %} +
This expense has already been approved/rejected.
+
Economy Team notes for this expense:
+
{{ expense.notes }}
+{% else %} +
+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button " Approve Expense" button_type="submit" button_class="btn-success" name="approve" %} + {% bootstrap_button " Reject Expense" button_type="submit" button_class="btn-danger" name="reject" %} + Cancel +
+{% endif %} + +{% endblock content %} + diff --git a/src/backoffice/templates/expense_manage_list.html b/src/backoffice/templates/expense_manage_list.html new file mode 100644 index 00000000..43b16a89 --- /dev/null +++ b/src/backoffice/templates/expense_manage_list.html @@ -0,0 +1,54 @@ +{% extends 'base.html' %} +{% load staticfiles %} + +{% block extra_head %} + + +{% endblock extra_head %} + +{% block content %} +
+

Expenses for {{ camp.title }}

+
+ This page shows all expenses for {{ camp.title }}. +
+
+
+
+ + + + + + + + + + + + + + {% for expense in expense_list %} + + + + + + + + + + {% endfor %} + +
UserDescriptionAmountPaid byApproved?Reimbursement?Actions
{{ expense.user }}{{ expense.description }}{{ expense.amount }} DKK{% if expense.paid_by_bornhack %}BornHack{% else %}{{ expense.user }}{% endif %}{{ expense.approval_status }}{{ expense.reimbursement.pk }} + Details +
+
+ + + +{% endblock content %} diff --git a/src/backoffice/templates/index.html b/src/backoffice/templates/index.html index 67760f5d..8db37aa3 100644 --- a/src/backoffice/templates/index.html +++ b/src/backoffice/templates/index.html @@ -14,7 +14,8 @@

- {% if perms.camps.infodesk_permission %} + {% if perms.camps.infoteam_permission %} +

Info Team

Hand Out Products

Use this view to mark products such as merchandise, cabins, fridges and so on as handed out.

@@ -29,14 +30,16 @@
{% endif %} - {% if perms.program.can_approve_proposals %} + {% if perms.camps.contentteam_permission %} +

Content Team

Manage Proposals

Use this view to manage SpeakerProposals and EventProposals

{% endif %} - {% if user.is_superuser %} + {% if perms.camps.orgateam_permission %} +

Orga Team

Approve Public Credit Names

Use this view to check and approve users Public Credit Names

@@ -58,6 +61,18 @@

Use this view to generate a list of village gear that needs to be ordered

{% endif %} + + {% if perms.camps.economyteam_permission %} +

Economy Team

+ +

Reimbursements

+

Use this view to view and create reimbursements

+
+ +

Expenses

+

Use this view to see and approve/reject expenses

+
+ {% endif %}
diff --git a/src/backoffice/templates/reimbursement_create.html b/src/backoffice/templates/reimbursement_create.html new file mode 100644 index 00000000..1649118e --- /dev/null +++ b/src/backoffice/templates/reimbursement_create.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Create Reimbursement for User {{ user }}

+ +
+
+

The following approved expenses will be covered by this reimbursement:

+
+
+ + + + + + + + + + + {% for expense in expenses %} + + + + + + + {% endfor %} + +
DescriptionAmountInvoiceResponsible Team
{{ expense.description }}{{ expense.amount }}{{ expense.invoice }}{{ expense.responsible_team }} Team
+
+
+ +

The total amount for this reimbursement will be {{ total_amount.amount__sum }} DKK

+ +
+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button " Approve" button_type="submit" button_class="btn-success" name="approve" %} + Cancel +
+{% endblock content %} + diff --git a/src/backoffice/templates/reimbursement_create_userselect.html b/src/backoffice/templates/reimbursement_create_userselect.html new file mode 100644 index 00000000..429a5315 --- /dev/null +++ b/src/backoffice/templates/reimbursement_create_userselect.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block content %} + +
+

Create Reimbursement - Select User

+
+ Start by selecting the user for whom you wish to create a reimbursement below: +
+
+ +
+ +
+ +{% endblock content %} + diff --git a/src/backoffice/templates/reimbursement_detail_backoffice.html b/src/backoffice/templates/reimbursement_detail_backoffice.html new file mode 100644 index 00000000..50ab1bd3 --- /dev/null +++ b/src/backoffice/templates/reimbursement_detail_backoffice.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Reimbursement Details

+ +{% include 'includes/reimbursement_detail_panel.html' %} + +Back to reimbursement list + +{% endblock content %} + diff --git a/src/backoffice/templates/reimbursement_list.html b/src/backoffice/templates/reimbursement_list.html new file mode 100644 index 00000000..b145ca6c --- /dev/null +++ b/src/backoffice/templates/reimbursement_list.html @@ -0,0 +1,55 @@ +{% extends 'base.html' %} +{% load staticfiles %} + +{% block extra_head %} + + +{% endblock extra_head %} + +{% block content %} +
+

Reimbursements for {{ camp.title }}

+
+
+
+ + + + + + + + + + + + + + + {% for reim in reimbursement_list %} + + + + + + + + + + + {% endfor %} + +
CampCreated byReimbursement UserEconomy Team NotesAmountPaidExpensesActions
{{ reim.camp }}{{ reim.user }}{{ reim.reimbursement_user }}{{ reim.notes|default:"N/A" }}{{ reim.amount }} DKK{{ reim.paid }}{% for expense in reim.expenses.all %}{{ expense.pk }}
{% endfor %}
+ Details +
+
+ +Create New Reimbursement + + + +{% endblock content %} diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index df740b38..cd81763d 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -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//', ExpenseManageDetailView.as_view(), name='expense_manage_detail'), + path('economy/reimbursements/', ReimbursementListView.as_view(), name='reimbursement_list'), + path('economy/reimbursements//', ReimbursementDetailView.as_view(), name='reimbursement_detail'), + path('economy/reimbursements/create/', ReimbursementCreateUserSelectView.as_view(), name='reimbursement_create_userselect'), + path('economy/reimbursements/create//', ReimbursementCreateView.as_view(), name='reimbursement_create'), ] diff --git a/src/backoffice/views.py b/src/backoffice/views.py index 81d614ed..47ff7104 100644 --- a/src/backoffice/views.py +++ b/src/backoffice/views.py @@ -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})) + diff --git a/src/bornhack/environment_settings.py.dist b/src/bornhack/environment_settings.py.dist index c047b63c..c62434b5 100644 --- a/src/bornhack/environment_settings.py.dist +++ b/src/bornhack/environment_settings.py.dist @@ -89,3 +89,8 @@ CHANNEL_LAYERS = { "CONFIG": {{ django_channels_config }} }, } + +ACCOUNTINGSYSTEM_EMAIL = "{{ django_accountingsystem_email }}" +ECONOMYTEAM_EMAIL = "{{ django_economyteam_email }}" +ECONOMYTEAM_NAME = "Economy" + diff --git a/src/bornhack/environment_settings.py.dist.dev b/src/bornhack/environment_settings.py.dist.dev index 00fc45ac..3009d2c9 100644 --- a/src/bornhack/environment_settings.py.dist.dev +++ b/src/bornhack/environment_settings.py.dist.dev @@ -76,3 +76,5 @@ CHANNEL_LAYERS = { "BACKEND": "channels.layers.InMemoryChannelLayer", }, } + +REIMBURSEMENT_MAIL = "reimbursement@example.com" diff --git a/src/bornhack/settings.py b/src/bornhack/settings.py index eb3c47d5..27d6caf2 100644 --- a/src/bornhack/settings.py +++ b/src/bornhack/settings.py @@ -49,6 +49,7 @@ INSTALLED_APPS = [ 'rideshare', 'tokens', 'feedback', + 'economy', 'allauth', 'allauth.account', diff --git a/src/bornhack/urls.py b/src/bornhack/urls.py index 79b34a00..f9a10d44 100644 --- a/src/bornhack/urls.py +++ b/src/bornhack/urls.py @@ -202,6 +202,10 @@ urlpatterns = [ name='feedback' ), + path( + 'economy/', + include('economy.urls', namespace='economy'), + ), ]) ) ] diff --git a/src/camps/migrations/0031_auto_20180830_0014.py b/src/camps/migrations/0031_auto_20180830_0014.py new file mode 100644 index 00000000..07b06c4e --- /dev/null +++ b/src/camps/migrations/0031_auto_20180830_0014.py @@ -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'}, + ), + ] diff --git a/src/camps/models.py b/src/camps/models.py index b0610a7e..e6ede33d 100644 --- a/src/camps/models.py +++ b/src/camps/models.py @@ -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', diff --git a/src/economy/__init__.py b/src/economy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/economy/admin.py b/src/economy/admin.py new file mode 100644 index 00000000..53ffa8fb --- /dev/null +++ b/src/economy/admin.py @@ -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] + diff --git a/src/economy/apps.py b/src/economy/apps.py new file mode 100644 index 00000000..03724994 --- /dev/null +++ b/src/economy/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class EconomyConfig(AppConfig): + name = 'economy' diff --git a/src/economy/migrations/0001_initial.py b/src/economy/migrations/0001_initial.py new file mode 100644 index 00000000..ca016507 --- /dev/null +++ b/src/economy/migrations/0001_initial.py @@ -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), + ), + ] diff --git a/src/economy/migrations/__init__.py b/src/economy/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/economy/mixins.py b/src/economy/mixins.py new file mode 100644 index 00000000..c4db4766 --- /dev/null +++ b/src/economy/mixins.py @@ -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() + diff --git a/src/economy/models.py b/src/economy/models.py new file mode 100644 index 00000000..26146163 --- /dev/null +++ b/src/economy/models.py @@ -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 + diff --git a/src/economy/templates/emails/accountingsystem_email.txt b/src/economy/templates/emails/accountingsystem_email.txt new file mode 100644 index 00000000..22688461 --- /dev/null +++ b/src/economy/templates/emails/accountingsystem_email.txt @@ -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 + diff --git a/src/economy/templates/emails/expense_approved_email.txt b/src/economy/templates/emails/expense_approved_email.txt new file mode 100644 index 00000000..5f23d887 --- /dev/null +++ b/src/economy/templates/emails/expense_approved_email.txt @@ -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 + diff --git a/src/economy/templates/emails/expense_awaiting_approval_email.txt b/src/economy/templates/emails/expense_awaiting_approval_email.txt new file mode 100644 index 00000000..70d0cea0 --- /dev/null +++ b/src/economy/templates/emails/expense_awaiting_approval_email.txt @@ -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 + diff --git a/src/economy/templates/emails/expense_rejected_email.txt b/src/economy/templates/emails/expense_rejected_email.txt new file mode 100644 index 00000000..fb4783ef --- /dev/null +++ b/src/economy/templates/emails/expense_rejected_email.txt @@ -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 + diff --git a/src/economy/templates/expense_detail.html b/src/economy/templates/expense_detail.html new file mode 100644 index 00000000..ac232641 --- /dev/null +++ b/src/economy/templates/expense_detail.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block title %} +Expense Details | {{ block.super }} +{% endblock %} + +{% block content %} +
+{% include 'includes/expense_detail_panel.html' %} +
+Back to Expense List +{% endblock %} diff --git a/src/economy/templates/expense_form.html b/src/economy/templates/expense_form.html new file mode 100644 index 00000000..ad29c3af --- /dev/null +++ b/src/economy/templates/expense_form.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Create {{ camp.title }} Expense

+
+ {% csrf_token %} + {% bootstrap_form form %} + +
+{% endblock %} diff --git a/src/economy/templates/expense_list.html b/src/economy/templates/expense_list.html new file mode 100644 index 00000000..43a6cb12 --- /dev/null +++ b/src/economy/templates/expense_list.html @@ -0,0 +1,96 @@ +{% extends 'base.html' %} +{% load staticfiles %} + +{% block title %} +Expenses | {{ block.super }} +{% endblock %} + +{% block extra_head %} + + +{% endblock extra_head %} + +<{% block content %} +

Your {{ camp.title }} Expenses

+ +{% if expense_list %} + + + + + + + + + + + + + {% for expense in expense_list %} + + + + + + + + + {% endfor %} + +
Paid byAmountDescriptionResponsible TeamApprovedActions
{% if expense.paid_by_bornhack %}BornHack{% else %}You ({{ expense.user }}){% endif %}{{ expense.amount }} DKK{{ expense.description }}{{ expense.responsible_team.name }} Team{{ expense.approval_status }} + Details +
+{% else %} +

No expenses found for BornHack 2018, you haven't submitted any yet!

+{% endif %} + +{% if perms.camps.expense_create_permission %} + Create Expense +{% else %} +

You don't have permission to add expenses. Please ask someone from the Economy team to add the permission if you need it.

+{% endif %} + +
+ +

Your {{ camp.title }} Reimbursements

+ +{% if reimbursement_list %} + + + + + + + + + + + + + + {% for reim in reimbursement_list %} + + + + + + + + + + {% endfor %} + +
CampUserEconomy Team NotesAmountPaidExpensesActions
{{ reim.camp }}{{ reim.user }}{{ reim.notes|default:"N/A" }}{{ reim.amount }} DKK{{ reim.paid }}{% for expense in reim.expenses.all %}{{ expense.pk }}
{% endfor %}
+ Details +
+{% else %} +

No reimbursements found for BornHack 2018

+{% endif %} + + + +{% endblock %} diff --git a/src/economy/templates/includes/expense_detail_panel.html b/src/economy/templates/includes/expense_detail_panel.html new file mode 100644 index 00000000..ff491ef4 --- /dev/null +++ b/src/economy/templates/includes/expense_detail_panel.html @@ -0,0 +1,45 @@ +
+
Expense Details for {{ expense.pk }}
+
+ + + + + + + + + + + + + + + + + + + + + + {% if not expense.paid_by_bornhack %} + + + + + {% endif %} + + + + + + + + +
Amount{{ expense.amount }} DKK
Description{{ expense.description }}
Paid by BornHack?This expense was paid by {% if expense.paid_by_bornhack %}BornHack{% else %}{{ expense.user }}, and will be reimbursed when approved.{% endif %}
Filename{{ expense.invoice }}
Approved?{{ expense.approval_status }}
Reimbursement?{% if expense.reimbursement %}{{ expense.reimbursement.pk }}{% else %}N/A{% endif %}
Invoice +
+ Filename: {{ expense.invoice_filename }} +
Economy Team Notes{{ expense.notes|default:"N/A" }}
+
+
+ diff --git a/src/economy/templates/includes/reimbursement_detail_panel.html b/src/economy/templates/includes/reimbursement_detail_panel.html new file mode 100644 index 00000000..4bd83f70 --- /dev/null +++ b/src/economy/templates/includes/reimbursement_detail_panel.html @@ -0,0 +1,30 @@ +
+
Reimbursement Details for {{ reimbursement.pk }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
Amount{{ reimbursement.amount }} DKK
Economy Team Notes{{ reimbursement.notes|default:"N/A" }}
Total amount{{ reimbursement.amount }} DKK
Paid{{ reimbursement.paid }}
Created{{ reimbursement.created }}
Expenses{% for expense in reimbursement.expenses.all %}{% if not expense.paid_by_bornhack %}{{ expense.pk }} - {{ expense.amount }} DKK - {{ expense.description }}
{% endif %}{% endfor %}
+
+
diff --git a/src/economy/templates/reimbursement_detail.html b/src/economy/templates/reimbursement_detail.html new file mode 100644 index 00000000..7e3aeeb1 --- /dev/null +++ b/src/economy/templates/reimbursement_detail.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Reimbursement Details

+ +{% include 'includes/reimbursement_detail_panel.html' %} + +{% endblock content %} + diff --git a/src/economy/urls.py b/src/economy/urls.py new file mode 100644 index 00000000..0f47bc75 --- /dev/null +++ b/src/economy/urls.py @@ -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//', + ExpenseDetailView.as_view(), + name='expense_detail' + ), + path( + 'expenses//invoice/', + ExpenseInvoiceView.as_view(), + name='expense_invoice' + ), + path( + 'reimbursements//', + ReimbursementDetailView.as_view(), + name='reimbursement_detail' + ), +] + diff --git a/src/economy/views.py b/src/economy/views.py new file mode 100644 index 00000000..bf4e1344 --- /dev/null +++ b/src/economy/views.py @@ -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' + diff --git a/src/program/migrations/0071_auto_20180827_1958.py b/src/program/migrations/0071_auto_20180827_1958.py new file mode 100644 index 00000000..00e7ce51 --- /dev/null +++ b/src/program/migrations/0071_auto_20180827_1958.py @@ -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={}, + ), + ] diff --git a/src/program/models.py b/src/program/models.py index c103ba64..348127c7 100644 --- a/src/program/models.py +++ b/src/program/models.py @@ -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', diff --git a/src/requirements/production.txt b/src/requirements/production.txt index ee23449a..afc168f2 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -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 diff --git a/src/static_src/img/na.jpg b/src/static_src/img/na.jpg new file mode 100644 index 0000000000000000000000000000000000000000..95597ba6129928417c87df015857f8c74b919540 GIT binary patch literal 4243 zcmeHJS6q|X5>Js1YGPu+2oeZF0!T5iG+|BX%}64k6s2k`XizbzAU3*OmIN?ZAhZw= zHWW1!Q7J-1ltn-S2m%2Cfu$)Wz{UF$yzcd7_deW*y>lMEnK@_X`_G&?=giXErEb7_ zYjZ1e01yZO0G9_~sU3g=h>MDfiHeAeiHS)_h)YVtWTd5}q_;pftb=Wnm6zKli$H8w z(%7*bsiufPsOYJxX=?4-wM%}7zVT1mMjAT1w7)n3N=QgZOG$5*k=d-RfKbr>)3o#i z01*eO0oA}jBme{ff+4`ADga^`>M{Yq%UHjXxQLi22)yRYtkrq|5CjH`NJ)Z4MMVBJ zzbvm25fy_#H|pv&Zc=b09;Tf6g~llm-vC2u{Y*F=dIcU=sJ&<3*;J;Y63R0`$Lr~{ z%4JuuFDdz+zGxx;spSKtm(hSAU5 z9H4$K?PhlxXYI)|v}Q0yPu0lkj^BwbT&o`2VZ)^OO6|#~U>8$(OYYDe+ae zTFx~l)9xxY6h2_2NauE>k1LJ|=Ka znGs#B!xQ~mT?j#H8Uj#EVTpr%2g<6q+5lXkJ*P`OKFn24IhMA*o)TWTl)ho z1o}BWj>PY+ek1``p%C$0UsuoEj`Y3Gi{@&cTyGFd+|{$2-Kg~EzBw{Vue ziTP}FuS)w$SUxVD^0fZ^2)n)CoBc>yvjRnx03DedM(;Gc-jPChuo4z;CI2ZuGX7pC zu>xfu6V*Q5LUfOk;+m!%{~A^{eI@^<-^@T>*akmFB zbWgPBbahAv`y@bYOLI;Ilpn0mv9mxIs_fQ+SGeRUJ8@P4w)=kAQ?E& zP&duO)Q_PQ{2xkCJ7}MSds~Nx*b_x-$Jb&MpLtvQTi__j;+L)j!llT_^QG^b%`Pme zgs>}Lx!Q3K2xvm+(V18K;&Od4k7uOjr9Lgbs@pnlEc5#ivwR5vGig4ANUJNf%((-V zk4~4USY-h-#v@+vQ3{ro#Ac5np)m{#cl9~#AS5P(Xd2Tw^wyPJFLy42Y!Pbjb(=L_0tlas4OBOn7rP4Z z_%4#i=?NqCkpt2lrHtF>43&+VE%@m00>aROzJeJ6E5Ykb%mH_v3 zU)2lMoj>~rMMPFCphHAvs;EAofjtWisUvb%9*G6fY?) z_}T)YW@2aOp~C|xuUF|XyxW+?^)i7&@@BzowlfonL@JBl?KVm86>RaCihQXNDlt>A zA6j37U(_4JZlcRJdz3NXed0uZ)|Y)_OCklviRU%iI~**%L}0`v#$dG!H3f-RW!M9` zx|z~=dvmPGcGaAelwjbwH?{s`T_e6)pLeD7er3rnw$|o*xJeHZc)b0=-3S>rXOI`4 z`>yNhqqN0al7cyf%7cm**YjI+4EZ&g;xRGXARD@qdr$_Sy`EiE$v(!!T_D}`>a`#S zI8g}%r%SMPW_=?bQ-*dXxX3=sfZs2ERC0&PVXj={=Xn%MI)@)AjBi2nYa47|)Fj@@ zsxKZ-DQCBOFHVw--xia0XK+Myl(!e`+M)QqoYl|OYpl(VL<2v6E3MV*J_En{UWS^t zeSJC{(JOD<E$avhTh;{tr5i?i9>B`#`Yrs#ax<}ywOx*Y# z_U{-j_b>)-ZtSuVR`NiK@d%9%WL3}U*H+-3ciOc!tI61>>X{g-YPPY|Z@dYmN1_e4 z3Da>>JOgfjHjE*yr%bGJvNOZGO&!~SVwFYaQ5G&Ars}^gN0t)vs{FE5MNse2OMtDd zaH7Ec;kvT)wU+{5JD-fU+)JoWYMN=NdcH8@9pb6^Vf%TTj{N@V4q`8MR%e5qmE}+K z6sBiX7+Oa?I81rsOv;>3Cd&!Qf&Zcjg<(C;SVoOF#2@IJzZnp9NcOBhFIFL%=20je`*DAWlNmI+s3S*_~ZA_AF)4rSd~{hs=sG< zj2iIRmX}dArF!JIyS`iL_j!qCL|fv_$*~jXkIL02n`Q&mN5Assqn}!D#C@zQlKcns z-3tHzh5t=@R-2IBhNbQ9vKEEPO;7nzPNbsgS~@yKLMuz B9lihn literal 0 HcmV?d00001 diff --git a/src/templates/includes/menuitems.html b/src/templates/includes/menuitems.html index 0434b7d3..b3567914 100644 --- a/src/templates/includes/menuitems.html +++ b/src/templates/includes/menuitems.html @@ -5,11 +5,17 @@ Villages Sponsors Teams + {% if request.user.is_authenticated %} Rideshare Feedback {% endif %} - {% if request.user.is_staff or perms.camps.infodesk_permission %} + + {% if perms.camps.expense_create_permission %} + Expenses + {% endif %} + + {% if perms.camps.backoffice_permission %} Backoffice {% endif %} diff --git a/src/utils/mixins.py b/src/utils/mixins.py index 592c343b..dec55f7a 100644 --- a/src/utils/mixins.py +++ b/src/utils/mixins.py @@ -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 +