Coverage for jacc/admin.py: 46%

# Coverage for jacc/admin.py : 46%

## 508 statements 234 run 274 missing 0 excluded

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

2 import logging

3 import traceback

4 from datetime import datetime

5 from decimal import Decimal

6 from typing import Optional, List, Sequence, Any, Dict

7 from django.contrib import messages

8 from django.contrib.admin import SimpleListFilter

9 from django.contrib.messages import add_message, INFO

10 from django.db.models.functions import Coalesce

11 from django import forms

12 from django.shortcuts import render

13 from django.urls import reverse, ResolverMatch

14 from django.utils.formats import date_format

15 from django.utils.html import format_html

16 from django.utils.safestring import mark_safe

17 from django.utils.text import format_lazy

18

19 from jacc.forms import ReverseChargeForm

20 from jacc.models import Account, AccountEntry, Invoice, AccountType, EntryType, AccountEntrySourceFile, INVOICE_STATE

21 from django.conf import settings

22 from django.conf.urls import url

23 from django.contrib import admin

24 from django.contrib.auth.models import User

25 from django.core.exceptions import ValidationError

26 from django.db.models import QuerySet, Sum, Count

27 from django.http import HttpRequest

28 from django.utils.translation import gettext_lazy as _

29 from jacc.settle import settle_assigned_invoice

30 from jutil.admin import ModelAdminBase, admin_log

31 from jutil.format import choices_label, dec2

32 from jutil.model import clone_model

33

34 logger = logging.getLogger(__name__)

35

36

37 def 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)

56

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

68

69

70 def 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()))

74

75

76 def 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)

90

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']

98

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')

103

104

105 class SettlementAccountEntryFilter(SimpleListFilter):

106 title = _('settlement')

107 parameter_name = 'is_settlement'

108

109 def lookups(self, request, model_admin):

110 return [

111 ('1', _('settlement')),

112 ('0', _('not settlement')),

113 ]

114

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

123

124

125 class EntryTypeAccountEntryFilter(SimpleListFilter):

126 title = _('account entry type')

127 parameter_name = 'type'

128

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

134

135 def queryset(self, request, queryset):

136 val = self.value()

137 if val:

138 queryset = queryset.filter(type__code=val)

139 return queryset

140

141

142 class AccountTypeAccountEntryFilter(SimpleListFilter):

143 title = _('account type')

144 parameter_name = 'atype'

145

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

151

152 def queryset(self, request, queryset):

153 val = self.value()

154 if val:

155 queryset = queryset.filter(account__type__code=val)

156 return queryset

157

158

159 def add_reverse_charge(modeladmin, request, qs):

160 assert hasattr(modeladmin, 'reverse_charge_form')

161 assert hasattr(modeladmin, 'reverse_charge_template')

162

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'))

171

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 }

183

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

207

208

209 class AccountEntryAdminForm(forms.ModelForm):

210 def clean(self):

211 if self.instance.archived:

212 raise ValidationError(_('cannot.modify.archived.account.entry'))

213 return super().clean()

214

215

216 class 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

294

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

303

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

309

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

317

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

325

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

333

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()

342

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

357

358

359 class 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

393

394

395 class 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)

421

422

423 class 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)

429

430

431 class 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)

437

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

448

449

450 class 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 ]

478

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

487

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

493

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)

499

500

501 class 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 ]

536

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

544

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)

550

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

559

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

568

569

570 def 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)

584

585

586 class InvoiceLateDaysFilter(SimpleListFilter):

587 title = _('late days')

588 parameter_name = 'late_days_range'

589

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 ]

601

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

611

612

613 def summarize_invoice_statistics(modeladmin, request: HttpRequest, qs: QuerySet): # pylint: disable=unused-argument

614 invoice_states = list([state for state, name in INVOICE_STATE])

615

616 invoiced_total_amount = Decimal('0.00')

617 invoiced_total_count = 0

618

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)

626

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

632

633 lines.append('{state_name} | x{count} | {amount:.2f}'.format(

634 state_name=state_name, amount=invoiced_amount, count=invoiced_count))

635

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>')

639

640 lines = align_lines(lines, '|')

641 messages.add_message(request, INFO, format_html('<br>'.join(lines)), extra_tags='safe')

642

643

644 class 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', )

721

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)

727

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')

739

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

745

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

751

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

757

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

763

764

765 def set_as_asset(modeladmin, request, qs): # pylint: disable=unused-argument

766 qs.update(is_asset=True)

767

768

769 def set_as_liability(modeladmin, request, qs): # pylint: disable=unused-argument

770 qs.update(is_asset=False)

771

772

773 class 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

787

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

792

793

794 class ContractAdmin(ModelAdminBase):

795 list_display = [

796 'id',

797 'name',

798 ]

799 ordering = ['-id', ]

800 allow_add = True

801 allow_delete = True

802

803

804 def 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)

810

811

812 def 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)

818

819

820 class 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

845

846

847 class 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

875

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

884

885

886 add_reverse_charge.short_description = _('Add reverse charge') # type: ignore

887 resend_invoices.short_description = _('Re-send invoices') # type: ignore

888 refresh_cached_fields.short_description = _('Refresh cached fields') # type: ignore

889 summarize_account_entries.short_description = _('Summarize account entries') # type: ignore

890 summarize_invoice_statistics.short_description = _('Summarize invoice statistics') # type: ignore

891 set_as_asset.short_description = _('set_as_asset') # type: ignore

892 set_as_liability.short_description = _('set_as_liability') # type: ignore

893

894 admin.site.register(Account, AccountAdmin)

895 admin.site.register(Invoice, InvoiceAdmin) # TODO: override in app

896 admin.site.register(AccountEntry, AccountEntryAdmin) # TODO: override in app

897 admin.site.register(AccountType, AccountTypeAdmin)

898 admin.site.register(EntryType, EntryTypeAdmin)

899 admin.site.register(AccountEntrySourceFile, AccountEntrySourceFileAdmin)