Preliminar implementation of SEPA payment system

This commit is contained in:
Marc 2014-07-29 14:29:59 +00:00
parent bef7af084b
commit 30cc1d9922
14 changed files with 333 additions and 29 deletions

View file

@ -2,7 +2,7 @@ from django import forms
from django.contrib import admin
from django.forms.models import BaseInlineFormSet
from .utils import set_default_filter
from .utils import set_url_query
class ExtendedModelAdmin(admin.ModelAdmin):
@ -55,9 +55,9 @@ class ChangeListDefaultFilter(object):
def changelist_view(self, request, extra_context=None):
""" Default filter as 'my_nodes=True' """
defaults = []
for queryarg, value in self.default_changelist_filters:
set_default_filter(queryarg, request, value)
defaults.append(queryarg)
for key, value in self.default_changelist_filters:
set_url_query(request, key, value)
defaults.append(key)
# hack response cl context in order to hook default filter awaearness into search_form.html template
response = super(ChangeListDefaultFilter, self).changelist_view(request, extra_context=extra_context)
if hasattr(response, 'context_data') and 'cl' in response.context_data:

View file

@ -63,13 +63,13 @@ def wrap_admin_view(modeladmin, view):
return update_wrapper(wrapper, view)
def set_default_filter(queryarg, request, value):
def set_url_query(request, key, value):
""" set default filters for changelist_view """
if queryarg not in request.GET:
if key not in request.GET:
request_copy = request.GET.copy()
if callable(value):
value = value(request)
request_copy[queryarg] = value
request_copy[key] = value
request.GET = request_copy
request.META['QUERY_STRING'] = request.GET.urlencode()

View file

