From 13df74228470e3b91327381e65b4e11100df9292 Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 4 Sep 2014 15:55:43 +0000 Subject: [PATCH] Refactor payment methods plugability --- TODO.md | 2 + orchestra/admin/options.py | 1 - orchestra/admin/utils.py | 6 +- orchestra/apps/accounts/admin.py | 11 +- orchestra/apps/accounts/models.py | 7 + orchestra/apps/bills/actions.py | 23 ++-- orchestra/apps/bills/admin.py | 22 +-- .../apps/bills/migrations/0001_initial.py | 126 ++++++++++++++++++ .../migrations/0002_bill_payment_source.py | 21 +++ orchestra/apps/bills/migrations/__init__.py | 0 orchestra/apps/bills/models.py | 32 ++++- orchestra/apps/bills/settings.py | 5 + .../admin/bills/close_confirmation.html | 34 +++++ .../templates/bills/bill-notification.email | 2 + orchestra/apps/contacts/models.py | 23 +++- orchestra/apps/contacts/serializers.py | 3 +- orchestra/apps/contacts/settings.py | 10 -- orchestra/apps/issues/models.py | 3 +- .../orders/order/bill_selected_options.html | 4 +- orchestra/apps/payments/actions.py | 6 +- orchestra/apps/payments/admin.py | 18 ++- .../apps/payments/methods/sepadirectdebit.py | 12 +- orchestra/apps/payments/models.py | 41 ++++-- orchestra/utils/options.py | 10 +- 24 files changed, 340 insertions(+), 82 deletions(-) create mode 100644 orchestra/apps/bills/migrations/0001_initial.py create mode 100644 orchestra/apps/bills/migrations/0002_bill_payment_source.py create mode 100644 orchestra/apps/bills/migrations/__init__.py create mode 100644 orchestra/apps/bills/templates/admin/bills/close_confirmation.html create mode 100644 orchestra/apps/bills/templates/bills/bill-notification.email diff --git a/TODO.md b/TODO.md index 57e0b36a..2b7b6e70 100644 --- a/TODO.md +++ b/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. * LAST version of this shit http://wkhtmltopdf.org/downloads.html +* Rename pack to plan ? one can have multiple plans? +* transaction.process FK? diff --git a/orchestra/admin/options.py b/orchestra/admin/options.py index 53d65d81..714e74f0 100644 --- a/orchestra/admin/options.py +++ b/orchestra/admin/options.py @@ -73,7 +73,6 @@ class ChangeViewActionsMixin(object): view.url_name.capitalize().replace('_', ' ')) view.css_class = getattr(action, 'css_class', 'historylink') view.description = getattr(action, 'description', '') - view.__name__ = action.__name__ views.append(view) return views diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py index a8ec4855..90c9d1ca 100644 --- a/orchestra/admin/utils.py +++ b/orchestra/admin/utils.py @@ -1,4 +1,4 @@ -from functools import update_wrapper +from functools import wraps from django.conf import settings from django.contrib import admin @@ -59,9 +59,10 @@ def insertattr(model, name, value, weight=0): def wrap_admin_view(modeladmin, view): """ Add admin authentication to view """ + @wraps(view) def wrapper(*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): @@ -77,6 +78,7 @@ def set_url_query(request, key, value): def action_to_view(action, modeladmin): """ Converts modeladmin action to view function """ + @wraps(action) def action_view(request, object_id=1, modeladmin=modeladmin, action=action): queryset = modeladmin.model.objects.filter(pk=object_id) response = action(modeladmin, request, queryset) diff --git a/orchestra/apps/accounts/admin.py b/orchestra/apps/accounts/admin.py index 2f0c7eef..9d61bb13 100644 --- a/orchestra/apps/accounts/admin.py +++ b/orchestra/apps/accounts/admin.py @@ -142,6 +142,12 @@ class AccountAdminMixin(object): account_link.allow_tags = True 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): """ Select related for performance """ qs = super(AccountAdminMixin, self).get_queryset(request) @@ -211,11 +217,6 @@ class AccountAdminMixin(object): class SelectAccountAdminMixin(AccountAdminMixin): """ 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): inlines = super(AccountAdminMixin, self).get_inline_instances(request, obj=obj) if hasattr(self, 'account'): diff --git a/orchestra/apps/accounts/models.py b/orchestra/apps/accounts/models.py index 1d555203..a0c7ce80 100644 --- a/orchestra/apps/accounts/models.py +++ b/orchestra/apps/accounts/models.py @@ -4,6 +4,7 @@ from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from orchestra.core import services +from orchestra.utils import send_email_template from . import settings @@ -30,6 +31,12 @@ class Account(models.Model): @classmethod def get_main(cls): 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) diff --git a/orchestra/apps/bills/actions.py b/orchestra/apps/bills/actions.py index 4a800ed7..c2aed2f5 100644 --- a/orchestra/apps/bills/actions.py +++ b/orchestra/apps/bills/actions.py @@ -7,20 +7,13 @@ from django.utils.translation import ugettext_lazy as _ 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): if queryset.count() > 1: stringio = StringIO.StringIO() archive = zipfile.ZipFile(stringio, 'w') 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.close() response = HttpResponse(stringio.getvalue(), content_type='application/pdf') @@ -35,14 +28,22 @@ download_bills.url_name = 'download' def view_bill(modeladmin, request, queryset): bill = queryset.get() - bill.html = bill.render() - return HttpResponse(bill.html) + html = bill.html or bill.render() + return HttpResponse(html) view_bill.verbose_name = _("View") view_bill.url_name = 'view' def close_bills(modeladmin, request, queryset): + # TODO confirmation with payment source selection for bill in queryset: bill.close() close_bills.verbose_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' diff --git a/orchestra/apps/bills/admin.py b/orchestra/apps/bills/admin.py index e6fbf3f3..a86064ba 100644 --- a/orchestra/apps/bills/admin.py +++ b/orchestra/apps/bills/admin.py @@ -11,7 +11,7 @@ from orchestra.admin.utils import admin_link, admin_date from orchestra.apps.accounts.admin import AccountAdminMixin 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 .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget, BillLine, BudgetLine) @@ -51,6 +51,7 @@ class BudgetLineInline(admin.TabularInline): fields = ('description', 'rate', 'amount', 'tax', 'total') +# TODO hide raw when status = oPen class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): list_display = ( 'number', 'status', 'type_link', 'account_link', 'created_on_display', @@ -68,8 +69,8 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): 'fields': ('html',), }), ) - actions = [render_bills, download_bills, close_bills] - change_view_actions = [render_bills, view_bill, download_bills] + actions = [download_bills, close_bills, send_bills] + change_view_actions = [view_bill, download_bills, send_bills, close_bills] change_readonly_fields = ('account_link', 'type', 'status') readonly_fields = ('number', 'display_total') inlines = [BillLineInline] @@ -82,7 +83,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): num_lines.short_description = _("lines") 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.short_description = _("total") @@ -102,10 +103,15 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): def get_change_view_actions(self, obj=None): actions = super(BillAdmin, self).get_change_view_actions(obj) - if obj and not obj.html: - actions = [action for action in actions - if action.__name__ not in ('view_bill', 'download_bills')] - return actions + discard = [] + if obj: + if obj.status != Bill.OPEN: + 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): if self.model is Budget: diff --git a/orchestra/apps/bills/migrations/0001_initial.py b/orchestra/apps/bills/migrations/0001_initial.py new file mode 100644 index 00000000..c86be902 --- /dev/null +++ b/orchestra/apps/bills/migrations/0001_initial.py @@ -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',), + ), + ] diff --git a/orchestra/apps/bills/migrations/0002_bill_payment_source.py b/orchestra/apps/bills/migrations/0002_bill_payment_source.py new file mode 100644 index 00000000..bfac4dde --- /dev/null +++ b/orchestra/apps/bills/migrations/0002_bill_payment_source.py @@ -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, + ), + ] diff --git a/orchestra/apps/bills/migrations/__init__.py b/orchestra/apps/bills/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/bills/models.py b/orchestra/apps/bills/models.py index 6e5680a3..e51cfc13 100644 --- a/orchestra/apps/bills/models.py +++ b/orchestra/apps/bills/models.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from orchestra.apps.accounts.models import Account from orchestra.core import accounts from orchestra.utils.functional import cached +from orchestra.utils.html import html_to_pdf from . import settings @@ -25,16 +26,16 @@ class BillManager(models.Manager): class Bill(models.Model): OPEN = 'OPEN' CLOSED = 'CLOSED' - SEND = 'SEND' - RETURNED = 'RETURNED' + SENT = 'SENT' PAID = 'PAID' + RETURNED = 'RETURNED' BAD_DEBT = 'BAD_DEBT' STATUSES = ( (OPEN, _("Open")), (CLOSED, _("Closed")), - (SEND, _("Sent")), - (RETURNED, _("Returned")), + (SENT, _("Sent")), (PAID, _("Paid")), + (RETURNED, _("Returned")), (BAD_DEBT, _("Bad debt")), ) @@ -50,6 +51,9 @@ class Bill(models.Model): blank=True) account = models.ForeignKey('accounts.Account', verbose_name=_("account"), 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) status = models.CharField(_("status"), max_length=16, choices=STATUSES, default=OPEN) @@ -111,8 +115,26 @@ class Bill(models.Model): prefix=prefix, year=year, number=number) def close(self): - self.status = self.CLOSED 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() def render(self): diff --git a/orchestra/apps/bills/settings.py b/orchestra/apps/bills/settings.py index a90bcb4c..81dabbd6 100644 --- a/orchestra/apps/bills/settings.py +++ b/orchestra/apps/bills/settings.py @@ -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_WEBSITE = getattr(settings, 'BILLS_SELLER_WEBSITE', 'www.orchestra.lan') + + + +BILLS_EMAIL_NOTIFICATION_TEMPLATE = getattr(settings, 'BILLS_EMAIL_NOTIFICATION_TEMPLATE', + 'bills/bill-notification.email') diff --git a/orchestra/apps/bills/templates/admin/bills/close_confirmation.html b/orchestra/apps/bills/templates/admin/bills/close_confirmation.html new file mode 100644 index 00000000..019c09bb --- /dev/null +++ b/orchestra/apps/bills/templates/admin/bills/close_confirmation.html @@ -0,0 +1,34 @@ +{% extends "admin/base_site.html" %} +{% load i18n l10n staticfiles admin_urls %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + + +{% block breadcrumbs %} +TODO +{% endblock %} + + + +{% block content %} +

