Imporvements on payments, bills and upgraded domain label validation to RFC 1123

This commit is contained in:
Marc Aymerich 2015-06-02 12:59:49 +00:00
parent a8cad48fed
commit a17e8d9b8c
19 changed files with 215 additions and 80 deletions

11
TODO.md
View File

@ -407,20 +407,17 @@ uwsgi --reload /tmp/project-master.pid
touch /tmp/somefile
# Change zone ttl
# batch zone edditing
# inherit registers from parent?
# Disable pagination on membership fees (allways one page)
# datetime metric storage granularity: otherwise innacurate detection of billed metric on order.billed_on
# Serializers.validation migration to DRF3: grep -r 'attrs, source' *|grep -v '~'
serailzer self.instance on create.
# generate Direct debit q19 on a protected path, or store it on the transaction.proc
# regenerate direct debit q19
# add transproc.method for regeneration
# set_password serializer: "just-the-password" not {"password": "password"}
# TODO wrapp admin delete: delete proc undo processing on related transactions
# use namedtuples!
# Negative transactionsx

View File

@ -161,6 +161,9 @@ STATIC_URL = '/static/'
# Example: "/home/media/media.lawrence.com/static/"
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
# Absolute filesystem path to the directory that will hold user-uploaded files.
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Path used for database translations files
LOCALE_PATHS = (

View File

@ -1,5 +1,6 @@
from django.conf.urls import include, url
urlpatterns = [
url(r'', include('orchestra.urls')),
]

View File

@ -17,7 +17,7 @@ from orchestra.contrib.accounts.admin import AccountAdminMixin, AccountAdmin
from orchestra.forms.widgets import paddingCheckboxSelectMultiple
from . import settings, actions
from .filters import BillTypeListFilter, HasBillContactListFilter, PositivePriceListFilter
from .filters import BillTypeListFilter, HasBillContactListFilter, TotalListFilter, PaymentStateListFilter
from .models import Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForma, BillLine, BillContact
@ -39,7 +39,7 @@ class BillLineInline(admin.TabularInline):
order_link = admin_link('order', display='pk')
def display_total(self, line):
total = line.get_total()
total = line.compute_total()
sublines = line.sublines.all()
if sublines:
content = '\n'.join(['%s: %s' % (sub.description, sub.total) for sub in sublines])
@ -89,7 +89,7 @@ class ClosedBillLineInline(BillLineInline):
display_subtotal.allow_tags = True
def display_total(self, line):
return line.get_total()
return line.compute_total()
display_total.short_description = _("Total")
display_total.allow_tags = True
@ -180,7 +180,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
'number', 'type_link', 'account_link', 'created_on_display',
'num_lines', 'display_total', 'display_payment_state', 'is_open', 'is_sent'
)
list_filter = (BillTypeListFilter, 'is_open', 'is_sent', PositivePriceListFilter)
list_filter = (BillTypeListFilter, 'is_open', 'is_sent', TotalListFilter, PaymentStateListFilter)
add_fields = ('account', 'type', 'is_open', 'due_on', 'comments')
fieldsets = (
(None, {

View File

@ -1,5 +1,7 @@
from django.contrib.admin import SimpleListFilter
from django.core.urlresolvers import reverse
from django.db.models import Q
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
@ -37,21 +39,25 @@ class BillTypeListFilter(SimpleListFilter):
}
class PositivePriceListFilter(SimpleListFilter):
title = _("positive price")
parameter_name = 'positive_price'
class TotalListFilter(SimpleListFilter):
title = _("total")
parameter_name = 'total'
def lookups(self, request, model_admin):
return (
('True', _("Yes")),
('False', _("No")),
('gt', mark_safe("total > 0")),
('eq', "total = 0"),
('lt', mark_safe("total < 0")),
)
def queryset(self, request, queryset):
if self.value() == 'True':
if self.value() == 'gt':
return queryset.filter(computed_total__gt=0)
if self.value() == 'False':
return queryset.filter(computed_total__lte=0)
elif self.value() == 'eq':
return queryset.filter(computed_total=0)
elif self.value() == 'lt':
return queryset.filter(computed_total__lt=0)
return queryset
class HasBillContactListFilter(SimpleListFilter):
@ -68,5 +74,45 @@ class HasBillContactListFilter(SimpleListFilter):
def queryset(self, request, queryset):
if self.value() == 'True':
return queryset.filter(billcontact__isnull=False)
if self.value() == 'False':
elif self.value() == 'False':
return queryset.filter(billcontact__isnull=True)
class PaymentStateListFilter(SimpleListFilter):
title = _("payment state")
parameter_name = 'payment_state'
def lookups(self, request, model_admin):
return (
('OPEN', _("Open")),
('PAID', _("Paid")),
('PENDING', _("Pending")),
('BAD_DEBT', _("Bad debt")),
)
def queryset(self, request, queryset):
Transaction = queryset.model.transactions.related.related_model
if self.value() == 'OPEN':
return queryset.filter(Q(is_open=True)|Q(type=queryset.model.PROFORMA))
elif self.value() == 'PAID':
zeros = queryset.filter(computed_total=0).values_list('id', flat=True)
ammounts = Transaction.objects.exclude(bill_id__in=zeros).secured().group_by('bill_id')
paid = []
for bill_id, total in queryset.exclude(computed_total=0).values_list('id', 'computed_total'):
try:
ammount = sum([t.ammount for t in ammounts[bill_id]])
except KeyError:
pass
else:
if abs(total) <= abs(ammount):
paid.append(bill_id)
return queryset.filter(Q(computed_total=0)|Q(id__in=paid))
elif self.value() == 'PENDING':
has_transaction = queryset.exclude(transactions__isnull=True)
non_rejected = has_transaction.exclude(transactions__state=Transaction.REJECTED)
non_rejected = non_rejected.values_list('id', flat=True).distinct()
return queryset.filter(pk__in=non_rejected)
elif self.value() == 'BAD_DEBT':
non_rejected = queryset.exclude(transactions__state=Transaction.REJECTED)
non_rejected = non_rejected.values_list('id', flat=True).distinct()
return queryset.exclude(pk__in=non_rejected)

View File

@ -93,7 +93,7 @@ class Bill(models.Model):
due_on = models.DateField(_("due on"), null=True, blank=True)
updated_on = models.DateField(_("updated on"), auto_now=True)
# TODO allways compute total or what?
total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
total = models.DecimalField(max_digits=12, decimal_places=2, null=True)
comments = models.TextField(_("comments"), blank=True)
html = models.TextField(_("HTML"), blank=True)
@ -105,6 +105,10 @@ class Bill(models.Model):
def __str__(self):
return self.number
@classmethod
def get_class_type(cls):
return cls.__name__.upper()
@cached_property
def seller(self):
return Account.get_main().billcontact
@ -113,20 +117,29 @@ class Bill(models.Model):
def buyer(self):
return self.account.billcontact
@property
def has_multiple_pages(self):
return self.type != self.FEE
@cached_property
def payment_state(self):
if self.is_open or self.get_type() == self.PROFORMA:
return self.OPEN
secured = self.transactions.secured().amount() or 0
if secured >= self.total:
if abs(secured) >= abs(self.get_total()):
return self.PAID
elif self.transactions.exclude_rejected().exists():
return self.PENDING
return self.BAD_DEBT
@property
def has_multiple_pages(self):
return self.type != self.FEE
def get_total(self):
if not self.is_open:
return self.total
try:
return self.computed_total
except AttributeError:
self.computed_total = self.compute_total()
return self.computed_total
def get_payment_state_display(self):
value = self.payment_state
@ -135,10 +148,6 @@ class Bill(models.Model):
def get_current_transaction(self):
return self.transactions.exclude_rejected().first()
@classmethod
def get_class_type(cls):
return cls.__name__.upper()
def get_type(self):
return self.type or self.get_class_type()
@ -177,7 +186,7 @@ class Bill(models.Model):
payment = self.account.paymentsources.get_default()
if not self.due_on:
self.due_on = self.get_due_date(payment=payment)
self.total = self.get_total()
self.total = self.compute_total()
transaction = None
if self.get_type() != self.PROFORMA:
transaction = self.transactions.create(bill=self, source=payment, amount=self.total)
@ -241,7 +250,7 @@ class Bill(models.Model):
self.number = self.get_number()
super(Bill, self).save(*args, **kwargs)
def get_subtotals(self):
def compute_subtotals(self):
subtotals = {}
lines = self.lines.annotate(totals=(F('subtotal') + Coalesce(F('sublines__total'), 0)))
for tax, total in lines.values_list('tax', 'totals'):
@ -250,7 +259,7 @@ class Bill(models.Model):
subtotals[tax] = (subtotal, round(tax/100*subtotal, 2))
return subtotals
def get_total(self):
def compute_total(self):
totals = self.lines.annotate(
totals=(F('subtotal') + Coalesce(F('sublines__total'), 0)) * (1+F('tax')/100))
return round(totals.aggregate(Sum('totals'))['totals__sum'], 2)
@ -304,7 +313,7 @@ class BillLine(models.Model):
def __str__(self):
return "#%i" % self.pk
def get_total(self):
def compute_total(self):
""" Computes subline discounts """
if self.pk:
return self.subtotal + sum([sub.total for sub in self.sublines.all()])

View File

@ -107,7 +107,7 @@
</div>
<div id="totals">
<br>&nbsp;<br>
{% for tax, subtotal in bill.get_subtotals.items %}
{% for tax, subtotal in bill.compute_subtotals.items %}
<span class="subtotal column-title">{% trans "subtotal" %} {{ tax }}% {% trans "VAT" %}</span>
<span class="subtotal column-value">{{ subtotal | first }} &{{ currency.lower }};</span>
<br>

View File

@ -49,21 +49,19 @@ def validate_zone_interval(value):
def validate_zone_label(value):
"""
http://www.ietf.org/rfc/rfc1035.txt
The labels must follow the rules for ARPANET host names. They must
start with a letter, end with a letter or digit, and have as interior
characters only letters, digits, and hyphen. There are also some
restrictions on the length. Labels must be 63 characters or less.
Allowable characters in a label for a host name are only ASCII letters, digits, and the `-' character.
Labels may not be all numbers, but may have a leading digit (e.g., 3com.com).
Labels must end and begin only with a letter or digit. See [RFC 1035] and [RFC 1123].
"""
if not re.match(r'^[a-z][\.\-0-9a-z]*[\.0-9a-z]$', value):
msg = _("Labels must start with a letter, end with a letter or digit, "
"and have as interior characters only letters, digits, and hyphen")
if not re.match(r'^[a-z0-9][\.\-0-9a-z]*[\.0-9a-z]$', value):
msg = _("Labels must start and end with a letter or digit, "
"and have as interior characters only letters, digits, and hyphen.")
raise ValidationError(msg)
if not value.endswith('.'):
msg = _("Use a fully expanded domain name ending with a dot")
msg = _("Use a fully expanded domain name ending with a dot.")
raise ValidationError(msg)
if len(value) > 63:
raise ValidationError(_("Labels must be 63 characters or less"))
raise ValidationError(_("Labels must be 63 characters or less."))
def validate_mx_record(value):

View File

@ -14,8 +14,6 @@ from .models import Message
def send_message(message, num=0, connection=None, bulk=100):
if not message.pk:
message.save()
if num >= bulk:
connection.close()
connection = None

View File

@ -10,6 +10,7 @@ from . import engine, settings
@task
def send_message(message):
message.save()
engine.send_message(message)

View File

@ -11,6 +11,7 @@ from django.utils.translation import ungettext, ugettext_lazy as _
from orchestra.admin.decorators import action_with_confirmation
from orchestra.admin.utils import change_url
from . import helpers
from .methods import PaymentMethod
from .models import Transaction
@ -175,25 +176,9 @@ commit.verbose_name = _("Commit")
def delete_selected(modeladmin, request, queryset):
""" Has to have same name as admin.actions.delete_selected """
if not queryset:
messages.warning(request, "No transaction process selected.")
return
if queryset.exclude(transactions__state=Transaction.WAITTING_EXECUTION).exists():
messages.error(request, "Done nothing. Not all related transactions in waitting execution.")
return
# Store before deleting
related_transactions = []
for process in queryset:
related_transactions.extend(process.transactions.filter(state=Transaction.WAITTING_EXECUTION))
related_transactions = helpers.pre_delete_processes(modelamdin, request, queryset)
response = actions.delete_selected(modeladmin, request, queryset)
if response is None:
# Confirmation
num = 0
for transaction in related_transactions:
transaction.state = Transaction.WAITTING_PROCESSING
transaction.save(update_fields=('state',))
num += 1
modeladmin.log_change(request, transaction, _("Unprocessed"))
messages.success(request, "%i related transactions marked as waitting for processing." % num)
helpers.post_delete_processes(modelamdin, request, related_transactions)
return response
delete_selected.short_description = actions.delete_selected.short_description

View File

@ -1,5 +1,6 @@
from django.contrib import admin
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ChangeViewActionsMixin, ExtendedModelAdmin
@ -7,7 +8,7 @@ from orchestra.admin.utils import admin_colored, admin_link
from orchestra.contrib.accounts.admin import AccountAdminMixin, SelectAccountAdminMixin
from orchestra.plugins.admin import SelectPluginAdminMixin
from . import actions
from . import actions, helpers
from .methods import PaymentMethod
from .models import PaymentSource, Transaction, TransactionProcess
@ -167,6 +168,14 @@ class TransactionProcessAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
exclude = ['mark_process_as_executed', 'abort', 'commit']
return [action for action in actions if action.__name__ not in exclude]
def delete_view(self, request, object_id, extra_context=None):
queryset = self.model.objects.filter(id=object_id)
related_transactions = helpers.pre_delete_processes(self, request, queryset)
response = super(TransactionProcessAdmin, self).delete_view(request, object_id, extra_context)
if isinstance(response, HttpResponseRedirect):
helpers.post_delete_processes(self, request, related_transactions)
return response
admin.site.register(PaymentSource, PaymentSourceAdmin)
admin.site.register(Transaction, TransactionAdmin)

View File

@ -0,0 +1,37 @@
from django.contrib import messages
from django.utils.translation import ungettext, ugettext_lazy as _
from .models import Transaction
def pre_delete_processes(modeladmin, request, queryset):
""" Has to have same name as admin.actions.delete_selected """
if not queryset:
messages.warning(request,
_("No transaction process selected."))
return
if queryset.exclude(transactions__state=Transaction.WAITTING_EXECUTION).exists():
messages.error(request,
_("Done nothing. Not all related transactions in waitting execution."))
return
# Store before deleting
related_transactions = []
for process in queryset:
waitting_execution = process.transactions.filter(state=Transaction.WAITTING_EXECUTION)
related_transactions.extend(waitting_execution)
return related_transactions
def post_delete_processes(modeladmin, request, related_transactions):
# Confirmation
num = 0
for transaction in related_transactions:
transaction.state = Transaction.WAITTING_PROCESSING
transaction.save(update_fields=('state',))
num += 1
modeladmin.log_change(request, transaction, _("Unprocessed"))
messages.success(request, ungettext(
"One related transaction has been marked as <i>waitting for processing</i>",
"%i related transactions have been marked as <i>waitting for processing</i>." % num,
num
))

View File

@ -185,7 +185,9 @@ class SEPADirectDebit(PaymentMethod):
data = transaction.source.data
yield E.DrctDbtTxInf( # Direct Debit Transaction Info
E.PmtId( # Payment Id
E.EndToEndId(str(transaction.id)) # Payment Id/End to End
E.EndToEndId( # Payment Id/End to End
str(transaction.bill.number)+'-'+str(transaction.id)
)
),
E.InstdAmt( # Instructed Amount
str(abs(transaction.amount)),
@ -207,7 +209,7 @@ class SEPADirectDebit(PaymentMethod):
)
),
E.Dbtr( # Debtor
E.Nm(account.name), # Name
E.Nm(account.billcontact.get_name()), # Name
),
E.DbtrAcct( # Debtor Account
E.Id(

View File

@ -4,6 +4,7 @@ from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField
from orchestra.models.fields import PrivateFileField
from orchestra.models.queryset import group_by
from . import settings
@ -164,7 +165,7 @@ class TransactionProcess(models.Model):
)
data = JSONField(_("data"), blank=True)
file = models.FileField(_("file"), blank=True)
file = PrivateFileField(_("file"), blank=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)

View File

@ -6,7 +6,7 @@
<p>{{ content_message }}</p>
<ul>
{% for proc in processes %}
<li> <a href="{% url admin:payments_transactionprocess_change' proc.pk|admin_urlquote %}">Process #{{ proc.id }}</a>
<li> <a href="{% url 'admin:payments_transactionprocess_change' proc.pk|admin_urlquote %}">Process #{{ proc.id }}</a>
{% if proc.file %}
<ul><li>File: <a href="{{ proc.file.url }}">{{ proc.file }}</a></li></ul>
{% endif %}

View File

@ -1,5 +1,9 @@
import os
from django.core import exceptions
from django.core.urlresolvers import reverse
from django.db import models
from django.db.models.fields.files import FileField, FieldFile
from django.utils.text import capfirst
from ..forms.fields import MultiSelectFormField
@ -58,7 +62,32 @@ class NullableCharField(models.CharField):
return value or None
if isinstalled('south'):
from south.modelsinspector import add_introspection_rules
add_introspection_rules([], ["^orchestra\.models\.fields\.MultiSelectField"])
add_introspection_rules([], ["^orchestra\.models\.fields\.NullableCharField"])
class PrivateFieldFile(FieldFile):
@property
def url(self):
self._require_file()
app_label = self.instance._meta.app_label
model_name = self.instance._meta.object_name.lower()
field_name = self.field.name
pk = self.instance.pk
filename = os.path.basename(self.path)
args = [app_label, model_name, field_name, pk, filename]
return reverse('private-media', args=args)
@property
def condition(self):
return self.field.condition
@property
def attachment(self):
return self.field.attachment
class PrivateFileField(FileField):
attr_class = PrivateFieldFile
def __init__(self, verbose_name=None, name=None, upload_to='', storage=None, attachment=True,
condition=lambda request, instance: request.user.is_superuser, **kwargs):
super(PrivateFileField, self).__init__(verbose_name, name, upload_to, storage, **kwargs)
self.condition = condition
self.attachment = attachment

View File

@ -21,11 +21,10 @@ urlpatterns = [
'rest_framework.authtoken.views.obtain_auth_token',
name='api-token-auth'
),
# TODO make this private
url(r'^media/(?P<path>.*)$', 'django.views.static.serve', {
'document_root': settings.MEDIA_ROOT,
'show_indexes': True
})
url(r'^media/(.+)/(.+)/(.+)/(.+)/(.+)$',
'orchestra.views.serve_private_media',
name='private-media'
),
]

20
orchestra/views.py Normal file
View File

@ -0,0 +1,20 @@
from django.http import Http404
from django.contrib.admin.util import unquote
from django.core.exceptions import PermissionDenied
from django.db.models import get_model
from django.shortcuts import get_object_or_404
from django.views.static import serve
def serve_private_media(request, app_label, model_name, field_name, object_id, filename):
model = get_model(app_label, model_name)
if model is None:
raise Http404('')
instance = get_object_or_404(model, pk=unquote(object_id))
if not hasattr(instance, field_name):
raise Http404('')
field = getattr(instance, field_name)
if field.condition(request, instance):
return serve(request, field.name, document_root=field.storage.location)
else:
raise PermissionDenied()