Refactoring payment process

This commit is contained in:
Marc 2014-09-18 15:07:39 +00:00
parent 721bcd9002
commit 1456c457fc
25 changed files with 375 additions and 183 deletions

View file

@ -5,6 +5,9 @@ from django.contrib.admin import helpers
from django.template.response import TemplateResponse
from django.utils.decorators import available_attrs
from django.utils.encoding import force_text
from django.utils.html import format_html
from django.utils.text import capfirst
from django.utils.translation import ugettext_lazy as _
def admin_field(method):
@ -24,6 +27,17 @@ def admin_field(method):
return admin_field_wrapper
def format_display_objects(modeladmin, request, queryset):
from .utils import change_url
opts = modeladmin.model._meta
objects = []
for obj in queryset:
objects.append(format_html('{0}: <a href="{1}">{2}</a>',
capfirst(opts.verbose_name), change_url(obj), obj)
)
return objects
def action_with_confirmation(action_name=None, extra_context={},
template='admin/orchestra/generic_confirmation.html'):
"""
@ -31,11 +45,12 @@ def action_with_confirmation(action_name=None, extra_context={},
If custom template is provided the form must contain:
<input type="hidden" name="post" value="generic_confirmation" />
"""
def decorator(func, extra_context=extra_context, template=template, action_name=action_name):
@wraps(func, assigned=available_attrs(func))
def inner(modeladmin, request, queryset, action_name=action_name):
def inner(modeladmin, request, queryset, action_name=action_name, extra_context=extra_context):
# The user has already confirmed the action.
if request.POST.get('post') == "generic_confirmation":
if request.POST.get('post') == 'generic_confirmation':
stay = func(modeladmin, request, queryset)
if not stay:
return
@ -51,19 +66,23 @@ def action_with_confirmation(action_name=None, extra_context={},
if not action_name:
action_name = func.__name__
context = {
"title": "Are you sure?",
"content_message": "Are you sure you want to %s the selected %s?" %
(action_name, objects_name),
"action_name": action_name.capitalize(),
"action_value": action_value,
"display_objects": queryset,
'title': _("Are you sure?"),
'content_message': _("Are you sure you want to {action} the selected {item}?").format(
action=action_name, item=objects_name),
'action_name': action_name.capitalize(),
'action_value': action_value,
'queryset': queryset,
"opts": opts,
"app_label": app_label,
'opts': opts,
'app_label': app_label,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
}
if callable(extra_context):
extra_context = extra_context(modeladmin, request, queryset)
context.update(extra_context)
if 'display_objects' not in context:
# Compute it only when necessary
context['display_objects'] = format_display_objects(modeladmin, request, queryset)
# Display the confirmation page
return TemplateResponse(request, template,

View file

@ -91,7 +91,7 @@ def action_to_view(action, modeladmin):
return action_view
def admin_change_url(obj):
def change_url(obj):
opts = obj._meta
view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
return reverse(view_name, args=(obj.pk,))
@ -106,7 +106,7 @@ def admin_link(*args, **kwargs):
obj = get_field_value(instance, kwargs['field'])
if not getattr(obj, 'pk', None):
return '---'
url = admin_change_url(obj)
url = change_url(obj)
extra = ''
if kwargs['popup']:
extra = 'onclick="return showAddAnotherPopup(this);"'

View file

@ -9,7 +9,8 @@ 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, set_url_query
from orchestra.admin.utils import (wrap_admin_view, admin_link, set_url_query,
change_url)
from orchestra.core import services, accounts
from .filters import HasMainUserListFilter
@ -136,8 +137,7 @@ class AccountAdminMixin(object):
def account_link(self, instance):
account = instance.account if instance.pk else self.account
url = reverse('admin:accounts_account_change', args=(account.pk,))
pk = account.pk
url = change_url(account)
return '<a href="%s">%s</a>' % (url, str(account))
account_link.short_description = _("account")
account_link.allow_tags = True

View file

@ -42,7 +42,7 @@ view_bill.url_name = 'view'
def close_bills(modeladmin, request, queryset):
queryset = queryset.filter(status=queryset.model.OPEN)
queryset = queryset.filter(is_open=True)
if not queryset:
messages.warning(request, _("Selected bills should be in open state"))
return

View file

@ -1,9 +1,7 @@
from django import forms
from django.contrib import admin
#from django.contrib.admin.utils import unquote
from django.core.urlresolvers import reverse
from django.db import models
#from django.shortcuts import redirect
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
@ -17,23 +15,30 @@ from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForm
BillLine)
PAYMENT_STATE_COLORS = {
Bill.PAID: 'green',
Bill.PENDING: 'darkorange',
Bill.BAD_DEBT: 'red',
}
class BillLineInline(admin.TabularInline):
model = BillLine
fields = ('description', 'rate', 'amount', 'tax', 'total', 'get_total')
fields = ('description', 'rate', 'quantity', 'tax', 'subtotal', 'get_total')
readonly_fields = ('get_total',)
def get_readonly_fields(self, request, obj=None):
if obj and obj.status != Bill.OPEN:
if obj and not obj.is_open:
return self.fields
return super(BillLineInline, self).get_readonly_fields(request, obj=obj)
def has_add_permission(self, request):
if request.__bill__ and request.__bill__.status != Bill.OPEN:
if request.__bill__ and not request.__bill__.is_open:
return False
return super(BillLineInline, self).has_add_permission(request)
def has_delete_permission(self, request, obj=None):
if obj and obj.status != Bill.OPEN:
if obj and not obj.is_open:
return False
return super(BillLineInline, self).has_delete_permission(request, obj=obj)
@ -48,15 +53,15 @@ class BillLineInline(admin.TabularInline):
class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
list_display = (
'number', 'status', 'type_link', 'account_link', 'created_on_display',
'num_lines', 'display_total'
'number', 'is_open', 'type_link', 'account_link', 'created_on_display',
'num_lines', 'display_total', 'display_payment_state'
)
list_filter = (BillTypeListFilter, 'status',)
add_fields = ('account', 'type', 'status', 'due_on', 'comments')
list_filter = (BillTypeListFilter, 'is_open',)
add_fields = ('account', 'type', 'is_open', 'due_on', 'comments')
fieldsets = (
(None, {
'fields': ('number', 'display_total', 'account_link', 'type',
'status', 'due_on', 'comments'),
'is_open', 'display_payment_state', 'is_sent', 'due_on', 'comments'),
}),
(_("Raw"), {
'classes': ('collapse',),
@ -65,8 +70,8 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
)
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')
change_readonly_fields = ('account_link', 'type', 'is_open')
readonly_fields = ('number', 'display_total', 'display_payment_state')
inlines = [BillLineInline]
created_on_display = admin_date('created_on')
@ -90,29 +95,36 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
type_link.short_description = _("type")
type_link.admin_order_field = 'type'
def display_payment_state(self, bill):
topts = bill.transactions.model._meta
url = reverse('admin:%s_%s_changelist' % (topts.app_label, topts.module_name))
url += '?bill=%i' % bill.pk
state = bill.get_payment_state_display().upper()
color = PAYMENT_STATE_COLORS.get(bill.payment_state, 'grey')
return '<a href="{url}" style="color:{color}">{name}</a>'.format(
url=url, color=color, name=state)
display_payment_state.allow_tags = True
display_payment_state.short_description = _("Payment")
def get_readonly_fields(self, request, obj=None):
fields = super(BillAdmin, self).get_readonly_fields(request, obj=obj)
if obj and obj.status != Bill.OPEN:
if obj and not obj.is_open:
fields += self.add_fields
return fields
def get_fieldsets(self, request, obj=None):
fieldsets = super(BillAdmin, self).get_fieldsets(request, obj=obj)
if obj and obj.status == obj.OPEN:
if obj and obj.is_open:
fieldsets = (fieldsets[0],)
return fieldsets
def get_change_view_actions(self, obj=None):
actions = super(BillAdmin, self).get_change_view_actions()
discard = []
exclude = []
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]
if not obj.is_open:
exclude.append('close_bills')
return [action for action in actions if action.__name__ not in exclude]
def get_inline_instances(self, request, obj=None):
# Make parent object available for inline.has_add_permission()

View file

@ -4,6 +4,7 @@ from dateutil.relativedelta import relativedelta
from django.db import models
from django.template import loader, Context
from django.utils import timezone
from django.utils.encoding import force_text
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
@ -25,16 +26,13 @@ class BillManager(models.Manager):
class Bill(models.Model):
OPEN = 'OPEN'
CLOSED = 'CLOSED'
SENT = 'SENT'
OPEN = ''
PAID = 'PAID'
PENDING = 'PENDING'
BAD_DEBT = 'BAD_DEBT'
STATUSES = (
(OPEN, _("Open")),
(CLOSED, _("Closed")),
(SENT, _("Sent")),
PAYMENT_STATES = (
(PAID, _("Paid")),
(PENDING, _("Pending")),
(BAD_DEBT, _("Bad debt")),
)
@ -51,10 +49,10 @@ class Bill(models.Model):
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
related_name='%(class)s')
type = models.CharField(_("type"), max_length=16, choices=TYPES)
status = models.CharField(_("status"), max_length=16, choices=STATUSES,
default=OPEN)
created_on = models.DateTimeField(_("created on"), auto_now_add=True)
closed_on = models.DateTimeField(_("closed on"), blank=True, null=True)
is_open = models.BooleanField(_("is open"), default=True)
is_sent = models.BooleanField(_("is sent"), default=False)
due_on = models.DateField(_("due on"), null=True, blank=True)
last_modified_on = models.DateTimeField(_("last modified on"), auto_now=True)
total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
@ -74,6 +72,21 @@ class Bill(models.Model):
def buyer(self):
return self.account.invoicecontact
@cached_property
def payment_state(self):
if self.is_open:
return self.OPEN
secured = self.transactions.secured().amount()
if secured >= self.total:
return self.PAID
elif self.transactions.exclude_rejected().exists():
return self.PENDING
return self.BAD_DEBT
def get_payment_state_display(self):
value = self.payment_state
return force_text(dict(self.PAYMENT_STATES).get(value, value))
@classmethod
def get_class_type(cls):
return cls.__name__.upper()
@ -87,7 +100,7 @@ class Bill(models.Model):
if bill_type == 'BILL':
raise TypeError("get_new_number() can not be used on a Bill class")
prefix = getattr(settings, 'BILLS_%s_NUMBER_PREFIX' % bill_type)
if self.status == self.OPEN:
if self.is_open:
prefix = 'O{}'.format(prefix)
bills = cls.objects.filter(number__regex=r'^%s[1-9]+' % prefix)
last_number = bills.order_by('-number').values_list('number', flat=True).first()
@ -110,7 +123,7 @@ class Bill(models.Model):
return now + relativedelta(months=1)
def close(self, payment=False):
assert self.status == self.OPEN, "Bill not in Open state"
assert self.is_open, "Bill not in Open state"
if payment is False:
payment = self.account.paymentsources.get_default()
if not self.due_on:
@ -119,7 +132,8 @@ class Bill(models.Model):
self.html = self.render(payment=payment)
self.transactions.create(bill=self, source=payment, amount=self.total)
self.closed_on = timezone.now()
self.status = self.CLOSED
self.is_open = False
self.is_sent = False
self.save()
def send(self):
@ -134,7 +148,7 @@ class Bill(models.Model):
('%s.pdf' % self.number, html_to_pdf(self.html), 'application/pdf')
]
)
self.status = self.SENT
self.is_sent = True
self.save()
def render(self, payment=False):
@ -166,7 +180,7 @@ class Bill(models.Model):
def save(self, *args, **kwargs):
if not self.type:
self.type = self.get_type()
if not self.number or (self.number.startswith('O') and self.status != self.OPEN):
if not self.number or (self.number.startswith('O') and not self.is_open):
self.set_number()
super(Bill, self).save(*args, **kwargs)
@ -217,8 +231,8 @@ class BillLine(models.Model):
description = models.CharField(_("description"), max_length=256)
rate = models.DecimalField(_("rate"), blank=True, null=True,
max_digits=12, decimal_places=2)
amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2)
total = models.DecimalField(_("total"), max_digits=12, decimal_places=2)
quantity = models.DecimalField(_("quantity"), max_digits=12, decimal_places=2)
subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2)
tax = models.PositiveIntegerField(_("tax"))
# TODO
# order_id = models.ForeignKey('orders.Order', null=True, blank=True,
@ -236,15 +250,15 @@ class BillLine(models.Model):
def get_total(self):
""" Computes subline discounts """
subtotal = self.total
total = self.subtotal
for subline in self.sublines.all():
subtotal += subline.total
return subtotal
total += subline.total
return total
def save(self, *args, **kwargs):
# TODO cost of this shit
super(BillLine, self).save(*args, **kwargs)
if self.bill.status == self.bill.OPEN:
if self.bill.is_open:
self.bill.total = self.bill.get_total()
self.bill.save()
@ -260,7 +274,7 @@ class BillSubline(models.Model):
def save(self, *args, **kwargs):
# TODO cost of this shit
super(BillSubline, self).save(*args, **kwargs)
if self.line.bill.status == self.line.bill.OPEN:
if self.line.bill.is_open:
self.line.bill.total = self.line.bill.get_total()
self.line.bill.save()

