Imporvements on payments, bills and upgraded domain label validation to RFC 1123
This commit is contained in:
parent
a8cad48fed
commit
a17e8d9b8c
11
TODO.md
11
TODO.md
|
@ -407,20 +407,17 @@ uwsgi --reload /tmp/project-master.pid
|
||||||
touch /tmp/somefile
|
touch /tmp/somefile
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Change zone ttl
|
# Change zone ttl
|
||||||
# batch zone edditing
|
# batch zone edditing
|
||||||
# inherit registers from parent?
|
# 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
|
# 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 '~'
|
# Serializers.validation migration to DRF3: grep -r 'attrs, source' *|grep -v '~'
|
||||||
serailzer self.instance on create.
|
serailzer self.instance on create.
|
||||||
|
|
||||||
# generate Direct debit q19 on a protected path, or store it on the transaction.proc
|
# set_password serializer: "just-the-password" not {"password": "password"}
|
||||||
# regenerate direct debit q19
|
|
||||||
# add transproc.method for regeneration
|
|
||||||
|
|
||||||
# TODO wrapp admin delete: delete proc undo processing on related transactions
|
# use namedtuples!
|
||||||
|
|
||||||
|
# Negative transactionsx
|
||||||
|
|
|
@ -161,6 +161,9 @@ STATIC_URL = '/static/'
|
||||||
# Example: "/home/media/media.lawrence.com/static/"
|
# Example: "/home/media/media.lawrence.com/static/"
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, '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
|
# Path used for database translations files
|
||||||
LOCALE_PATHS = (
|
LOCALE_PATHS = (
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'', include('orchestra.urls')),
|
url(r'', include('orchestra.urls')),
|
||||||
]
|
]
|
||||||
|
|
|
@ -17,7 +17,7 @@ from orchestra.contrib.accounts.admin import AccountAdminMixin, AccountAdmin
|
||||||
from orchestra.forms.widgets import paddingCheckboxSelectMultiple
|
from orchestra.forms.widgets import paddingCheckboxSelectMultiple
|
||||||
|
|
||||||
from . import settings, actions
|
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
|
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')
|
order_link = admin_link('order', display='pk')
|
||||||
|
|
||||||
def display_total(self, line):
|
def display_total(self, line):
|
||||||
total = line.get_total()
|
total = line.compute_total()
|
||||||
sublines = line.sublines.all()
|
sublines = line.sublines.all()
|
||||||
if sublines:
|
if sublines:
|
||||||
content = '\n'.join(['%s: %s' % (sub.description, sub.total) for sub in 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
|
display_subtotal.allow_tags = True
|
||||||
|
|
||||||
def display_total(self, line):
|
def display_total(self, line):
|
||||||
return line.get_total()
|
return line.compute_total()
|
||||||
display_total.short_description = _("Total")
|
display_total.short_description = _("Total")
|
||||||
display_total.allow_tags = True
|
display_total.allow_tags = True
|
||||||
|
|
||||||
|
@ -180,7 +180,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
||||||
'number', 'type_link', 'account_link', 'created_on_display',
|
'number', 'type_link', 'account_link', 'created_on_display',
|
||||||
'num_lines', 'display_total', 'display_payment_state', 'is_open', 'is_sent'
|
'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')
|
add_fields = ('account', 'type', 'is_open', 'due_on', 'comments')
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
|
@ -238,7 +238,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
||||||
state = bill.get_payment_state_display().upper()
|
state = bill.get_payment_state_display().upper()
|
||||||
color = PAYMENT_STATE_COLORS.get(bill.payment_state, 'grey')
|
color = PAYMENT_STATE_COLORS.get(bill.payment_state, 'grey')
|
||||||
return '<a href="{url}" style="color:{color}">{name}</a>'.format(
|
return '<a href="{url}" style="color:{color}">{name}</a>'.format(
|
||||||
url=url, color=color, name=state)
|
url=url, color=color, name=state)
|
||||||
display_payment_state.allow_tags = True
|
display_payment_state.allow_tags = True
|
||||||
display_payment_state.short_description = _("Payment")
|
display_payment_state.short_description = _("Payment")
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
from django.contrib.admin import SimpleListFilter
|
from django.contrib.admin import SimpleListFilter
|
||||||
from django.core.urlresolvers import reverse
|
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 _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,21 +39,25 @@ class BillTypeListFilter(SimpleListFilter):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PositivePriceListFilter(SimpleListFilter):
|
class TotalListFilter(SimpleListFilter):
|
||||||
title = _("positive price")
|
title = _("total")
|
||||||
parameter_name = 'positive_price'
|
parameter_name = 'total'
|
||||||
|
|
||||||
def lookups(self, request, model_admin):
|
def lookups(self, request, model_admin):
|
||||||
return (
|
return (
|
||||||
('True', _("Yes")),
|
('gt', mark_safe("total > 0")),
|
||||||
('False', _("No")),
|
('eq', "total = 0"),
|
||||||
|
('lt', mark_safe("total < 0")),
|
||||||
)
|
)
|
||||||
|
|
||||||
def queryset(self, request, queryset):
|
def queryset(self, request, queryset):
|
||||||
if self.value() == 'True':
|
if self.value() == 'gt':
|
||||||
return queryset.filter(computed_total__gt=0)
|
return queryset.filter(computed_total__gt=0)
|
||||||
if self.value() == 'False':
|
elif self.value() == 'eq':
|
||||||
return queryset.filter(computed_total__lte=0)
|
return queryset.filter(computed_total=0)
|
||||||
|
elif self.value() == 'lt':
|
||||||
|
return queryset.filter(computed_total__lt=0)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class HasBillContactListFilter(SimpleListFilter):
|
class HasBillContactListFilter(SimpleListFilter):
|
||||||
|
@ -68,5 +74,45 @@ class HasBillContactListFilter(SimpleListFilter):
|
||||||
def queryset(self, request, queryset):
|
def queryset(self, request, queryset):
|
||||||
if self.value() == 'True':
|
if self.value() == 'True':
|
||||||
return queryset.filter(billcontact__isnull=False)
|
return queryset.filter(billcontact__isnull=False)
|
||||||
if self.value() == 'False':
|
elif self.value() == 'False':
|
||||||
return queryset.filter(billcontact__isnull=True)
|
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)
|
||||||
|
|
|
@ -93,7 +93,7 @@ class Bill(models.Model):
|
||||||
due_on = models.DateField(_("due on"), null=True, blank=True)
|
due_on = models.DateField(_("due on"), null=True, blank=True)
|
||||||
updated_on = models.DateField(_("updated on"), auto_now=True)
|
updated_on = models.DateField(_("updated on"), auto_now=True)
|
||||||
# TODO allways compute total or what?
|
# 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)
|
comments = models.TextField(_("comments"), blank=True)
|
||||||
html = models.TextField(_("HTML"), blank=True)
|
html = models.TextField(_("HTML"), blank=True)
|
||||||
|
|
||||||
|
@ -105,6 +105,10 @@ class Bill(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.number
|
return self.number
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_class_type(cls):
|
||||||
|
return cls.__name__.upper()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def seller(self):
|
def seller(self):
|
||||||
return Account.get_main().billcontact
|
return Account.get_main().billcontact
|
||||||
|
@ -113,20 +117,29 @@ class Bill(models.Model):
|
||||||
def buyer(self):
|
def buyer(self):
|
||||||
return self.account.billcontact
|
return self.account.billcontact
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_multiple_pages(self):
|
||||||
|
return self.type != self.FEE
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def payment_state(self):
|
def payment_state(self):
|
||||||
if self.is_open or self.get_type() == self.PROFORMA:
|
if self.is_open or self.get_type() == self.PROFORMA:
|
||||||
return self.OPEN
|
return self.OPEN
|
||||||
secured = self.transactions.secured().amount() or 0
|
secured = self.transactions.secured().amount() or 0
|
||||||
if secured >= self.total:
|
if abs(secured) >= abs(self.get_total()):
|
||||||
return self.PAID
|
return self.PAID
|
||||||
elif self.transactions.exclude_rejected().exists():
|
elif self.transactions.exclude_rejected().exists():
|
||||||
return self.PENDING
|
return self.PENDING
|
||||||
return self.BAD_DEBT
|
return self.BAD_DEBT
|
||||||
|
|
||||||
@property
|
def get_total(self):
|
||||||
def has_multiple_pages(self):
|
if not self.is_open:
|
||||||
return self.type != self.FEE
|
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):
|
def get_payment_state_display(self):
|
||||||
value = self.payment_state
|
value = self.payment_state
|
||||||
|
@ -135,10 +148,6 @@ class Bill(models.Model):
|
||||||
def get_current_transaction(self):
|
def get_current_transaction(self):
|
||||||
return self.transactions.exclude_rejected().first()
|
return self.transactions.exclude_rejected().first()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_class_type(cls):
|
|
||||||
return cls.__name__.upper()
|
|
||||||
|
|
||||||
def get_type(self):
|
def get_type(self):
|
||||||
return self.type or self.get_class_type()
|
return self.type or self.get_class_type()
|
||||||
|
|
||||||
|
@ -177,7 +186,7 @@ class Bill(models.Model):
|
||||||
payment = self.account.paymentsources.get_default()
|
payment = self.account.paymentsources.get_default()
|
||||||
if not self.due_on:
|
if not self.due_on:
|
||||||
self.due_on = self.get_due_date(payment=payment)
|
self.due_on = self.get_due_date(payment=payment)
|
||||||
self.total = self.get_total()
|
self.total = self.compute_total()
|
||||||
transaction = None
|
transaction = None
|
||||||
if self.get_type() != self.PROFORMA:
|
if self.get_type() != self.PROFORMA:
|
||||||
transaction = self.transactions.create(bill=self, source=payment, amount=self.total)
|
transaction = self.transactions.create(bill=self, source=payment, amount=self.total)
|
||||||
|
@ -241,7 +250,7 @@ class Bill(models.Model):
|
||||||
self.number = self.get_number()
|
self.number = self.get_number()
|
||||||
super(Bill, self).save(*args, **kwargs)
|
super(Bill, self).save(*args, **kwargs)
|
||||||
|
|
||||||
def get_subtotals(self):
|
def compute_subtotals(self):
|
||||||
subtotals = {}
|
subtotals = {}
|
||||||
lines = self.lines.annotate(totals=(F('subtotal') + Coalesce(F('sublines__total'), 0)))
|
lines = self.lines.annotate(totals=(F('subtotal') + Coalesce(F('sublines__total'), 0)))
|
||||||
for tax, total in lines.values_list('tax', 'totals'):
|
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))
|
subtotals[tax] = (subtotal, round(tax/100*subtotal, 2))
|
||||||
return subtotals
|
return subtotals
|
||||||
|
|
||||||
def get_total(self):
|
def compute_total(self):
|
||||||
totals = self.lines.annotate(
|
totals = self.lines.annotate(
|
||||||
totals=(F('subtotal') + Coalesce(F('sublines__total'), 0)) * (1+F('tax')/100))
|
totals=(F('subtotal') + Coalesce(F('sublines__total'), 0)) * (1+F('tax')/100))
|
||||||
return round(totals.aggregate(Sum('totals'))['totals__sum'], 2)
|
return round(totals.aggregate(Sum('totals'))['totals__sum'], 2)
|
||||||
|
@ -304,7 +313,7 @@ class BillLine(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "#%i" % self.pk
|
return "#%i" % self.pk
|
||||||
|
|
||||||
def get_total(self):
|
def compute_total(self):
|
||||||
""" Computes subline discounts """
|
""" Computes subline discounts """
|
||||||
if self.pk:
|
if self.pk:
|
||||||
return self.subtotal + sum([sub.total for sub in self.sublines.all()])
|
return self.subtotal + sum([sub.total for sub in self.sublines.all()])
|
||||||
|
|
|
@ -107,7 +107,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="totals">
|
<div id="totals">
|
||||||
<br> <br>
|
<br> <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-title">{% trans "subtotal" %} {{ tax }}% {% trans "VAT" %}</span>
|
||||||
<span class="subtotal column-value">{{ subtotal | first }} &{{ currency.lower }};</span>
|
<span class="subtotal column-value">{{ subtotal | first }} &{{ currency.lower }};</span>
|
||||||
<br>
|
<br>
|
||||||
|
|
|
@ -49,21 +49,19 @@ def validate_zone_interval(value):
|
||||||
|
|
||||||
def validate_zone_label(value):
|
def validate_zone_label(value):
|
||||||
"""
|
"""
|
||||||
http://www.ietf.org/rfc/rfc1035.txt
|
Allowable characters in a label for a host name are only ASCII letters, digits, and the `-' character.
|
||||||
The labels must follow the rules for ARPANET host names. They must
|
Labels may not be all numbers, but may have a leading digit (e.g., 3com.com).
|
||||||
start with a letter, end with a letter or digit, and have as interior
|
Labels must end and begin only with a letter or digit. See [RFC 1035] and [RFC 1123].
|
||||||
characters only letters, digits, and hyphen. There are also some
|
|
||||||
restrictions on the length. Labels must be 63 characters or less.
|
|
||||||
"""
|
"""
|
||||||
if not re.match(r'^[a-z][\.\-0-9a-z]*[\.0-9a-z]$', value):
|
if not re.match(r'^[a-z0-9][\.\-0-9a-z]*[\.0-9a-z]$', value):
|
||||||
msg = _("Labels must start with a letter, end with a letter or digit, "
|
msg = _("Labels must start and end with a letter or digit, "
|
||||||
"and have as interior characters only letters, digits, and hyphen")
|
"and have as interior characters only letters, digits, and hyphen.")
|
||||||
raise ValidationError(msg)
|
raise ValidationError(msg)
|
||||||
if not value.endswith('.'):
|
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)
|
raise ValidationError(msg)
|
||||||
if len(value) > 63:
|
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):
|
def validate_mx_record(value):
|
||||||
|
|
|
@ -14,8 +14,6 @@ from .models import Message
|
||||||
|
|
||||||
|
|
||||||
def send_message(message, num=0, connection=None, bulk=100):
|
def send_message(message, num=0, connection=None, bulk=100):
|
||||||
if not message.pk:
|
|
||||||
message.save()
|
|
||||||
if num >= bulk:
|
if num >= bulk:
|
||||||
connection.close()
|
connection.close()
|
||||||
connection = None
|
connection = None
|
||||||
|
|
|
@ -10,6 +10,7 @@ from . import engine, settings
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def send_message(message):
|
def send_message(message):
|
||||||
|
message.save()
|
||||||
engine.send_message(message)
|
engine.send_message(message)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ from django.utils.translation import ungettext, ugettext_lazy as _
|
||||||
from orchestra.admin.decorators import action_with_confirmation
|
from orchestra.admin.decorators import action_with_confirmation
|
||||||
from orchestra.admin.utils import change_url
|
from orchestra.admin.utils import change_url
|
||||||
|
|
||||||
|
from . import helpers
|
||||||
from .methods import PaymentMethod
|
from .methods import PaymentMethod
|
||||||
from .models import Transaction
|
from .models import Transaction
|
||||||
|
|
||||||
|
@ -175,25 +176,9 @@ commit.verbose_name = _("Commit")
|
||||||
|
|
||||||
def delete_selected(modeladmin, request, queryset):
|
def delete_selected(modeladmin, request, queryset):
|
||||||
""" Has to have same name as admin.actions.delete_selected """
|
""" Has to have same name as admin.actions.delete_selected """
|
||||||
if not queryset:
|
related_transactions = helpers.pre_delete_processes(modelamdin, request, 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))
|
|
||||||
response = actions.delete_selected(modeladmin, request, queryset)
|
response = actions.delete_selected(modeladmin, request, queryset)
|
||||||
if response is None:
|
if response is None:
|
||||||
# Confirmation
|
helpers.post_delete_processes(modelamdin, request, related_transactions)
|
||||||
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)
|
|
||||||
return response
|
return response
|
||||||
delete_selected.short_description = actions.delete_selected.short_description
|
delete_selected.short_description = actions.delete_selected.short_description
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.admin import ChangeViewActionsMixin, ExtendedModelAdmin
|
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.contrib.accounts.admin import AccountAdminMixin, SelectAccountAdminMixin
|
||||||
from orchestra.plugins.admin import SelectPluginAdminMixin
|
from orchestra.plugins.admin import SelectPluginAdminMixin
|
||||||
|
|
||||||
from . import actions
|
from . import actions, helpers
|
||||||
from .methods import PaymentMethod
|
from .methods import PaymentMethod
|
||||||
from .models import PaymentSource, Transaction, TransactionProcess
|
from .models import PaymentSource, Transaction, TransactionProcess
|
||||||
|
|
||||||
|
@ -167,6 +168,14 @@ class TransactionProcessAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
|
||||||
exclude = ['mark_process_as_executed', 'abort', 'commit']
|
exclude = ['mark_process_as_executed', 'abort', 'commit']
|
||||||
return [action for action in actions if action.__name__ not in exclude]
|
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(PaymentSource, PaymentSourceAdmin)
|
||||||
admin.site.register(Transaction, TransactionAdmin)
|
admin.site.register(Transaction, TransactionAdmin)
|
||||||
|
|
37
orchestra/contrib/payments/helpers.py
Normal file
37
orchestra/contrib/payments/helpers.py
Normal 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
|
||||||
|
))
|
|
@ -185,7 +185,9 @@ class SEPADirectDebit(PaymentMethod):
|
||||||
data = transaction.source.data
|
data = transaction.source.data
|
||||||
yield E.DrctDbtTxInf( # Direct Debit Transaction Info
|
yield E.DrctDbtTxInf( # Direct Debit Transaction Info
|
||||||
E.PmtId( # Payment Id
|
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
|
E.InstdAmt( # Instructed Amount
|
||||||
str(abs(transaction.amount)),
|
str(abs(transaction.amount)),
|
||||||
|
@ -207,7 +209,7 @@ class SEPADirectDebit(PaymentMethod):
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
E.Dbtr( # Debtor
|
E.Dbtr( # Debtor
|
||||||
E.Nm(account.name), # Name
|
E.Nm(account.billcontact.get_name()), # Name
|
||||||
),
|
),
|
||||||
E.DbtrAcct( # Debtor Account
|
E.DbtrAcct( # Debtor Account
|
||||||
E.Id(
|
E.Id(
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.utils.functional import cached_property
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from jsonfield import JSONField
|
from jsonfield import JSONField
|
||||||
|
|
||||||
|
from orchestra.models.fields import PrivateFileField
|
||||||
from orchestra.models.queryset import group_by
|
from orchestra.models.queryset import group_by
|
||||||
|
|
||||||
from . import settings
|
from . import settings
|
||||||
|
@ -164,7 +165,7 @@ class TransactionProcess(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
data = JSONField(_("data"), blank=True)
|
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)
|
state = models.CharField(_("state"), max_length=16, choices=STATES, default=CREATED)
|
||||||
created_at = models.DateTimeField(_("created"), auto_now_add=True)
|
created_at = models.DateTimeField(_("created"), auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(_("updated"), auto_now=True)
|
updated_at = models.DateTimeField(_("updated"), auto_now=True)
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<p>{{ content_message }}</p>
|
<p>{{ content_message }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
{% for proc in processes %}
|
{% 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 %}
|
{% if proc.file %}
|
||||||
<ul><li>File: <a href="{{ proc.file.url }}">{{ proc.file }}</a></li></ul>
|
<ul><li>File: <a href="{{ proc.file.url }}">{{ proc.file }}</a></li></ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
|
import os
|
||||||
|
|
||||||
from django.core import exceptions
|
from django.core import exceptions
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.fields.files import FileField, FieldFile
|
||||||
from django.utils.text import capfirst
|
from django.utils.text import capfirst
|
||||||
|
|
||||||
from ..forms.fields import MultiSelectFormField
|
from ..forms.fields import MultiSelectFormField
|
||||||
|
@ -58,7 +62,32 @@ class NullableCharField(models.CharField):
|
||||||
return value or None
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
if isinstalled('south'):
|
class PrivateFieldFile(FieldFile):
|
||||||
from south.modelsinspector import add_introspection_rules
|
@property
|
||||||
add_introspection_rules([], ["^orchestra\.models\.fields\.MultiSelectField"])
|
def url(self):
|
||||||
add_introspection_rules([], ["^orchestra\.models\.fields\.NullableCharField"])
|
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
|
||||||
|
|
|
@ -21,11 +21,10 @@ urlpatterns = [
|
||||||
'rest_framework.authtoken.views.obtain_auth_token',
|
'rest_framework.authtoken.views.obtain_auth_token',
|
||||||
name='api-token-auth'
|
name='api-token-auth'
|
||||||
),
|
),
|
||||||
# TODO make this private
|
url(r'^media/(.+)/(.+)/(.+)/(.+)/(.+)$',
|
||||||
url(r'^media/(?P<path>.*)$', 'django.views.static.serve', {
|
'orchestra.views.serve_private_media',
|
||||||
'document_root': settings.MEDIA_ROOT,
|
name='private-media'
|
||||||
'show_indexes': True
|
),
|
||||||
})
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
20
orchestra/views.py
Normal file
20
orchestra/views.py
Normal 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()
|
Loading…
Reference in a new issue