@ -5,10 +5,11 @@ from django.contrib.admin.util import unquote
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from django.utils.safestring import mark_safe
from django.utils.six.moves.urllib.parse import parse_qsl
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import wrap_admin_view, admin_link
from orchestra.admin.utils import wrap_admin_view, admin_link, set_url_query
from orchestra.core import services, accounts
from .filters import HasMainUserListFilter
@ -129,6 +130,8 @@ class AccountAdminMixin(object):
""" Provide basic account support to ModelAdmin and AdminInline classes """
readonly_fields = ('account_link',)
filter_by_account_fields = []
change_list_template = 'admin/accounts/account/change_list.html'
change_form_template = 'admin/accounts/account/change_form.html'
def account_link(self, instance):
account = instance.account if instance.pk else self.account
@ -162,6 +165,48 @@ class AccountAdminMixin(object):
formfield.queryset = formfield.queryset.filter(account=self.account)
return formfield
def get_account_from_preserve_filters(self, request):
preserved_filters = self.get_preserved_filters(request)
preserved_filters = dict(parse_qsl(preserved_filters))
cl_filters = preserved_filters.get('_changelist_filters')
if cl_filters:
return dict(parse_qsl(cl_filters)).get('account')
def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
account_id = self.get_account_from_preserve_filters(request)
verb = 'change' if object_id else 'add'
if not object_id:
if account_id:
# Preselect account
set_url_query(request, 'account', account_id)
context = {
'from_account': bool(account_id),
'account': not account_id or Account.objects.get(pk=account_id),
'account_opts': Account._meta,
}
context.update(extra_context or {})
return super(AccountAdminMixin, self).changeform_view(request,
object_id=object_id, form_url=form_url, extra_context=context)
def changelist_view(self, request, extra_context=None):
account_id = request.GET.get('account')
context = {
'from_account': False
}
if account_id:
opts = self.model._meta
account = Account.objects.get(pk=account_id)
context = {
'from_account': True,
'title': _("Select %s to change for %s") % (
opts.verbose_name, account.name),
'account': not account_id or Account.objects.get(pk=account_id),
'account_opts': Account._meta,
}
context.update(extra_context or {})
return super(AccountAdminMixin, self).changelist_view(request,
extra_context=context)
class SelectAccountAdminMixin(AccountAdminMixin):
""" Provides support for accounts on ModelAdmin """
@ -196,14 +241,21 @@ class SelectAccountAdminMixin(AccountAdminMixin):
def add_view(self, request, form_url='', extra_context=None):
""" Redirects to select account view if required """
if request.user.is_superuser:
if 'account' in request.GET or Account.objects.count() == 1:
from_account_id = self.get_account_from_preserve_filters(request)
if from_account_id:
set_url_query(request, 'account', from_account_id)
account_id = request.GET.get('account')
if account_id or Account.objects.count() == 1:
kwargs = {}
if 'account' in request.GET:
kwargs = dict(pk=request.GET['account'])
if account_id:
kwargs = dict(pk=account_id)
self.account = Account.objects.get(**kwargs)
opts = self.model._meta
context = {
'title': _("Add %s for %s") % (opts.verbose_name, self.account.name)
'title': _("Add %s for %s") % (opts.verbose_name, self.account.name),
'from_account': bool(from_account_id),
'account': self.account,
'account_opts': Account._meta,
}
context.update(extra_context or {})
return super(AccountAdminMixin, self).add_view(request,

View file

@ -2,18 +2,34 @@
{% load i18n admin_urls admin_static admin_modify %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
{% if from_account %}
&rsaquo; <a href="{% url 'admin:app_list' app_label=account_opts.app_label %}">{{ account_opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url account_opts|admin_urlname:'changelist' %}">{{ account_opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url account_opts|admin_urlname:'change' account.pk|admin_urlquote %}">{{ account|truncatewords:"18" }}</a>
&rsaquo; {% if has_change_permission %}<a href="{% url opts|admin_urlname:'changelist' %}?account={{ account.pk }}">{{ opts.verbose_name_plural|capfirst }}</a>{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
{% else %}
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; {% if has_change_permission %}<a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
{% endif %}
&rsaquo; {% if add %}{% trans 'Add' %} {{ opts.verbose_name }}{% else %}{{ original|truncatewords:"18" }}{% endif %}
</div>
{% endblock %}
{% block object-tools-items %}
{% if services %}
{% for service in services %}
<li>
<a href="{% url service|admin_urlname:'changelist' %}?account={{ original.pk }}" class="historylink">{{ service.verbose_name_plural|capfirst }}</a>
</li>
{% endfor %}
</ul>
<h5 style="visibility:hidden; margin: 1.5em 1.5em 0;">Account</h5>
{% endif %}
{% if accounts %}
<ul class="object-tools">
{% for account in accounts %}
<li>
@ -21,10 +37,9 @@
</li>
{% endfor %}
</ul>
</p>
<h5 style="visibility:hidden; margin: 1.5em 1.5em 0;">a</h5>
<ul class="object-tools">
{% endif %}
<li>
<a href="disable/" class="historylink">{% trans "Disable" %}</a>
</li>
@ -32,6 +47,5 @@
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
<a href="{% add_preserved_filters history_url %}" class="historylink">{% trans "History" %}</a>
</li>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
{% if from_account %}
&rsaquo; <a href="{% url 'admin:app_list' app_label=account_opts.app_label %}">{{ account_opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url account_opts|admin_urlname:'changelist' %}">{{ account_opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url account_opts|admin_urlname:'change' account.pk|admin_urlquote %}">{{ account|truncatewords:"18" }}</a>
{% else %}
&rsaquo; <a href="{% url 'admin:app_list' app_label=cl.opts.app_label %}">{{ cl.opts.app_config.verbose_name }}</a>
{% endif %}
&rsaquo; {{ cl.opts.verbose_name_plural|capfirst }}
</div>
{% endblock %}
{% block object-tools-items %}
{% if from_account %}
<li>
<a href="./" class="historylink">{% trans 'Show all' %}</a>
</li>
{% endif %}
<li>
{% url cl.opts|admin_urlname:'add' as add_url %}
<a href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink">
{% blocktrans with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktrans %}
</a>
</li>
{% endblock %}

View file

@ -12,9 +12,16 @@ from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget,
class BillLineInline(admin.TabularInline):
model = BillLine
fields = (
'description', 'initial_date', 'final_date', 'price', 'amount', 'tax'
)
class BudgetLineInline(admin.TabularInline):
model = Budget
fields = (
'description', 'initial_date', 'final_date', 'price', 'amount', 'tax'
)
class BillAdmin(AccountAdminMixin, admin.ModelAdmin):

View file

@ -1,10 +1,11 @@
from django import forms
from django.contrib import admin
from django.utils.translation import ugettext, ugettext_lazy as _
from orchestra.admin import AtLeastOneRequiredInlineFormSet
from orchestra.admin.utils import insertattr
from orchestra.apps.accounts.admin import AccountAdmin, AccountAdminMixin
from orchestra.forms.widgets import paddingCheckboxSelectMultiple
from .filters import HasInvoiceContactListFilter
from .models import Contact, InvoiceContact
@ -19,6 +20,50 @@ class ContactAdmin(AccountAdminMixin, admin.ModelAdmin):
'contact__user__username', 'short_name', 'full_name', 'phone', 'phone2',
'email'
)
fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('account_link', 'short_name', 'full_name')
}),
(_("Email"), {
'classes': ('wide',),
'fields': ('email', 'email_usage',)
}),
(_("Phone"), {
'classes': ('wide',),
'fields': ('phone', 'phone2'),
}),
(_("Postal address"), {
'classes': ('wide',),
'fields': ('address', ('zipcode', 'city'), 'country')
}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('account', 'short_name', 'full_name')
}),
(_("Email"), {
'classes': ('wide',),
'fields': ('email', 'email_usage',)
}),
(_("Phone"), {
'classes': ('wide',),
'fields': ('phone', 'phone_alternative'),
}),
(_("Postal address"), {
'classes': ('wide',),
'fields': ('address', ('zip_code', 'city'), 'country')
}),
)
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
if db_field.name == 'address':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
if db_field.name == 'email_usage':
kwargs['widget'] = paddingCheckboxSelectMultiple(130)
return super(ContactAdmin, self).formfield_for_dbfield(db_field, **kwargs)
admin.site.register(Contact, ContactAdmin)
@ -32,6 +77,8 @@ class InvoiceContactInline(admin.StackedInline):
""" Make value input widget bigger """
if db_field.name == 'address':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
if db_field.name == 'email_usage':
kwargs['widget'] = paddingCheckboxSelectMultiple(45)
return super(InvoiceContactInline, self).formfield_for_dbfield(db_field, **kwargs)
@ -62,6 +109,9 @@ insertattr(AccountAdmin, 'inlines', ContactInline)
insertattr(AccountAdmin, 'inlines', InvoiceContactInline)
insertattr(AccountAdmin, 'list_display', has_invoice)
insertattr(AccountAdmin, 'list_filter', HasInvoiceContactListFilter)
for field in ('contacts__short_name', 'contacts__full_name', 'contacts__phone',
'contacts__phone2', 'contacts__email'):
search_fields = (
'contacts__short_name', 'contacts__full_name', 'contacts__phone',
'contacts__phone2', 'contacts__email'
)
for field in search_fields:
insertattr(AccountAdmin, 'search_fields', field)

