Skip to content

Commit

Permalink
Merge branch 'main' into 120-availability-comments
Browse files Browse the repository at this point in the history
  • Loading branch information
Kurocon committed Oct 11, 2024
2 parents c0b8b13 + 2d420e9 commit 383bdd7
Show file tree
Hide file tree
Showing 19 changed files with 1,175 additions and 199 deletions.
50 changes: 49 additions & 1 deletion alexia/api/v1/methods/juliana.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
ForbiddenError, InvalidParamsError, ObjectNotFoundError,
)
from alexia.apps.billing.models import (
Authorization, Order, Product, Purchase, RfidCard,
Authorization, Order, Product, Purchase, RfidCard, WriteoffCategory, WriteOffOrder, WriteOffPurchase
)
from alexia.apps.scheduling.models import Event

Expand Down Expand Up @@ -186,3 +186,51 @@ def juliana_user_check(request, event_id, user_id):
return int(order_sum * 100)
else:
return 0

@jsonrpc_method('juliana.writeoff.save(Number,Number,Array) -> Nil', site=api_v1_site, authenticated=True)
@transaction.atomic
def juliana_writeoff_save(request, event_id, writeoff_id, purchases):
"""Saves a writeoff order in the Database"""
event = _get_validate_event(request, event_id)

try:
writeoff_cat = WriteoffCategory.objects.get(id=writeoff_id)
except WriteoffCategory.DoesNotExist:
raise InvalidParamsError('Writeoff Category %s not found' % writeoff_id)

order = WriteOffOrder(event=event, added_by=request.user, writeoff_category=writeoff_cat)
order.save()

for p in purchases:
try:
product = Product.objects.get(pk=p['product'])
except Product.DoesNotExist:
raise InvalidParamsError('Product %s not found' % p['product'])

if product.is_permanent:
product = product.permanentproduct
if product.organization != event.organizer \
or product.productgroup not in event.pricegroup.productgroups.all():
raise InvalidParamsError('Product %s is not available for this event' % p['product'])
elif product.is_temporary:
product = product.temporaryproduct
if event != product.event:
raise InvalidParamsError('Product %s is not available for this event' % p['product'])
else:
raise OtherError('Product %s is broken' % p['product'])

amount = p['amount']

if p['amount'] <= 0:
raise InvalidParamsError('Zero or negative amount not allowed')

price = amount * product.get_price(event)

if price != p['price'] / Decimal(100):
raise InvalidParamsError('Price for product %s is incorrect' % p['product'])

purchase = WriteOffPurchase(order=order, product=product.name, amount=amount, price=price)
purchase.save()

order.save(force_update=True) # ensure order.amount is correct
return True
34 changes: 33 additions & 1 deletion alexia/apps/billing/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from .models import (
Authorization, Order, PermanentProduct, PriceGroup, ProductGroup, Purchase,
RfidCard, SellingPrice,
RfidCard, SellingPrice, WriteOffOrder, WriteOffPurchase
)


Expand All @@ -18,6 +18,7 @@ class AuthorizationAdmin(admin.ModelAdmin):
class PurchaseInline(admin.TabularInline):
model = Purchase
can_delete = False
extra = 0

def get_readonly_fields(self, request, obj=None):
if obj and obj.synchronized:
Expand Down Expand Up @@ -50,6 +51,37 @@ def save_formset(self, request, form, formset, change):
form.instance.save() # Updates Order.amount


class WriteOffPurchaseInline(admin.TabularInline):
model = WriteOffPurchase
can_delete = False
extra = 0

def get_readonly_fields(self, request, obj=None):
if obj:
return self.readonly_fields + ('product', 'amount', 'price')
return self.readonly_fields


@admin.register(WriteOffOrder)
class WriteOffOrderAdmin(admin.ModelAdmin):
date_hierarchy = 'placed_at'
inlines = [WriteOffPurchaseInline]
list_display = ['event', 'placed_at', 'amount']
list_filter = ['placed_at']
raw_id_fields = ['added_by', 'event']
readonly_fields = ['placed_at']

def get_readonly_fields(self, request, obj=None):
return self.readonly_fields

def has_delete_permission(self, request, obj=None):
return False

def save_formset(self, request, form, formset, change):
formset.save()
form.instance.save() # Updates Order.amount


class SellingPriceInline(admin.TabularInline):
model = SellingPrice
extra = 1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Generated by Django 2.2.28 on 2024-10-11 10:06

import alexia.core.validators
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):

