Coverage for jacc/admin.py : 46%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# pylint: disable=protected-access
2import logging
3import traceback
4from datetime import datetime
5from decimal import Decimal
6from typing import Optional, List, Sequence, Any, Dict
7from django.contrib import messages
8from django.contrib.admin import SimpleListFilter
9from django.contrib.messages import add_message, INFO
10from django.db.models.functions import Coalesce
11from django import forms
12from django.shortcuts import render
13from django.urls import reverse, ResolverMatch
14from django.utils.formats import date_format
15from django.utils.html import format_html
16from django.utils.safestring import mark_safe
17from django.utils.text import format_lazy
19from jacc.forms import ReverseChargeForm
20from jacc.models import Account, AccountEntry, Invoice, AccountType, EntryType, AccountEntrySourceFile, INVOICE_STATE
21from django.conf import settings
22from django.conf.urls import url
23from django.contrib import admin
24from django.contrib.auth.models import User
25from django.core.exceptions import ValidationError
26from django.db.models import QuerySet, Sum, Count
27from django.http import HttpRequest
28from django.utils.translation import gettext_lazy as _
29from jacc.settle import settle_assigned_invoice
30from jutil.admin import ModelAdminBase, admin_log
31from jutil.format import choices_label, dec2
32from jutil.model import clone_model
34logger = logging.getLogger(__name__)
37def align_lines(lines: list, column_separator: str = '|') -> list:
38 """
39 Pads lines so that all rows in single column match. Columns separated by '|' in every line.
40 :param lines: list of lines
41 :param column_separator: column separator. default is '|'
42 :return: list of lines
43 """
44 rows = []
45 col_len: List[int] = []
46 for line in lines:
47 line = str(line)
48 cols = []
49 for col_index, col in enumerate(line.split(column_separator)):
50 col = str(col).strip()
51 cols.append(col)
52 if col_index >= len(col_len):
53 col_len.append(0)
54 col_len[col_index] = max(col_len[col_index], len(col))
55 rows.append(cols)
57 lines_out: List[str] = []
58 for row in rows:
59 cols_out = []
60 for col_index, col in enumerate(row):
61 if col_index == 0:
62 col = col.ljust(col_len[col_index])
63 else:
64 col = col.rjust(col_len[col_index])
65 cols_out.append(col)
66 lines_out.append(' '.join(cols_out))
67 return lines_out
70def refresh_cached_fields(modeladmin, request, qs): # pylint: disable=unused-argument
71 for e in qs:
72 e.update_cached_fields()
73 add_message(request, messages.SUCCESS, 'Cached fields refreshed ({})'.format(qs.count()))
76def summarize_account_entries(modeladmin, request, qs): # pylint: disable=unused-argument
77 # {total_count} entries:
78 # {amount1} {currency} x {count1} = {total1} {currency}
79 # {amount2} {currency} x {count2} = {total2} {currency}
80 # Total {total_amount} {currency}
81 e_type_entries = list(qs.distinct('type').order_by('type'))
82 total_debits = Decimal('0.00')
83 total_credits = Decimal('0.00')
84 lines = ['<pre>',
85 _('({total_count} account entries)').format(total_count=qs.count())]
86 for e_type_entry in e_type_entries:
87 assert isinstance(e_type_entry, AccountEntry)
88 e_type = e_type_entry.type
89 assert isinstance(e_type, EntryType)
91 qs2 = qs.filter(type=e_type)
92 res_debit = qs2.filter(amount__gt=0).aggregate(total=Coalesce(Sum('amount'), 0), count=Count('amount'))
93 res_credit = qs2.filter(amount__lt=0).aggregate(total=Coalesce(Sum('amount'), 0), count=Count('amount'))
94 lines.append('{type_name} (debit) | x{count} | {total:.2f}'.format(type_name=e_type.name, **res_debit))
95 lines.append('{type_name} (credit) | x{count} | {total:.2f}'.format(type_name=e_type.name, **res_credit))
96 total_debits += res_debit['total']
97 total_credits += res_credit['total']
99 lines.append(_('Total debits {total_debits:.2f} | - total credits {total_credits:.2f} | = {total_amount:.2f}').format(
100 total_debits=total_debits, total_credits=total_credits, total_amount=total_debits + total_credits))
101 lines = align_lines(lines, '|')
102 messages.add_message(request, INFO, format_html('<br>'.join(lines)), extra_tags='safe')
105class SettlementAccountEntryFilter(SimpleListFilter):
106 title = _('settlement')
107 parameter_name = 'is_settlement'
109 def lookups(self, request, model_admin):
110 return [
111 ('1', _('settlement')),
112 ('0', _('not settlement')),
113 ]
115 def queryset(self, request, queryset):
116 val = self.value()
117 if val:
118 if val == '1':
119 queryset = queryset.filter(parent=None, type__is_settlement=True)
120 elif val == '0':
121 queryset = queryset.exclude(type__is_settlement=True)
122 return queryset
125class EntryTypeAccountEntryFilter(SimpleListFilter):
126 title = _('account entry type')
127 parameter_name = 'type'
129 def lookups(self, request, model_admin):
130 a = []
131 for e in EntryType.objects.all().filter(is_settlement=True).order_by('name'):
132 a.append((e.code, e.name))
133 return a
135 def queryset(self, request, queryset):
136 val = self.value()
137 if val:
138 queryset = queryset.filter(type__code=val)
139 return queryset
142class AccountTypeAccountEntryFilter(SimpleListFilter):
143 title = _('account type')
144 parameter_name = 'atype'
146 def lookups(self, request, model_admin):
147 a = []
148 for e in AccountType.objects.all().order_by('name'):
149 a.append((e.code, e.name))
150 return a
152 def queryset(self, request, queryset):
153 val = self.value()
154 if val:
155 queryset = queryset.filter(account__type__code=val)
156 return queryset
159def add_reverse_charge(modeladmin, request, qs):
160 assert hasattr(modeladmin, 'reverse_charge_form')
161 assert hasattr(modeladmin, 'reverse_charge_template')
163 cx: Dict[str, Any] = {}
164 try:
165 if qs.count() != 1:
166 raise ValidationError(_('Exactly one account entry must be selected'))
167 e = qs.first()
168 assert isinstance(e, AccountEntry)
169 if e.amount is None or dec2(e.amount) == Decimal('0.00'):
170 raise ValidationError(_('Exactly one account entry must be selected'))
172 form_cls = modeladmin.reverse_charge_form # Type: ignore
173 initial = {
174 'amount': -e.amount,
175 'description': form_cls().fields['description'].initial,
176 }
177 if e.description:
178 initial['description'] += ' / {}'.format(e.description)
179 cx = {
180 'qs': qs,
181 'original': e,
182 }
184 if 'save' in request.POST:
185 cx['form'] = form = form_cls(request.POST, initial=initial)
186 if not form.is_valid():
187 raise ValidationError(form.errors)
188 timestamp = form.cleaned_data['timestamp']
189 amount = form.cleaned_data['amount']
190 description = form.cleaned_data['description']
191 reverse_e = clone_model(e, parent=e.parent, amount=amount, description=description, timestamp=timestamp,
192 commit=False)
193 reverse_e.full_clean()
194 reverse_e.save()
195 messages.info(request, '{} {}'.format(reverse_e, _('created')))
196 else:
197 cx['form'] = form = form_cls(initial=initial)
198 return render(request, modeladmin.reverse_charge_template, context=cx)
199 except ValidationError as e:
200 if cx:
201 return render(request, modeladmin.reverse_charge_template, context=cx)
202 messages.error(request, '{}\n'.join(e.messages))
203 except Exception as e:
204 logger.error('add_reverse_charge: %s', traceback.format_exc())
205 messages.error(request, '{}'.format(e))
206 return None
209class AccountEntryAdminForm(forms.ModelForm):
210 def clean(self):
211 if self.instance.archived:
212 raise ValidationError(_('cannot.modify.archived.account.entry'))
213 return super().clean()
216class AccountEntryAdmin(ModelAdminBase):
217 form = AccountEntryAdminForm
218 date_hierarchy = 'timestamp'
219 list_per_page = 50
220 reverse_charge_form = ReverseChargeForm
221 reverse_charge_template = 'admin/jacc/accountentry/reverse_entry.html'
222 actions = [
223 summarize_account_entries,
224 add_reverse_charge,
225 ]
226 list_display: Sequence[str] = [
227 'id',
228 'timestamp',
229 'type',
230 'amount',
231 'account_link',
232 'source_invoice_link',
233 'settled_invoice_link',
234 'settled_item_link',
235 'source_file_link',
236 'parent',
237 ]
238 raw_id_fields: Sequence[str] = [
239 'account',
240 'source_file',
241 'type',
242 'parent',
243 'source_invoice',
244 'settled_invoice',
245 'settled_item',
246 'parent',
247 ]
248 ordering: Sequence[str] = [
249 '-id',
250 ]
251 search_fields: Sequence[str] = [
252 'description',
253 '=amount',
254 ]
255 fields: Sequence[str] = [
256 'id',
257 'account',
258 'timestamp',
259 'created',
260 'last_modified',
261 'type',
262 'description',
263 'amount',
264 'source_file',
265 'source_invoice',
266 'settled_invoice',
267 'settled_item',
268 'parent',
269 'archived',
270 ]
271 readonly_fields: Sequence[str] = [
272 'id',
273 'created',
274 'last_modified',
275 'balance',
276 'source_invoice_link',
277 'settled_invoice_link',
278 'settled_item_link',
279 'archived',
280 ]
281 list_filter: Sequence[Any] = [
282 SettlementAccountEntryFilter,
283 EntryTypeAccountEntryFilter,
284 AccountTypeAccountEntryFilter,
285 'archived',
286 ]
287 account_admin_change_view_name = 'admin:jacc_account_change'
288 invoice_admin_change_view_name = 'admin:jacc_invoice_change'
289 accountentrysourcefile_admin_change_view_name = 'admin:jacc_accountentrysourcefile_change'
290 accountentry_admin_change_view_name = 'admin:jacc_accountentry_change'
291 allow_add = False
292 allow_delete = False
293 allow_change = False
295 def source_file_link(self, obj):
296 assert isinstance(obj, AccountEntry)
297 if not obj.source_file:
298 return ''
299 admin_url = reverse(self.accountentrysourcefile_admin_change_view_name, args=(obj.source_file.id, ))
300 return format_html("<a href='{}'>{}</a>", mark_safe(admin_url), obj.source_file)
301 source_file_link.admin_order_field = 'source_file' # type: ignore
302 source_file_link.short_description = _('account entry source file') # type: ignore
304 def account_link(self, obj):
305 admin_url = reverse(self.account_admin_change_view_name, args=(obj.account.id, ))
306 return format_html("<a href='{}'>{}</a>", mark_safe(admin_url), obj.account)
307 account_link.admin_order_field = 'account' # type: ignore
308 account_link.short_description = _('account') # type: ignore
310 def source_invoice_link(self, obj):
311 if not obj.source_invoice:
312 return ''
313 admin_url = reverse(self.invoice_admin_change_view_name, args=(obj.source_invoice.id, ))
314 return format_html("<a href='{}'>{}</a>", mark_safe(admin_url), obj.source_invoice)
315 source_invoice_link.admin_order_field = 'source_invoice' # type: ignore
316 source_invoice_link.short_description = _('source invoice') # type: ignore
318 def settled_invoice_link(self, obj):
319 if not obj.settled_invoice:
320 return ''
321 admin_url = reverse(self.invoice_admin_change_view_name, args=(obj.settled_invoice.id, ))
322 return format_html("<a href='{}'>{}</a>", mark_safe(admin_url), obj.settled_invoice)
323 settled_invoice_link.admin_order_field = 'settled_invoice' # type: ignore
324 settled_invoice_link.short_description = _('settled invoice') # type: ignore
326 def settled_item_link(self, obj):
327 if not obj.settled_item:
328 return ''
329 admin_url = reverse(self.accountentry_admin_change_view_name, args=(obj.settled_item.id, ))
330 return format_html("<a href='{}'>{}</a>", mark_safe(admin_url), obj.settled_item)
331 settled_item_link.admin_order_field = 'settled_item' # type: ignore
332 settled_item_link.short_description = _('settled item') # type: ignore
334 def get_urls(self):
335 info = self.model._meta.app_label, self.model._meta.model_name
336 return [
337 url(r'^by-account/(?P<pk>\d+)/$', self.admin_site.admin_view(self.kw_changelist_view), name='%s_%s_account_changelist' % info),
338 url(r'^by-source-invoice/(?P<pk>\d+)/$', self.admin_site.admin_view(self.kw_changelist_view), name='%s_%s_source_invoice_changelist' % info),
339 url(r'^by-settled-invoice/(?P<pk>\d+)/$', self.admin_site.admin_view(self.kw_changelist_view), name='%s_%s_settled_invoice_changelist' % info),
340 url(r'^by-source-file/(?P<pk>\d+)/$', self.admin_site.admin_view(self.kw_changelist_view), name='%s_%s_sourcefile_changelist' % info),
341 ] + super().get_urls()
343 def get_queryset(self, request: HttpRequest):
344 qs = super().get_queryset(request)
345 rm = request.resolver_match
346 assert isinstance(rm, ResolverMatch)
347 pk = rm.kwargs.get('pk', None)
348 if rm.url_name == 'jacc_accountentry_account_changelist' and pk:
349 return qs.filter(account=pk)
350 if rm.url_name == 'jacc_accountentry_sourcefile_invoice_changelist' and pk:
351 return qs.filter(source_invoice=pk)
352 if rm.url_name == 'jacc_accountentry_settled_invoice_changelist' and pk:
353 return qs.filter(settled_invoice=pk)
354 if rm.url_name == 'jacc_accountentry_sourcefile_changelist' and pk:
355 return qs.filter(source_file=pk)
356 return qs
359class AccountAdmin(ModelAdminBase):
360 list_display: Sequence[str] = [
361 'id',
362 'type',
363 'name',
364 'balance',
365 'currency',
366 'is_asset',
367 ]
368 fields: Sequence[str] = [
369 'id',
370 'type',
371 'name',
372 'balance',
373 'currency',
374 ]
375 readonly_fields: Sequence[str] = [
376 'id',
377 'balance',
378 'is_asset',
379 ]
380 raw_id_fields: Sequence[str] = [
381 'type',
382 ]
383 ordering: Sequence[str] = [
384 '-id',
385 ]
386 list_filter: Sequence[Any] = [
387 'type',
388 'type__is_asset',
389 ]
390 allow_add = True
391 allow_delete = True
392 list_per_page = 20
395class AccountEntryInlineFormSet(forms.BaseInlineFormSet):
396 def clean_entries(self, source_invoice: Optional[Invoice], settled_invoice: Optional[Invoice], account: Optional[Account], **kw):
397 """
398 This needs to be called from a derived class clean().
399 :param source_invoice:
400 :param settled_invoice:
401 :param account:
402 :return: None
403 """
404 for form in self.forms:
405 obj = form.instance
406 assert isinstance(obj, AccountEntry)
407 if account is not None:
408 obj.account = account
409 obj.source_invoice = source_invoice
410 obj.settled_invoice = settled_invoice
411 if obj.parent:
412 if obj.amount is None:
413 obj.amount = obj.parent.amount
414 if obj.type is None:
415 obj.type = obj.parent.type
416 if obj.amount is not None and obj.parent.amount is not None:
417 if obj.amount > obj.parent.amount > Decimal(0) or obj.amount < obj.parent.amount < Decimal(0):
418 raise ValidationError(_('Derived account entry amount cannot be larger than original'))
419 for k, v in kw.items():
420 setattr(obj, k, v)
423class SingleReceivablesAccountInvoiceItemInlineFormSet(AccountEntryInlineFormSet):
424 def clean(self):
425 instance = self.instance
426 assert isinstance(instance, Invoice)
427 receivables_account = Account.objects.get(type__code=settings.ACCOUNT_RECEIVABLES)
428 self.clean_entries(instance, None, receivables_account)
431class SingleSettlementsAccountSettlementInlineFormSet(AccountEntryInlineFormSet):
432 def clean(self):
433 instance = self.instance
434 assert isinstance(instance, Invoice)
435 settlement_account = Account.objects.get(type__code=settings.ACCOUNT_SETTLEMENTS)
436 self.clean_entries(None, instance, settlement_account)
438 def save(self, commit=True):
439 instance = self.instance
440 assert isinstance(instance, Invoice)
441 entries = super().save(commit)
442 settlement_account = Account.objects.get(type__code=settings.ACCOUNT_SETTLEMENTS)
443 assert isinstance(settlement_account, Account)
444 for e in entries:
445 if settlement_account.needs_settling(e):
446 settle_assigned_invoice(instance.receivables_account, e, AccountEntry)
447 return entries
450class InvoiceItemInline(admin.TabularInline): # TODO: override in app
451 model = AccountEntry
452 formset = SingleReceivablesAccountInvoiceItemInlineFormSet # TODO: override in app
453 fk_name = 'source_invoice'
454 verbose_name = _('invoice items')
455 verbose_name_plural = _('invoices items')
456 extra = 0
457 can_delete = True
458 account_entry_change_view_name = 'admin:jacc_accountentry_change'
459 fields = [
460 'id_link',
461 'timestamp',
462 'type',
463 'description',
464 'amount',
465 ]
466 raw_id_fields = [
467 'account',
468 'type',
469 'source_invoice',
470 'settled_invoice',
471 'settled_item',
472 'source_file',
473 'parent',
474 ]
475 readonly_fields = [
476 'id_link',
477 ]
479 def id_link(self, obj):
480 if obj and obj.id:
481 assert isinstance(obj, AccountEntry)
482 admin_url = reverse(self.account_entry_change_view_name, args=(obj.id, ))
483 return format_html("<a href='{}'>{}</a>", mark_safe(admin_url), obj.id)
484 return ''
485 id_link.admin_order_field = 'id' # type: ignore
486 id_link.short_description = _('id') # type: ignore
488 def get_queryset(self, request):
489 queryset = self.model._default_manager.get_queryset().filter(type__is_settlement=False)
490 if not self.has_change_permission(request):
491 queryset = queryset.none()
492 return queryset
494 def get_field_queryset(self, db, db_field, request):
495 related_admin = self.admin_site._registry.get(db_field.remote_field.model)
496 if related_admin and db_field.name == 'type':
497 return related_admin.get_queryset(request).filter(is_settlement=False).order_by('name')
498 return super().get_field_queryset(db, db_field, request)
501class InvoiceSettlementInline(admin.TabularInline): # TODO: override in app
502 model = AccountEntry
503 formset = SingleSettlementsAccountSettlementInlineFormSet # TODO: override in app
504 fk_name = 'settled_invoice'
505 verbose_name = _('settlements')
506 verbose_name_plural = _('settlements')
507 show_non_settlements = False
508 extra = 0
509 can_delete = True
510 account_entry_change_view_name = 'admin:jacc_accountentry_change' # TODO: override in app
511 account_change_view_name = 'admin:jacc_account_change' # TODO: override in app
512 fields = [
513 'id_link',
514 'account_link',
515 'timestamp',
516 'type',
517 'description',
518 'amount',
519 'parent',
520 'settled_item',
521 ]
522 raw_id_fields = [
523 'account',
524 'type',
525 'source_invoice',
526 'settled_invoice',
527 'source_file',
528 'parent',
529 'settled_item',
530 ]
531 readonly_fields = [
532 'id_link',
533 'account_link',
534 'settled_item',
535 ]
537 def get_queryset(self, request):
538 queryset = self.model._default_manager.get_queryset()
539 if not self.show_non_settlements:
540 queryset = queryset.filter(type__is_settlement=True)
541 if not self.has_change_permission(request):
542 queryset = queryset.none()
543 return queryset
545 def get_field_queryset(self, db, db_field, request):
546 related_admin = self.admin_site._registry.get(db_field.remote_field.model)
547 if related_admin and db_field.name == 'type' and not self.show_non_settlements:
548 return related_admin.get_queryset(request).filter(is_settlement=True).order_by('name')
549 return super().get_field_queryset(db, db_field, request)
551 def id_link(self, obj):
552 if obj and obj.id:
553 assert isinstance(obj, AccountEntry)
554 admin_url = reverse(self.account_entry_change_view_name, args=(obj.id, ))
555 return format_html("<a href='{}'>{}</a>", mark_safe(admin_url), obj.id)
556 return ''
557 id_link.admin_order_field = 'id' # type: ignore
558 id_link.short_description = _('id') # type: ignore
560 def account_link(self, obj):
561 if obj and obj.id:
562 assert isinstance(obj, AccountEntry)
563 admin_url = reverse(self.account_change_view_name, args=(obj.account.id, ))
564 return format_html("<a href='{}'>{}</a>", mark_safe(admin_url), obj.account)
565 return ''
566 account_link.admin_order_field = 'account' # type: ignore
567 account_link.short_description = _('account') # type: ignore
570def resend_invoices(modeladmin, request: HttpRequest, queryset: QuerySet): # pylint: disable=unused-argument
571 """
572 Marks invoices with as un-sent.
573 :param modeladmin:
574 :param request:
575 :param queryset:
576 :return:
577 """
578 user = request.user
579 assert isinstance(user, User)
580 for obj in queryset:
581 assert isinstance(obj, Invoice)
582 admin_log([obj, user], 'Invoice id={invoice} marked for re-sending'.format(invoice=obj.id), who=user)
583 queryset.update(sent=None)
586class InvoiceLateDaysFilter(SimpleListFilter):
587 title = _('late days')
588 parameter_name = 'late_days_range'
590 def lookups(self, request, model_admin):
591 if hasattr(settings, 'INVOICE_LATE_DAYS_LIST_FILTER'):
592 return settings.INVOICE_LATE_DAYS_LIST_FILTER
593 return [
594 ('<0', _('late.days.filter.not.due')),
595 ('0<7', format_lazy(_('late.days.filter.late.range'), 1, 7)),
596 ('7<14', format_lazy(_('late.days.filter.late.range'), 7, 14)),
597 ('14<21', format_lazy(_('late.days.filter.late.range'), 14, 21)),
598 ('21<28', format_lazy(_('late.days.filter.late.range'), 21, 28)),
599 ('28<', format_lazy(_('late.days.filter.late.over.days'), 28)),
600 ]
602 def queryset(self, request, queryset):
603 val = self.value()
604 if val:
605 begin, end = str(val).split('<')
606 if begin:
607 queryset = queryset.filter(late_days__gte=int(begin))
608 if end:
609 queryset = queryset.filter(late_days__lt=int(end))
610 return queryset
613def summarize_invoice_statistics(modeladmin, request: HttpRequest, qs: QuerySet): # pylint: disable=unused-argument
614 invoice_states = list([state for state, name in INVOICE_STATE])
616 invoiced_total_amount = Decimal('0.00')
617 invoiced_total_count = 0
619 lines = [
620 '<pre>',
621 _('({total_count} invoices)').format(total_count=qs.count()),
622 ]
623 for state in invoice_states:
624 state_name = choices_label(INVOICE_STATE, state)
625 qs2 = qs.filter(state=state)
627 invoiced = qs2.filter(state=state).aggregate(amount=Coalesce(Sum('amount'), 0), count=Count('*'))
628 invoiced_amount = Decimal(invoiced['amount'])
629 invoiced_count = int(invoiced['count'])
630 invoiced_total_amount += invoiced_amount
631 invoiced_total_count += invoiced_count
633 lines.append('{state_name} | x{count} | {amount:.2f}'.format(
634 state_name=state_name, amount=invoiced_amount, count=invoiced_count))
636 lines.append(_('Total') + ' {label} | x{count} | {amount:.2f}'.format(
637 label=_('amount'), amount=invoiced_total_amount, count=invoiced_total_count))
638 lines.append('</pre>')
640 lines = align_lines(lines, '|')
641 messages.add_message(request, INFO, format_html('<br>'.join(lines)), extra_tags='safe')
644class InvoiceAdmin(ModelAdminBase):
645 """
646 Invoice admin. Override following in derived classes:
647 - InvoiceSettlementInline with formset derived from AccountEntryInlineFormSet, override clean and call clean_entries()
648 - InvoiceItemsInline with formset derived from AccountEntryInlineFormSet, override clean and call clean_entries()
649 - inlines = [] set with above mentioned derived classes
650 """
651 date_hierarchy = 'created'
652 actions = [
653 summarize_invoice_statistics,
654 refresh_cached_fields,
655 # resend_invoices,
656 ]
657 # override in derived class
658 inlines = [
659 InvoiceItemInline, # TODO: override in app
660 InvoiceSettlementInline, # TODO: override in app
661 ]
662 list_display: Sequence[str] = [
663 'number',
664 'created_brief',
665 'sent_brief',
666 'due_date_brief',
667 'close_date_brief',
668 'late_days',
669 'amount',
670 'paid_amount',
671 'unpaid_amount',
672 ]
673 fields: Sequence[str] = [
674 'type',
675 'number',
676 'due_date',
677 'notes',
678 'filename',
679 'amount',
680 'paid_amount',
681 'unpaid_amount',
682 'state',
683 'overpaid_amount',
684 'close_date',
685 'late_days',
686 'created',
687 'last_modified',
688 'sent',
689 ]
690 readonly_fields: Sequence[str] = [
691 'created',
692 'last_modified',
693 'sent',
694 'close_date',
695 'created_brief',
696 'sent_brief',
697 'due_date_brief',
698 'close_date_brief',
699 'filename',
700 'amount',
701 'paid_amount',
702 'unpaid_amount',
703 'state',
704 'overpaid_amount',
705 'late_days',
706 ]
707 raw_id_fields: Sequence[str] = [
708 ]
709 search_fields: Sequence[str] = [
710 '=amount',
711 '=filename',
712 '=number',
713 ]
714 list_filter: Sequence[Any] = [
715 'state',
716 InvoiceLateDaysFilter,
717 ]
718 allow_add = True
719 allow_delete = True
720 ordering = ('-id', )
722 def construct_change_message(self, request, form, formsets, add=False):
723 instance = form.instance
724 assert isinstance(instance, Invoice)
725 instance.update_cached_fields()
726 return super().construct_change_message(request, form, formsets, add)
728 def _format_date(self, obj) -> str:
729 """
730 Short date format.
731 :param obj: date or datetime or None
732 :return: str
733 """
734 if obj is None:
735 return ''
736 if isinstance(obj, datetime):
737 obj = obj.date()
738 return date_format(obj, 'SHORT_DATE_FORMAT')
740 def created_brief(self, obj):
741 assert isinstance(obj, Invoice)
742 return self._format_date(obj.created)
743 created_brief.admin_order_field = 'created' # type: ignore
744 created_brief.short_description = _('created') # type: ignore
746 def sent_brief(self, obj):
747 assert isinstance(obj, Invoice)
748 return self._format_date(obj.sent)
749 sent_brief.admin_order_field = 'sent' # type: ignore
750 sent_brief.short_description = _('sent') # type: ignore
752 def due_date_brief(self, obj):
753 assert isinstance(obj, Invoice)
754 return self._format_date(obj.due_date)
755 due_date_brief.admin_order_field = 'due_date' # type: ignore
756 due_date_brief.short_description = _('due date') # type: ignore
758 def close_date_brief(self, obj):
759 assert isinstance(obj, Invoice)
760 return self._format_date(obj.close_date)
761 close_date_brief.admin_order_field = 'close_date' # type: ignore
762 close_date_brief.short_description = _('close date') # type: ignore
765def set_as_asset(modeladmin, request, qs): # pylint: disable=unused-argument
766 qs.update(is_asset=True)
769def set_as_liability(modeladmin, request, qs): # pylint: disable=unused-argument
770 qs.update(is_asset=False)
773class AccountTypeAdmin(ModelAdminBase):
774 list_display = [
775 'code',
776 'name',
777 'is_asset',
778 'is_liability',
779 ]
780 actions = [
781 set_as_asset,
782 set_as_liability,
783 ]
784 ordering = ('name', )
785 allow_add = True
786 allow_delete = True
788 def is_liability(self, obj):
789 return obj.is_liability
790 is_liability.short_description = _('is liability') # type: ignore
791 is_liability.boolean = True # type: ignore
794class ContractAdmin(ModelAdminBase):
795 list_display = [
796 'id',
797 'name',
798 ]
799 ordering = ['-id', ]
800 allow_add = True
801 allow_delete = True
804def toggle_settlement(modeladmin, request: HttpRequest, queryset: QuerySet): # pylint: disable=unused-argument
805 for e in queryset:
806 assert isinstance(e, EntryType)
807 e.is_settlement = not e.is_settlement
808 e.save()
809 admin_log([e], 'Toggled settlement flag {}'.format('on' if e.is_settlement else 'off'), who=request.user)
812def toggle_payment(modeladmin, request: HttpRequest, queryset: QuerySet): # pylint: disable=unused-argument
813 for e in queryset:
814 assert isinstance(e, EntryType)
815 e.is_payment = not e.is_payment
816 e.save()
817 admin_log([e], 'Toggled payment flag {}'.format('on' if e.is_settlement else 'off'), who=request.user)
820class EntryTypeAdmin(ModelAdminBase):
821 list_display = [
822 'id',
823 'identifier',
824 'name',
825 'is_settlement',
826 'is_payment',
827 'payback_priority',
828 ]
829 list_filter: Sequence[Any] = (
830 'is_settlement',
831 'is_payment',
832 )
833 search_fields: Sequence[str] = (
834 'name',
835 'code',
836 )
837 actions = [
838 toggle_settlement,
839 toggle_payment,
840 ]
841 exclude: Sequence[str] = ()
842 ordering: Sequence[str] = ['name', ]
843 allow_add = True
844 allow_delete = True
847class AccountEntrySourceFileAdmin(ModelAdminBase):
848 list_display: Sequence[str] = [
849 'id',
850 'created',
851 'entries_link',
852 ]
853 date_hierarchy = 'created'
854 ordering: Sequence[str] = [
855 '-id',
856 ]
857 fields: Sequence[str] = [
858 'id',
859 'name',
860 'created',
861 'last_modified',
862 ]
863 search_fields: Sequence[str] = [
864 '=name',
865 ]
866 readonly_fields: Sequence[str] = [
867 'id',
868 'created',
869 'name',
870 'last_modified',
871 'entries_link',
872 ]
873 allow_add = True
874 allow_delete = True
876 def entries_link(self, obj):
877 if obj and obj.id:
878 assert isinstance(obj, AccountEntrySourceFile)
879 admin_url = reverse('admin:jacc_accountentry_sourcefile_changelist', args=(obj.id, ))
880 return format_html("<a href='{}'>{}</a>", mark_safe(admin_url), obj.name)
881 return ''
882 entries_link.admin_order_field = 'name' # type: ignore
883 entries_link.short_description = _('account entry source file') # type: ignore
886add_reverse_charge.short_description = _('Add reverse charge') # type: ignore
887resend_invoices.short_description = _('Re-send invoices') # type: ignore
888refresh_cached_fields.short_description = _('Refresh cached fields') # type: ignore
889summarize_account_entries.short_description = _('Summarize account entries') # type: ignore
890summarize_invoice_statistics.short_description = _('Summarize invoice statistics') # type: ignore
891set_as_asset.short_description = _('set_as_asset') # type: ignore
892set_as_liability.short_description = _('set_as_liability') # type: ignore
894admin.site.register(Account, AccountAdmin)
895admin.site.register(Invoice, InvoiceAdmin) # TODO: override in app
896admin.site.register(AccountEntry, AccountEntryAdmin) # TODO: override in app
897admin.site.register(AccountType, AccountTypeAdmin)
898admin.site.register(EntryType, EntryTypeAdmin)
899admin.site.register(AccountEntrySourceFile, AccountEntrySourceFileAdmin)