Refactor payment methods plugability
This commit is contained in:
parent
1f00b27667
commit
13df742284
2
TODO.md
2
TODO.md
|
@ -78,4 +78,6 @@ at + clock time, midnight, noon- At 3:30 p.m., At 4:01, At noon
|
||||||
* make account_link to autoreplace account on change view.
|
* make account_link to autoreplace account on change view.
|
||||||
|
|
||||||
* LAST version of this shit http://wkhtmltopdf.org/downloads.html
|
* LAST version of this shit http://wkhtmltopdf.org/downloads.html
|
||||||
|
* Rename pack to plan ? one can have multiple plans?
|
||||||
|
|
||||||
|
* transaction.process FK?
|
||||||
|
|
|
@ -73,7 +73,6 @@ class ChangeViewActionsMixin(object):
|
||||||
view.url_name.capitalize().replace('_', ' '))
|
view.url_name.capitalize().replace('_', ' '))
|
||||||
view.css_class = getattr(action, 'css_class', 'historylink')
|
view.css_class = getattr(action, 'css_class', 'historylink')
|
||||||
view.description = getattr(action, 'description', '')
|
view.description = getattr(action, 'description', '')
|
||||||
view.__name__ = action.__name__
|
|
||||||
views.append(view)
|
views.append(view)
|
||||||
return views
|
return views
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from functools import update_wrapper
|
from functools import wraps
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
@ -59,9 +59,10 @@ def insertattr(model, name, value, weight=0):
|
||||||
|
|
||||||
def wrap_admin_view(modeladmin, view):
|
def wrap_admin_view(modeladmin, view):
|
||||||
""" Add admin authentication to view """
|
""" Add admin authentication to view """
|
||||||
|
@wraps(view)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
return modeladmin.admin_site.admin_view(view)(*args, **kwargs)
|
return modeladmin.admin_site.admin_view(view)(*args, **kwargs)
|
||||||
return update_wrapper(wrapper, view)
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def set_url_query(request, key, value):
|
def set_url_query(request, key, value):
|
||||||
|
@ -77,6 +78,7 @@ def set_url_query(request, key, value):
|
||||||
|
|
||||||
def action_to_view(action, modeladmin):
|
def action_to_view(action, modeladmin):
|
||||||
""" Converts modeladmin action to view function """
|
""" Converts modeladmin action to view function """
|
||||||
|
@wraps(action)
|
||||||
def action_view(request, object_id=1, modeladmin=modeladmin, action=action):
|
def action_view(request, object_id=1, modeladmin=modeladmin, action=action):
|
||||||
queryset = modeladmin.model.objects.filter(pk=object_id)
|
queryset = modeladmin.model.objects.filter(pk=object_id)
|
||||||
response = action(modeladmin, request, queryset)
|
response = action(modeladmin, request, queryset)
|
||||||
|
|
|
@ -142,6 +142,12 @@ class AccountAdminMixin(object):
|
||||||
account_link.allow_tags = True
|
account_link.allow_tags = True
|
||||||
account_link.admin_order_field = 'account__user__username'
|
account_link.admin_order_field = 'account__user__username'
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
""" provide account for filter_by_account_fields """
|
||||||
|
if obj:
|
||||||
|
self.account = obj.account
|
||||||
|
return super(AccountAdminMixin, self).get_readonly_fields(request, obj=obj)
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
""" Select related for performance """
|
""" Select related for performance """
|
||||||
qs = super(AccountAdminMixin, self).get_queryset(request)
|
qs = super(AccountAdminMixin, self).get_queryset(request)
|
||||||
|
@ -211,11 +217,6 @@ class AccountAdminMixin(object):
|
||||||
|
|
||||||
class SelectAccountAdminMixin(AccountAdminMixin):
|
class SelectAccountAdminMixin(AccountAdminMixin):
|
||||||
""" Provides support for accounts on ModelAdmin """
|
""" Provides support for accounts on ModelAdmin """
|
||||||
def get_readonly_fields(self, request, obj=None):
|
|
||||||
if obj:
|
|
||||||
self.account = obj.account
|
|
||||||
return super(AccountAdminMixin, self).get_readonly_fields(request, obj=obj)
|
|
||||||
|
|
||||||
def get_inline_instances(self, request, obj=None):
|
def get_inline_instances(self, request, obj=None):
|
||||||
inlines = super(AccountAdminMixin, self).get_inline_instances(request, obj=obj)
|
inlines = super(AccountAdminMixin, self).get_inline_instances(request, obj=obj)
|
||||||
if hasattr(self, 'account'):
|
if hasattr(self, 'account'):
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.utils.functional import cached_property
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.core import services
|
from orchestra.core import services
|
||||||
|
from orchestra.utils import send_email_template
|
||||||
|
|
||||||
from . import settings
|
from . import settings
|
||||||
|
|
||||||
|
@ -31,5 +32,11 @@ class Account(models.Model):
|
||||||
def get_main(cls):
|
def get_main(cls):
|
||||||
return cls.objects.get(pk=settings.ACCOUNTS_MAIN_PK)
|
return cls.objects.get(pk=settings.ACCOUNTS_MAIN_PK)
|
||||||
|
|
||||||
|
def send_email(self, template, context, contacts=[], attachments=[], html=None):
|
||||||
|
contacts = self.contacts.filter(email_usages=contacts)
|
||||||
|
email_to = contacts.values_list('email', flat=True)
|
||||||
|
send_email_template(template, context, email_to, html=html,
|
||||||
|
attachments=attachments)
|
||||||
|
|
||||||
|
|
||||||
services.register(Account, menu=False)
|
services.register(Account, menu=False)
|
||||||
|
|
|
@ -7,20 +7,13 @@ from django.utils.translation import ugettext_lazy as _
|
||||||
from orchestra.utils.html import html_to_pdf
|
from orchestra.utils.html import html_to_pdf
|
||||||
|
|
||||||
|
|
||||||
def render_bills(modeladmin, request, queryset):
|
|
||||||
for bill in queryset:
|
|
||||||
bill.html = bill.render()
|
|
||||||
bill.save()
|
|
||||||
render_bills.verbose_name = _("Render")
|
|
||||||
render_bills.url_name = 'render'
|
|
||||||
|
|
||||||
|
|
||||||
def download_bills(modeladmin, request, queryset):
|
def download_bills(modeladmin, request, queryset):
|
||||||
if queryset.count() > 1:
|
if queryset.count() > 1:
|
||||||
stringio = StringIO.StringIO()
|
stringio = StringIO.StringIO()
|
||||||
archive = zipfile.ZipFile(stringio, 'w')
|
archive = zipfile.ZipFile(stringio, 'w')
|
||||||
for bill in queryset:
|
for bill in queryset:
|
||||||
pdf = html_to_pdf(bill.html)
|
html = bill.html or bill.render()
|
||||||
|
pdf = html_to_pdf(html)
|
||||||
archive.writestr('%s.pdf' % bill.number, pdf)
|
archive.writestr('%s.pdf' % bill.number, pdf)
|
||||||
archive.close()
|
archive.close()
|
||||||
response = HttpResponse(stringio.getvalue(), content_type='application/pdf')
|
response = HttpResponse(stringio.getvalue(), content_type='application/pdf')
|
||||||
|
@ -35,14 +28,22 @@ download_bills.url_name = 'download'
|
||||||
|
|
||||||
def view_bill(modeladmin, request, queryset):
|
def view_bill(modeladmin, request, queryset):
|
||||||
bill = queryset.get()
|
bill = queryset.get()
|
||||||
bill.html = bill.render()
|
html = bill.html or bill.render()
|
||||||
return HttpResponse(bill.html)
|
return HttpResponse(html)
|
||||||
view_bill.verbose_name = _("View")
|
view_bill.verbose_name = _("View")
|
||||||
view_bill.url_name = 'view'
|
view_bill.url_name = 'view'
|
||||||
|
|
||||||
|
|
||||||
def close_bills(modeladmin, request, queryset):
|
def close_bills(modeladmin, request, queryset):
|
||||||
|
# TODO confirmation with payment source selection
|
||||||
for bill in queryset:
|
for bill in queryset:
|
||||||
bill.close()
|
bill.close()
|
||||||
close_bills.verbose_name = _("Close")
|
close_bills.verbose_name = _("Close")
|
||||||
close_bills.url_name = 'close'
|
close_bills.url_name = 'close'
|
||||||
|
|
||||||
|
|
||||||
|
def send_bills(modeladmin, request, queryset):
|
||||||
|
for bill in queryset:
|
||||||
|
bill.send()
|
||||||
|
send_bills.verbose_name = _("Send")
|
||||||
|
send_bills.url_name = 'send'
|
||||||
|
|
|
@ -11,7 +11,7 @@ from orchestra.admin.utils import admin_link, admin_date
|
||||||
from orchestra.apps.accounts.admin import AccountAdminMixin
|
from orchestra.apps.accounts.admin import AccountAdminMixin
|
||||||
|
|
||||||
from . import settings
|
from . import settings
|
||||||
from .actions import render_bills, download_bills, view_bill, close_bills
|
from .actions import download_bills, view_bill, close_bills, send_bills
|
||||||
from .filters import BillTypeListFilter
|
from .filters import BillTypeListFilter
|
||||||
from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget,
|
from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget,
|
||||||
BillLine, BudgetLine)
|
BillLine, BudgetLine)
|
||||||
|
@ -51,6 +51,7 @@ class BudgetLineInline(admin.TabularInline):
|
||||||
fields = ('description', 'rate', 'amount', 'tax', 'total')
|
fields = ('description', 'rate', 'amount', 'tax', 'total')
|
||||||
|
|
||||||
|
|
||||||
|
# TODO hide raw when status = oPen
|
||||||
class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
'number', 'status', 'type_link', 'account_link', 'created_on_display',
|
'number', 'status', 'type_link', 'account_link', 'created_on_display',
|
||||||
|
@ -68,8 +69,8 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
||||||
'fields': ('html',),
|
'fields': ('html',),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
actions = [render_bills, download_bills, close_bills]
|
actions = [download_bills, close_bills, send_bills]
|
||||||
change_view_actions = [render_bills, view_bill, download_bills]
|
change_view_actions = [view_bill, download_bills, send_bills, close_bills]
|
||||||
change_readonly_fields = ('account_link', 'type', 'status')
|
change_readonly_fields = ('account_link', 'type', 'status')
|
||||||
readonly_fields = ('number', 'display_total')
|
readonly_fields = ('number', 'display_total')
|
||||||
inlines = [BillLineInline]
|
inlines = [BillLineInline]
|
||||||
|
@ -82,7 +83,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
||||||
num_lines.short_description = _("lines")
|
num_lines.short_description = _("lines")
|
||||||
|
|
||||||
def display_total(self, bill):
|
def display_total(self, bill):
|
||||||
return "%i &%s;" % (bill.get_total(), settings.BILLS_CURRENCY.lower())
|
return "%s &%s;" % (bill.get_total(), settings.BILLS_CURRENCY.lower())
|
||||||
display_total.allow_tags = True
|
display_total.allow_tags = True
|
||||||
display_total.short_description = _("total")
|
display_total.short_description = _("total")
|
||||||
|
|
||||||
|
@ -102,10 +103,15 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
||||||
|
|
||||||
def get_change_view_actions(self, obj=None):
|
def get_change_view_actions(self, obj=None):
|
||||||
actions = super(BillAdmin, self).get_change_view_actions(obj)
|
actions = super(BillAdmin, self).get_change_view_actions(obj)
|
||||||
if obj and not obj.html:
|
discard = []
|
||||||
actions = [action for action in actions
|
if obj:
|
||||||
if action.__name__ not in ('view_bill', 'download_bills')]
|
if obj.status != Bill.OPEN:
|
||||||
return actions
|
discard = ['close_bills']
|
||||||
|
if obj.status != Bill.CLOSED:
|
||||||
|
discard = ['send_bills']
|
||||||
|
if not discard:
|
||||||
|
return actions
|
||||||
|
return [action for action in actions if action.__name__ not in discard]
|
||||||
|
|
||||||
def get_inline_instances(self, request, obj=None):
|
def get_inline_instances(self, request, obj=None):
|
||||||
if self.model is Budget:
|
if self.model is Budget:
|
||||||
|
|
126
orchestra/apps/bills/migrations/0001_initial.py
Normal file
126
orchestra/apps/bills/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import models, migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '__first__'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Bill',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
|
('number', models.CharField(unique=True, max_length=16, verbose_name='number', blank=True)),
|
||||||
|
('type', models.CharField(max_length=16, verbose_name='type', choices=[(b'INVOICE', 'Invoice'), (b'AMENDMENTINVOICE', 'Amendment invoice'), (b'FEE', 'Fee'), (b'AMENDMENTFEE', 'Amendment Fee'), (b'BUDGET', 'Budget')])),
|
||||||
|
('status', models.CharField(default=b'OPEN', max_length=16, verbose_name='status', choices=[(b'OPEN', 'Open'), (b'CLOSED', 'Closed'), (b'SENT', 'Sent'), (b'PAID', 'Paid'), (b'RETURNED', 'Returned'), (b'BAD_DEBT', 'Bad debt')])),
|
||||||
|
('created_on', models.DateTimeField(auto_now_add=True, verbose_name='created on')),
|
||||||
|
('due_on', models.DateField(null=True, verbose_name='due on', blank=True)),
|
||||||
|
('last_modified_on', models.DateTimeField(auto_now=True, verbose_name='last modified on')),
|
||||||
|
('comments', models.TextField(verbose_name='comments', blank=True)),
|
||||||
|
('html', models.TextField(verbose_name='HTML', blank=True)),
|
||||||
|
('account', models.ForeignKey(related_name=b'bill', verbose_name='account', to='accounts.Account')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
},
|
||||||
|
bases=(models.Model,),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BillLine',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
|
('description', models.CharField(max_length=256, verbose_name='description')),
|
||||||
|
('rate', models.DecimalField(null=True, verbose_name='rate', max_digits=12, decimal_places=2, blank=True)),
|
||||||
|
('amount', models.DecimalField(verbose_name='amount', max_digits=12, decimal_places=2)),
|
||||||
|
('total', models.DecimalField(verbose_name='total', max_digits=12, decimal_places=2)),
|
||||||
|
('tax', models.PositiveIntegerField(verbose_name='tax')),
|
||||||
|
('order_id', models.PositiveIntegerField(null=True, blank=True)),
|
||||||
|
('order_last_bill_date', models.DateTimeField(null=True)),
|
||||||
|
('order_billed_until', models.DateTimeField(null=True)),
|
||||||
|
('auto', models.BooleanField(default=False)),
|
||||||
|
('amended_line', models.ForeignKey(related_name=b'amendment_lines', verbose_name='amended line', blank=True, to='bills.BillLine', null=True)),
|
||||||
|
('bill', models.ForeignKey(related_name=b'billlines', verbose_name='bill', to='bills.Bill')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=(models.Model,),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BillSubline',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
|
('description', models.CharField(max_length=256, verbose_name='description')),
|
||||||
|
('total', models.DecimalField(max_digits=12, decimal_places=2)),
|
||||||
|
('bill_line', models.ForeignKey(related_name=b'sublines', verbose_name='bill line', to='bills.BillLine')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
},
|
||||||
|
bases=(models.Model,),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BudgetLine',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
|
('description', models.CharField(max_length=256, verbose_name='description')),
|
||||||
|
('rate', models.DecimalField(null=True, verbose_name='rate', max_digits=12, decimal_places=2, blank=True)),
|
||||||
|
('amount', models.DecimalField(verbose_name='amount', max_digits=12, decimal_places=2)),
|
||||||
|
('total', models.DecimalField(verbose_name='total', max_digits=12, decimal_places=2)),
|
||||||
|
('tax', models.PositiveIntegerField(verbose_name='tax')),
|
||||||
|
('bill', models.ForeignKey(related_name=b'budgetlines', verbose_name='bill', to='bills.Bill')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=(models.Model,),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AmendmentFee',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'proxy': True,
|
||||||
|
},
|
||||||
|
bases=('bills.bill',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AmendmentInvoice',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'proxy': True,
|
||||||
|
},
|
||||||
|
bases=('bills.bill',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Budget',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'proxy': True,
|
||||||
|
},
|
||||||
|
bases=('bills.bill',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Fee',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'proxy': True,
|
||||||
|
},
|
||||||
|
bases=('bills.bill',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Invoice',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'proxy': True,
|
||||||
|
},
|
||||||
|
bases=('bills.bill',),
|
||||||
|
),
|
||||||
|
]
|
21
orchestra/apps/bills/migrations/0002_bill_payment_source.py
Normal file
21
orchestra/apps/bills/migrations/0002_bill_payment_source.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import models, migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('payments', '__first__'),
|
||||||
|
('bills', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='bill',
|
||||||
|
name='payment_source',
|
||||||
|
field=models.ForeignKey(blank=True, to='payments.PaymentSource', help_text='Optionally specify a payment source for this bill', null=True, verbose_name='payment source'),
|
||||||
|
preserve_default=True,
|
||||||
|
),
|
||||||
|
]
|
0
orchestra/apps/bills/migrations/__init__.py
Normal file
0
orchestra/apps/bills/migrations/__init__.py
Normal file
|
@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||||
from orchestra.apps.accounts.models import Account
|
from orchestra.apps.accounts.models import Account
|
||||||
from orchestra.core import accounts
|
from orchestra.core import accounts
|
||||||
from orchestra.utils.functional import cached
|
from orchestra.utils.functional import cached
|
||||||
|
from orchestra.utils.html import html_to_pdf
|
||||||
|
|
||||||
from . import settings
|
from . import settings
|
||||||
|
|
||||||
|
@ -25,16 +26,16 @@ class BillManager(models.Manager):
|
||||||
class Bill(models.Model):
|
class Bill(models.Model):
|
||||||
OPEN = 'OPEN'
|
OPEN = 'OPEN'
|
||||||
CLOSED = 'CLOSED'
|
CLOSED = 'CLOSED'
|
||||||
SEND = 'SEND'
|
SENT = 'SENT'
|
||||||
RETURNED = 'RETURNED'
|
|
||||||
PAID = 'PAID'
|
PAID = 'PAID'
|
||||||
|
RETURNED = 'RETURNED'
|
||||||
BAD_DEBT = 'BAD_DEBT'
|
BAD_DEBT = 'BAD_DEBT'
|
||||||
STATUSES = (
|
STATUSES = (
|
||||||
(OPEN, _("Open")),
|
(OPEN, _("Open")),
|
||||||
(CLOSED, _("Closed")),
|
(CLOSED, _("Closed")),
|
||||||
(SEND, _("Sent")),
|
(SENT, _("Sent")),
|
||||||
(RETURNED, _("Returned")),
|
|
||||||
(PAID, _("Paid")),
|
(PAID, _("Paid")),
|
||||||
|
(RETURNED, _("Returned")),
|
||||||
(BAD_DEBT, _("Bad debt")),
|
(BAD_DEBT, _("Bad debt")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -50,6 +51,9 @@ class Bill(models.Model):
|
||||||
blank=True)
|
blank=True)
|
||||||
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
|
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
|
||||||
related_name='%(class)s')
|
related_name='%(class)s')
|
||||||
|
payment_source = models.ForeignKey('payments.PaymentSource', null=True,
|
||||||
|
verbose_name=_("payment source"),
|
||||||
|
help_text=_("Optionally specify a payment source for this bill"))
|
||||||
type = models.CharField(_("type"), max_length=16, choices=TYPES)
|
type = models.CharField(_("type"), max_length=16, choices=TYPES)
|
||||||
status = models.CharField(_("status"), max_length=16, choices=STATUSES,
|
status = models.CharField(_("status"), max_length=16, choices=STATUSES,
|
||||||
default=OPEN)
|
default=OPEN)
|
||||||
|
@ -111,8 +115,26 @@ class Bill(models.Model):
|
||||||
prefix=prefix, year=year, number=number)
|
prefix=prefix, year=year, number=number)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.status = self.CLOSED
|
|
||||||
self.html = self.render()
|
self.html = self.render()
|
||||||
|
self.status = self.CLOSED
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def send(self):
|
||||||
|
from orchestra.apps.contacts.models import Contact
|
||||||
|
self.account.send_email(
|
||||||
|
template=settings.BILLS_EMAIL_NOTIFICATION_TEMPLATE,
|
||||||
|
context={
|
||||||
|
'bill': self,
|
||||||
|
},
|
||||||
|
contacts=(Contact.BILLING,),
|
||||||
|
attachments=[
|
||||||
|
('%s.pdf' % self.number, html_to_pdf(self.html), 'application/pdf')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.transactions.create(
|
||||||
|
bill=self, source=self.payment_source, amount=self.get_total()
|
||||||
|
)
|
||||||
|
self.status = self.SENT
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
|
|
|
@ -29,3 +29,8 @@ BILLS_SELLER_PHONE = getattr(settings, 'BILLS_SELLER_PHONE', '111-112-11-222')
|
||||||
BILLS_SELLER_EMAIL = getattr(settings, 'BILLS_SELLER_EMAIL', 'sales@orchestra.lan')
|
BILLS_SELLER_EMAIL = getattr(settings, 'BILLS_SELLER_EMAIL', 'sales@orchestra.lan')
|
||||||
|
|
||||||
BILLS_SELLER_WEBSITE = getattr(settings, 'BILLS_SELLER_WEBSITE', 'www.orchestra.lan')
|
BILLS_SELLER_WEBSITE = getattr(settings, 'BILLS_SELLER_WEBSITE', 'www.orchestra.lan')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
BILLS_EMAIL_NOTIFICATION_TEMPLATE = getattr(settings, 'BILLS_EMAIL_NOTIFICATION_TEMPLATE',
|
||||||
|
'bills/bill-notification.email')
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n l10n staticfiles admin_urls %}
|
||||||
|
|
||||||
|
{% block extrastyle %}
|
||||||
|
{{ block.super }}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
TODO
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Are you sure you want to close selected bills</h1>
|
||||||
|
<p>Once a bill is closed it can not be further modified.</p>
|
||||||
|
<p>Please select a payment source for the selected bills </p>
|
||||||
|
<form action="" method="post">{% csrf_token %}
|
||||||
|
<div>
|
||||||
|
<div style="margin:20px;">
|
||||||
|
{{ form.as_admin }}
|
||||||
|
</div>
|
||||||
|
{% for obj in queryset %}
|
||||||
|
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />
|
||||||
|
{% endfor %}
|
||||||
|
<input type="hidden" name="action" value="close_selected_bills"/>
|
||||||
|
<input type="submit" value="{% trans "Yes, close bills" %}" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% if subject %}Bill {{ bill.number }}{% endif %}
|
||||||
|
{% if message %}Find attached your invoice{% endif %}
|
|
@ -7,14 +7,35 @@ from orchestra.models.fields import MultiSelectField
|
||||||
from . import settings
|
from . import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ContactQuerySet(models.QuerySet):
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
usages = kwargs.pop('email_usages', [])
|
||||||
|
qs = models.Q()
|
||||||
|
for usage in usages:
|
||||||
|
qs = qs | models.Q(email_usage__regex=r'.*(^|,)+%s($|,)+.*' % usage)
|
||||||
|
return super(ContactQuerySet, self).filter(qs, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Contact(models.Model):
|
class Contact(models.Model):
|
||||||
|
BILLING = 'BILLING'
|
||||||
|
EMAIL_USAGES = (
|
||||||
|
('SUPPORT', _("Support tickets")),
|
||||||
|
('ADMIN', _("Administrative")),
|
||||||
|
(BILLING, _("Billing")),
|
||||||
|
('TECH', _("Technical")),
|
||||||
|
('ADDS', _("Announcements")),
|
||||||
|
('EMERGENCY', _("Emergency contact")),
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = ContactQuerySet.as_manager()
|
||||||
|
|
||||||
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
|
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
|
||||||
related_name='contacts', null=True)
|
related_name='contacts', null=True)
|
||||||
short_name = models.CharField(_("short name"), max_length=128)
|
short_name = models.CharField(_("short name"), max_length=128)
|
||||||
full_name = models.CharField(_("full name"), max_length=256, blank=True)
|
full_name = models.CharField(_("full name"), max_length=256, blank=True)
|
||||||
email = models.EmailField()
|
email = models.EmailField()
|
||||||
email_usage = MultiSelectField(_("email usage"), max_length=256, blank=True,
|
email_usage = MultiSelectField(_("email usage"), max_length=256, blank=True,
|
||||||
choices=settings.CONTACTS_EMAIL_USAGES,
|
choices=EMAIL_USAGES,
|
||||||
default=settings.CONTACTS_DEFAULT_EMAIL_USAGES)
|
default=settings.CONTACTS_DEFAULT_EMAIL_USAGES)
|
||||||
phone = models.CharField(_("phone"), max_length=32, blank=True)
|
phone = models.CharField(_("phone"), max_length=32, blank=True)
|
||||||
phone2 = models.CharField(_("alternative phone"), max_length=32, blank=True)
|
phone2 = models.CharField(_("alternative phone"), max_length=32, blank=True)
|
||||||
|
|
|
@ -3,12 +3,11 @@ from rest_framework import serializers
|
||||||
from orchestra.api.serializers import MultiSelectField
|
from orchestra.api.serializers import MultiSelectField
|
||||||
from orchestra.apps.accounts.serializers import AccountSerializerMixin
|
from orchestra.apps.accounts.serializers import AccountSerializerMixin
|
||||||
|
|
||||||
from . import settings
|
|
||||||
from .models import Contact, InvoiceContact
|
from .models import Contact, InvoiceContact
|
||||||
|
|
||||||
|
|
||||||
class ContactSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
|
class ContactSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
|
||||||
email_usage = MultiSelectField(choices=settings.CONTACTS_EMAIL_USAGES)
|
email_usage = MultiSelectField(choices=Contact.EMAIL_USAGES)
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Contact
|
model = Contact
|
||||||
fields = (
|
fields = (
|
||||||
|
|
|
@ -2,16 +2,6 @@ from django.conf import settings
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
CONTACTS_EMAIL_USAGES = getattr(settings, 'CONTACTS_EMAIL_USAGES', (
|
|
||||||
('SUPPORT', _("Support tickets")),
|
|
||||||
('ADMIN', _("Administrative")),
|
|
||||||
('BILL', _("Billing")),
|
|
||||||
('TECH', _("Technical")),
|
|
||||||
('ADDS', _("Announcements")),
|
|
||||||
('EMERGENCY', _("Emergency contact")),
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
CONTACTS_DEFAULT_EMAIL_USAGES = getattr(settings, 'CONTACTS_DEFAULT_EMAIL_USAGES',
|
CONTACTS_DEFAULT_EMAIL_USAGES = getattr(settings, 'CONTACTS_DEFAULT_EMAIL_USAGES',
|
||||||
('SUPPORT', 'ADMIN', 'BILL', 'TECH', 'ADDS', 'EMERGENCY')
|
('SUPPORT', 'ADMIN', 'BILL', 'TECH', 'ADDS', 'EMERGENCY')
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.db.models import Q
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.apps.contacts import settings as contacts_settings
|
from orchestra.apps.contacts import settings as contacts_settings
|
||||||
|
from orchestra.apps.contacts.models import Contact
|
||||||
from orchestra.models.fields import MultiSelectField
|
from orchestra.models.fields import MultiSelectField
|
||||||
from orchestra.utils import send_email_template
|
from orchestra.utils import send_email_template
|
||||||
|
|
||||||
|
@ -14,7 +15,7 @@ class Queue(models.Model):
|
||||||
name = models.CharField(_("name"), max_length=128, unique=True)
|
name = models.CharField(_("name"), max_length=128, unique=True)
|
||||||
default = models.BooleanField(_("default"), default=False)
|
default = models.BooleanField(_("default"), default=False)
|
||||||
notify = MultiSelectField(_("notify"), max_length=256, blank=True,
|
notify = MultiSelectField(_("notify"), max_length=256, blank=True,
|
||||||
choices=contacts_settings.CONTACTS_EMAIL_USAGES,
|
choices=Contact.EMAIL_USAGES,
|
||||||
default=contacts_settings.CONTACTS_DEFAULT_EMAIL_USAGES,
|
default=contacts_settings.CONTACTS_DEFAULT_EMAIL_USAGES,
|
||||||
help_text=_("Contacts to notify by email"))
|
help_text=_("Contacts to notify by email"))
|
||||||
|
|
||||||
|
|
|
@ -9,8 +9,8 @@
|
||||||
{% block breadcrumbs %}
|
{% block breadcrumbs %}
|
||||||
<div class="breadcrumbs">
|
<div class="breadcrumbs">
|
||||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||||
› <a href="{% url 'admin:app_list' app_label='orders' %}">Slices</a>
|
› <a href="{% url 'admin:app_list' app_label='orders' %}">Orders</a>
|
||||||
› <a href="{% url 'admin:orders_order_changelist' %}">Slices</a>
|
› <a href="{% url 'admin:orders_order_changelist' %}">Order</a>
|
||||||
› {{ title }}
|
› {{ title }}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
def process_transactions(modeladmin, request, queryset):
|
def process_transactions(modeladmin, request, queryset):
|
||||||
from .methods import SEPADirectDebit
|
for source, transactions in queryset.group_by('source'):
|
||||||
SEPADirectDebit().process(queryset)
|
if source:
|
||||||
|
source.method_class().process(transactions)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
@ -12,7 +13,7 @@ from .models import PaymentSource, Transaction, PaymentProcess
|
||||||
|
|
||||||
STATE_COLORS = {
|
STATE_COLORS = {
|
||||||
Transaction.WAITTING_PROCESSING: 'darkorange',
|
Transaction.WAITTING_PROCESSING: 'darkorange',
|
||||||
Transaction.WAITTING_CONFIRMATION: 'orange',
|
Transaction.WAITTING_CONFIRMATION: 'purple',
|
||||||
Transaction.CONFIRMED: 'green',
|
Transaction.CONFIRMED: 'green',
|
||||||
Transaction.REJECTED: 'red',
|
Transaction.REJECTED: 'red',
|
||||||
Transaction.LOCKED: 'magenta',
|
Transaction.LOCKED: 'magenta',
|
||||||
|
@ -20,14 +21,16 @@ STATE_COLORS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TransactionAdmin(admin.ModelAdmin):
|
class TransactionAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
'id', 'bill_link', 'account_link', 'source', 'display_state', 'amount'
|
'id', 'bill_link', 'account_link', 'source_link', 'display_state', 'amount'
|
||||||
)
|
)
|
||||||
list_filter = ('source__method', 'state')
|
list_filter = ('source__method', 'state')
|
||||||
actions = (process_transactions,)
|
actions = (process_transactions,)
|
||||||
|
filter_by_account_fields = ['source']
|
||||||
|
|
||||||
bill_link = admin_link('bill')
|
bill_link = admin_link('bill')
|
||||||
|
source_link = admin_link('source')
|
||||||
account_link = admin_link('bill__account')
|
account_link = admin_link('bill__account')
|
||||||
display_state = admin_colored('state', colors=STATE_COLORS)
|
display_state = admin_colored('state', colors=STATE_COLORS)
|
||||||
|
|
||||||
|
@ -39,8 +42,13 @@ class TransactionAdmin(admin.ModelAdmin):
|
||||||
class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin):
|
class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('label', 'method', 'number', 'account_link', 'is_active')
|
list_display = ('label', 'method', 'number', 'account_link', 'is_active')
|
||||||
list_filter = ('method', 'is_active')
|
list_filter = ('method', 'is_active')
|
||||||
form = SEPADirectDebit().get_form()
|
|
||||||
# TODO select payment source method
|
def get_form(self, request, obj=None, **kwargs):
|
||||||
|
if obj:
|
||||||
|
self.form = obj.method_class().get_form()
|
||||||
|
else:
|
||||||
|
self.form = forms.ModelForm
|
||||||
|
return super(PaymentSourceAdmin, self).get_form(request, obj=obj, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class PaymentProcessAdmin(admin.ModelAdmin):
|
class PaymentProcessAdmin(admin.ModelAdmin):
|
||||||
|
|
|
@ -29,7 +29,7 @@ class SEPADirectDebitSerializer(serializers.Serializer):
|
||||||
|
|
||||||
|
|
||||||
class SEPADirectDebit(PaymentMethod):
|
class SEPADirectDebit(PaymentMethod):
|
||||||
verbose_name = _("Direct Debit")
|
verbose_name = _("SEPA Direct Debit")
|
||||||
label_field = 'name'
|
label_field = 'name'
|
||||||
number_field = 'iban'
|
number_field = 'iban'
|
||||||
process_credit = True
|
process_credit = True
|
||||||
|
@ -154,10 +154,9 @@ class SEPADirectDebit(PaymentMethod):
|
||||||
def _get_debt_transactions(self, transactions):
|
def _get_debt_transactions(self, transactions):
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
self.object.transactions.add(transaction)
|
self.object.transactions.add(transaction)
|
||||||
# TODO transaction.account
|
account = transaction.account
|
||||||
account = transaction.bill.account
|
# TODO
|
||||||
# FIXME
|
data = account.paymentsources.first().data
|
||||||
data = account.payment_sources.first().data
|
|
||||||
transaction.state = transaction.WAITTING_CONFIRMATION
|
transaction.state = transaction.WAITTING_CONFIRMATION
|
||||||
transaction.save()
|
transaction.save()
|
||||||
yield E.DrctDbtTxInf( # Direct Debit Transaction Info
|
yield E.DrctDbtTxInf( # Direct Debit Transaction Info
|
||||||
|
@ -196,8 +195,7 @@ class SEPADirectDebit(PaymentMethod):
|
||||||
def _get_credit_transactions(self, transactions):
|
def _get_credit_transactions(self, transactions):
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
self.object.transactions.add(transaction)
|
self.object.transactions.add(transaction)
|
||||||
# TODO transaction.account
|
account = transaction.account
|
||||||
account = transaction.bill.account
|
|
||||||
# FIXME
|
# FIXME
|
||||||
data = account.payment_sources.first().data
|
data = account.payment_sources.first().data
|
||||||
transaction.state = transaction.WAITTING_CONFIRMATION
|
transaction.state = transaction.WAITTING_CONFIRMATION
|
||||||
|
|
|
@ -5,37 +5,46 @@ from django.utils.translation import ugettext_lazy as _
|
||||||
from jsonfield import JSONField
|
from jsonfield import JSONField
|
||||||
|
|
||||||
from orchestra.core import accounts
|
from orchestra.core import accounts
|
||||||
|
from orchestra.models.queryset import group_by
|
||||||
|
|
||||||
from . import settings
|
from . import settings
|
||||||
from .methods import PaymentMethod
|
from .methods import PaymentMethod
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentSourcesQueryset(models.QuerySet):
|
||||||
|
def get_source(self):
|
||||||
|
# TODO
|
||||||
|
return self.filter(is_active=True).first()
|
||||||
|
|
||||||
|
|
||||||
class PaymentSource(models.Model):
|
class PaymentSource(models.Model):
|
||||||
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
|
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
|
||||||
related_name='payment_sources')
|
related_name='paymentsources')
|
||||||
method = models.CharField(_("method"), max_length=32,
|
method = models.CharField(_("method"), max_length=32,
|
||||||
choices=PaymentMethod.get_plugin_choices())
|
choices=PaymentMethod.get_plugin_choices())
|
||||||
data = JSONField(_("data"))
|
data = JSONField(_("data"))
|
||||||
is_active = models.BooleanField(_("is active"), default=True)
|
is_active = models.BooleanField(_("is active"), default=True)
|
||||||
|
|
||||||
|
objects = PaymentSourcesQueryset.as_manager()
|
||||||
|
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
return self.label or str(self.account)
|
return "%s (%s)" % (self.label, self.method_class.verbose_name)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def method_class(self):
|
||||||
|
return PaymentMethod.get_plugin(self.method)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def label(self):
|
def label(self):
|
||||||
try:
|
return self.method_class().get_label(self.data)
|
||||||
plugin = PaymentMethod.get_plugin(self.method)()
|
|
||||||
except KeyError:
|
|
||||||
return None
|
|
||||||
return plugin.get_label(self.data)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def number(self):
|
def number(self):
|
||||||
try:
|
return self.method_class().get_number(self.data)
|
||||||
plugin = PaymentMethod.get_plugin(self.method)()
|
|
||||||
except KeyError:
|
|
||||||
return None
|
class TransactionQuerySet(models.QuerySet):
|
||||||
return plugin.get_number(self.data)
|
group_by = group_by
|
||||||
|
|
||||||
|
|
||||||
# TODO lock transaction in waiting confirmation
|
# TODO lock transaction in waiting confirmation
|
||||||
|
@ -55,7 +64,8 @@ class Transaction(models.Model):
|
||||||
(DISCARTED, _("Discarted")),
|
(DISCARTED, _("Discarted")),
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO account fk?
|
objects = TransactionQuerySet.as_manager()
|
||||||
|
|
||||||
bill = models.ForeignKey('bills.bill', verbose_name=_("bill"),
|
bill = models.ForeignKey('bills.bill', verbose_name=_("bill"),
|
||||||
related_name='transactions')
|
related_name='transactions')
|
||||||
source = models.ForeignKey(PaymentSource, null=True, blank=True,
|
source = models.ForeignKey(PaymentSource, null=True, blank=True,
|
||||||
|
@ -66,11 +76,14 @@ class Transaction(models.Model):
|
||||||
currency = models.CharField(max_length=10, default=settings.PAYMENT_CURRENCY)
|
currency = models.CharField(max_length=10, default=settings.PAYMENT_CURRENCY)
|
||||||
created_on = models.DateTimeField(auto_now_add=True)
|
created_on = models.DateTimeField(auto_now_add=True)
|
||||||
modified_on = models.DateTimeField(auto_now=True)
|
modified_on = models.DateTimeField(auto_now=True)
|
||||||
related = models.ForeignKey('self', null=True, blank=True)
|
|
||||||
|
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
return "Transaction {}".format(self.id)
|
return "Transaction {}".format(self.id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account(self):
|
||||||
|
return self.bill.account
|
||||||
|
|
||||||
|
|
||||||
# TODO rename to TransactionProcess or PaymentRequest TransactionRequest
|
# TODO rename to TransactionProcess or PaymentRequest TransactionRequest
|
||||||
class PaymentProcess(models.Model):
|
class PaymentProcess(models.Model):
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.template.loader import render_to_string
|
||||||
from django.template import Context
|
from django.template import Context
|
||||||
|
|
||||||
|
|
||||||
def send_email_template(template, context, to, email_from=None, html=None):
|
def send_email_template(template, context, to, email_from=None, html=None, attachments=[]):
|
||||||
"""
|
"""
|
||||||
Renders an email template with this format:
|
Renders an email template with this format:
|
||||||
{% if subject %}Subject{% endif %}
|
{% if subject %}Subject{% endif %}
|
||||||
|
@ -32,7 +32,7 @@ def send_email_template(template, context, to, email_from=None, html=None):
|
||||||
#subject cannot have new lines
|
#subject cannot have new lines
|
||||||
subject = render_to_string(template, {'subject': True}, context).strip()
|
subject = render_to_string(template, {'subject': True}, context).strip()
|
||||||
message = render_to_string(template, {'message': True}, context)
|
message = render_to_string(template, {'message': True}, context)
|
||||||
msg = EmailMultiAlternatives(subject, message, email_from, to)
|
msg = EmailMultiAlternatives(subject, message, email_from, to, attachments=attachments)
|
||||||
if html:
|
if html:
|
||||||
html_message = render_to_string(html, {'message': True}, context)
|
html_message = render_to_string(html, {'message': True}, context)
|
||||||
msg.attach_alternative(html_message, "text/html")
|
msg.attach_alternative(html_message, "text/html")
|
||||||
|
|
Loading…
Reference in a new issue