dependencies = [
('organization', '0025_organization_writeoff_enabled'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('scheduling', '0021_auto_20240926_1621'),
('billing', '0018_product_shortcut'),
]

operations = [
migrations.CreateModel(
name='WriteoffCategory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, verbose_name='name')),
('description', models.CharField(max_length=80, verbose_name='short description')),
('color', models.CharField(blank=True, max_length=6, validators=[alexia.core.validators.validate_color], verbose_name='color')),
('is_active', models.BooleanField(default=False, verbose_name='active')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='organization.Organization', verbose_name='organization')),
],
),
migrations.CreateModel(
name='WriteOffOrder',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('placed_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='placed at')),
('amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='amount')),
('added_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='added by')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_orders', to='scheduling.Event', verbose_name='event')),
('writeoff_category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='billing.WriteoffCategory', verbose_name='writeoff category')),
],
options={
'verbose_name': 'writeoff order',
'verbose_name_plural': 'writeoff orders',
'ordering': ['-placed_at'],
},
),
migrations.CreateModel(
name='WriteOffPurchase',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product', models.CharField(max_length=32, verbose_name='product')),
('amount', models.PositiveSmallIntegerField(verbose_name='amount')),
('price', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='price')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='writeoff_purchases', to='billing.WriteOffOrder', verbose_name='order')),
],
options={
'verbose_name': 'purchase',
'verbose_name_plural': 'purchases',
},
),
]
94 changes: 94 additions & 0 deletions alexia/apps/billing/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import unicode_literals

from collections import defaultdict
from decimal import Decimal

from django.conf import settings
Expand Down Expand Up @@ -331,3 +332,96 @@ class Meta:

def __str__(self):
return '%s x %s' % (self.amount, self.product)

@python_2_unicode_compatible
class WriteoffCategory(models.Model):
name = models.CharField(_('name'), max_length=20)
description = models.CharField(_('short description'), max_length=80)
color = models.CharField(verbose_name=_('color'), blank=True, max_length=6, validators=[validate_color])
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
verbose_name=_('organization')
)
is_active = models.BooleanField(_('active'), default=False)

def __str__(self):
return _('{name} for {organization}').format(
name=self.name,
organization=self.organization
)

@python_2_unicode_compatible
class WriteOffOrder(models.Model):
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='writeoff_orders', verbose_name=_('event'))
placed_at = models.DateTimeField(_('placed at'), default=timezone.now)
added_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
verbose_name=_('added by'),
related_name='+',
)
amount = models.DecimalField(_('amount'), max_digits=15, decimal_places=2)

writeoff_category = models.ForeignKey(
WriteoffCategory,
on_delete=models.PROTECT, # We cannot delete purchases
verbose_name=_('writeoff category')
)

class Meta:
ordering = ['-placed_at']
verbose_name = _('writeoff order')
verbose_name_plural = _('writeoff orders')

def __str__(self):
return _('{time} on {event}').format(
time=self.placed_at.strftime('%H:%M'),
event=self.event.name,
)

def save(self, *args, **kwargs):
self.amount = self.get_price()
super(WriteOffOrder, self).save(*args, **kwargs)

def get_price(self):
amount = Decimal('0.0')
for purchase in self.writeoff_purchases.all():
amount += purchase.price
return amount


@python_2_unicode_compatible
class WriteOffPurchase(models.Model):
order = models.ForeignKey(WriteOffOrder, on_delete=models.CASCADE, related_name='writeoff_purchases', verbose_name=_('order'))
product = models.CharField(_('product'), max_length=32)
amount = models.PositiveSmallIntegerField(_('amount'))
price = models.DecimalField(_('price'), max_digits=15, decimal_places=2)

class Meta:
verbose_name = _('purchase')
verbose_name_plural = _('purchases')

def __str__(self):
return '%s x %s' % (self.amount, self.product)

@classmethod
def get_writeoff_products(cls, event):
writeoff_products = WriteOffPurchase.objects.filter(order__event=event) \
.values('order__writeoff_category__name', 'order__writeoff_category__description', 'product') \
.annotate(total_amount=models.Sum('amount'), total_price=models.Sum('price')) \
.order_by('order__writeoff_category__name', 'product')

# Group writeoffs by category
grouped_writeoff_products = defaultdict(lambda: {'products': [], 'total_amount': 0, 'total_price': 0, 'description': ''})

# calculate total product amount and total price per category
for product in writeoff_products:
category_name = product['order__writeoff_category__name']
grouped_writeoff_products[category_name]['description'] = product['order__writeoff_category__description']
grouped_writeoff_products[category_name]['products'].append(product)
grouped_writeoff_products[category_name]['total_amount'] += product['total_amount']
grouped_writeoff_products[category_name]['total_price'] += product['total_price']

