from django import forms from django.urls import re_path as url from django.contrib import admin, messages from django.contrib.admin.utils import unquote from django.urls import reverse from django.db import models from django.db.models import F, Sum, Prefetch from django.db.models.functions import Coalesce from django.templatetags.static import static from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django.shortcuts import redirect from orchestra.admin import ExtendedModelAdmin from orchestra.admin.utils import admin_date, insertattr, admin_link, change_url from orchestra.contrib.accounts.actions import list_accounts from orchestra.contrib.accounts.admin import AccountAdminMixin, AccountAdmin from orchestra.forms.widgets import PaddingCheckboxSelectMultiple from . import settings, actions from .filters import (BillTypeListFilter, HasBillContactListFilter, TotalListFilter, PaymentStateListFilter, AmendedListFilter) from .models import (Bill, Invoice, AmendmentInvoice, AbonoInvoice, Fee, AmendmentFee, ProForma, BillLine, BillSubline, BillContact) PAYMENT_STATE_COLORS = { Bill.OPEN: 'grey', Bill.CREATED: 'magenta', Bill.PROCESSED: 'darkorange', Bill.AMENDED: 'blue', Bill.PAID: 'green', Bill.EXECUTED: 'olive', Bill.BAD_DEBT: 'red', Bill.INCOMPLETE: 'red', } class BillSublineInline(admin.TabularInline): model = BillSubline fields = ('description', 'total', 'type') def get_readonly_fields(self, request, obj=None): fields = super().get_readonly_fields(request, obj) if obj and not obj.bill.is_open: return self.get_fields(request) return fields def get_max_num(self, request, obj=None): if obj and not obj.bill.is_open: return 0 return super().get_max_num(request, obj) def has_delete_permission(self, request, obj=None): if obj and not obj.bill.is_open: return False return super().has_delete_permission(request, obj) class BillLineInline(admin.TabularInline): model = BillLine fields = ( 'description', 'order_link', 'start_on', 'end_on', 'rate', 'quantity', 'tax', 'subtotal', 'display_total', ) readonly_fields = ('display_total', 'order_link') order_link = admin_link('order', display='pk') @admin.display( description=_("Total") ) @mark_safe def display_total(self, line): if line.pk: total = line.compute_total() sublines = line.sublines.all() url = change_url(line) if sublines: content = '\n'.join(['%s: %s' % (sub.description, sub.total) for sub in sublines]) img = static('admin/img/icon-alert.svg') return '%s ' % (url, content, total, img) return '%s' % (url, total) def formfield_for_dbfield(self, db_field, **kwargs): """ Make value input widget bigger """ if db_field.name == 'description': kwargs['widget'] = forms.TextInput(attrs={'size':'50'}) elif db_field.name not in ('start_on', 'end_on'): kwargs['widget'] = forms.TextInput(attrs={'size':'6'}) return super().formfield_for_dbfield(db_field, **kwargs) def get_queryset(self, request): qs = super().get_queryset(request) return qs.prefetch_related('sublines').select_related('order') class ClosedBillLineInline(BillLineInline): # TODO reimplement as nested inlines when upstream # https://code.djangoproject.com/ticket/9025 fields = ( 'display_description', 'order_link', 'start_on', 'end_on', 'rate', 'quantity', 'tax', 'display_subtotal', 'display_total' ) readonly_fields = fields can_delete = False @admin.display( description=_("Description") ) @mark_safe def display_description(self, line): descriptions = [line.description] for subline in line.sublines.all(): descriptions.append(' ' * 4 + subline.description) return '
'.join(descriptions) @admin.display( description=_("Subtotal") ) @mark_safe def display_subtotal(self, line): subtotals = [' ' + str(line.subtotal)] for subline in line.sublines.all(): subtotals.append(str(subline.total)) return '
'.join(subtotals) @admin.display( description=_("Total") ) def display_total(self, line): if line.pk: return line.compute_total() def has_add_permission(self, request, obj): return False @admin.register(BillLine) class BillLineAdmin(admin.ModelAdmin): list_display = ( 'description', 'bill_link', 'display_is_open', 'account_link', 'rate', 'quantity', 'tax', 'subtotal', 'display_sublinetotal', 'display_total' ) actions = ( actions.undo_billing, actions.move_lines, actions.copy_lines, actions.service_report, actions.list_bills, ) fieldsets = ( (None, { 'fields': ('bill_link', 'description', 'tax', 'start_on', 'end_on', 'amended_line_link') }), (_("Totals"), { 'fields': ('rate', ('quantity', 'verbose_quantity'), 'subtotal', 'display_sublinetotal', 'display_total'), }), (_("Order"), { 'fields': ('order_link', 'order_billed_on', 'order_billed_until',) }), ) readonly_fields = ( 'bill_link', 'order_link', 'amended_line_link', 'display_sublinetotal', 'display_total' ) list_filter = ('tax', 'bill__is_open', 'order__service') list_select_related = ('bill', 'bill__account') search_fields = ('description', 'bill__number') inlines = (BillSublineInline,) account_link = admin_link('bill__account') bill_link = admin_link('bill') order_link = admin_link('order') amended_line_link = admin_link('amended_line') @admin.display( description=_("Is open"), boolean=True, ) def display_is_open(self, instance): return instance.bill.is_open @admin.display( description=_("Sublines"), ordering='subline_total', ) def display_sublinetotal(self, instance): total = instance.subline_total return total if total is not None else '---' @admin.display( description=_("Total"), ordering='computed_total', ) def display_total(self, instance): return round(instance.computed_total or 0, 2) def get_readonly_fields(self, request, obj=None): fields = super().get_readonly_fields(request, obj) if obj and not obj.bill.is_open: return list(fields) + [ 'description', 'tax', 'start_on', 'end_on', 'rate', 'quantity', 'verbose_quantity', 'subtotal', 'order_billed_on', 'order_billed_until' ] return fields def get_queryset(self, request): qs = super().get_queryset(request) qs = qs.annotate( subline_total=Sum('sublines__total'), computed_total=(F('subtotal') + Sum(Coalesce('sublines__total', 0))) * (1+F('tax')/100), ) return qs def has_delete_permission(self, request, obj=None): if obj and not obj.bill.is_open: return False return super().has_delete_permission(request, obj) class BillLineManagerAdmin(BillLineAdmin): def get_queryset(self, request): qset = super().get_queryset(request) if self.bill_ids: return qset.filter(bill_id__in=self.bill_ids) return qset def changelist_view(self, request, extra_context=None): GET_copy = request.GET.copy() bill_ids = GET_copy.pop('ids', None) if bill_ids: bill_ids = bill_ids[0] request.GET = GET_copy bill_ids = list(map(int, bill_ids.split(','))) else: messages.error(request, _("No bills selected.")) return redirect('..') self.bill_ids = bill_ids bill = None if len(bill_ids) == 1: bill_url = reverse('admin:bills_bill_change', args=(bill_ids[0],)) bill = Bill.objects.get(pk=bill_ids[0]) bill_link = '%s' % (bill_url, bill.number) title = mark_safe(_("Manage %s bill lines") % bill_link) if not bill.is_open: messages.warning(request, _("Bill not in open state.")) else: if Bill.objects.filter(id__in=bill_ids, is_open=False).exists(): messages.warning(request, _("Not all bills are in open state.")) title = _("Manage bill lines of multiple bills") context = { 'title': title, 'bill': bill, } context.update(extra_context or {}) return super().changelist_view(request, context) class BillAdminMixin(AccountAdminMixin): @admin.display( description=_("total"), ordering='approx_total', ) @mark_safe def display_total_with_subtotals(self, bill): if bill.pk: currency = settings.BILLS_CURRENCY.lower() subtotals = [] for tax, subtotal in bill.compute_subtotals().items(): subtotals.append(_("Subtotal %s%% VAT %s &%s;") % (tax, subtotal[0], currency)) subtotals.append(_("Taxes %s%% VAT %s &%s;") % (tax, subtotal[1], currency)) subtotals = '\n'.join(subtotals) return '%s &%s;' % (subtotals, bill.compute_total(), currency) @admin.display( description=_("Payment") ) @mark_safe def display_payment_state(self, bill): if bill.pk: t_opts = bill.transactions.model._meta if bill.get_type() == bill.PROFORMA: return '---' transactions = bill.transactions.all() if len(transactions) == 1: args = (transactions[0].pk,) view = 'admin:%s_%s_change' % (t_opts.app_label, t_opts.model_name) url = reverse(view, args=args) else: url = reverse('admin:%s_%s_changelist' % (t_opts.app_label, t_opts.model_name)) url += '?bill=%i' % bill.pk state = bill.get_payment_state_display().upper() title = '' if bill.closed_amends: state = '%s*' % state title = _("This bill has been amended, this value may not be valid.") color = PAYMENT_STATE_COLORS.get(bill.payment_state, 'grey') return '{name}'.format( url=url, color=color, name=state, title=title) def get_queryset(self, request): qs = super().get_queryset(request) qs = qs.annotate( models.Count('lines'), # FIXME https://code.djangoproject.com/ticket/10060 approx_total=Coalesce(Sum( (F('lines__subtotal') + Coalesce('lines__sublines__total', 0)) * (1+F('lines__tax')/100), ), 0), ) qs = qs.prefetch_related( Prefetch('amends', queryset=Bill.objects.filter(is_open=False), to_attr='closed_amends') ) return qs.defer('html') class AmendInline(BillAdminMixin, admin.TabularInline): model = Bill fields = ( 'self_link', 'type', 'display_total_with_subtotals', 'display_payment_state', 'is_open', 'is_sent' ) readonly_fields = fields verbose_name_plural = _("Amends") can_delete = False extra = 0 self_link = admin_link('__str__') def has_add_permission(self, *args, **kwargs): return False @admin.register(AbonoInvoice, AmendmentFee, AmendmentInvoice, Bill, Fee, Invoice, ProForma) class BillAdmin(BillAdminMixin, ExtendedModelAdmin): list_display = ( 'number', 'type_link', 'account_link', 'closed_on_display', 'updated_on_display', 'num_lines', 'display_total', 'display_payment_state', 'is_sent' ) list_filter = ( BillTypeListFilter, 'is_open', 'is_sent', TotalListFilter, PaymentStateListFilter, AmendedListFilter, 'account__is_active', ) add_fields = ('account', 'type', 'amend_of', 'is_open', 'due_on', 'comments') change_list_template = 'admin/bills/bill/change_list.html' fieldsets = ( (None, { 'fields': ['number', 'type', (), 'account_link', 'display_total_with_subtotals', 'display_payment_state', 'is_sent', 'comments'], }), (_("Dates"), { 'classes': ('collapse',), 'fields': ('created_on_display', 'closed_on_display', 'updated_on_display', 'due_on'), }), (_("Raw"), { 'classes': ('collapse',), 'fields': ('html',), }), ) list_prefetch_related = ('transactions', 'lines__sublines') search_fields = ('number', 'account__username', 'comments') change_view_actions = [ actions.manage_lines, actions.view_bill, actions.download_bills, actions.send_bills, actions.close_bills, actions.amend_bills, actions.close_send_download_bills, ] actions = [ actions.manage_lines, actions.download_bills, actions.close_bills, actions.send_bills, actions.amend_bills, actions.bill_report, actions.service_report, actions.close_send_download_bills, list_accounts, ] change_readonly_fields = ('account_link', 'type', 'is_open', 'amend_of_link') readonly_fields = ( 'number', 'display_total', 'is_sent', 'display_payment_state', 'created_on_display', 'closed_on_display', 'updated_on_display', 'display_total_with_subtotals', ) date_hierarchy = 'closed_on' created_on_display = admin_date('created_on', short_description=_("Created")) closed_on_display = admin_date('closed_on', short_description=_("Closed")) updated_on_display = admin_date('updated_on', short_description=_("Updated")) amend_of_link = admin_link('amend_of') # def amend_links(self, bill): # links = [] # for amend in bill.amends.all(): # url = reverse('admin:bills_bill_change', args=(amend.id,)) # links.append('{num}'.format(url=url, num=amend.number)) # return '
'.join(links) # amend_links.short_description = _("Amends") # amend_links.allow_tags = True @admin.display( description=_("lines"), ordering='lines__count', ) def num_lines(self, bill): return bill.lines__count @admin.display( description=_("total"), ordering='approx_total', ) def display_total(self, bill): currency = settings.BILLS_CURRENCY.lower() return format_html('{} &{};', bill.compute_total(), currency) @admin.display( description=_("type"), ordering='type', ) def type_link(self, bill): bill_type = bill.type.lower() url = reverse('admin:bills_%s_changelist' % bill_type) return format_html('{}', url, bill.get_type_display()) def get_urls(self): """ Hook bill lines management URLs on bill admin """ urls = super().get_urls() admin_site = self.admin_site extra_urls = [ url("^manage-lines/$", admin_site.admin_view(BillLineManagerAdmin(BillLine, admin_site).changelist_view), name='bills_bill_manage_lines'), ] return extra_urls + urls def get_readonly_fields(self, request, obj=None): fields = super().get_readonly_fields(request, obj) if obj and not obj.is_open: fields += self.add_fields return fields def get_fieldsets(self, request, obj=None): fieldsets = super().get_fieldsets(request, obj) if obj: # Switches between amend_of_link and amend_links fields fields = fieldsets[0][1]['fields'] if obj.amend_of_id: fields[2] = 'amend_of_link' else: fields[2] = () if obj.is_open: fieldsets = fieldsets[0:-1] return fieldsets def get_change_view_actions(self, obj=None): actions = super().get_change_view_actions(obj) exclude = [] if obj: if not obj.is_open: exclude += ['close_bills', 'close_send_download_bills'] if obj.type not in obj.AMEND_MAP: exclude += ['amend_bills'] return [action for action in actions if action.__name__ not in exclude] def get_inline_instances(self, request, obj=None): cls = type(self) if obj and not obj.is_open: if obj.amends.all(): cls.inlines = [AmendInline, ClosedBillLineInline] else: cls.inlines = [ClosedBillLineInline] else: cls.inlines = [BillLineInline] return super().get_inline_instances(request, obj) def formfield_for_dbfield(self, db_field, **kwargs): """ Make value input widget bigger """ if db_field.name == 'comments': kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4}) elif db_field.name == 'html': kwargs['widget'] = forms.Textarea(attrs={'cols': 150, 'rows': 20}) formfield = super().formfield_for_dbfield(db_field, **kwargs) if db_field.name == 'amend_of': formfield.queryset = formfield.queryset.filter(is_open=False) return formfield def change_view(self, request, object_id, **kwargs): # TODO raise404, here and everywhere bill = self.get_object(request, unquote(object_id)) actions.validate_contact(request, bill, error=False) return super().change_view(request, object_id, **kwargs) class BillContactInline(admin.StackedInline): model = BillContact fields = ('name', 'address', ('city', 'zipcode'), 'country', 'vat') def formfield_for_dbfield(self, db_field, **kwargs): """ Make value input widget bigger """ if db_field.name == 'name': kwargs['widget'] = forms.TextInput(attrs={'size':'90'}) if db_field.name == 'address': kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2}) if db_field.name == 'email_usage': kwargs['widget'] = PaddingCheckboxSelectMultiple(45) return super().formfield_for_dbfield(db_field, **kwargs) @admin.display( boolean=True, ordering='billcontact', ) def has_bill_contact(account): return hasattr(account, 'billcontact') insertattr(AccountAdmin, 'inlines', BillContactInline) insertattr(AccountAdmin, 'list_display', has_bill_contact) insertattr(AccountAdmin, 'list_filter', HasBillContactListFilter) insertattr(AccountAdmin, 'list_select_related', 'billcontact')