bornhack-website/src/economy/models.py

891 lines
29 KiB
Python
Raw Normal View History

import os
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.db import models
2020-08-11 00:22:36 +00:00
from django.urls import reverse
from utils.models import CampRelatedModel, CreatedUpdatedModel, UUIDModel
SpeakerAvailability, EventSession, autoscheduler, and other goodies (#497) * fix old bug where the get_days() method would return the wrong number of days, this was not discovered because our bootstrap script has been creating 9 day camps instead of 8 day camps (this has been fixed in a different commit) * remove stray debug print * output camp days in local timezone (CEST usually), not UTC * speakeravailability commit of doom, originally intended for #385 but goes a bit further than that. Adds SpeakerAvailability and EventSession models, and models for the new autoscheduler. Update bootstrap script and more. New conference_autoscheduler dependency. Work in progress, but ready for playing around! * add conference-scheduler to requirements * rework migrations, work at bit with postgres range fields and bounds, change how speakeravailability is saved (continuous ranges instead of 1 hour chunks), add tests for utils/range_fields.py including adding hypothesis to requirements/dev.txt, add a test which runs our bootstrap script * catch name collision in the right place, and load missing postgres extension in the migration * add some verbosity to see what the travis issue might be * manually create btree_gist extension in postgres, not sure why the BtreeGistExtension() operation in program/migrations/0085... isn't working in travis? * create extension in the right database maybe * lets try this then * ok so the problem is not that the btree_gist extension isn't getting loaded, the problem is that GIST indexes do not work with uuid fields in postgres 9.6, lets take another stab at getting pg10 with postgis to work with in travis * lets try normal socket connection * add SPEAKER_AVAILABILITY_DAYCHUNK_HOURS=3 to travis environment_settings.py * rework migrations, change so an autoschedule can work with multiple eventtypes, change AutoSlot model to use a DateTimeRangeField so we can use the database for more efficient lookups, add 'conflicts' self m2m for EventLocation to indicate when a room conflicts with another room, add a support_autoscheduling bool to EventType, add workshops to bootstrap script, add timing output to bootstrap script * update README a bit, move some functionality to model methods, update jquery and jquery.datatables, include datatables in base.html instead of in each page, start adding backoffice schedule management views (unfinished), yolo commit so I can show valberg something * Switch to a more simple way of using the autoscheduler, meaning we can remove the whole autoscheduler app and all models. All autoscheduler code is now in program/autoscheduler.py and a bit in backoffice views. Add more backoffice CRUD views for schedule management. Add datatables moment.js plugin to help table sorting of dates. Add Speaker{Proposal}EventConflict model to allow speakers to inform us which events they want to attend so we dont schedule them at the same time. Add EventTag model. New models not hooked up to anything yet. * handle cases where there is no solution without failing, also dont return anything here * wrong block kiddo * switch from EventInstance to EventSlot as the way we schedule events. Finish backoffice content team views (mostly). Many small changes. Prod will need data migration of EventInstances -> EventSlots when the time comes. * keep speakeravailability stuff a bit more DRY by using the AvailabilityMatrixViewMixin everywhere, add event_duration_minutes to EventSession create/update form, reverse the order we delete/create EventSlot objects when updating an EventSession * go through all views, fix various little bugs here and there * add missing migration * add django-taggit, add tags for Events, add tags in bootstrap script, make AutoScheduler use tags. Add tags in forms and templates. * fix taggit entry in requirements * Fix our iCal view: Add uuid field to Event, add uuid property to EventSlot which calculates a consitent UUID for an event at a start time at a location. Use this as the schedule uuid. While here fix so our iCal export is valid, a few fields were missing, the iCal file now validates 100% OK. * fix our FRAB xml export view * comment the EventSlot.uuid property better * typo in comment * language Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk> * language Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk> * Update src/backoffice/templates/autoschedule_debug_events.html Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk> * add a field to make this form look less weird. No difference in functionality. * remove stray print and refactor this form init a bit * fix ScheduleView * only show slots where all speakers are available when scheduling events manually in backoffice * make event list sortable by video recording column * update description on {speaker|event}proposal models reason field * remove badge showing number of scheduled slots for each event in backoffice eventlist. it was unclear what the number meant and it doesn't really fit * remember to consider events in the same location when deciding whether a slot is available or not * add is_available() method to EventLocation, add clean_location() method to EventSlot, call it from EventSlot.clean(), update a bit of text in eventslotunschedule template * fix EventSession.get_available_slots() so it doesnt return busy slots as available, and since this means we can no longer schedule stuff in the lunchbreak lower the number of talks in the bootstrap script a bit so we have a better chance of having a solvable problem * fix the excludefilter in EventSession.get_available_slots() for real this time, also fix an icon and add link in event schedule template in backoffice * show message when no slots are available for manual scheduling in backoffice * add event_conflicts to SpeakerUpdateView form in backoffice * fix link to speaker object in speakerproposal list in backoffice * allow blank tags * make duration validation depend on the eventtype event_duration_minutes if we have one. fix help_text and label and placeholder for all duration fields * allow music acts up to 180 mins in the bootstrap data * fix wrong eventtype name for recreational events in speakerproposalform * stretch the colspan one cell more * save event_conflicts m2m when submitting speaker and event together * form not self, and add succes message * move js function toggleclass() to bornhack.js and rename to toggle_sa_form_class(), function is used in several templates and was missing when submitting combined proposals * move the no-js removal to the top of ready() function This will allow other javascript initialization (eg. DataTable) to see the elements and initialize accordingly (eg. column width for tables) * Fixed problem with event feedback detail view * Fixed problem with event feedback list view * introduce a get_tzrange_days() function and use that to get the relevant days for the matrix instead of camp.get_days(), thereby fixing some display issues when eventsessions cross dates * show submitting user and link to proposal on backoffice event detail page, change User to Submitter in backoffice speaker list table * show warning by the buttons when a proposal cannot be approved, and show better text on approve/reject buttons * disable js schedule, save m2m, prefetch some stuff * fix broken date header in table * remove use of djangos regular slugify function, use the new utils.slugs.unique_slugify() instead Co-authored-by: Thomas Steen Rasmussen <tykling@bornhack.org> Co-authored-by: Benjamin Balder Bach <benjamin@overtag.dk> Co-authored-by: Thomas Flummer <tf@flummer.net>
2020-06-03 19:18:06 +00:00
from utils.slugs import unique_slugify
from .email import (
send_accountingsystem_expense_email,
send_accountingsystem_revenue_email,
send_expense_approved_email,
send_expense_rejected_email,
send_revenue_approved_email,
send_revenue_rejected_email,
)
2019-06-16 12:32:24 +00:00
class ChainManager(models.Manager):
"""
ChainManager adds 'expenses_total' and 'revenues_total' to the Chain qs
Also adds 'expenses_count' and 'revenues_count' and prefetches all expenses
and revenues for the credebtors.
"""
2019-06-16 12:32:24 +00:00
def get_queryset(self):
qs = super().get_queryset()
qs = qs.prefetch_related(
models.Prefetch("credebtors__expenses", to_attr="all_expenses"),
models.Prefetch("credebtors__revenues", to_attr="all_revenues"),
)
qs = qs.annotate(
all_expenses_amount=models.Sum(
"credebtors__expenses__amount", distinct=True
)
)
qs = qs.annotate(
all_expenses_count=models.Count("credebtors__expenses", distinct=True)
)
qs = qs.annotate(
all_revenues_amount=models.Sum(
"credebtors__revenues__amount", distinct=True
)
)
qs = qs.annotate(
all_revenues_count=models.Count("credebtors__revenues", distinct=True)
)
return qs
class Chain(CreatedUpdatedModel, UUIDModel):
"""
A chain of Credebtors. Used to group when several Creditors/Debtors
belong to the same Chain/company, like XL Byg stores or Netto stores.
"""
2019-06-16 12:32:24 +00:00
class Meta:
2019-06-16 12:32:24 +00:00
ordering = ["name"]
objects = ChainManager()
name = models.CharField(
max_length=100,
unique=True,
2019-06-16 12:32:24 +00:00
help_text='A short name for this Chain, like "Netto" or "XL Byg". 100 characters or fewer.',
)
slug = models.SlugField(
unique=True,
2019-03-28 06:16:02 +00:00
blank=True,
2019-06-16 12:32:24 +00:00
help_text="The url slug for this Chain. Leave blank to auto generate a slug.",
)
notes = models.TextField(
2019-06-16 12:32:24 +00:00
help_text="Any notes for this Chain. Will be shown to anyone creating Expenses or Revenues for this Chain.",
blank=True,
)
def __str__(self):
return self.name
def save(self, **kwargs):
if not self.slug:
SpeakerAvailability, EventSession, autoscheduler, and other goodies (#497) * fix old bug where the get_days() method would return the wrong number of days, this was not discovered because our bootstrap script has been creating 9 day camps instead of 8 day camps (this has been fixed in a different commit) * remove stray debug print * output camp days in local timezone (CEST usually), not UTC * speakeravailability commit of doom, originally intended for #385 but goes a bit further than that. Adds SpeakerAvailability and EventSession models, and models for the new autoscheduler. Update bootstrap script and more. New conference_autoscheduler dependency. Work in progress, but ready for playing around! * add conference-scheduler to requirements * rework migrations, work at bit with postgres range fields and bounds, change how speakeravailability is saved (continuous ranges instead of 1 hour chunks), add tests for utils/range_fields.py including adding hypothesis to requirements/dev.txt, add a test which runs our bootstrap script * catch name collision in the right place, and load missing postgres extension in the migration * add some verbosity to see what the travis issue might be * manually create btree_gist extension in postgres, not sure why the BtreeGistExtension() operation in program/migrations/0085... isn't working in travis? * create extension in the right database maybe * lets try this then * ok so the problem is not that the btree_gist extension isn't getting loaded, the problem is that GIST indexes do not work with uuid fields in postgres 9.6, lets take another stab at getting pg10 with postgis to work with in travis * lets try normal socket connection * add SPEAKER_AVAILABILITY_DAYCHUNK_HOURS=3 to travis environment_settings.py * rework migrations, change so an autoschedule can work with multiple eventtypes, change AutoSlot model to use a DateTimeRangeField so we can use the database for more efficient lookups, add 'conflicts' self m2m for EventLocation to indicate when a room conflicts with another room, add a support_autoscheduling bool to EventType, add workshops to bootstrap script, add timing output to bootstrap script * update README a bit, move some functionality to model methods, update jquery and jquery.datatables, include datatables in base.html instead of in each page, start adding backoffice schedule management views (unfinished), yolo commit so I can show valberg something * Switch to a more simple way of using the autoscheduler, meaning we can remove the whole autoscheduler app and all models. All autoscheduler code is now in program/autoscheduler.py and a bit in backoffice views. Add more backoffice CRUD views for schedule management. Add datatables moment.js plugin to help table sorting of dates. Add Speaker{Proposal}EventConflict model to allow speakers to inform us which events they want to attend so we dont schedule them at the same time. Add EventTag model. New models not hooked up to anything yet. * handle cases where there is no solution without failing, also dont return anything here * wrong block kiddo * switch from EventInstance to EventSlot as the way we schedule events. Finish backoffice content team views (mostly). Many small changes. Prod will need data migration of EventInstances -> EventSlots when the time comes. * keep speakeravailability stuff a bit more DRY by using the AvailabilityMatrixViewMixin everywhere, add event_duration_minutes to EventSession create/update form, reverse the order we delete/create EventSlot objects when updating an EventSession * go through all views, fix various little bugs here and there * add missing migration * add django-taggit, add tags for Events, add tags in bootstrap script, make AutoScheduler use tags. Add tags in forms and templates. * fix taggit entry in requirements * Fix our iCal view: Add uuid field to Event, add uuid property to EventSlot which calculates a consitent UUID for an event at a start time at a location. Use this as the schedule uuid. While here fix so our iCal export is valid, a few fields were missing, the iCal file now validates 100% OK. * fix our FRAB xml export view * comment the EventSlot.uuid property better * typo in comment * language Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk> * language Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk> * Update src/backoffice/templates/autoschedule_debug_events.html Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk> * add a field to make this form look less weird. No difference in functionality. * remove stray print and refactor this form init a bit * fix ScheduleView * only show slots where all speakers are available when scheduling events manually in backoffice * make event list sortable by video recording column * update description on {speaker|event}proposal models reason field * remove badge showing number of scheduled slots for each event in backoffice eventlist. it was unclear what the number meant and it doesn't really fit * remember to consider events in the same location when deciding whether a slot is available or not * add is_available() method to EventLocation, add clean_location() method to EventSlot, call it from EventSlot.clean(), update a bit of text in eventslotunschedule template * fix EventSession.get_available_slots() so it doesnt return busy slots as available, and since this means we can no longer schedule stuff in the lunchbreak lower the number of talks in the bootstrap script a bit so we have a better chance of having a solvable problem * fix the excludefilter in EventSession.get_available_slots() for real this time, also fix an icon and add link in event schedule template in backoffice * show message when no slots are available for manual scheduling in backoffice * add event_conflicts to SpeakerUpdateView form in backoffice * fix link to speaker object in speakerproposal list in backoffice * allow blank tags * make duration validation depend on the eventtype event_duration_minutes if we have one. fix help_text and label and placeholder for all duration fields * allow music acts up to 180 mins in the bootstrap data * fix wrong eventtype name for recreational events in speakerproposalform * stretch the colspan one cell more * save event_conflicts m2m when submitting speaker and event together * form not self, and add succes message * move js function toggleclass() to bornhack.js and rename to toggle_sa_form_class(), function is used in several templates and was missing when submitting combined proposals * move the no-js removal to the top of ready() function This will allow other javascript initialization (eg. DataTable) to see the elements and initialize accordingly (eg. column width for tables) * Fixed problem with event feedback detail view * Fixed problem with event feedback list view * introduce a get_tzrange_days() function and use that to get the relevant days for the matrix instead of camp.get_days(), thereby fixing some display issues when eventsessions cross dates * show submitting user and link to proposal on backoffice event detail page, change User to Submitter in backoffice speaker list table * show warning by the buttons when a proposal cannot be approved, and show better text on approve/reject buttons * disable js schedule, save m2m, prefetch some stuff * fix broken date header in table * remove use of djangos regular slugify function, use the new utils.slugs.unique_slugify() instead Co-authored-by: Thomas Steen Rasmussen <tykling@bornhack.org> Co-authored-by: Benjamin Balder Bach <benjamin@overtag.dk> Co-authored-by: Thomas Flummer <tf@flummer.net>
2020-06-03 19:18:06 +00:00
self.slug = unique_slugify(
self.name,
slugs_in_use=self.__class__.objects.all().values_list(
"slug", flat=True
),
)
super().save(**kwargs)
@property
def expenses(self):
return Expense.objects.filter(creditor__chain__pk=self.pk)
@property
def revenues(self):
return Revenue.objects.filter(debtor__chain__pk=self.pk)
class CredebtorManager(models.Manager):
"""
CredebtorManager adds 'expenses_total' and 'revenues_total' to the Credebtor qs,
and prefetches expenses and revenues for the credebtor(s).
"""
2019-06-16 12:32:24 +00:00
def get_queryset(self):
qs = super().get_queryset()
qs = qs.prefetch_related(
models.Prefetch("expenses", to_attr="all_expenses"),
models.Prefetch("revenues", to_attr="all_revenues"),
)
qs = qs.annotate(all_expenses_amount=models.Sum("expenses__amount"))
qs = qs.annotate(all_revenues_amount=models.Sum("revenues__amount"))
return qs
class Credebtor(CreatedUpdatedModel, UUIDModel):
"""
The Credebtor model represents the specific "instance" of a Chain,
like "XL Byg Rønne" or "Netto Gelsted".
The model is used for both creditors and debtors, since there is a
lot of overlap between them.
"""
2019-06-16 12:32:24 +00:00
class Meta:
2019-06-16 12:32:24 +00:00
ordering = ["name"]
unique_together = ("chain", "slug")
objects = CredebtorManager()
chain = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"economy.Chain",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="credebtors",
help_text="The Chain to which this Credebtor belongs.",
)
name = models.CharField(
max_length=100,
unique=True,
2019-06-16 12:32:24 +00:00
help_text='The name of this Credebtor, like "XL Byg Rønne" or "Netto Gelsted". 100 characters or fewer.',
)
slug = models.SlugField(
2019-03-28 06:16:02 +00:00
blank=True,
2019-06-16 12:32:24 +00:00
help_text="The url slug for this Credebtor. Leave blank to auto generate a slug.",
)
2019-06-16 12:32:24 +00:00
address = models.TextField(help_text="The address of this Credebtor.")
notes = models.TextField(
blank=True,
2019-06-16 12:32:24 +00:00
help_text="Any notes for this Credebtor. Shown when creating an Expense or Revenue for this Credebtor.",
)
def __str__(self):
return self.name
def save(self, **kwargs):
"""
Generate slug as needed
"""
if not self.slug:
SpeakerAvailability, EventSession, autoscheduler, and other goodies (#497) * fix old bug where the get_days() method would return the wrong number of days, this was not discovered because our bootstrap script has been creating 9 day camps instead of 8 day camps (this has been fixed in a different commit) * remove stray debug print * output camp days in local timezone (CEST usually), not UTC * speakeravailability commit of doom, originally intended for #385 but goes a bit further than that. Adds SpeakerAvailability and EventSession models, and models for the new autoscheduler. Update bootstrap script and more. New conference_autoscheduler dependency. Work in progress, but ready for playing around! * add conference-scheduler to requirements * rework migrations, work at bit with postgres range fields and bounds, change how speakeravailability is saved (continuous ranges instead of 1 hour chunks), add tests for utils/range_fields.py including adding hypothesis to requirements/dev.txt, add a test which runs our bootstrap script * catch name collision in the right place, and load missing postgres extension in the migration * add some verbosity to see what the travis issue might be * manually create btree_gist extension in postgres, not sure why the BtreeGistExtension() operation in program/migrations/0085... isn't working in travis? * create extension in the right database maybe * lets try this then * ok so the problem is not that the btree_gist extension isn't getting loaded, the problem is that GIST indexes do not work with uuid fields in postgres 9.6, lets take another stab at getting pg10 with postgis to work with in travis * lets try normal socket connection * add SPEAKER_AVAILABILITY_DAYCHUNK_HOURS=3 to travis environment_settings.py * rework migrations, change so an autoschedule can work with multiple eventtypes, change AutoSlot model to use a DateTimeRangeField so we can use the database for more efficient lookups, add 'conflicts' self m2m for EventLocation to indicate when a room conflicts with another room, add a support_autoscheduling bool to EventType, add workshops to bootstrap script, add timing output to bootstrap script * update README a bit, move some functionality to model methods, update jquery and jquery.datatables, include datatables in base.html instead of in each page, start adding backoffice schedule management views (unfinished), yolo commit so I can show valberg something * Switch to a more simple way of using the autoscheduler, meaning we can remove the whole autoscheduler app and all models. All autoscheduler code is now in program/autoscheduler.py and a bit in backoffice views. Add more backoffice CRUD views for schedule management. Add datatables moment.js plugin to help table sorting of dates. Add Speaker{Proposal}EventConflict model to allow speakers to inform us which events they want to attend so we dont schedule them at the same time. Add EventTag model. New models not hooked up to anything yet. * handle cases where there is no solution without failing, also dont return anything here * wrong block kiddo * switch from EventInstance to EventSlot as the way we schedule events. Finish backoffice content team views (mostly). Many small changes. Prod will need data migration of EventInstances -> EventSlots when the time comes. * keep speakeravailability stuff a bit more DRY by using the AvailabilityMatrixViewMixin everywhere, add event_duration_minutes to EventSession create/update form, reverse the order we delete/create EventSlot objects when updating an EventSession * go through all views, fix various little bugs here and there * add missing migration * add django-taggit, add tags for Events, add tags in bootstrap script, make AutoScheduler use tags. Add tags in forms and templates. * fix taggit entry in requirements * Fix our iCal view: Add uuid field to Event, add uuid property to EventSlot which calculates a consitent UUID for an event at a start time at a location. Use this as the schedule uuid. While here fix so our iCal export is valid, a few fields were missing, the iCal file now validates 100% OK. * fix our FRAB xml export view * comment the EventSlot.uuid property better * typo in comment * language Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk> * language Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk> * Update src/backoffice/templates/autoschedule_debug_events.html Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk> * add a field to make this form look less weird. No difference in functionality. * remove stray print and refactor this form init a bit * fix ScheduleView * only show slots where all speakers are available when scheduling events manually in backoffice * make event list sortable by video recording column * update description on {speaker|event}proposal models reason field * remove badge showing number of scheduled slots for each event in backoffice eventlist. it was unclear what the number meant and it doesn't really fit * remember to consider events in the same location when deciding whether a slot is available or not * add is_available() method to EventLocation, add clean_location() method to EventSlot, call it from EventSlot.clean(), update a bit of text in eventslotunschedule template * fix EventSession.get_available_slots() so it doesnt return busy slots as available, and since this means we can no longer schedule stuff in the lunchbreak lower the number of talks in the bootstrap script a bit so we have a better chance of having a solvable problem * fix the excludefilter in EventSession.get_available_slots() for real this time, also fix an icon and add link in event schedule template in backoffice * show message when no slots are available for manual scheduling in backoffice * add event_conflicts to SpeakerUpdateView form in backoffice * fix link to speaker object in speakerproposal list in backoffice * allow blank tags * make duration validation depend on the eventtype event_duration_minutes if we have one. fix help_text and label and placeholder for all duration fields * allow music acts up to 180 mins in the bootstrap data * fix wrong eventtype name for recreational events in speakerproposalform * stretch the colspan one cell more * save event_conflicts m2m when submitting speaker and event together * form not self, and add succes message * move js function toggleclass() to bornhack.js and rename to toggle_sa_form_class(), function is used in several templates and was missing when submitting combined proposals * move the no-js removal to the top of ready() function This will allow other javascript initialization (eg. DataTable) to see the elements and initialize accordingly (eg. column width for tables) * Fixed problem with event feedback detail view * Fixed problem with event feedback list view * introduce a get_tzrange_days() function and use that to get the relevant days for the matrix instead of camp.get_days(), thereby fixing some display issues when eventsessions cross dates * show submitting user and link to proposal on backoffice event detail page, change User to Submitter in backoffice speaker list table * show warning by the buttons when a proposal cannot be approved, and show better text on approve/reject buttons * disable js schedule, save m2m, prefetch some stuff * fix broken date header in table * remove use of djangos regular slugify function, use the new utils.slugs.unique_slugify() instead Co-authored-by: Thomas Steen Rasmussen <tykling@bornhack.org> Co-authored-by: Benjamin Balder Bach <benjamin@overtag.dk> Co-authored-by: Thomas Flummer <tf@flummer.net>
2020-06-03 19:18:06 +00:00
self.slug = unique_slugify(
self.name,
slugs_in_use=self.__class__.objects.filter(
chain=self.chain
).values_list("slug", flat=True),
)
super().save(**kwargs)
class Revenue(CampRelatedModel, UUIDModel):
"""
The Revenue model represents any type of income for BornHack.
Most Revenue objects will have a FK to the Invoice model,
but only if the revenue relates directly to an Invoice in our system.
Other Revenue objects (such as money returned from bottle deposits) will
not have a related BornHack Invoice object.
"""
2019-06-16 12:32:24 +00:00
camp = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"camps.Camp",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="revenues",
help_text="The camp to which this revenue belongs",
)
debtor = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"economy.Credebtor",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="revenues",
help_text="The Debtor to which this revenue belongs",
)
user = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"auth.User",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="revenues",
help_text="The user who submitted this revenue",
)
amount = models.DecimalField(
decimal_places=2,
max_digits=12,
2019-06-16 12:32:24 +00:00
help_text="The amount of this revenue in DKK. Must match the amount on the documentation uploaded below.",
)
description = models.CharField(
max_length=200,
2019-06-16 12:32:24 +00:00
help_text="A short description of this revenue. Please keep it meningful as it helps the Economy team a lot when categorising revenue. 200 characters or fewer.",
)
invoice = models.ImageField(
2019-06-16 12:32:24 +00:00
help_text="The invoice file for this revenue. Please make sure the amount on the invoice matches the amount you entered above. All common image formats are accepted, as well as PDF.",
upload_to="revenues/",
)
invoice_date = models.DateField(
2019-06-16 12:32:24 +00:00
help_text="The invoice date for this Revenue. This must match the invoice date on the documentation uploaded below. Format is YYYY-MM-DD."
)
invoice_fk = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"shop.Invoice",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="revenues",
help_text="The Invoice object to which this Revenue object relates. Can be None if this revenue does not have a related BornHack Invoice.",
blank=True,
null=True,
)
responsible_team = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"teams.Team",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="revenues",
help_text="The team to which this revenue belongs. When in doubt pick the Economy team.",
)
2020-08-11 00:35:18 +00:00
approved = models.BooleanField(
blank=True,
2020-08-11 00:35:18 +00:00
null=True,
default=None,
2019-06-16 12:32:24 +00:00
help_text="True if this Revenue has been approved by the responsible team. False if it has been rejected. Blank if noone has decided yet.",
)
notes = models.TextField(
blank=True,
2019-06-16 12:32:24 +00:00
help_text="Economy Team notes for this revenue. Only visible to the Economy team and the submitting user.",
)
def clean(self):
if self.amount < 0:
2019-06-16 12:32:24 +00:00
raise ValidationError("Amount of a Revenue object can not be negative")
@property
def invoice_filename(self):
return os.path.basename(self.invoice.file.name)
@property
def approval_status(self):
if self.approved is None:
return "Pending approval"
elif self.approved:
return "Approved"
else:
return "Rejected"
def approve(self, request):
"""
This method marks a revenue as approved.
Approving a revenue triggers an email to the economy system, and another email to the user who submitted the revenue
"""
if request.user == self.user:
2019-06-16 12:32:24 +00:00
messages.error(
request,
"You cannot approve your own revenues, aka. the anti-stein-bagger defense",
)
return
# mark as approved and save
self.approved = True
self.save()
# send email to economic for this revenue
send_accountingsystem_revenue_email(revenue=self)
# send email to the user
send_revenue_approved_email(revenue=self)
# message to the browser
messages.success(request, "Revenue %s approved" % self.pk)
def reject(self, request):
"""
This method marks a revenue as not approved.
Not approving a revenue triggers an email to the user who submitted the revenue in the first place.
"""
# mark as not approved and save
self.approved = False
self.save()
# send email to the user
send_revenue_rejected_email(revenue=self)
# message to the browser
messages.success(request, "Revenue %s rejected" % self.pk)
class Expense(CampRelatedModel, UUIDModel):
camp = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"camps.Camp",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="expenses",
help_text="The camp to which this expense belongs",
)
creditor = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"economy.Credebtor",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="expenses",
help_text="The Creditor to which this expense belongs",
)
user = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"auth.User",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="expenses",
help_text="The user to which this expense belongs",
)
amount = models.DecimalField(
decimal_places=2,
max_digits=12,
2019-06-16 12:32:24 +00:00
help_text="The amount of this expense in DKK. Must match the amount on the invoice uploaded below.",
)
description = models.CharField(
max_length=200,
2019-06-16 12:32:24 +00:00
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(
2019-06-16 12:32:24 +00:00
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/",
)
invoice_date = models.DateField(
2019-06-16 12:32:24 +00:00
help_text="The invoice date for this Expense. This must match the invoice date on the documentation uploaded below. Format is YYYY-MM-DD."
)
responsible_team = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"teams.Team",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
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.",
)
2020-08-11 00:35:18 +00:00
approved = models.BooleanField(
blank=True,
2020-08-11 00:35:18 +00:00
null=True,
default=None,
2019-06-16 12:32:24 +00:00
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(
2019-06-16 12:32:24 +00:00
"economy.Reimbursement",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="expenses",
null=True,
blank=True,
2019-06-16 12:32:24 +00:00
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,
2019-06-16 12:32:24 +00:00
help_text="Economy Team notes for this expense. Only visible to the Economy team and the submitting user.",
)
def clean(self):
if self.amount < 0:
2019-06-16 12:32:24 +00:00
raise ValidationError("Amount of an expense can not be negative")
@property
def invoice_filename(self):
return os.path.basename(self.invoice.file.name)
@property
def approval_status(self):
if self.approved is None:
return "Pending approval"
elif self.approved:
return "Approved"
else:
return "Rejected"
def approve(self, request):
"""
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.
"""
if request.user == self.user:
2019-06-16 12:32:24 +00:00
messages.error(
request,
"You cannot approve your own expenses, aka. the anti-stein-bagger defense",
)
return
# mark as approved and save
self.approved = True
self.save()
# send email to economic for this expense
send_accountingsystem_expense_email(expense=self)
# send email to the user
send_expense_approved_email(expense=self)
# message to the browser
messages.success(request, "Expense %s approved" % self.pk)
def reject(self, request):
"""
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.
"""
# mark as not approved and save
self.approved = False
self.save()
# send email to the user
send_expense_rejected_email(expense=self)
# message to the browser
messages.success(request, "Expense %s rejected" % self.pk)
class Reimbursement(CampRelatedModel, UUIDModel):
"""
A reimbursement covers one or more expenses.
"""
2019-06-16 12:32:24 +00:00
camp = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"camps.Camp",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="reimbursements",
help_text="The camp to which this reimbursement belongs",
)
user = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"auth.User",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="created_reimbursements",
help_text="The economy team member who created this reimbursement.",
)
reimbursement_user = models.ForeignKey(
2019-06-16 12:32:24 +00:00
"auth.User",
on_delete=models.PROTECT,
2019-06-16 12:32:24 +00:00
related_name="reimbursements",
help_text="The user this reimbursement belongs to.",
)
notes = models.TextField(
blank=True,
2019-06-16 12:32:24 +00:00
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 covered_expenses(self):
"""
Returns a queryset of all expenses covered by this reimbursement. Does not include the expense that paid for the reimbursement.
"""
return self.expenses.filter(paid_by_bornhack=False)
@property
def amount(self):
"""
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.filter(paid_by_bornhack=False):
amount += expense.amount
return amount
2020-08-11 00:22:36 +00:00
class Pos(CampRelatedModel, UUIDModel):
"""A Pos is a point-of-sale like the bar or infodesk."""
class Meta:
ordering = ["name"]
name = models.CharField(max_length=255, help_text="The point-of-sale name")
slug = models.SlugField(
max_length=255,
blank=True,
help_text="Url slug for this POS. Leave blank to generate based on POS name.",
)
team = models.ForeignKey(
"teams.Team",
on_delete=models.PROTECT,
help_text="The Team managning this POS",
2020-08-11 00:22:36 +00:00
)
def save(self, **kwargs):
"""Generate slug if needed."""
if not self.slug:
self.slug = unique_slugify(
self.name,
slugs_in_use=self.__class__.objects.filter(
team__camp=self.team.camp
).values_list("slug", flat=True),
)
super().save(**kwargs)
@property
def camp(self):
return self.team.camp
camp_filter = "team__camp"
def get_absolute_url(self):
return reverse(
"backoffice:pos_detail",
kwargs={"camp_slug": self.team.camp.slug, "pos_slug": self.slug},
)
class PosReport(CampRelatedModel, UUIDModel):
"""A PosReport contains the HAX/DKK counts and the csv report from the POS system."""
2020-08-13 21:46:21 +00:00
class Meta:
ordering = ["date", "pos"]
2020-08-11 00:22:36 +00:00
pos = models.ForeignKey(
"economy.Pos",
on_delete=models.PROTECT,
related_name="pos_reports",
help_text="The Pos this PosReport belongs to.",
)
bank_responsible = models.ForeignKey(
"auth.User",
on_delete=models.PROTECT,
related_name="pos_reports_banker",
help_text="The banker responsible for this PosReport",
)
pos_responsible = models.ForeignKey(
"auth.User",
on_delete=models.PROTECT,
related_name="pos_reports_poser",
help_text="The POS person responsible for this PosReport",
)
date = models.DateField(
help_text="The date this report covers (pick the starting date if opening hours cross midnight).",
)
pos_json = models.JSONField(
null=True,
blank=True,
help_text="The JSON exported from the external POS system",
)
2020-08-13 16:55:41 +00:00
comments = models.TextField(
blank=True,
help_text="Any comments about this PosReport",
2020-08-13 16:55:41 +00:00
)
2020-09-02 23:32:26 +00:00
dkk_sales_izettle = models.PositiveIntegerField(
default=0, help_text="The total DKK amount in iZettle cash sales"
)
hax_sold_izettle = models.PositiveIntegerField(
default=0,
help_text="The number of HAX sold through the iZettle from the POS",
)
hax_sold_website = models.PositiveIntegerField(
default=0,
help_text="The number of HAX sold through webshop tickets being used in the POS",
)
2020-08-11 00:22:36 +00:00
# bank count start of day
bank_count_dkk_start = models.PositiveIntegerField(
default=0,
help_text="The number of DKK handed out from the bank to the POS at the start of the business day (counted by the bank responsible)",
)
bank_count_hax5_start = models.PositiveIntegerField(
default=0,
help_text="The number of 5 HAX coins handed out from the bank to the POS at the start of the business day (counted by the bank responsible)",
)
bank_count_hax10_start = models.PositiveIntegerField(
default=0,
help_text="The number of 10 HAX coins handed out from the bank to the POS at the start of the business day (counted by the bank responsible)",
)
bank_count_hax20_start = models.PositiveIntegerField(
default=0,
help_text="The number of 20 HAX coins handed out from the bank to the POS at the start of the business day (counted by the bank responsible)",
)
bank_count_hax50_start = models.PositiveIntegerField(
default=0,
help_text="The number of 50 HAX coins handed out from the bank to the POS at the start of the business day (counted by the bank responsible)",
)
bank_count_hax100_start = models.PositiveIntegerField(
default=0,
help_text="The number of 100 HAX coins handed out from the bank to the POS at the start of the business day (counted by the bank responsible)",
)
# POS count start of day
pos_count_dkk_start = models.PositiveIntegerField(
default=0,
help_text="The number of DKK handed out from the bank to the POS at the start of the business day (counted by the POS responsible)",
)
pos_count_hax5_start = models.PositiveIntegerField(
default=0,
help_text="The number of 5 HAX coins received by the POS from the bank at the start of the business day (counted by the POS responsible)",
)
pos_count_hax10_start = models.PositiveIntegerField(
default=0,
help_text="The number of 10 HAX coins received by the POS from the bank at the start of the business day (counted by the POS responsible)",
)
pos_count_hax20_start = models.PositiveIntegerField(
default=0,
help_text="The number of 20 HAX coins received by the POS from the bank at the start of the business day (counted by the POS responsible)",
)
pos_count_hax50_start = models.PositiveIntegerField(
default=0,
help_text="The number of 50 HAX coins received by the POS from the bank at the start of the business day (counted by the POS responsible)",
)
pos_count_hax100_start = models.PositiveIntegerField(
default=0,
help_text="The number of 100 HAX coins received by the POS from the bank at the start of the business day (counted by the POS responsible)",
)
# bank count end of day
bank_count_dkk_end = models.PositiveIntegerField(
default=0,
help_text="The number of DKK handed back from the POS to the bank at the end of the business day (counted by the bank responsible)",
)
bank_count_hax5_end = models.PositiveIntegerField(
default=0,
help_text="The number of 5 HAX coins handed back from the POS to the bank at the end of the business day (counted by the bank responsible)",
)
bank_count_hax10_end = models.PositiveIntegerField(
default=0,
help_text="The number of 10 HAX coins handed back from the POS to the bank at the end of the business day (counted by the bank responsible)",
)
bank_count_hax20_end = models.PositiveIntegerField(
default=0,
help_text="The number of 20 HAX coins handed back from the POS to the bank at the end of the business day (counted by the bank responsible)",
)
bank_count_hax50_end = models.PositiveIntegerField(
default=0,
help_text="The number of 50 HAX coins handed back from the POS to the bank at the end of the business day (counted by the bank responsible)",
)
bank_count_hax100_end = models.PositiveIntegerField(
default=0,
help_text="The number of 100 HAX coins handed back from the POS to the bank at the end of the business day (counted by the bank responsible)",
)
# pos count end of day
pos_count_dkk_end = models.PositiveIntegerField(
default=0,
help_text="The number of DKK handed back from the POS to the bank at the end of the business day (counted by the POS responsible)",
)
pos_count_hax5_end = models.PositiveIntegerField(
default=0,
help_text="The number of 5 HAX coins received by the bank from the POS at the end of the business day (counted by the POS responsible)",
)
pos_count_hax10_end = models.PositiveIntegerField(
default=0,
help_text="The number of 10 HAX coins received by the bank from the POS at the end of the business day (counted by the POS responsible)",
)
pos_count_hax20_end = models.PositiveIntegerField(
default=0,
help_text="The number of 20 HAX coins received by the bank from the POS at the end of the business day (counted by the POS responsible)",
)
pos_count_hax50_end = models.PositiveIntegerField(
default=0,
help_text="The number of 50 HAX coins received by the bank from the POS at the end of the business day (counted by the POS responsible)",
)
pos_count_hax100_end = models.PositiveIntegerField(
default=0,
help_text="The number of 100 HAX coins received by the bank from the POS at the end of the business day (counted by the POS responsible)",
)
@property
def camp(self):
return self.pos.team.camp
camp_filter = "pos__team__camp"
def get_absolute_url(self):
return reverse(
"backoffice:posreport_detail",
kwargs={
"camp_slug": self.camp.slug,
"pos_slug": self.pos.slug,
"posreport_uuid": self.uuid,
},
)
2020-08-13 16:35:17 +00:00
@property
def dkk_start_ok(self):
return self.bank_count_dkk_start == self.pos_count_dkk_start
@property
def hax5_start_ok(self):
return self.bank_count_hax5_start == self.pos_count_hax5_start
@property
def hax10_start_ok(self):
return self.bank_count_hax10_start == self.pos_count_hax10_start
@property
def hax20_start_ok(self):
return self.bank_count_hax20_start == self.pos_count_hax20_start
@property
def hax50_start_ok(self):
return self.bank_count_hax50_start == self.pos_count_hax50_start
@property
def hax100_start_ok(self):
return self.bank_count_hax100_start == self.pos_count_hax100_start
@property
def bank_start_hax(self):
return (
(self.bank_count_hax5_start * 5)
+ (self.bank_count_hax10_start * 10)
+ (self.bank_count_hax20_start * 20)
+ (self.bank_count_hax50_start * 50)
+ (self.bank_count_hax100_start * 100)
)
@property
def pos_start_hax(self):
return (
(self.pos_count_hax5_start * 5)
+ (self.pos_count_hax10_start * 10)
+ (self.pos_count_hax20_start * 20)
+ (self.pos_count_hax50_start * 50)
+ (self.pos_count_hax100_start * 100)
)
@property
def bank_end_hax(self):
return (
(self.bank_count_hax5_end * 5)
+ (self.bank_count_hax10_end * 10)
+ (self.bank_count_hax20_end * 20)
+ (self.bank_count_hax50_end * 50)
+ (self.bank_count_hax100_end * 100)
)
@property
def pos_end_hax(self):
return (
(self.pos_count_hax5_end * 5)
+ (self.pos_count_hax10_end * 10)
+ (self.pos_count_hax20_end * 20)
+ (self.pos_count_hax50_end * 50)
+ (self.pos_count_hax100_end * 100)
)
2020-08-13 16:35:17 +00:00
@property
def dkk_end_ok(self):
return self.bank_count_dkk_end == self.pos_count_dkk_end
@property
def hax5_end_ok(self):
return self.bank_count_hax5_end == self.pos_count_hax5_end
@property
def hax10_end_ok(self):
return self.bank_count_hax10_end == self.pos_count_hax10_end
@property
def hax20_end_ok(self):
return self.bank_count_hax20_end == self.pos_count_hax20_end
@property
def hax50_end_ok(self):
return self.bank_count_hax50_end == self.pos_count_hax50_end
@property
def hax100_end_ok(self):
return self.bank_count_hax100_end == self.pos_count_hax100_end
def allok(self):
return all(
[
self.dkk_start_ok,
self.hax5_start_ok,
self.hax10_start_ok,
self.hax20_start_ok,
self.hax50_start_ok,
self.hax100_start_ok,
self.dkk_end_ok,
self.hax5_end_ok,
self.hax10_end_ok,
self.hax20_end_ok,
self.hax50_end_ok,
self.hax100_end_ok,
]
)
@property
def pos_json_sales(self):
"""Calculate the total HAX sales and number of transactions."""
transactions = 0
total = 0
if self.pos_json:
for tx in self.pos_json:
transactions += 1
total += tx["amount"]
return transactions, total
@property
def hax_balance(self):
"""Return the hax balance all things considered."""
balance = 0
# start by adding what the POS got at the start of the day
balance += self.bank_start_hax
# then substract the HAX the POS sold via the izettle
balance -= self.hax_sold_izettle
# then substract the HAX sold through webshop tickets
balance -= self.hax_sold_website
# then add the HAX sales from the POS json
balance += self.pos_json_sales[1]
# finally substract the HAX received from the POS at the end of the day
balance -= self.bank_end_hax
# all good
return balance
2020-09-02 23:32:26 +00:00
@property
def dkk_balance(self):
"""Return the DKK balance all things considered."""
balance = 0
# start with the bank count at the start of the day
balance += self.bank_count_dkk_start
# then add the iZettle sales for the day
balance += self.dkk_sales_izettle
# then substract what was returned to the bank at days end
balance -= self.bank_count_dkk_end
# all good
return balance