# Convert defaultdict to a regular dict for easier template use
return dict(grouped_writeoff_products)
2 changes: 2 additions & 0 deletions alexia/apps/billing/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
urlpatterns = [
url(r'^order/$', views.OrderListView.as_view(), name='orders'),
url(r'^order/(?P<pk>[0-9]+)/$', views.OrderDetailView.as_view(), name='event-orders'),
url(r'^order/writeoff/(?P<pk>[0-9]+)/$', views.WriteOffExportView.as_view(), name='writeoff_export'),
url(r'^writeoff/(?P<pk>[0-9]+)/$', views.WriteOffDetailView.as_view(), name='writeoff-order'),
url(r'^order/export/$', views.OrderExportView.as_view(), name='export-orders'),
url(r'^stats/(?P<year>[0-9]{4})/$', views.OrderYearView.as_view(), name='year-orders'),
url(r'^stats/(?P<year>[0-9]{4})/(?P<month>[0-9]{1,2})/$', views.OrderMonthView.as_view(), name='month-orders'),
Expand Down
55 changes: 52 additions & 3 deletions alexia/apps/billing/views.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.core import serializers
from django.db.models import Count, Sum
from django.db.models.functions import ExtractYear, TruncMonth
from django.http import Http404, HttpResponseRedirect
from django.http import Http404, JsonResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse, reverse_lazy
from django.utils.dates import MONTHS
from django.utils.translation import ugettext as _
from django.views import View
from django.views.generic.base import RedirectView, TemplateView
from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.edit import (
Expand All @@ -33,8 +35,8 @@
OrganizationFilterMixin, OrganizationFormMixin,
)

from .models import Order, Purchase

from .models import Order, Purchase, WriteOffOrder, WriteOffPurchase, WriteoffCategory
import json

class JulianaView(TenderRequiredMixin, DetailView):
template_name = 'billing/juliana.html'
Expand All @@ -57,6 +59,8 @@ def get_context_data(self, **kwargs):
'products': self.get_product_list(),
'countdown': settings.JULIANA_COUNTDOWN if hasattr(settings, 'JULIANA_COUNTDOWN') else 5,
'androidapp': self.request.META.get('HTTP_X_REQUESTED_WITH') == 'net.inter_actief.juliananfc',
'writeoff': self.object.organizer.writeoff_enabled,
'writeoff_categories' : WriteoffCategory.objects.filter(organization=self.object.organizer, is_active=True)
})
return context

Expand Down Expand Up @@ -126,15 +130,60 @@ def get_context_data(self, **kwargs):
.values('product') \
.annotate(amount=Sum('amount'), price=Sum('price'))

writeoff_exists = self.object.writeoff_orders.exists

grouped_writeoff_products = None
writeoff_orders = None
if writeoff_exists:
grouped_writeoff_products = WriteOffPurchase.get_writeoff_products(event=self.object)
writeoff_orders = WriteOffOrder.objects.filter(event=self.object).order_by('-placed_at')

context = super(OrderDetailView, self).get_context_data(**kwargs)
context.update({
'orders': self.object.orders.select_related('authorization__user').order_by('-placed_at'),
'writeoff_orders': writeoff_orders,
'products': products,
'revenue': products.aggregate(Sum('price'))['price__sum'],
'writeoff_exists': writeoff_exists,
'grouped_writeoff_data': grouped_writeoff_products
})

return context


class WriteOffDetailView(LoginRequiredMixin, DetailView):
model = WriteOffOrder
template_name = 'billing/writeoff_order_detail.html'

def get_object(self, queryset=None):
obj = super(WriteOffDetailView, self).get_object(queryset)

if not self.request.user.is_superuser \
and not self.request.user.profile.is_manager(obj.authorization.organization) \
and not obj.authorization.user == self.request.user:
raise PermissionDenied

return obj


class WriteOffExportView(ManagerRequiredMixin, DenyWrongOrganizationMixin, View):
organization_field = 'organizer'

# get event
def get(self, request, *args, **kwargs):
event_pk = kwargs.get('pk') # Extract the pk from kwargs
event = get_object_or_404(Event, pk=event_pk) # Fetch the Event or raise 404 if not found

writeoff_exists = event.writeoff_orders.exists

if not writeoff_exists:
raise Http404

grouped_writeoff_products = WriteOffPurchase.get_writeoff_products(event=event)

return JsonResponse(grouped_writeoff_products)


class OrderExportView(ManagerRequiredMixin, FormView):
template_name = 'billing/order_export_form.html'
form_class = FilterEventForm
Expand Down
Loading

0 comments on commit 383bdd7

Please sign in to comment.