2017-08-17 15:52:22 +00:00
|
|
|
import logging
|
2020-02-12 12:10:41 +00:00
|
|
|
from datetime import timedelta
|
|
|
|
from decimal import Decimal
|
2017-08-17 15:52:22 +00:00
|
|
|
|
2016-05-25 17:13:45 +00:00
|
|
|
from django.conf import settings
|
2016-06-19 06:43:56 +00:00
|
|
|
from django.contrib import messages
|
2020-08-11 00:35:18 +00:00
|
|
|
from django.contrib.postgres.fields import DateTimeRangeField
|
2020-02-12 12:10:41 +00:00
|
|
|
from django.core.exceptions import ValidationError
|
|
|
|
from django.db import models
|
2021-07-21 22:07:30 +00:00
|
|
|
from django.db.models import F, Sum
|
2020-02-12 12:10:41 +00:00
|
|
|
from django.urls import reverse_lazy
|
|
|
|
from django.utils import timezone
|
|
|
|
from django.utils.dateparse import parse_datetime
|
2016-04-22 20:38:44 +00:00
|
|
|
from django.utils.translation import ugettext_lazy as _
|
2016-08-23 17:33:53 +00:00
|
|
|
from unidecode import unidecode
|
2021-07-19 13:06:10 +00:00
|
|
|
|
2020-02-12 12:10:41 +00:00
|
|
|
from utils.models import 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
|
2020-02-12 12:10:41 +00:00
|
|
|
|
|
|
|
from .managers import OrderQuerySet, ProductQuerySet
|
2017-08-17 15:52:22 +00:00
|
|
|
|
|
|
|
logger = logging.getLogger("bornhack.%s" % __name__)
|
|
|
|
|
2016-04-22 20:38:44 +00:00
|
|
|
|
2016-07-12 20:33:53 +00:00
|
|
|
class CustomOrder(CreatedUpdatedModel):
|
2019-03-29 21:19:49 +00:00
|
|
|
text = models.TextField(help_text=_("The invoice text"))
|
2016-07-12 21:16:45 +00:00
|
|
|
|
2019-03-29 21:19:49 +00:00
|
|
|
customer = models.TextField(help_text=_("The customer info for this order"))
|
2016-07-12 20:33:53 +00:00
|
|
|
|
|
|
|
amount = models.IntegerField(
|
2019-03-29 21:19:49 +00:00
|
|
|
help_text=_("Amount of this custom order (in DKK, including VAT).")
|
2016-07-12 20:33:53 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
paid = models.BooleanField(
|
2019-03-29 21:19:49 +00:00
|
|
|
verbose_name=_("Paid?"),
|
|
|
|
help_text=_(
|
|
|
|
"Check when this custom order has been paid (or if it gets cancelled out by a Credit Note)"
|
|
|
|
),
|
2016-07-12 20:33:53 +00:00
|
|
|
default=False,
|
|
|
|
)
|
|
|
|
|
2019-03-29 21:19:49 +00:00
|
|
|
danish_vat = models.BooleanField(help_text="Danish VAT?", default=True)
|
2017-09-14 19:02:59 +00:00
|
|
|
|
2017-01-31 22:39:49 +00:00
|
|
|
def __str__(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return "custom order id #%s" % self.pk
|
2016-07-12 20:33:53 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def vat(self):
|
2017-09-14 19:02:59 +00:00
|
|
|
if self.danish_vat:
|
2019-03-29 21:19:49 +00:00
|
|
|
return Decimal(round(self.amount * Decimal(0.2), 2))
|
2017-09-14 19:02:59 +00:00
|
|
|
else:
|
|
|
|
return 0
|
2016-05-10 20:20:01 +00:00
|
|
|
|
2016-07-12 20:33:53 +00:00
|
|
|
|
|
|
|
class Order(CreatedUpdatedModel):
|
2016-05-15 22:09:00 +00:00
|
|
|
class Meta:
|
2019-03-29 21:19:49 +00:00
|
|
|
unique_together = ("user", "open")
|
|
|
|
ordering = ["-created"]
|
2016-05-15 22:09:00 +00:00
|
|
|
|
2016-05-10 20:20:01 +00:00
|
|
|
products = models.ManyToManyField(
|
2019-03-29 21:19:49 +00:00
|
|
|
"shop.Product", through="shop.OrderProductRelation"
|
2016-05-10 20:20:01 +00:00
|
|
|
)
|
2016-04-22 20:38:44 +00:00
|
|
|
|
2016-06-18 18:59:07 +00:00
|
|
|
user = models.ForeignKey(
|
2019-03-29 21:19:49 +00:00
|
|
|
"auth.User",
|
|
|
|
verbose_name=_("User"),
|
|
|
|
help_text=_("The user this shop order belongs to."),
|
|
|
|
related_name="orders",
|
2018-03-04 15:26:35 +00:00
|
|
|
on_delete=models.PROTECT,
|
2016-06-18 18:59:07 +00:00
|
|
|
)
|
2016-04-22 20:38:44 +00:00
|
|
|
|
|
|
|
paid = models.BooleanField(
|
2019-03-29 21:19:49 +00:00
|
|
|
verbose_name=_("Paid?"),
|
|
|
|
help_text=_("Whether this shop order has been paid."),
|
2016-04-22 20:38:44 +00:00
|
|
|
default=False,
|
|
|
|
)
|
|
|
|
|
2020-08-11 00:35:18 +00:00
|
|
|
# We are using a nullable BooleanField here to ensure that we only have one open order per user at a time.
|
2019-03-27 21:53:23 +00:00
|
|
|
# This "hack" is possible since postgres treats null values as different, and thus we have database level integrity.
|
2020-08-11 00:35:18 +00:00
|
|
|
open = models.BooleanField(
|
2020-08-11 08:16:53 +00:00
|
|
|
blank=True,
|
2020-08-11 00:35:18 +00:00
|
|
|
null=True,
|
2019-03-29 21:19:49 +00:00
|
|
|
verbose_name=_("Open?"),
|
2016-07-12 20:33:53 +00:00
|
|
|
help_text=_('Whether this shop order is open or not. "None" means closed.'),
|
2016-05-15 22:09:00 +00:00
|
|
|
default=True,
|
2016-05-13 06:37:47 +00:00
|
|
|
)
|
|
|
|
|
2019-03-29 21:19:49 +00:00
|
|
|
CREDIT_CARD = "credit_card"
|
|
|
|
BLOCKCHAIN = "blockchain"
|
|
|
|
BANK_TRANSFER = "bank_transfer"
|
2020-08-12 15:49:24 +00:00
|
|
|
IN_PERSON = "in_person"
|
2016-05-10 15:55:54 +00:00
|
|
|
|
2020-08-12 15:49:24 +00:00
|
|
|
PAYMENT_METHODS = [CREDIT_CARD, BLOCKCHAIN, BANK_TRANSFER, IN_PERSON]
|
2016-05-15 22:09:00 +00:00
|
|
|
|
|
|
|
PAYMENT_METHOD_CHOICES = [
|
2019-03-29 21:19:49 +00:00
|
|
|
(CREDIT_CARD, "Credit card"),
|
|
|
|
(BLOCKCHAIN, "Blockchain"),
|
|
|
|
(BANK_TRANSFER, "Bank transfer"),
|
2020-08-12 15:49:24 +00:00
|
|
|
(IN_PERSON, "In Person"),
|
2016-05-10 15:55:54 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
payment_method = models.CharField(
|
2019-03-29 21:19:49 +00:00
|
|
|
max_length=50, choices=PAYMENT_METHOD_CHOICES, default="", blank=True
|
2016-05-10 15:55:54 +00:00
|
|
|
)
|
|
|
|
|
2016-06-01 09:10:06 +00:00
|
|
|
cancelled = models.BooleanField(default=False)
|
|
|
|
|
2016-06-18 21:42:58 +00:00
|
|
|
refunded = models.BooleanField(
|
2019-03-29 21:19:49 +00:00
|
|
|
verbose_name=_("Refunded?"),
|
|
|
|
help_text=_("Whether this order has been refunded."),
|
2016-06-18 21:42:58 +00:00
|
|
|
default=False,
|
|
|
|
)
|
|
|
|
|
2016-11-09 13:34:55 +00:00
|
|
|
customer_comment = models.TextField(
|
2019-03-29 21:19:49 +00:00
|
|
|
verbose_name=_("Customer comment"),
|
|
|
|
help_text=_("If you have any comments about the order please enter them here."),
|
|
|
|
default="",
|
2017-02-20 18:34:12 +00:00
|
|
|
blank=True,
|
2016-11-09 13:34:55 +00:00
|
|
|
)
|
|
|
|
|
2018-08-27 09:52:42 +00:00
|
|
|
invoice_address = models.TextField(
|
2019-03-29 21:19:49 +00:00
|
|
|
help_text=_(
|
|
|
|
"The invoice address for this order. Leave blank to use the email associated with the logged in user."
|
|
|
|
),
|
|
|
|
blank=True,
|
2018-08-27 09:52:42 +00:00
|
|
|
)
|
|
|
|
|
2018-08-27 10:44:53 +00:00
|
|
|
notes = models.TextField(
|
2019-03-29 21:19:49 +00:00
|
|
|
help_text="Any internal notes about this order can be entered here. They will not be printed on the invoice or shown to the customer in any way.",
|
|
|
|
default="",
|
2018-08-27 10:44:53 +00:00
|
|
|
blank=True,
|
|
|
|
)
|
|
|
|
|
2019-07-09 08:38:14 +00:00
|
|
|
pdf = models.FileField(null=True, blank=True, upload_to="proforma_invoices/")
|
|
|
|
|
2016-06-01 09:10:06 +00:00
|
|
|
objects = OrderQuerySet.as_manager()
|
|
|
|
|
2017-01-31 22:39:49 +00:00
|
|
|
def __str__(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return "shop order id #%s" % self.pk
|
2016-05-30 15:32:53 +00:00
|
|
|
|
2016-05-14 17:39:34 +00:00
|
|
|
def get_number_of_items(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return self.products.aggregate(sum=Sum("orderproductrelation__quantity"))["sum"]
|
2016-05-14 17:39:34 +00:00
|
|
|
|
2016-05-16 14:14:16 +00:00
|
|
|
@property
|
|
|
|
def vat(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return Decimal(self.total * Decimal(0.2))
|
2016-05-16 14:14:16 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def total(self):
|
2016-06-04 07:49:22 +00:00
|
|
|
if self.products.all():
|
2019-03-29 21:19:49 +00:00
|
|
|
return Decimal(
|
|
|
|
self.products.aggregate(
|
|
|
|
sum=Sum(
|
|
|
|
models.F("orderproductrelation__product__price")
|
|
|
|
* models.F("orderproductrelation__quantity"),
|
|
|
|
output_field=models.IntegerField(),
|
|
|
|
)
|
|
|
|
)["sum"]
|
|
|
|
)
|
2016-06-04 07:49:22 +00:00
|
|
|
else:
|
|
|
|
return False
|
2016-05-16 14:09:25 +00:00
|
|
|
|
2016-05-29 10:29:38 +00:00
|
|
|
def get_coinify_callback_url(self, request):
|
2021-07-19 13:06:10 +00:00
|
|
|
"""Check settings for an alternative COINIFY_CALLBACK_HOSTNAME otherwise use the one from the request"""
|
2019-03-29 21:19:49 +00:00
|
|
|
if (
|
|
|
|
hasattr(settings, "COINIFY_CALLBACK_HOSTNAME")
|
|
|
|
and settings.COINIFY_CALLBACK_HOSTNAME
|
|
|
|
):
|
2017-07-12 15:05:27 +00:00
|
|
|
host = settings.COINIFY_CALLBACK_HOSTNAME
|
2017-07-11 09:06:46 +00:00
|
|
|
else:
|
|
|
|
host = request.get_host()
|
2019-03-29 21:19:49 +00:00
|
|
|
return (
|
|
|
|
"https://"
|
|
|
|
+ host
|
|
|
|
+ str(reverse_lazy("shop:coinify_callback", kwargs={"pk": self.pk}))
|
|
|
|
)
|
2016-05-29 10:29:38 +00:00
|
|
|
|
|
|
|
def get_coinify_thanks_url(self, request):
|
2019-03-29 21:19:49 +00:00
|
|
|
return (
|
|
|
|
"https://"
|
|
|
|
+ request.get_host()
|
|
|
|
+ str(reverse_lazy("shop:coinify_thanks", kwargs={"pk": self.pk}))
|
|
|
|
)
|
2016-05-29 10:29:38 +00:00
|
|
|
|
2016-05-17 05:06:25 +00:00
|
|
|
def get_epay_accept_url(self, request):
|
2019-03-29 21:19:49 +00:00
|
|
|
return (
|
|
|
|
"https://"
|
|
|
|
+ request.get_host()
|
|
|
|
+ str(reverse_lazy("shop:epay_thanks", kwargs={"pk": self.pk}))
|
|
|
|
)
|
2016-05-16 14:54:11 +00:00
|
|
|
|
2016-05-29 11:00:00 +00:00
|
|
|
def get_cancel_url(self, request):
|
2019-03-29 21:19:49 +00:00
|
|
|
return (
|
|
|
|
"https://"
|
|
|
|
+ request.get_host()
|
|
|
|
+ str(reverse_lazy("shop:order_detail", kwargs={"pk": self.pk}))
|
|
|
|
)
|
2016-05-17 05:06:25 +00:00
|
|
|
|
2016-05-25 20:48:02 +00:00
|
|
|
def get_epay_callback_url(self, request):
|
2019-03-29 21:19:49 +00:00
|
|
|
return (
|
|
|
|
"https://"
|
|
|
|
+ request.get_host()
|
|
|
|
+ str(reverse_lazy("shop:epay_callback", kwargs={"pk": self.pk}))
|
|
|
|
)
|
2016-05-25 20:48:02 +00:00
|
|
|
|
2016-05-17 05:06:25 +00:00
|
|
|
@property
|
|
|
|
def description(self):
|
2016-12-25 14:52:55 +00:00
|
|
|
return "Order #%s" % self.pk
|
2016-05-06 20:33:59 +00:00
|
|
|
|
2016-05-17 05:21:22 +00:00
|
|
|
def get_absolute_url(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return str(reverse_lazy("shop:order_detail", kwargs={"pk": self.pk}))
|
2016-05-17 05:21:22 +00:00
|
|
|
|
2019-03-17 15:28:01 +00:00
|
|
|
def create_tickets(self, request=None):
|
2019-07-30 20:51:01 +00:00
|
|
|
tickets = []
|
2016-05-25 18:05:31 +00:00
|
|
|
for order_product in self.orderproductrelation_set.all():
|
2017-10-03 21:45:22 +00:00
|
|
|
# if this is a Ticket product?
|
|
|
|
if order_product.product.ticket_type:
|
2019-03-17 15:28:01 +00:00
|
|
|
query_kwargs = dict(
|
|
|
|
product=order_product.product,
|
|
|
|
ticket_type=order_product.product.ticket_type,
|
|
|
|
)
|
|
|
|
|
2019-07-30 20:51:01 +00:00
|
|
|
if order_product.product.ticket_type.single_ticket_per_product:
|
|
|
|
# This ticket type is one where we only create one ticket
|
|
|
|
ticket, created = self.shoptickets.get_or_create(**query_kwargs)
|
|
|
|
|
|
|
|
if created:
|
|
|
|
msg = (
|
|
|
|
"Created ticket for product %s on order %s (quantity: %s)"
|
|
|
|
% (
|
|
|
|
order_product.product,
|
|
|
|
order_product.order.pk,
|
|
|
|
order_product.quantity,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
tickets.append(ticket)
|
|
|
|
else:
|
|
|
|
msg = "Ticket already created for product %s on order %s" % (
|
|
|
|
order_product.product,
|
|
|
|
order_product.order.pk,
|
|
|
|
)
|
2019-03-29 21:19:49 +00:00
|
|
|
|
2019-03-17 15:28:01 +00:00
|
|
|
if request:
|
|
|
|
messages.success(request, msg)
|
2019-07-30 20:51:01 +00:00
|
|
|
else:
|
|
|
|
# We should create a number of tickets equal to OrderProductRelation quantity
|
|
|
|
already_created_tickets = self.shoptickets.filter(
|
|
|
|
**query_kwargs
|
|
|
|
).count()
|
|
|
|
tickets_to_create = max(
|
|
|
|
0, order_product.quantity - already_created_tickets
|
|
|
|
)
|
2019-03-17 15:28:01 +00:00
|
|
|
|
2019-07-30 20:51:01 +00:00
|
|
|
# create the number of tickets required
|
|
|
|
if tickets_to_create > 0:
|
2020-02-12 12:10:41 +00:00
|
|
|
for i in range(
|
2019-07-30 20:51:01 +00:00
|
|
|
0, (order_product.quantity - already_created_tickets)
|
|
|
|
):
|
|
|
|
ticket = self.shoptickets.create(**query_kwargs)
|
|
|
|
tickets.append(ticket)
|
|
|
|
|
|
|
|
msg = "Created %s tickets of type: %s" % (
|
|
|
|
order_product.quantity,
|
|
|
|
order_product.product.ticket_type.name,
|
|
|
|
)
|
|
|
|
if request:
|
|
|
|
messages.success(request, msg)
|
|
|
|
|
2021-07-21 22:35:07 +00:00
|
|
|
# and mark the OPR as ticket_generated=True
|
|
|
|
order_product.ticket_generated = True
|
|
|
|
order_product.save()
|
2019-07-30 20:51:01 +00:00
|
|
|
|
|
|
|
return tickets
|
2019-03-17 15:28:01 +00:00
|
|
|
|
|
|
|
def mark_as_paid(self, request=None):
|
|
|
|
self.paid = True
|
|
|
|
self.open = None
|
|
|
|
self.create_tickets(request)
|
2016-05-25 18:05:31 +00:00
|
|
|
self.save()
|
|
|
|
|
2019-03-17 15:28:01 +00:00
|
|
|
def mark_as_refunded(self, request=None):
|
2016-06-19 06:38:43 +00:00
|
|
|
if not self.paid:
|
2019-03-17 15:28:01 +00:00
|
|
|
msg = "Order %s is not paid, so cannot mark it as refunded!" % self.pk
|
|
|
|
if request:
|
|
|
|
messages.error(request, msg)
|
|
|
|
else:
|
|
|
|
print(msg)
|
2016-06-19 06:38:43 +00:00
|
|
|
else:
|
2018-03-04 14:38:40 +00:00
|
|
|
self.refunded = True
|
|
|
|
# delete any tickets related to this order
|
2019-03-17 15:28:01 +00:00
|
|
|
if self.shoptickets.all():
|
2019-03-29 21:19:49 +00:00
|
|
|
msg = "Order %s marked as refunded, deleting %s tickets..." % (
|
|
|
|
self.pk,
|
|
|
|
self.shoptickets.count(),
|
|
|
|
)
|
2019-03-17 15:28:01 +00:00
|
|
|
if request:
|
|
|
|
messages.success(request, msg)
|
|
|
|
else:
|
|
|
|
print(msg)
|
|
|
|
self.shoptickets.all().delete()
|
2016-06-19 06:58:26 +00:00
|
|
|
else:
|
2019-03-17 15:28:01 +00:00
|
|
|
msg = "Order %s marked as refunded, no tickets to delete" % self.pk
|
|
|
|
if request:
|
|
|
|
messages.success(request, msg)
|
|
|
|
else:
|
|
|
|
print(msg)
|
2016-06-19 06:38:43 +00:00
|
|
|
self.save()
|
2016-06-18 21:42:58 +00:00
|
|
|
|
2019-03-17 15:28:01 +00:00
|
|
|
def mark_as_cancelled(self, request=None):
|
2019-03-17 14:04:28 +00:00
|
|
|
if self.paid:
|
2019-03-17 15:28:01 +00:00
|
|
|
msg = "Order %s is paid, cannot cancel a paid order!" % self.pk
|
|
|
|
if request:
|
|
|
|
messages.error(request, msg)
|
|
|
|
else:
|
|
|
|
print(msg)
|
2019-03-17 14:04:28 +00:00
|
|
|
else:
|
|
|
|
self.cancelled = True
|
2019-03-17 14:21:03 +00:00
|
|
|
self.open = None
|
2019-03-17 14:04:28 +00:00
|
|
|
self.save()
|
|
|
|
|
2019-07-18 19:20:29 +00:00
|
|
|
def is_not_ticket_generated(self):
|
2019-07-29 23:46:27 +00:00
|
|
|
if self.orderproductrelation_set.filter(ticket_generated=True).count() == 0:
|
2016-05-29 12:28:47 +00:00
|
|
|
return True
|
|
|
|
else:
|
|
|
|
return False
|
|
|
|
|
2019-07-18 19:20:29 +00:00
|
|
|
def is_partially_ticket_generated(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
if (
|
2019-07-18 19:20:29 +00:00
|
|
|
self.orderproductrelation_set.filter(ticket_generated=True).count() != 0
|
2019-07-24 19:08:36 +00:00
|
|
|
and self.orderproductrelation_set.filter(ticket_generated=False).count()
|
|
|
|
!= 0
|
2019-03-29 21:19:49 +00:00
|
|
|
):
|
2016-05-29 12:28:47 +00:00
|
|
|
# some products are handed out, others are not
|
|
|
|
return True
|
|
|
|
else:
|
|
|
|
return False
|
|
|
|
|
2019-07-18 19:20:29 +00:00
|
|
|
def is_fully_ticket_generated(self):
|
|
|
|
if self.orderproductrelation_set.filter(ticket_generated=False).count() == 0:
|
2016-05-29 12:28:47 +00:00
|
|
|
return True
|
|
|
|
else:
|
|
|
|
return False
|
|
|
|
|
|
|
|
@property
|
2019-07-18 19:20:29 +00:00
|
|
|
def ticket_generated_status(self):
|
|
|
|
if self.is_not_ticket_generated():
|
2016-05-29 12:28:47 +00:00
|
|
|
return "no"
|
2019-07-18 19:20:29 +00:00
|
|
|
elif self.is_partially_ticket_generated():
|
2016-05-29 12:28:47 +00:00
|
|
|
return "partially"
|
2019-07-18 19:20:29 +00:00
|
|
|
elif self.is_fully_ticket_generated():
|
2016-05-29 12:28:47 +00:00
|
|
|
return "fully"
|
|
|
|
else:
|
|
|
|
return False
|
|
|
|
|
2017-06-20 07:02:13 +00:00
|
|
|
@property
|
|
|
|
def coinifyapiinvoice(self):
|
|
|
|
if not self.coinify_api_invoices.exists():
|
|
|
|
return False
|
|
|
|
|
|
|
|
for tempinvoice in self.coinify_api_invoices.all():
|
|
|
|
# we already have a coinifyinvoice for this order, check if it expired
|
|
|
|
if not tempinvoice.expired:
|
|
|
|
# this invoice is not expired, we are good to go
|
|
|
|
return tempinvoice
|
|
|
|
|
|
|
|
# nope
|
|
|
|
return False
|
|
|
|
|
2019-07-09 08:38:14 +00:00
|
|
|
@property
|
|
|
|
def filename(self):
|
|
|
|
return "bornhack_proforma_invoice_order_%s.pdf" % self.pk
|
|
|
|
|
2016-05-25 18:05:31 +00:00
|
|
|
|
2016-05-10 20:20:01 +00:00
|
|
|
class ProductCategory(CreatedUpdatedModel, UUIDModel):
|
|
|
|
class Meta:
|
2019-03-29 21:19:49 +00:00
|
|
|
verbose_name = "Product category"
|
|
|
|
verbose_name_plural = "Product categories"
|
2016-05-10 20:20:01 +00:00
|
|
|
|
|
|
|
name = models.CharField(max_length=150)
|
2016-05-15 22:09:00 +00:00
|
|
|
slug = models.SlugField()
|
2016-05-17 18:56:11 +00:00
|
|
|
public = models.BooleanField(default=True)
|
2020-02-07 17:46:34 +00:00
|
|
|
weight = models.IntegerField(
|
|
|
|
default=100, help_text="Sorting weight. Heavier items sink to the bottom."
|
|
|
|
)
|
2016-05-10 20:20:01 +00:00
|
|
|
|
2017-01-31 22:39:49 +00:00
|
|
|
def __str__(self):
|
2016-05-10 20:20:01 +00:00
|
|
|
return self.name
|
2016-05-10 15:55:54 +00:00
|
|
|
|
2016-05-15 22:09:00 +00:00
|
|
|
def save(self, **kwargs):
|
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)
|
2016-05-15 22:09:00 +00:00
|
|
|
|
2016-04-22 20:38:44 +00:00
|
|
|
|
2021-07-21 19:29:55 +00:00
|
|
|
class ProductStatsManager(models.Manager):
|
|
|
|
def with_ticket_stats(self):
|
2021-07-21 22:07:30 +00:00
|
|
|
return (
|
2021-07-22 05:12:13 +00:00
|
|
|
self.filter(orderproductrelation__order__paid=True)
|
|
|
|
.annotate(total_units_sold=Sum("orderproductrelation__quantity"))
|
2021-07-21 22:07:30 +00:00
|
|
|
.annotate(profit=F("price") - F("cost"))
|
|
|
|
.annotate(total_revenue=F("price") * F("total_units_sold"))
|
|
|
|
.annotate(total_cost=F("cost") * F("total_units_sold"))
|
|
|
|
.annotate(total_profit=F("profit") * F("total_units_sold"))
|
|
|
|
)
|
2021-07-21 19:29:55 +00:00
|
|
|
|
|
|
|
|
2016-05-10 20:20:01 +00:00
|
|
|
class Product(CreatedUpdatedModel, UUIDModel):
|
2016-04-22 20:38:44 +00:00
|
|
|
class Meta:
|
2019-03-29 21:19:49 +00:00
|
|
|
verbose_name = "Product"
|
|
|
|
verbose_name_plural = "Products"
|
|
|
|
ordering = ["available_in", "price", "name"]
|
2016-04-22 20:38:44 +00:00
|
|
|
|
2016-05-15 22:09:00 +00:00
|
|
|
category = models.ForeignKey(
|
2019-03-29 21:19:49 +00:00
|
|
|
"shop.ProductCategory", related_name="products", on_delete=models.PROTECT
|
2016-05-15 22:09:00 +00:00
|
|
|
)
|
2016-04-22 20:38:44 +00:00
|
|
|
|
2016-05-10 20:20:01 +00:00
|
|
|
name = models.CharField(max_length=150)
|
2017-03-19 22:08:36 +00:00
|
|
|
slug = models.SlugField(unique=True, max_length=100)
|
2016-04-22 20:38:44 +00:00
|
|
|
|
|
|
|
price = models.IntegerField(
|
2019-03-29 21:19:49 +00:00
|
|
|
help_text=_("Price of the product (in DKK, including VAT).")
|
2016-04-22 20:38:44 +00:00
|
|
|
)
|
|
|
|
|
2016-05-10 20:20:01 +00:00
|
|
|
description = models.TextField()
|
|
|
|
|
2016-04-22 20:38:44 +00:00
|
|
|
available_in = DateTimeRangeField(
|
2016-05-06 20:33:59 +00:00
|
|
|
help_text=_(
|
2019-03-29 21:19:49 +00:00
|
|
|
"Which period is this product available for purchase? | "
|
|
|
|
"(Format: YYYY-MM-DD HH:MM) | Only one of start/end is required"
|
2016-05-06 20:33:59 +00:00
|
|
|
)
|
2016-04-22 20:38:44 +00:00
|
|
|
)
|
2017-01-31 22:39:49 +00:00
|
|
|
|
2017-08-17 15:52:22 +00:00
|
|
|
ticket_type = models.ForeignKey(
|
2019-03-29 21:19:49 +00:00
|
|
|
"tickets.TicketType", on_delete=models.PROTECT, null=True, blank=True
|
2017-08-17 15:52:22 +00:00
|
|
|
)
|
|
|
|
|
2018-04-21 15:00:39 +00:00
|
|
|
stock_amount = models.IntegerField(
|
|
|
|
help_text=(
|
2019-03-29 21:19:49 +00:00
|
|
|
"Initial amount available in stock if there is a limited "
|
|
|
|
"supply, e.g. fridge space"
|
2018-04-21 15:00:39 +00:00
|
|
|
),
|
|
|
|
null=True,
|
2019-03-29 21:19:49 +00:00
|
|
|
blank=True,
|
2018-04-21 15:00:39 +00:00
|
|
|
)
|
|
|
|
|
2020-10-17 00:35:12 +00:00
|
|
|
cost = models.IntegerField(
|
|
|
|
default=0,
|
|
|
|
help_text="The cost for this product, including VAT. Used for profit calculations in the economy system.",
|
|
|
|
)
|
|
|
|
|
|
|
|
comment = models.TextField(
|
|
|
|
blank=True, help_text="Internal comments for this product."
|
|
|
|
)
|
|
|
|
|
2016-05-10 20:20:01 +00:00
|
|
|
objects = ProductQuerySet.as_manager()
|
2021-07-21 19:29:55 +00:00
|
|
|
statsobjects = ProductStatsManager()
|
2016-05-06 20:33:59 +00:00
|
|
|
|
2017-01-31 22:39:49 +00:00
|
|
|
def __str__(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return "{} ({} DKK)".format(self.name, self.price)
|
2016-05-06 20:33:59 +00:00
|
|
|
|
2017-08-19 20:06:32 +00:00
|
|
|
def clean(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
if self.category.name == "Tickets" and not self.ticket_type:
|
|
|
|
raise ValidationError("Products with category Tickets need a ticket_type")
|
2017-08-19 20:06:32 +00:00
|
|
|
|
2016-05-06 20:33:59 +00:00
|
|
|
def is_available(self):
|
2020-10-17 00:35:12 +00:00
|
|
|
"""Is the product available or not?
|
2018-04-21 15:00:39 +00:00
|
|
|
|
|
|
|
Checks for the following:
|
|
|
|
|
|
|
|
- Whether now is in the self.available_in
|
|
|
|
- If a stock is defined, that there are items left
|
|
|
|
"""
|
2018-04-24 16:06:19 +00:00
|
|
|
predicates = [self.is_time_available]
|
|
|
|
if self.stock_amount:
|
|
|
|
predicates.append(self.is_stock_available)
|
|
|
|
return all(predicates)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def is_time_available(self):
|
2016-05-06 20:33:59 +00:00
|
|
|
now = timezone.now()
|
2018-04-21 15:00:39 +00:00
|
|
|
time_available = now in self.available_in
|
2018-04-24 16:06:19 +00:00
|
|
|
return time_available
|
2016-05-10 15:55:54 +00:00
|
|
|
|
2016-08-24 22:10:13 +00:00
|
|
|
def is_old(self):
|
|
|
|
now = timezone.now()
|
2019-03-29 21:19:49 +00:00
|
|
|
if hasattr(self.available_in, "upper") and self.available_in.upper:
|
2016-08-24 22:10:13 +00:00
|
|
|
return self.available_in.upper < now
|
|
|
|
return False
|
|
|
|
|
|
|
|
def is_upcoming(self):
|
|
|
|
now = timezone.now()
|
2017-08-19 20:06:32 +00:00
|
|
|
return self.available_in.lower > now
|
2016-08-24 22:10:13 +00:00
|
|
|
|
2018-04-24 16:06:19 +00:00
|
|
|
@property
|
2018-04-21 15:00:39 +00:00
|
|
|
def left_in_stock(self):
|
2018-04-24 16:06:19 +00:00
|
|
|
if self.stock_amount:
|
2019-03-27 21:53:23 +00:00
|
|
|
# All orders that are not open and not cancelled count towards what has
|
|
|
|
# been "reserved" from stock.
|
|
|
|
#
|
|
|
|
# This means that an order has either been paid (by card or blockchain)
|
|
|
|
# or is marked to be paid with cash or bank transfer, meaning it is a
|
|
|
|
# "reservation" of the product in question.
|
2018-04-24 16:06:19 +00:00
|
|
|
sold = OrderProductRelation.objects.filter(
|
2019-03-29 21:19:49 +00:00
|
|
|
product=self, order__open=None, order__cancelled=False
|
|
|
|
).aggregate(Sum("quantity"))["quantity__sum"]
|
2018-04-21 15:00:39 +00:00
|
|
|
|
2018-04-24 16:06:19 +00:00
|
|
|
total_left = self.stock_amount - (sold or 0)
|
2018-04-21 15:00:39 +00:00
|
|
|
|
2018-04-24 16:06:19 +00:00
|
|
|
return total_left
|
|
|
|
return None
|
2018-04-21 15:00:39 +00:00
|
|
|
|
2018-04-24 16:06:19 +00:00
|
|
|
@property
|
|
|
|
def is_stock_available(self):
|
|
|
|
if self.stock_amount:
|
|
|
|
stock_available = self.left_in_stock > 0
|
|
|
|
return stock_available
|
|
|
|
# If there is no stock defined the product is generally available.
|
|
|
|
return True
|
2016-05-10 15:55:54 +00:00
|
|
|
|
2019-03-17 14:31:15 +00:00
|
|
|
|
2016-05-16 13:25:12 +00:00
|
|
|
class OrderProductRelation(CreatedUpdatedModel):
|
2019-03-29 21:19:49 +00:00
|
|
|
order = models.ForeignKey("shop.Order", on_delete=models.PROTECT)
|
|
|
|
product = models.ForeignKey("shop.Product", on_delete=models.PROTECT)
|
2016-05-10 20:20:01 +00:00
|
|
|
quantity = models.PositiveIntegerField()
|
2019-07-18 19:04:49 +00:00
|
|
|
ticket_generated = models.BooleanField(default=False)
|
2016-05-10 20:20:01 +00:00
|
|
|
|
2016-05-15 22:09:00 +00:00
|
|
|
@property
|
|
|
|
def total(self):
|
2016-05-31 06:01:55 +00:00
|
|
|
return Decimal(self.product.price * self.quantity)
|
2016-05-15 22:09:00 +00:00
|
|
|
|
2019-03-17 14:31:15 +00:00
|
|
|
def clean(self):
|
2019-07-18 19:20:29 +00:00
|
|
|
if self.ticket_generated and not self.order.paid:
|
2019-03-17 14:31:15 +00:00
|
|
|
raise ValidationError(
|
2019-03-29 21:19:49 +00:00
|
|
|
"Product can not be handed out when order is not paid."
|
2019-03-17 14:31:15 +00:00
|
|
|
)
|
|
|
|
|
2016-05-10 20:20:01 +00:00
|
|
|
|
2016-05-10 15:55:54 +00:00
|
|
|
class EpayCallback(CreatedUpdatedModel, UUIDModel):
|
|
|
|
class Meta:
|
2019-03-29 21:19:49 +00:00
|
|
|
verbose_name = "Epay Callback"
|
|
|
|
verbose_name_plural = "Epay Callbacks"
|
|
|
|
ordering = ["-created"]
|
2016-05-11 06:37:39 +00:00
|
|
|
|
2020-08-11 00:35:18 +00:00
|
|
|
payload = models.JSONField()
|
2016-05-17 05:59:42 +00:00
|
|
|
md5valid = models.BooleanField(default=False)
|
2016-05-10 15:55:54 +00:00
|
|
|
|
2017-01-31 22:39:49 +00:00
|
|
|
def __str__(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return "callback at %s (md5 valid: %s)" % (self.created, self.md5valid)
|
2016-05-17 06:04:53 +00:00
|
|
|
|
2016-05-10 15:55:54 +00:00
|
|
|
|
|
|
|
class EpayPayment(CreatedUpdatedModel, UUIDModel):
|
|
|
|
class Meta:
|
2019-03-29 21:19:49 +00:00
|
|
|
verbose_name = "Epay Payment"
|
|
|
|
verbose_name_plural = "Epay Payments"
|
2016-05-10 15:55:54 +00:00
|
|
|
|
2019-03-29 21:19:49 +00:00
|
|
|
order = models.OneToOneField("shop.Order", on_delete=models.PROTECT)
|
|
|
|
callback = models.ForeignKey("shop.EpayCallback", on_delete=models.PROTECT)
|
2016-05-10 15:55:54 +00:00
|
|
|
txnid = models.IntegerField()
|
2016-05-16 19:45:34 +00:00
|
|
|
|
2016-05-17 13:09:40 +00:00
|
|
|
|
2016-06-18 18:51:53 +00:00
|
|
|
class CreditNote(CreatedUpdatedModel):
|
2016-06-19 19:37:25 +00:00
|
|
|
class Meta:
|
2019-03-29 21:19:49 +00:00
|
|
|
ordering = ["-created"]
|
2016-06-19 19:37:25 +00:00
|
|
|
|
2019-03-29 21:19:49 +00:00
|
|
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
2017-09-17 12:20:21 +00:00
|
|
|
|
2019-03-29 21:19:49 +00:00
|
|
|
text = models.TextField(help_text="Description of what this credit note covers")
|
2017-09-17 12:20:21 +00:00
|
|
|
|
2019-03-29 21:19:49 +00:00
|
|
|
pdf = models.FileField(null=True, blank=True, upload_to="creditnotes/")
|
2017-09-17 12:20:21 +00:00
|
|
|
|
2016-06-18 18:51:53 +00:00
|
|
|
user = models.ForeignKey(
|
2019-03-29 21:19:49 +00:00
|
|
|
"auth.User",
|
|
|
|
verbose_name=_("User"),
|
|
|
|
help_text=_("The user this credit note belongs to, if any."),
|
|
|
|
related_name="creditnotes",
|
2018-03-04 15:26:35 +00:00
|
|
|
on_delete=models.PROTECT,
|
2017-09-17 12:20:21 +00:00
|
|
|
null=True,
|
2019-03-29 21:19:49 +00:00
|
|
|
blank=True,
|
2016-06-18 18:51:53 +00:00
|
|
|
)
|
2017-09-17 12:20:21 +00:00
|
|
|
|
|
|
|
customer = models.TextField(
|
2019-03-29 21:19:49 +00:00
|
|
|
help_text="Customer info if no user is selected", blank=True, default=""
|
2017-09-17 12:20:21 +00:00
|
|
|
)
|
|
|
|
|
2019-03-29 21:19:49 +00:00
|
|
|
danish_vat = models.BooleanField(help_text="Danish VAT?", default=True)
|
2017-09-17 13:00:38 +00:00
|
|
|
|
2016-06-18 18:51:53 +00:00
|
|
|
paid = models.BooleanField(
|
2019-03-29 21:19:49 +00:00
|
|
|
verbose_name=_("Paid?"),
|
|
|
|
help_text=_(
|
|
|
|
"Whether the amount in this creditnote has been paid back to the customer."
|
|
|
|
),
|
2016-06-18 18:51:53 +00:00
|
|
|
default=False,
|
|
|
|
)
|
2017-09-17 12:20:21 +00:00
|
|
|
|
2016-06-18 18:51:53 +00:00
|
|
|
sent_to_customer = models.BooleanField(default=False)
|
|
|
|
|
2017-09-17 12:20:21 +00:00
|
|
|
def clean(self):
|
|
|
|
errors = []
|
|
|
|
if self.user and self.customer:
|
|
|
|
msg = "Customer info should be blank if a user is selected."
|
2019-03-29 21:19:49 +00:00
|
|
|
errors.append(ValidationError({"user", msg}))
|
|
|
|
errors.append(ValidationError({"customer", msg}))
|
2017-09-17 12:20:21 +00:00
|
|
|
if not self.user and not self.customer:
|
|
|
|
msg = "Either pick a user or fill in Customer info"
|
2019-03-29 21:19:49 +00:00
|
|
|
errors.append(ValidationError({"user", msg}))
|
|
|
|
errors.append(ValidationError({"customer", msg}))
|
2017-09-17 12:20:21 +00:00
|
|
|
if errors:
|
|
|
|
raise ValidationError(errors)
|
|
|
|
|
2017-01-31 22:39:49 +00:00
|
|
|
def __str__(self):
|
2017-09-17 12:20:21 +00:00
|
|
|
if self.user:
|
2019-03-29 21:19:49 +00:00
|
|
|
return "creditnoote#%s - %s DKK (customer: user %s)" % (
|
2017-09-17 12:20:21 +00:00
|
|
|
self.id,
|
|
|
|
self.amount,
|
|
|
|
self.user.email,
|
|
|
|
)
|
|
|
|
else:
|
2019-03-29 21:19:49 +00:00
|
|
|
return "creditnoote#%s - %s DKK (customer: %s)" % (
|
2017-09-17 12:20:21 +00:00
|
|
|
self.id,
|
|
|
|
self.amount,
|
|
|
|
self.customer,
|
|
|
|
)
|
2016-06-18 18:51:53 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def vat(self):
|
2017-09-17 13:00:38 +00:00
|
|
|
if self.danish_vat:
|
2019-03-29 21:19:49 +00:00
|
|
|
return Decimal(round(self.amount * Decimal(0.2), 2))
|
2017-09-17 13:00:38 +00:00
|
|
|
else:
|
|
|
|
return 0
|
2016-06-18 18:51:53 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def filename(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return "bornhack_creditnote_%s.pdf" % self.pk
|
2016-06-18 18:51:53 +00:00
|
|
|
|
2016-06-18 18:59:07 +00:00
|
|
|
|
2016-05-17 13:09:40 +00:00
|
|
|
class Invoice(CreatedUpdatedModel):
|
2018-04-03 16:44:10 +00:00
|
|
|
order = models.OneToOneField(
|
2019-03-29 21:19:49 +00:00
|
|
|
"shop.Order", null=True, blank=True, on_delete=models.PROTECT
|
2018-04-03 16:44:10 +00:00
|
|
|
)
|
|
|
|
customorder = models.OneToOneField(
|
2019-03-29 21:19:49 +00:00
|
|
|
"shop.CustomOrder", null=True, blank=True, on_delete=models.PROTECT
|
2018-04-03 16:44:10 +00:00
|
|
|
)
|
2019-03-29 21:19:49 +00:00
|
|
|
pdf = models.FileField(null=True, blank=True, upload_to="invoices/")
|
2016-05-17 13:09:40 +00:00
|
|
|
sent_to_customer = models.BooleanField(default=False)
|
|
|
|
|
2017-01-31 22:39:49 +00:00
|
|
|
def __str__(self):
|
2016-07-12 20:33:53 +00:00
|
|
|
if self.order:
|
2019-03-29 21:19:49 +00:00
|
|
|
return "invoice#%s - shop order %s - %s - total %s DKK (sent to %s: %s)" % (
|
2016-07-12 20:33:53 +00:00
|
|
|
self.id,
|
|
|
|
self.order.id,
|
|
|
|
self.order.created,
|
|
|
|
self.order.total,
|
|
|
|
self.order.user.email,
|
|
|
|
self.sent_to_customer,
|
|
|
|
)
|
|
|
|
elif self.customorder:
|
2019-03-29 21:19:49 +00:00
|
|
|
return (
|
|
|
|
"invoice#%s - custom order %s - %s - amount %s DKK (customer: %s)"
|
|
|
|
% (
|
|
|
|
self.id,
|
|
|
|
self.customorder.id,
|
|
|
|
self.customorder.created,
|
|
|
|
self.customorder.amount,
|
|
|
|
unidecode(self.customorder.customer),
|
|
|
|
)
|
2016-07-12 20:33:53 +00:00
|
|
|
)
|
2016-05-17 13:09:40 +00:00
|
|
|
|
2016-05-30 14:58:55 +00:00
|
|
|
@property
|
|
|
|
def filename(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return "bornhack_invoice_%s.pdf" % self.pk
|
2016-05-30 14:58:55 +00:00
|
|
|
|
2016-05-31 05:42:01 +00:00
|
|
|
def regretdate(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return self.created + timedelta(days=15)
|
2016-05-25 20:48:02 +00:00
|
|
|
|
2016-05-31 06:01:55 +00:00
|
|
|
|
2016-05-25 20:48:02 +00:00
|
|
|
class CoinifyAPIInvoice(CreatedUpdatedModel):
|
2017-05-22 17:41:08 +00:00
|
|
|
coinify_id = models.IntegerField(null=True)
|
2020-08-11 00:35:18 +00:00
|
|
|
invoicejson = models.JSONField()
|
2019-03-29 21:19:49 +00:00
|
|
|
order = models.ForeignKey(
|
|
|
|
"shop.Order", related_name="coinify_api_invoices", on_delete=models.PROTECT
|
|
|
|
)
|
2016-05-25 20:48:02 +00:00
|
|
|
|
2017-01-31 22:39:49 +00:00
|
|
|
def __str__(self):
|
2016-05-31 20:22:17 +00:00
|
|
|
return "coinifyinvoice for order #%s" % self.order.id
|
|
|
|
|
2017-05-22 16:03:09 +00:00
|
|
|
@property
|
|
|
|
def expired(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return parse_datetime(self.invoicejson["expire_time"]) < timezone.now()
|
2017-05-22 16:03:09 +00:00
|
|
|
|
2016-05-31 20:22:17 +00:00
|
|
|
|
2016-05-29 17:33:26 +00:00
|
|
|
class CoinifyAPICallback(CreatedUpdatedModel):
|
2020-08-11 00:35:18 +00:00
|
|
|
headers = models.JSONField()
|
|
|
|
payload = models.JSONField(blank=True)
|
2019-03-29 21:19:49 +00:00
|
|
|
body = models.TextField(default="")
|
|
|
|
order = models.ForeignKey(
|
|
|
|
"shop.Order", related_name="coinify_api_callbacks", on_delete=models.PROTECT
|
|
|
|
)
|
2017-05-22 17:41:08 +00:00
|
|
|
authenticated = models.BooleanField(default=False)
|
2016-05-25 20:48:02 +00:00
|
|
|
|
2017-01-31 22:39:49 +00:00
|
|
|
def __str__(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return "order #%s callback at %s" % (self.order.id, self.created)
|
2016-05-25 20:48:02 +00:00
|
|
|
|
2016-05-25 20:53:02 +00:00
|
|
|
|
2017-05-22 17:41:08 +00:00
|
|
|
class CoinifyAPIRequest(CreatedUpdatedModel):
|
2019-03-29 21:19:49 +00:00
|
|
|
order = models.ForeignKey(
|
|
|
|
"shop.Order", related_name="coinify_api_requests", on_delete=models.PROTECT
|
|
|
|
)
|
2017-05-22 17:41:08 +00:00
|
|
|
method = models.CharField(max_length=100)
|
2020-08-11 00:35:18 +00:00
|
|
|
payload = models.JSONField()
|
|
|
|
response = models.JSONField()
|
2017-05-22 17:41:08 +00:00
|
|
|
|
|
|
|
def __str__(self):
|
2019-03-29 21:19:49 +00:00
|
|
|
return "order %s api request %s" % (self.order.id, self.method)
|