Hide keyboard shortcuts

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 

18 

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 

33 

34logger = logging.getLogger(__name__) 

35 

36 

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) 

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 

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

74 

75 

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) 

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 

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

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

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

159def 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 

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

214 

215 

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 

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 

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 

393 

394 

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) 

421 

422 

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) 

429 

430 

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) 

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 

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 ] 

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 

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 ] 

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 

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) 

584 

585 

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

613def 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 

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

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 

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

766 qs.update(is_asset=True) 

767 

768 

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

770 qs.update(is_asset=False) 

771 

772 

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 

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 

794class ContractAdmin(ModelAdminBase): 

795 list_display = [ 

796 'id', 

797 'name', 

798 ] 

799 ordering = ['-id', ] 

800 allow_add = True 

801 allow_delete = True 

802 

803 

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) 

810 

811 

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) 

818 

819 

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 

845 

846 

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 

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 

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 

893 

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)