View file

@ -8,7 +8,7 @@ from django.template.response import TemplateResponse
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ChangeListDefaultFilter, ExtendedModelAdmin
from orchestra.admin.utils import wrap_admin_view, admin_link
from orchestra.admin.utils import wrap_admin_view, admin_link, change_url
from orchestra.apps.accounts.admin import AccountAdminMixin
from orchestra.utils import apps
@ -79,7 +79,7 @@ class DomainAdmin(ChangeListDefaultFilter, AccountAdminMixin, ExtendedModelAdmin
if webs:
links = []
for web in webs:
url = reverse('admin:websites_website_change', args=(web.pk,))
url = change_url(web)
links.append('<a href="%s">%s</a>' % (url, web.name))
return '<br>'.join(links)
return _("No website")

View file

@ -5,6 +5,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from markdown import markdown
from orchestra.admin.utils import change_url
from orchestra.apps.users.models import User
from orchestra.forms.widgets import ReadOnlyWidget
@ -41,7 +42,7 @@ class MessageInlineForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(MessageInlineForm, self).__init__(*args, **kwargs)
admin_link = reverse('admin:users_user_change', args=(self.user.pk,))
admin_link = change_url(self.user)
self.fields['created_on'].widget = ReadOnlyWidget('')
def clean_content(self):

View file

@ -7,7 +7,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import insertattr, admin_link
from orchestra.admin.utils import insertattr, admin_link, change_url
from orchestra.apps.accounts.admin import SelectAccountAdminMixin, AccountAdminMixin
from orchestra.apps.domains.forms import DomainIterator
@ -57,7 +57,7 @@ class MailboxAdmin(AccountAdminMixin, ExtendedModelAdmin):
def display_addresses(self, mailbox):
addresses = []
for addr in mailbox.addresses.all():
url = reverse('admin:mails_address_change', args=(addr.pk,))
url = change_url(addr)
addresses.append('<a href="%s">%s</a>' % (url, addr.email))
return '<br>'.join(addresses)
display_addresses.short_description = _("Addresses")
@ -106,7 +106,7 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
def display_mailboxes(self, address):
boxes = []
for mailbox in address.mailboxes.all():
url = reverse('admin:mails_mailbox_change', args=(mailbox.pk,))
url = change_url(mailbox)
boxes.append('<a href="%s">%s</a>' % (url, mailbox.name))
return '<br>'.join(boxes)
display_mailboxes.short_description = _("Mailboxes")

View file

@ -33,8 +33,8 @@ class BillsBackend(object):
# Create bill line
billine = bill.lines.create(
rate=service.nominal_price,
amount=line.size,
total=line.subtotal,
quantity=line.size,
subtotal=line.subtotal,
tax=service.tax,
description=self.get_line_description(line),
)

View file

@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from orchestra.admin.forms import AdminFormMixin
from orchestra.admin.utils import admin_change_url
from orchestra.admin.utils import change_url
from .models import Order
@ -32,8 +32,8 @@ def selected_related_choices(queryset):
verbose = '<a href="{order_url}">{description}</a> '
verbose += '<a class="account" href="{account_url}">{account}</a>'
verbose = verbose.format(
order_url=admin_change_url(order), description=order.description,
account_url=admin_change_url(order.account), account=str(order.account)
order_url=change_url(order), description=order.description,
account_url=change_url(order.account), account=str(order.account)
)
yield (order.pk, mark_safe(verbose))

View file

@ -1,6 +1,5 @@
import sys
from django.core.exceptions import ValidationError
from django.db import models
from django.db.migrations.recorder import MigrationRecorder
from django.db.models import F, Q
@ -10,7 +9,6 @@ from django.dispatch import receiver
from django.contrib.admin.models import LogEntry
from django.contrib.contenttypes import generic
from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _

View file

@ -13,7 +13,7 @@ from orchestra.apps.users.models import User
from orchestra.utils.tests import BaseTestCase, random_ascii
class ServiceTests(BaseTestCase):
class BillingTests(BaseTestCase):
DEPENDENCIES = (
'orchestra.apps.services',
'orchestra.apps.users',
@ -91,3 +91,18 @@ class ServiceTests(BaseTestCase):
error = decimal.Decimal(0.05)
self.assertGreater(10*size+error*(10*size), bills[0].get_total())
self.assertLess(10*size-error*(10*size), bills[0].get_total())
def test_ftp_account_with_compensation(self):
account = self.create_account()
service = self.create_ftp_service()
user = self.create_ftp(account=account)
bp = timezone.now().date() + relativedelta.relativedelta(years=2)
bills = service.orders.bill(billing_point=bp, fixed_point=True)
user.delete()
user = self.create_ftp(account=account)
bp = timezone.now().date() + relativedelta.relativedelta(years=1)
bills = service.orders.bill(billing_point=bp, fixed_point=True)
for line in bills[0].lines.all():
print line
print line.sublines.all()
# TODO asserts

View file

@ -1,9 +1,14 @@
from functools import partial
from django.contrib import messages
from django.db import transaction
from django.shortcuts import render
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.translation import ugettext_lazy as _
from orchestra.admin.decorators import action_with_confirmation
from orchestra.admin.utils import change_url
from .methods import PaymentMethod
from .models import Transaction
@ -38,8 +43,7 @@ def process_transactions(modeladmin, request, queryset):
@transaction.atomic
@action_with_confirmation()
def mark_as_executed(modeladmin, request, queryset):
""" Mark a tickets as unread """
def mark_as_executed(modeladmin, request, queryset, extra_context={}):
for transaction in queryset:
transaction.mark_as_executed()
modeladmin.log_change(request, transaction, 'Executed')
@ -52,7 +56,6 @@ mark_as_executed.verbose_name = _("Mark as executed")
@transaction.atomic
@action_with_confirmation()
def mark_as_secured(modeladmin, request, queryset):
""" Mark a tickets as unread """
for transaction in queryset:
transaction.mark_as_secured()
modeladmin.log_change(request, transaction, 'Secured')
@ -65,7 +68,6 @@ mark_as_secured.verbose_name = _("Mark as secured")
@transaction.atomic
@action_with_confirmation()
def mark_as_rejected(modeladmin, request, queryset):
""" Mark a tickets as unread """
for transaction in queryset:
transaction.mark_as_rejected()
modeladmin.log_change(request, transaction, 'Rejected')
@ -73,3 +75,62 @@ def mark_as_rejected(modeladmin, request, queryset):
modeladmin.message_user(request, msg)
mark_as_rejected.url_name = 'reject'
mark_as_rejected.verbose_name = _("Mark as rejected")
def _format_display_objects(modeladmin, request, queryset, related):
objects = []
opts = modeladmin.model._meta
for obj in queryset:
objects.append(
mark_safe('{0}: <a href="{1}">{2}</a>'.format(
capfirst(opts.verbose_name), change_url(obj), obj))
)
subobjects = []
attr, verb = related
for related in getattr(obj.transactions, attr)():
subobjects.append(
mark_safe('{0}: <a href="{1}">{2}</a> will be marked as {3}'.format(
capfirst(subobj.get_type().lower()), change_url(subobj), subobj, verb))
)
objects.append(subobjects)
return {'display_objects': objects}
_format_executed = partial(_format_display_objects, related=('all', 'executed'))
_format_abort = partial(_format_display_objects, related=('processing', 'aborted'))
_format_commit = partial(_format_display_objects, related=('all', 'secured'))
@transaction.atomic
@action_with_confirmation(extra_context=_format_executed)
def mark_process_as_executed(modeladmin, request, queryset):
for process in queryset:
process.mark_as_executed()
modeladmin.log_change(request, process, 'Executed')
msg = _("%s selected processes have been marked as executed.") % queryset.count()
modeladmin.message_user(request, msg)
mark_process_as_executed.url_name = 'executed'
mark_process_as_executed.verbose_name = _("Mark as executed")
@transaction.atomic
@action_with_confirmation(extra_context=_format_abort)
def abort(modeladmin, request, queryset):
for process in queryset:
process.abort()
modeladmin.log_change(request, process, 'Aborted')
msg = _("%s selected processes have been aborted.") % queryset.count()
modeladmin.message_user(request, msg)
abort.url_name = 'abort'
abort.verbose_name = _("Abort")
@transaction.atomic
@action_with_confirmation(extra_context=_format_commit)
def commit(modeladmin, request, queryset):
for transaction in queryset:
transaction.mark_as_rejected()
modeladmin.log_change(request, transaction, 'Rejected')
msg = _("%s selected transactions have been marked as rejected.") % queryset.count()
modeladmin.message_user(request, msg)
commit.url_name = 'commit'
commit.verbose_name = _("Commit")

View file

@ -16,76 +16,13 @@ from .models import PaymentSource, Transaction, TransactionProcess
STATE_COLORS = {
Transaction.WAITTING_PROCESSING: 'darkorange',
Transaction.WAITTING_CONFIRMATION: 'magenta',
Transaction.WAITTING_EXECUTION: 'magenta',
Transaction.EXECUTED: 'olive',
Transaction.SECURED: 'green',
Transaction.REJECTED: 'red',
}
class TransactionInline(admin.TabularInline):
model = Transaction
can_delete = False
extra = 0
fields = (
'transaction_link', 'bill_link', 'source_link', 'display_state',
'amount', 'currency'
)
readonly_fields = fields
transaction_link = admin_link('__unicode__', short_description=_("ID"))
bill_link = admin_link('bill')
source_link = admin_link('source')
display_state = admin_colored('state', colors=STATE_COLORS)
class Media:
css = {
'all': ('orchestra/css/hide-inline-id.css',)
}
def has_add_permission(self, *args, **kwargs):
return False
class TransactionAdmin(ChangeViewActionsMixin, AccountAdminMixin, admin.ModelAdmin):
list_display = (
'id', 'bill_link', 'account_link', 'source_link', 'display_state',
'amount', 'process_link'
)
list_filter = ('source__method', 'state')
actions = (
actions.process_transactions, actions.mark_as_executed,
actions.mark_as_secured, actions.mark_as_rejected
)
change_view_actions = actions
filter_by_account_fields = ['source']
readonly_fields = ('bill_link', 'display_state', 'process_link', 'account_link')
bill_link = admin_link('bill')
source_link = admin_link('source')
process_link = admin_link('process', short_description=_("proc"))
account_link = admin_link('bill__account')
display_state = admin_colored('state', colors=STATE_COLORS)
def get_queryset(self, request):
qs = super(TransactionAdmin, self).get_queryset(request)
return qs.select_related('source', 'bill__account__user')
def get_change_view_actions(self, obj=None):
actions = super(TransactionAdmin, self).get_change_view_actions()
discard = []
if obj:
if obj.state == Transaction.EXECUTED:
discard = ['mark_as_executed']
elif obj.state == Transaction.REJECTED:
discard = ['mark_as_rejected']
elif obj.state == Transaction.SECURED:
discard = ['mark_as_secured']
if not discard:
return actions
return [action for action in actions if action.__name__ not in discard]
class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ('label', 'method', 'number', 'account_link', 'is_active')
list_filter = ('method', 'is_active')
@ -138,11 +75,74 @@ class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin):
obj.save()
class TransactionProcessAdmin(admin.ModelAdmin):
class TransactionInline(admin.TabularInline):
model = Transaction
can_delete = False
extra = 0
fields = (
'transaction_link', 'bill_link', 'source_link', 'display_state',
'amount', 'currency'
)
readonly_fields = fields
transaction_link = admin_link('__unicode__', short_description=_("ID"))
bill_link = admin_link('bill')
source_link = admin_link('source')
display_state = admin_colored('state', colors=STATE_COLORS)
class Media:
css = {
'all': ('orchestra/css/hide-inline-id.css',)
}
def has_add_permission(self, *args, **kwargs):
return False
class TransactionAdmin(ChangeViewActionsMixin, AccountAdminMixin, admin.ModelAdmin):
list_display = (
'id', 'bill_link', 'account_link', 'source_link', 'display_state',
'amount', 'process_link'
)
list_filter = ('source__method', 'state')
actions = (
actions.process_transactions, actions.mark_as_executed,
actions.mark_as_secured, actions.mark_as_rejected
)
change_view_actions = actions
filter_by_account_fields = ['source']
readonly_fields = ('bill_link', 'display_state', 'process_link', 'account_link')
bill_link = admin_link('bill')
source_link = admin_link('source')
process_link = admin_link('process', short_description=_("proc"))
account_link = admin_link('bill__account')
display_state = admin_colored('state', colors=STATE_COLORS)
def get_queryset(self, request):
qs = super(TransactionAdmin, self).get_queryset(request)
return qs.select_related('source', 'bill__account__user')
def get_change_view_actions(self, obj=None):
actions = super(TransactionAdmin, self).get_change_view_actions()
exclude = []
if obj:
if obj.state == Transaction.EXECUTED:
exclude.append('mark_as_executed')
elif obj.state == Transaction.REJECTED:
exclude.append('mark_as_rejected')
elif obj.state == Transaction.SECURED:
exclude.append('mark_as_secured')
return [action for action in actions if action.__name__ not in exclude]
class TransactionProcessAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
list_display = ('id', 'file_url', 'display_transactions', 'created_at')
fields = ('data', 'file_url', 'display_transactions', 'created_at')
readonly_fields = ('file_url', 'display_transactions', 'created_at')
inlines = [TransactionInline]
actions = (actions.mark_process_as_executed, actions.abort, actions.commit)
change_view_actions = actions
def file_url(self, process):
if process.file:
@ -170,6 +170,18 @@ class TransactionProcessAdmin(admin.ModelAdmin):
display_transactions.short_description = _("Transactions")
display_transactions.allow_tags = True
def get_change_view_actions(self, obj=None):
actions = super(TransactionProcessAdmin, self).get_change_view_actions()
exclude = []
if obj:
if obj.state == TransactionProcess.EXECUTED:
exclude.append('mark_process_as_executed')
elif obj.state == TransactionProcess.COMMITED:
exclude = ['mark_process_as_executed', 'abort', 'commit']
elif obj.state == TransactionProcess.ABORTED:
exclude = ['mark_process_as_executed', 'abort', 'commit']
return [action for action in actions if action.__name__ not in exclude]
admin.site.register(PaymentSource, PaymentSourceAdmin)
admin.site.register(Transaction, TransactionAdmin)

View file

@ -59,27 +59,36 @@ class TransactionQuerySet(models.QuerySet):
source = kwargs.get('source')
if source is None or not hasattr(source.method_class, 'process'):
# Manual payments don't need processing
kwargs['state']=self.model.WAITTING_CONFIRMATION
kwargs['state']=self.model.WAITTING_EXECUTION
return super(TransactionQuerySet, self).create(**kwargs)
def secured(self):
return self.filter(state=Transaction.SECURED)
def exclude_rejected(self):
return self.exclude(state=Transaction.REJECTED)
def amount(self):
return self.aggregate(models.Sum('amount')).values()[0]
def processing(self):
return self.filter(state__in=[Transaction.EXECUTED, Transaction.WAITTING_EXECUTION])
# TODO lock transaction in waiting confirmation
class Transaction(models.Model):
WAITTING_PROCESSING = 'WAITTING_PROCESSING' # CREATED
WAITTING_CONFIRMATION = 'WAITTING_CONFIRMATION' # PROCESSED
WAITTING_EXECUTION = 'WAITTING_EXECUTION' # PROCESSED
EXECUTED = 'EXECUTED'
SECURED = 'SECURED'
REJECTED = 'REJECTED'
STATES = (
(WAITTING_PROCESSING, _("Waitting processing")),
(WAITTING_CONFIRMATION, _("Waitting confirmation")),
(WAITTING_EXECUTION, _("Waitting execution")),
(EXECUTED, _("Executed")),
(SECURED, _("Secured")),
(REJECTED, _("Rejected")),
)
objects = TransactionQuerySet.as_manager()
bill = models.ForeignKey('bills.bill', verbose_name=_("bill"),
related_name='transactions')
source = models.ForeignKey(PaymentSource, null=True, blank=True,
@ -93,6 +102,8 @@ class Transaction(models.Model):
created_on = models.DateTimeField(auto_now_add=True)
modified_on = models.DateTimeField(auto_now=True)
objects = TransactionQuerySet.as_manager()
def __unicode__(self):
return "Transaction {}".format(self.id)
@ -100,19 +111,26 @@ class Transaction(models.Model):
def account(self):
return self.bill.account
def clean(self):
if not self.pk:
amount = self.bill.transactions.exclude(state=self.REJECTED).amount()
if amount >= self.bill.total:
raise ValidationError(_("New transactions can not be allocated for this bill"))
def mark_as_processed(self):
self.state = self.WAITTING_EXECUTION
self.save()
def mark_as_executed(self):
self.state = self.EXECUTED
self.save()
def mark_as_secured(self):
self.state = self.SECURED
# TODO think carefully about bill feedback
self.bill.mark_as_paid()
self.save()
def mark_as_rejected(self):
self.state = self.REJECTED
# TODO bill feedback
self.save()
@ -120,9 +138,22 @@ class TransactionProcess(models.Model):
"""
Stores arbitrary data generated by payment methods while processing transactions
"""
CREATED = 'CREATED'
EXECUTED = 'EXECUTED'
ABORTED = 'ABORTED'
COMMITED = 'COMMITED'
STATES = (
(CREATED, _("Created")),
(EXECUTED, _("Executed")),
(ABORTED, _("Aborted")),
(COMMITED, _("Commited")),
)
data = JSONField(_("data"), blank=True)
file = models.FileField(_("file"), blank=True)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
state = models.CharField(_("state"), max_length=16, choices=STATES, default=CREATED)
created_at = models.DateTimeField(_("created"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated"), auto_now=True)
class Meta:
verbose_name_plural = _("Transaction processes")
@ -130,6 +161,27 @@ class TransactionProcess(models.Model):
def __unicode__(self):
return str(self.id)
def mark_as_executed(self):
assert self.state == self.CREATED
self.state = self.EXECUTED
for transaction in self.transactions.all():
transaction.mark_as_executed()
self.save()
def abort(self):
assert self.state in [self.CREATED, self.EXCECUTED]
self.state = self.ABORTED
for transaction in self.transaction.all():
transaction.mark_as_aborted()
self.save()
def commit(self):
assert self.state in [self.CREATED, self.EXECUTED]
self.state = self.COMMITED
for transaction in self.transactions.processing():
transaction.mark_as_secured()
self.save()
accounts.register(PaymentSource)
accounts.register(Transaction)

View file

@ -164,7 +164,7 @@ class ServiceHandler(plugins.Plugin):
for order in givers:
if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until:
interval = helpers.Interval(order.cancelled_on, order.billed_until, order)
compensations.append[interval]
compensations.append(interval)
for order in receivers:
if not order.billed_until or order.billed_until < order.new_billed_until:
# receiver
@ -277,9 +277,10 @@ class ServiceHandler(plugins.Plugin):
ini = min(ini, cini)
end = max(end, bp)
related_orders = account.orders.filter(service=self.service)
if self.on_cancel == self.COMPENSATE:
if self.on_cancel == self.DISCOUNT:
# Get orders pending for compensation
givers = related_orders.filter_givers(ini, end)
givers = list(related_orders.filter_givers(ini, end))
print givers
givers.sort(cmp=helpers.cmp_billed_until_or_registered_on)
orders.sort(cmp=helpers.cmp_billed_until_or_registered_on)
self.compensate(givers, orders, commit=commit)
@ -341,6 +342,7 @@ class ServiceHandler(plugins.Plugin):
return lines
def generate_bill_lines(self, orders, account, **options):
# TODO filter out orders with cancelled_on < billed_until ?
if not self.metric:
lines = self.bill_with_orders(orders, account, **options)
else:

View file

@ -1,6 +1,5 @@
import sys
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q
from django.db.models.signals import pre_delete, post_delete, post_save
@ -90,7 +89,6 @@ class Service(models.Model):
NOTHING = 'NOTHING'
DISCOUNT = 'DISCOUNT'
REFOUND = 'REFOUND'
COMPENSATE = 'COMPENSATE'
PREPAY = 'PREPAY'
POSTPAY = 'POSTPAY'
STEP_PRICE = 'STEP_PRICE'
@ -174,7 +172,6 @@ class Service(models.Model):
choices=(
(NOTHING, _("Nothing")),
(DISCOUNT, _("Discount")),
(COMPENSATE, _("Discount and compensate")),
),
default=DISCOUNT)
payment_style = models.CharField(_("payment style"), max_length=16,
@ -229,11 +226,10 @@ 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)
raise ValidationError(msg)
ct = str(content_type)
raise ValidationError(_("Content type must be equal to '%s'.") % ct)
if not self.match:
msg =_("Match should be provided")
raise ValidationError(msg)
raise ValidationError(_("Match should be provided"))
try:
obj = content_type.model_class().objects.all()[0]
except IndexError:

View file

@ -136,7 +136,7 @@ class HandlerTests(BaseTestCase):
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
def test_sort_billed_until_or_registered_on(self):
now = timezone.now()
now = timezone.now().date()
order = Order(
billed_until=now+datetime.timedelta(days=200))
order1 = Order(
@ -158,7 +158,7 @@ class HandlerTests(BaseTestCase):
self.assertEqual(orders, sorted(orders, cmp=helpers.cmp_billed_until_or_registered_on))
def test_compensation(self):
now = timezone.now()
now = timezone.now().date()
order = Order(
description='0',
billed_until=now+datetime.timedelta(days=220),
@ -353,5 +353,16 @@ class HandlerTests(BaseTestCase):
self.assertEqual(rate['price'], result.price)
self.assertEqual(rate['quantity'], result.quantity)
def test_compensations(self):
pass
def test_generate_bill_lines_with_compensation(self):
service = self.create_ftp_service()
account = self.create_account()
now = timezone.now().date()
order = Order(
cancelled_on=now,
billed_until=now+relativedelta.relativedelta(years=2)
)
order1 = Order()
orders = [order, order1]
lines = service.handler.generate_bill_lines(orders, account, commit=False)
print lines
print len(lines)

View file

@ -10,7 +10,7 @@ from django.utils.encoding import force_text
from django.utils.html import escape
from django.utils.translation import ugettext, ugettext_lazy as _
from orchestra.admin.utils import get_modeladmin
from orchestra.admin.utils import get_modeladmin, change_url
from .forms import role_form_factory
from ..models import User
@ -71,9 +71,8 @@ class RoleAdmin(object):
modeladmin.log_change(request, request.user, "%s saved" % self.name.capitalize())
msg = _('The role %(name)s for user "%(obj)s" was %(action)s successfully.') % context
modeladmin.message_user(request, msg, messages.SUCCESS)
url = 'admin:%s_%s_change' % (opts.app_label, opts.module_name)
if not "_continue" in request.POST:
return redirect(url, object_id)
return redirect(change_url(user))
exists = True
if exists:
@ -117,7 +116,7 @@ class RoleAdmin(object):
obj_display = force_text(obj)
modeladmin.log_deletion(request, obj, obj_display)
modeladmin.delete_model(request, obj)
post_url = reverse('admin:users_user_change', args=(user.pk,))
post_url = change_url(user)
preserved_filters = modeladmin.get_preserved_filters(request)
post_url = add_preserved_filters(
{'preserved_filters': preserved_filters, 'opts': opts}, post_url

View file

@ -4,6 +4,7 @@ from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import change_url
from orchestra.apps.accounts.admin import SelectAccountAdminMixin
from .models import WebApp, WebAppOption
@ -37,7 +38,7 @@ class WebAppAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
websites = []
for content in webapp.content_set.all().select_related('website'):
website = content.website
url = reverse('admin:websites_website_change', args=(website.pk,))
url = change_url(website)
name = "%s on %s" % (website.name, content.path)
websites.append('<a href="%s">%s</a>' % (url, name))
return '<br>'.join(websites)

View file

@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import admin_link
from orchestra.admin.utils import admin_link, change_url
from orchestra.apps.accounts.admin import AccountAdminMixin, SelectAccountAdminMixin
from orchestra.apps.accounts.widgets import account_related_field_widget_factory
@ -72,7 +72,7 @@ class WebsiteAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
webapps = []
for content in website.content_set.all().select_related('webapp'):
webapp = content.webapp
url = reverse('admin:webapps_webapp_change', args=(webapp.pk,))
url = change_url(webapp)
name = "%s on %s" % (webapp.get_type_display(), content.path)
webapps.append('<a href="%s">%s</a>' % (url, name))
return '<br>'.join(webapps)

View file

@ -97,6 +97,7 @@ INSTALLED_APPS = (
'rest_framework',
'rest_framework.authtoken',
'passlib.ext.django',
'django_nose',
# Django.contrib
'django.contrib.auth',
@ -248,3 +249,6 @@ PASSLIB_CONFIG = (
"superuser__django_pbkdf2_sha256__default_rounds = 15000\n"
"superuser__sha512_crypt__default_rounds = 120000\n"
)
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'

View file

@ -27,11 +27,7 @@
<div>
<div style="margin:20px;">
<p>{{ content_message | safe }}</p>
<ul>
{% for display_object in display_objects %}
<li> <a href="{% url opts|admin_urlname:'change' display_object.pk %}">{{ display_object }} </a></li>
{% endfor %}
</ul>
<ul>{{ display_objects | unordered_list }}</ul>
<form action="" method="post">{% csrf_token %}
{% if form %}
<fieldset class="module aligned">
@ -53,7 +49,6 @@
{% if formset %}
{{ formset.as_admin }}
{% endif %}
<div>
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />

View file

@ -3,7 +3,7 @@ from django.core.urlresolvers import reverse, NoReverseMatch
from django.forms import CheckboxInput
from orchestra import get_version
from orchestra.admin.utils import admin_change_url
from orchestra.admin.utils import change_url
register = template.Library()
@ -49,4 +49,4 @@ def is_checkbox(field):
@register.filter
def admin_link(obj):
return admin_change_url(obj)
return change_url(obj)