View file

@ -1,6 +1,7 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from orchestra.core import accounts
from orchestra.models.fields import MultiSelectField
from . import settings
@ -15,8 +16,8 @@ class Contact(models.Model):
email_usage = MultiSelectField(_("email usage"), max_length=256, blank=True,
choices=settings.CONTACTS_EMAIL_USAGES,
default=settings.CONTACTS_DEFAULT_EMAIL_USAGES)
phone = models.CharField(_("Phone"), max_length=32, blank=True)
phone2 = models.CharField(_("Alternative 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)
address = models.TextField(_("address"), blank=True)
city = models.CharField(_("city"), max_length=128, blank=True,
default=settings.CONTACTS_DEFAULT_CITY)
@ -39,3 +40,6 @@ class InvoiceContact(models.Model):
country = models.CharField(_("country"), max_length=20,
default=settings.CONTACTS_DEFAULT_COUNTRY)
vat = models.CharField(_("VAT number"), max_length=64)
accounts.register(Contact)

View file

@ -88,7 +88,7 @@ class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
('is_active', 'True'),
)
content_object_link = admin_link('content_object')
content_object_link = admin_link('content_object', order=False)
display_registered_on = admin_date('registered_on')
display_cancelled_on = admin_date('cancelled_on')

View file

@ -186,7 +186,7 @@ class Service(models.Model):
def clean(self):
content_type = self.handler.get_content_type()
if self.content_type != content_type:
msg =_("Content type must be equal to '%s'." % str(content_type))
msg =_("Content type must be equal to '%s'.") % str(content_type)
raise ValidationError(msg)
if not self.match:
msg =_("Match should be provided")

View file

@ -1,3 +1,9 @@
import random
import string
from lxml import etree
from lxml.builder import E
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from django_iban.validators import IBANValidator, IBAN_COUNTRY_CODE_LENGTH
from rest_framework import serializers
@ -45,6 +51,116 @@ class BankTransfer(PaymentMethod):
form = BankTransferForm
serializer = BankTransferSerializer
def set_id(self):
size=6
chars=string.ascii_uppercase + string.digits
self.payment_id = ''.join(random.choice(chars) for _ in range(size))
def _process_transactions(self, transactions):
for transaction in transactions:
account = transaction.account
data = transaction.data
transaction.info = self.payment_id
transaction.state = transaction.WAITTING_CONFIRMATION
transaction.save()
yield E.DrctDbtTxInf( # Direct Debit Transaction Info
E.PmtId( # Payment Id
E.EndToEndId(str(transaction.id)) # Payment Id/End to End
),
E.InstdAmt(transaction.amount, Ccy="EUR"), # Instructed Amount
E.DrctDbtTx( # Direct Debit Transaction
E.MndtRltdInf( # Mandate Related Info
E.MndtId(str(account.id)), # Mandate Id
E.DtOfSgntr( # Date of Signature
account.registered_on.strfrm("%Y-%m-%d")
)
)
),
E.DbtrAgt( # Debtor Agent
E.FinInstnId( # Financial Institution Id
E.Othr(
E.Id('NOTPROVIDED')
)
)
),
E.Dbtr( # Debtor
E.Nm(account.name), # Name
),
E.DbtrAcct( # Debtor Account
E.Id(
E.IBAN(data['iban'])
),
),
)
def process(self, transactions)
self.set_id()
creditor_name = settings.PAYMENTS_DD_CREDITOR_NAME
creditor_iban = settings.PAYMENTS_DD_CREDITOR_IBAN
creditor_bic = settings.PAYMENTS_DD_CREDITOR_BIC
creditor_at02_id = settings.PAYMENTS_DD_CREDITOR_AT02_ID
now = timezone.now()
total = str(sum([transaction.amount for transaction in transactions]))
sepa = E.Document(
E.CstmrDrctDbtInitn(
E.GrpHdr( # Group Header
E.MsgId(self.payment_id), # Message Id
E.CreDtTm(now.strftime("%Y-%m-%dT%H:%M:%S")), # Creation Date Time
E.NbOfTxs(str(len(transactions))), # Number of Transactions
E.CtrlSum(total), # Control Sum
E.InitgPty( # Initiating Party
E.Nm(creditor_name), # Name
E.Id( # Identification
E.OrgId( # Organisation Id
E.Othr(
E.Id(creditor_at_02)
)
)
)
)
),
E.PmtInf( # Payment Info
E.PmtInfId(self.payment_id), # Payment Id
E.PmtMtd("DD"), # Payment Method
E.NbOfTxs(str(len(transactions))), # Number of Transactions
E.CtrlSum(total), # Control Sum
E.PmtTpInf( # Payment Type Info
E.SvcLvl( # Service Level
E.Cd("SEPA") # Code
),
E.LclInstrm( # Local Instrument
E.Cd("CORE") # Code
),
E.SeqTp("RCUR") # Sequence Type
),
E.ReqdColltnDt(now.strfrm("%Y-%m-%d")), # Requested Collection Date
E.Cdtr( # Creditor
E.Nm(creditor_name)
),
E.CdtrAcct( # Creditor Account
E.Id(
E.IBAN(creditor_iban)
)
),
E.CdtrAgt( # Creditor Agent
E.FinInstnId( # Financial Institution Id
E.BIC(creditor_bic)
)
),
*list(self._process_transactions(transactions)) # Transactions
)
), {
'xmlns': "urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
'xmlns:xsi': "http://www.w3.org/2001/XMLSchema-instance"
}
)
# http://www.iso20022.org/documents/messages/1_0_version/pain/schemas/pain.008.001.02.zip
schema = etree.parse('pain.008.001.02.xsd')
schema.assertValid(sepa)
# TODO where to save this shit?
# TODO new model? Payment with batch support, How this relates to transaction?
return etree.tostring(page, pretty_print=True, xml_declaration=True)
class CreditCard(PaymentMethod):
verbose_name = _("Credit card")

View file

@ -2,3 +2,13 @@ from django.conf import settings
PAYMENT_CURRENCY = getattr(settings, 'PAYMENT_CURRENCY', 'Eur')
PAYMENTS_DD_CREDITOR_NAME = getattr(settings, 'PAYMENTS_DD_CREDITOR_NAME',
'Orchestra')
PAYMENTS_DD_CREDITOR_IBAN = getattr(settings, 'PAYMENTS_DD_CREDITOR_IBAN',
'InvalidIBAN')
PAYMENTS_DD_CREDITOR_BIC = getattr(settings, 'PAYMENTS_DD_CREDITOR_BIC',
'InvalidBIC')
PAYMENTS_DD_CREDITOR_AT02_ID = getattr(settings, 'PAYMENTS_DD_CREDITOR_AT02_ID',
'InvalidAT02ID')

View file

@ -89,7 +89,7 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
def display_mailboxes(self, address):
boxes = []
for mailbox in address.mailboxes():
for mailbox in address.mailboxes.all():
user = mailbox.user
url = reverse('admin:users_user_mailbox_change', args=(user.pk,))
boxes.append('<a href="%s">%s</a>' % (url, user.username))

View file

@ -1,3 +1,5 @@
import re
from django import forms
from django.utils.safestring import mark_safe
from django.utils.encoding import force_text
@ -20,9 +22,13 @@ class ShowTextWidget(forms.Widget):
else:
final_value = '<br/>'.join(value.split('\n'))
if self.warning:
final_value = u'<ul class="messagelist"><li class="warning">%s</li></ul>' %(final_value)
final_value = (
u'<ul class="messagelist"><li class="warning">%s</li></ul>'
% final_value)
if self.hidden:
final_value = u'%s<input type="hidden" name="%s" value="%s"/>' % (final_value, name, value)
final_value = (
u'%s<input type="hidden" name="%s" value="%s"/>'
% (final_value, name, value))
return mark_safe(final_value)
def _has_changed(self, initial, data):
@ -44,3 +50,16 @@ class ReadOnlyWidget(forms.Widget):
def value_from_datadict(self, data, files, name):
return self.original_value
def paddingCheckboxSelectMultiple(padding):
""" Ugly hack to render this widget nicely on Django admin """
widget = forms.CheckboxSelectMultiple()
old_render = widget.render
def render(self, *args, **kwargs):
value = old_render(self, *args, **kwargs)
value = re.sub(r'^<ul id=(.*)>',
r'<ul id=\1 style="padding-left:%ipx">' % padding, value, 1)
return mark_safe(value)
widget.render = render
return widget