Are you sure you want to close selected bills

+

Once a bill is closed it can not be further modified.

+

Please select a payment source for the selected bills

+
{% csrf_token %} +
+
+ {{ form.as_admin }} +
+ {% for obj in queryset %} + + {% endfor %} + + +
+
+{% endblock %} + + diff --git a/orchestra/apps/bills/templates/bills/bill-notification.email b/orchestra/apps/bills/templates/bills/bill-notification.email new file mode 100644 index 00000000..52535d6e --- /dev/null +++ b/orchestra/apps/bills/templates/bills/bill-notification.email @@ -0,0 +1,2 @@ +{% if subject %}Bill {{ bill.number }}{% endif %} +{% if message %}Find attached your invoice{% endif %} diff --git a/orchestra/apps/contacts/models.py b/orchestra/apps/contacts/models.py index b5bf9438..c7fd4c93 100644 --- a/orchestra/apps/contacts/models.py +++ b/orchestra/apps/contacts/models.py @@ -7,14 +7,35 @@ from orchestra.models.fields import MultiSelectField 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): + 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"), related_name='contacts', null=True) short_name = models.CharField(_("short name"), max_length=128) full_name = models.CharField(_("full name"), max_length=256, blank=True) email = models.EmailField() email_usage = MultiSelectField(_("email usage"), max_length=256, blank=True, - choices=settings.CONTACTS_EMAIL_USAGES, + choices=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) diff --git a/orchestra/apps/contacts/serializers.py b/orchestra/apps/contacts/serializers.py index f0449823..555e3125 100644 --- a/orchestra/apps/contacts/serializers.py +++ b/orchestra/apps/contacts/serializers.py @@ -3,12 +3,11 @@ from rest_framework import serializers from orchestra.api.serializers import MultiSelectField from orchestra.apps.accounts.serializers import AccountSerializerMixin -from . import settings from .models import Contact, InvoiceContact class ContactSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): - email_usage = MultiSelectField(choices=settings.CONTACTS_EMAIL_USAGES) + email_usage = MultiSelectField(choices=Contact.EMAIL_USAGES) class Meta: model = Contact fields = ( diff --git a/orchestra/apps/contacts/settings.py b/orchestra/apps/contacts/settings.py index 653d9b36..c3632554 100644 --- a/orchestra/apps/contacts/settings.py +++ b/orchestra/apps/contacts/settings.py @@ -2,16 +2,6 @@ from django.conf import settings 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', ('SUPPORT', 'ADMIN', 'BILL', 'TECH', 'ADDS', 'EMERGENCY') ) diff --git a/orchestra/apps/issues/models.py b/orchestra/apps/issues/models.py index 7fa3ce95..b47efdb7 100644 --- a/orchestra/apps/issues/models.py +++ b/orchestra/apps/issues/models.py @@ -4,6 +4,7 @@ from django.db.models import Q from django.utils.translation import ugettext_lazy as _ from orchestra.apps.contacts import settings as contacts_settings +from orchestra.apps.contacts.models import Contact from orchestra.models.fields import MultiSelectField from orchestra.utils import send_email_template @@ -14,7 +15,7 @@ class Queue(models.Model): name = models.CharField(_("name"), max_length=128, unique=True) default = models.BooleanField(_("default"), default=False) 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, help_text=_("Contacts to notify by email")) diff --git a/orchestra/apps/orders/templates/admin/orders/order/bill_selected_options.html b/orchestra/apps/orders/templates/admin/orders/order/bill_selected_options.html index 03aa0626..f1383f53 100644 --- a/orchestra/apps/orders/templates/admin/orders/order/bill_selected_options.html +++ b/orchestra/apps/orders/templates/admin/orders/order/bill_selected_options.html @@ -9,8 +9,8 @@ {% block breadcrumbs %} {% endblock %} diff --git a/orchestra/apps/payments/actions.py b/orchestra/apps/payments/actions.py index 90e2606c..2accd6cc 100644 --- a/orchestra/apps/payments/actions.py +++ b/orchestra/apps/payments/actions.py @@ -1,4 +1,4 @@ def process_transactions(modeladmin, request, queryset): - from .methods import SEPADirectDebit - SEPADirectDebit().process(queryset) - + for source, transactions in queryset.group_by('source'): + if source: + source.method_class().process(transactions) diff --git a/orchestra/apps/payments/admin.py b/orchestra/apps/payments/admin.py index 1bfeb0fe..10ff7df1 100644 --- a/orchestra/apps/payments/admin.py +++ b/orchestra/apps/payments/admin.py @@ -1,3 +1,4 @@ +from django import forms from django.contrib import admin from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ @@ -12,7 +13,7 @@ from .models import PaymentSource, Transaction, PaymentProcess STATE_COLORS = { Transaction.WAITTING_PROCESSING: 'darkorange', - Transaction.WAITTING_CONFIRMATION: 'orange', + Transaction.WAITTING_CONFIRMATION: 'purple', Transaction.CONFIRMED: 'green', Transaction.REJECTED: 'red', Transaction.LOCKED: 'magenta', @@ -20,14 +21,16 @@ STATE_COLORS = { } -class TransactionAdmin(admin.ModelAdmin): +class TransactionAdmin(AccountAdminMixin, admin.ModelAdmin): 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') actions = (process_transactions,) + filter_by_account_fields = ['source'] bill_link = admin_link('bill') + source_link = admin_link('source') account_link = admin_link('bill__account') display_state = admin_colored('state', colors=STATE_COLORS) @@ -39,8 +42,13 @@ class TransactionAdmin(admin.ModelAdmin): class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin): list_display = ('label', 'method', 'number', 'account_link', '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): diff --git a/orchestra/apps/payments/methods/sepadirectdebit.py b/orchestra/apps/payments/methods/sepadirectdebit.py index 7b301124..0455f65b 100644 --- a/orchestra/apps/payments/methods/sepadirectdebit.py +++ b/orchestra/apps/payments/methods/sepadirectdebit.py @@ -29,7 +29,7 @@ class SEPADirectDebitSerializer(serializers.Serializer): class SEPADirectDebit(PaymentMethod): - verbose_name = _("Direct Debit") + verbose_name = _("SEPA Direct Debit") label_field = 'name' number_field = 'iban' process_credit = True @@ -154,10 +154,9 @@ class SEPADirectDebit(PaymentMethod): def _get_debt_transactions(self, transactions): for transaction in transactions: self.object.transactions.add(transaction) - # TODO transaction.account - account = transaction.bill.account - # FIXME - data = account.payment_sources.first().data + account = transaction.account + # TODO + data = account.paymentsources.first().data transaction.state = transaction.WAITTING_CONFIRMATION transaction.save() yield E.DrctDbtTxInf( # Direct Debit Transaction Info @@ -196,8 +195,7 @@ class SEPADirectDebit(PaymentMethod): def _get_credit_transactions(self, transactions): for transaction in transactions: self.object.transactions.add(transaction) - # TODO transaction.account - account = transaction.bill.account + account = transaction.account # FIXME data = account.payment_sources.first().data transaction.state = transaction.WAITTING_CONFIRMATION diff --git a/orchestra/apps/payments/models.py b/orchestra/apps/payments/models.py index 1c606c89..77d3e105 100644 --- a/orchestra/apps/payments/models.py +++ b/orchestra/apps/payments/models.py @@ -5,37 +5,46 @@ from django.utils.translation import ugettext_lazy as _ from jsonfield import JSONField from orchestra.core import accounts +from orchestra.models.queryset import group_by from . import settings from .methods import PaymentMethod +class PaymentSourcesQueryset(models.QuerySet): + def get_source(self): + # TODO + return self.filter(is_active=True).first() + + class PaymentSource(models.Model): account = models.ForeignKey('accounts.Account', verbose_name=_("account"), - related_name='payment_sources') + related_name='paymentsources') method = models.CharField(_("method"), max_length=32, choices=PaymentMethod.get_plugin_choices()) data = JSONField(_("data")) is_active = models.BooleanField(_("is active"), default=True) + objects = PaymentSourcesQueryset.as_manager() + 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 def label(self): - try: - plugin = PaymentMethod.get_plugin(self.method)() - except KeyError: - return None - return plugin.get_label(self.data) + return self.method_class().get_label(self.data) @cached_property def number(self): - try: - plugin = PaymentMethod.get_plugin(self.method)() - except KeyError: - return None - return plugin.get_number(self.data) + return self.method_class().get_number(self.data) + + +class TransactionQuerySet(models.QuerySet): + group_by = group_by # TODO lock transaction in waiting confirmation @@ -55,7 +64,8 @@ class Transaction(models.Model): (DISCARTED, _("Discarted")), ) - # TODO account fk? + objects = TransactionQuerySet.as_manager() + bill = models.ForeignKey('bills.bill', verbose_name=_("bill"), related_name='transactions') source = models.ForeignKey(PaymentSource, null=True, blank=True, @@ -66,10 +76,13 @@ class Transaction(models.Model): currency = models.CharField(max_length=10, default=settings.PAYMENT_CURRENCY) created_on = models.DateTimeField(auto_now_add=True) modified_on = models.DateTimeField(auto_now=True) - related = models.ForeignKey('self', null=True, blank=True) def __unicode__(self): return "Transaction {}".format(self.id) + + @property + def account(self): + return self.bill.account # TODO rename to TransactionProcess or PaymentRequest TransactionRequest diff --git a/orchestra/utils/options.py b/orchestra/utils/options.py index 7449eee2..d21db6af 100644 --- a/orchestra/utils/options.py +++ b/orchestra/utils/options.py @@ -6,20 +6,20 @@ from django.template.loader import render_to_string 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: {% if subject %}Subject{% endif %} {% if message %}Email body{% endif %} - + context can be a dictionary or a template.Context instance """ - + if isinstance(context, dict): context = Context(context) if type(to) in [str, unicode]: to = [to] - + if not 'site' in context: from orchestra import settings url = urlparse.urlparse(settings.SITE_URL) @@ -32,7 +32,7 @@ def send_email_template(template, context, to, email_from=None, html=None): #subject cannot have new lines subject = render_to_string(template, {'subject': True}, context).strip() 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: html_message = render_to_string(html, {'message': True}, context) msg.attach_alternative(html_message, "text/html")