diff --git a/TODO.md b/TODO.md
index 7fc67f7c..18c04cb1 100644
--- a/TODO.md
+++ b/TODO.md
@@ -83,7 +83,8 @@
* print open invoices as proforma?
-* env ORCHESTRA_MASTER_SERVER='test1.orchestra.lan' ORCHESTRA_SECOND_SERVER='test2.orchestra.lan' ORCHESTRA_SLAVE_SERVER='test3.orchestra.lan' python manage.py test orchestra.apps.domains.tests.functional_tests.tests:AdminBind9BackendDomainTest
+* env ORCHESTRA_MASTER_SERVER='test1.orchestra.lan' ORCHESTRA_SECOND_SERVER='test2.orchestra.lan' ORCHESTRA_SLAVE_SERVER='test3.orchestra.lan' python manage.py test orchestra.apps.domains.tests.functional_tests.tests:AdminBind9BackendDomainTest --nologcapture¶
+
* ForeignKey.swappable
@@ -222,13 +223,55 @@ require_once(‘/etc/moodles/’.$moodle_host.‘config.php’);``` moodle/drupl
* autoexpand mailbox.filter according to filtering options
* allow empty metric pack for default rates? changes on rating algo
-* rates plan verbose name!"!
-* IMPORTANT make sure no order is created for mailboxes that include disk? or just don't produce lines with cost == 0
-* IMPORTANT maildis updae and metric storage ?? threshold ? or what?
+* IMPORTANT make sure no order is created for mailboxes that include disk? or just don't produce lines with cost == 0 or quantity 0 ?
* Improve performance of admin change lists with debug toolbar and prefech_related
* and miscellaneous.service.name == 'domini-registre'
* DOMINI REGISTRE MIGRATION SCRIPTS
-* detect subdomains accounts correctly with subdomains: i.e. www.marcay.pangea.org
-* lines too long on invoice, double lines or cut
+* lines too long on invoice, double lines or cut, and make margin wider
+* PHP_TIMEOUT env variable in sync with fcgid idle timeout
+
+* payment methods icons
+* use server.name | server.address on python backends, like gitlab instead of settings?
+* saas change password feature (the only way of re.running a backend)
+
+* TODO raise404, here and everywhere
+* display subline links on billlines
+* update service orders on a celery task?
+
+* billline quantity eval('10x100') instead of miningless description '(10*100)'
+
+* order metric increases inside billed until period
+* do more test, make sure billed until doesn't get uodated whhen services are billed with les metric, and don't upgrade billed_until when undoing under this circumstances
+
+* move normurlpath to orchestra.utils from websites.utils
+
+* one time service metric change should update last value, only record for recurring invoicing.
+
+* write down insights
+
+* pluggable rate algorithms, with help_text, and change some services to match price
+
+* translation app, with generates the trans files from models
+* use english on services defs and so on, an translate them on render time
+* (miscellaneous.service.ident or '').startswith()
+
+
+
+Translation
+-----------
+
+python manage.py makemessages -l ca --domain database
+
+mkdir locale
+django-admin.py makemessages -l ca
+django-admin.py compilemessages -l ca
+
+https://docs.djangoproject.com/en/1.7/topics/i18n/translation/#joining-strings-string-concat
+
+from django.utils.translation import ugettext
+from django.utils import translation
+translation.activate('ca')
+ugettext("Fuck you")
+
diff --git a/orchestra/apps/bills/actions.py b/orchestra/apps/bills/actions.py
index 11371e12..ebec9709 100644
--- a/orchestra/apps/bills/actions.py
+++ b/orchestra/apps/bills/actions.py
@@ -111,3 +111,68 @@ def send_bills(modeladmin, request, queryset):
modeladmin.log_change(request, bill, 'Sent')
send_bills.verbose_name = lambda bill: _("Resend" if getattr(bill, 'is_sent', False) else "Send")
send_bills.url_name = 'send'
+
+
+def undo_billing(modeladmin, request, queryset):
+ group = {}
+ for line in queryset.select_related('order'):
+ if line.order_id:
+ try:
+ group[line.order].append(line)
+ except KeyError:
+ group[line.order] = [line]
+ # TODO force incomplete info
+ for order, lines in group.iteritems():
+ # Find path from ini to end
+ for attr in ['order_id', 'order_billed_on', 'order_billed_until']:
+ if not getattr(self, attr):
+ raise ValidationError(_("Not enough information stored for undoing"))
+ sorted(lines, key=lambda l: l.created_on)
+ if 'a' != order.billed_on:
+ raise ValidationError(_("Dates don't match"))
+ prev = order.billed_on
+ for ix in xrange(0, len(lines)):
+ if lines[ix].order_b: # TODO we need to look at the periods here
+ pass
+ order.billed_until = self.order_billed_until
+ order.billed_on = self.order_billed_on
+
+# TODO son't check for account equality
+def move_lines(modeladmin, request, queryset):
+ # Validate
+ account = None
+ for line in queryset.select_related('bill'):
+ bill = line.bill
+ if bill.state != bill.OPEN:
+ messages.error(request, _("Can not move lines which are not in open state."))
+ return
+ elif not account:
+ account = bill.account
+ elif bill.account != account:
+ messages.error(request, _("Can not move lines from different accounts"))
+ return
+ target = request.GET.get('target')
+ if not target:
+ # select target
+ return render(request, 'admin/orchestra/generic_confirmation.html', context)
+ target = Bill.objects.get(pk=int(pk))
+ if target.account != account:
+ messages.error(request, _("Target account different than lines account."))
+ return
+ if request.POST.get('post') == 'generic_confirmation':
+ for line in queryset:
+ line.bill = target
+ line.save(update_fields=['bill'])
+ # TODO bill history update
+ messages.success(request, _("Lines moved"))
+ # Final confirmation
+ return render(request, 'admin/orchestra/generic_confirmation.html', context)
+
+
+def copy_lines(modeladmin, request, queryset):
+ # same as move, but changing action behaviour
+ pass
+
+
+def delete_lines(modeladmin, request, queryset):
+ pass
diff --git a/orchestra/apps/bills/admin.py b/orchestra/apps/bills/admin.py
index 7521c755..fce1bf44 100644
--- a/orchestra/apps/bills/admin.py
+++ b/orchestra/apps/bills/admin.py
@@ -1,4 +1,5 @@
from django import forms
+from django.conf.urls import patterns, url
from django.contrib import admin
from django.contrib.admin.utils import unquote
from django.core.urlresolvers import reverse
@@ -12,8 +13,7 @@ from orchestra.admin.utils import admin_date, insertattr
from orchestra.apps.accounts.admin import AccountAdminMixin, AccountAdmin
from orchestra.forms.widgets import paddingCheckboxSelectMultiple
-from . import settings
-from .actions import download_bills, view_bill, close_bills, send_bills, validate_contact
+from . import settings, actions
from .filters import BillTypeListFilter, HasBillContactListFilter
from .models import Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForma, BillLine, BillContact
@@ -84,6 +84,36 @@ class ClosedBillLineInline(BillLineInline):
return False
+class BillLineManagerAdmin(admin.ModelAdmin):
+ list_display = ('description', 'rate', 'quantity', 'tax', 'subtotal')
+ actions = (actions.undo_billing, actions.move_lines, actions.copy_lines,)
+
+ def get_queryset(self, request):
+ qset = super(BillLineManagerAdmin, self).get_queryset(request)
+ return qset.filter(bill_id__in=self.bill_ids)
+
+ def changelist_view(self, request, extra_context=None):
+ GET = request.GET.copy()
+ bill_ids = GET.pop('bill_ids', ['0'])[0]
+ request.GET = GET
+ bill_ids = [int(id) for id in bill_ids.split(',')]
+ self.bill_ids = bill_ids
+ if not bill_ids:
+ return
+ elif len(bill_ids) > 1:
+ title = _("Manage bill lines of multiple bills.")
+ else:
+ bill_url = reverse('admin:bills_bill_change', args=(bill_ids[0],))
+ bill = Bill.objects.get(pk=bill_ids[0])
+ bill_link = '%s' % (bill_url, bill.ident)
+ title = mark_safe(_("Manage %s bill lines.") % bill_link)
+ context = {
+ 'title': title,
+ }
+ context.update(extra_context or {})
+ return super(BillLineManagerAdmin, self).changelist_view(request, context)
+
+
class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
list_display = (
'number', 'type_link', 'account_link', 'created_on_display',
@@ -101,8 +131,10 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
'fields': ('html',),
}),
)
- actions = [download_bills, close_bills, send_bills]
- change_view_actions = [view_bill, download_bills, send_bills, close_bills]
+ change_view_actions = [
+ actions.view_bill, actions.download_bills, actions.send_bills, actions.close_bills
+ ]
+ actions = [actions.download_bills, actions.close_bills, actions.send_bills]
change_readonly_fields = ('account_link', 'type', 'is_open')
readonly_fields = ('number', 'display_total', 'is_sent', 'display_payment_state')
inlines = [BillLineInline, ClosedBillLineInline]
@@ -144,6 +176,17 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
display_payment_state.allow_tags = True
display_payment_state.short_description = _("Payment")
+ def get_urls(self):
+ """ Hook bill lines management URLs on bill admin """
+ urls = super(BillAdmin, self).get_urls()
+ admin_site = self.admin_site
+ extra_urls = patterns("",
+ url("^manage-lines/$",
+ admin_site.admin_view(BillLineManagerAdmin(BillLine, admin_site).changelist_view),
+ name='bills_bill_manage_lines'),
+ )
+ return extra_urls + urls
+
def get_readonly_fields(self, request, obj=None):
fields = super(BillAdmin, self).get_readonly_fields(request, obj)
if obj and not obj.is_open:
@@ -187,7 +230,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
def change_view(self, request, object_id, **kwargs):
# TODO raise404, here and everywhere
bill = self.get_object(request, unquote(object_id))
- validate_contact(request, bill, error=False)
+ actions.validate_contact(request, bill, error=False)
return super(BillAdmin, self).change_view(request, object_id, **kwargs)
diff --git a/orchestra/apps/bills/locale/ca/LC_MESSAGES/django.po b/orchestra/apps/bills/locale/ca/LC_MESSAGES/django.po
new file mode 100644
index 00000000..8bfabe5e
--- /dev/null
+++ b/orchestra/apps/bills/locale/ca/LC_MESSAGES/django.po
@@ -0,0 +1,346 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR
Please select a "
+"payment source for the selected bills"
+msgstr ""
+
+#: actions.py:101
+msgid "Close"
+msgstr ""
+
+#: actions.py:112
+msgid "Resend"
+msgstr ""
+
+#: actions.py:129 models.py:308
+msgid "Not enough information stored for undoing"
+msgstr ""
+
+#: actions.py:132 models.py:310
+msgid "Dates don't match"
+msgstr ""
+
+#: actions.py:147
+msgid "Can not move lines which are not in open state."
+msgstr ""
+
+#: actions.py:152
+msgid "Can not move lines from different accounts"
+msgstr ""
+
+#: actions.py:160
+msgid "Target account different than lines account."
+msgstr ""
+
+#: actions.py:167
+msgid "Lines moved"
+msgstr ""
+
+#: admin.py:41 forms.py:12
+msgid "Total"
+msgstr ""
+
+#: admin.py:69
+msgid "Description"
+msgstr ""
+
+#: admin.py:77
+msgid "Subtotal"
+msgstr ""
+
+#: admin.py:104
+msgid "Manage bill lines of multiple bills."
+msgstr ""
+
+#: admin.py:109
+#, python-format
+msgid "Manage %s bill lines."
+msgstr ""
+
+#: admin.py:129
+msgid "Raw"
+msgstr ""
+
+#: admin.py:147
+msgid "lines"
+msgstr ""
+
+#: admin.py:152
+msgid "total"
+msgstr ""
+
+#: admin.py:160 models.py:85 models.py:339
+msgid "type"
+msgstr ""
+
+#: admin.py:177
+msgid "Payment"
+msgstr ""
+
+#: filters.py:17
+msgid "All"
+msgstr ""
+
+#: filters.py:18 models.py:75
+msgid "Invoice"
+msgstr ""
+
+#: filters.py:19 models.py:76
+msgid "Amendment invoice"
+msgstr ""
+
+#: filters.py:20 models.py:77
+msgid "Fee"
+msgstr ""
+
+#: filters.py:21
+msgid "Amendment fee"
+msgstr ""
+
+#: filters.py:22
+msgid "Pro-forma"
+msgstr ""
+
+#: filters.py:42
+msgid "has bill contact"
+msgstr ""
+
+#: filters.py:47
+msgid "Yes"
+msgstr ""
+
+#: filters.py:48
+msgid "No"
+msgstr ""
+
+#: forms.py:9
+msgid "Number"
+msgstr ""
+
+#: forms.py:11
+msgid "Account"
+msgstr ""
+
+#: forms.py:13
+msgid "Type"
+msgstr ""
+
+#: forms.py:15
+msgid "Source"
+msgstr ""
+
+#: helpers.py:10
+msgid ""
+"{relation} account \"{account}\" does not have a declared invoice contact. "
+"You should provide one"
+msgstr ""
+
+#: helpers.py:17
+msgid "Related"
+msgstr ""
+
+#: helpers.py:24
+msgid "Main"
+msgstr ""
+
+#: models.py:20 models.py:83
+msgid "account"
+msgstr ""
+
+#: models.py:22
+msgid "name"
+msgstr ""
+
+#: models.py:23
+msgid "Account full name will be used when left blank."
+msgstr ""
+
+#: models.py:24
+msgid "address"
+msgstr ""
+
+#: models.py:25
+msgid "city"
+msgstr ""
+
+#: models.py:27
+msgid "zip code"
+msgstr ""
+
+#: models.py:28
+msgid "Enter a valid zipcode."
+msgstr ""
+
+#: models.py:29
+msgid "country"
+msgstr ""
+
+#: models.py:32
+msgid "VAT number"
+msgstr ""
+
+#: models.py:64
+msgid "Paid"
+msgstr ""
+
+#: models.py:65
+msgid "Pending"
+msgstr ""
+
+#: models.py:66
+msgid "Bad debt"
+msgstr ""
+
+#: models.py:78
+msgid "Amendment Fee"
+msgstr ""
+
+#: models.py:79
+msgid "Pro forma"
+msgstr ""
+
+#: models.py:82
+msgid "number"
+msgstr ""
+
+#: models.py:86
+msgid "created on"
+msgstr ""
+
+#: models.py:87
+msgid "closed on"
+msgstr ""
+
+#: models.py:88
+msgid "open"
+msgstr ""
+
+#: models.py:89
+msgid "sent"
+msgstr ""
+
+#: models.py:90
+msgid "due on"
+msgstr ""
+
+#: models.py:91
+msgid "updated on"
+msgstr ""
+
+#: models.py:93
+msgid "comments"
+msgstr ""
+
+#: models.py:94
+msgid "HTML"
+msgstr ""
+
+#: models.py:270
+msgid "bill"
+msgstr ""
+
+#: models.py:271 models.py:336
+msgid "description"
+msgstr ""
+
+#: models.py:272
+msgid "rate"
+msgstr ""
+
+#: models.py:273
+msgid "quantity"
+msgstr ""
+
+#: models.py:274
+msgid "subtotal"
+msgstr ""
+
+#: models.py:275
+msgid "tax"
+msgstr ""
+
+#: models.py:281
+msgid "Informative link back to the order"
+msgstr ""
+
+#: models.py:282
+msgid "order billed"
+msgstr ""
+
+#: models.py:283
+msgid "order billed until"
+msgstr ""
+
+#: models.py:284
+msgid "created"
+msgstr ""
+
+#: models.py:286
+msgid "amended line"
+msgstr ""
+
+#: models.py:329
+msgid "Volume"
+msgstr ""
+
+#: models.py:330
+msgid "Compensation"
+msgstr ""
+
+#: models.py:331
+msgid "Other"
+msgstr ""
+
+#: models.py:335
+msgid "bill line"
+msgstr ""
diff --git a/orchestra/apps/bills/models.py b/orchestra/apps/bills/models.py
index e9d4926e..dc4e781f 100644
--- a/orchestra/apps/bills/models.py
+++ b/orchestra/apps/bills/models.py
@@ -274,8 +274,11 @@ class BillLine(models.Model):
subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2)
tax = models.PositiveIntegerField(_("tax"))
# Undo
+# initial = models.DateTimeField(null=True)
+# end = models.DateTimeField(null=True)
+
order = models.ForeignKey(settings.BILLS_ORDER_MODEL, null=True, blank=True,
- help_text=_("Informative link back to the order"))
+ help_text=_("Informative link back to the order"), on_delete=models.SET_NULL)
order_billed_on = models.DateField(_("order billed"), null=True, blank=True)
order_billed_until = models.DateField(_("order billed until"), null=True, blank=True)
created_on = models.DateField(_("created"), auto_now_add=True)
diff --git a/orchestra/apps/domains/forms.py b/orchestra/apps/domains/forms.py
index e53f1682..3b2c96b1 100644
--- a/orchestra/apps/domains/forms.py
+++ b/orchestra/apps/domains/forms.py
@@ -30,34 +30,33 @@ class BatchDomainCreationAdminForm(forms.ModelForm):
return target
def clean(self):
- """ inherit related top domain account, when exists """
+ """ inherit related parent domain account, when exists """
cleaned_data = super(BatchDomainCreationAdminForm, self).clean()
if not cleaned_data['account']:
account = None
for name in [cleaned_data['name']] + self.extra_names:
domain = Domain(name=name)
- top = domain.get_top()
- if not top:
+ parent = domain.get_parent()
+ if not parent:
# Fake an account to make django validation happy
account_model = self.fields['account']._queryset.model
cleaned_data['account'] = account_model()
raise ValidationError({
'account': _("An account should be provided for top domain names."),
})
- elif account and top.account != account:
+ elif account and parent.account != account:
# Fake an account to make django validation happy
account_model = self.fields['account']._queryset.model
cleaned_data['account'] = account_model()
raise ValidationError({
'account': _("Provided domain names belong to different accounts."),
})
- account = top.account
+ account = parent.account
cleaned_data['account'] = account
return cleaned_data
class RecordInlineFormSet(forms.models.BaseInlineFormSet):
- # TODO
def clean(self):
""" Checks if everything is consistent """
if any(self.errors):
diff --git a/orchestra/apps/domains/helpers.py b/orchestra/apps/domains/helpers.py
index ff32046d..ea36da5e 100644
--- a/orchestra/apps/domains/helpers.py
+++ b/orchestra/apps/domains/helpers.py
@@ -17,7 +17,7 @@ def domain_for_validation(instance, records):
if not domain.pk:
# top domain lookup for new domains
- domain.top = domain.get_top()
+ domain.top = domain.get_parent(top=True)
if domain.top:
# is a subdomain
subdomains = [sub for sub in domain.top.subdomains.all() if sub.pk != domain.pk]
diff --git a/orchestra/apps/domains/models.py b/orchestra/apps/domains/models.py
index 8cb70acc..dd700997 100644
--- a/orchestra/apps/domains/models.py
+++ b/orchestra/apps/domains/models.py
@@ -27,15 +27,18 @@ class Domain(models.Model):
return self.name
@classmethod
- def get_top_domain(cls, name):
+ def get_parent_domain(cls, name, top=False):
+ """ get the next domain on the chain """
split = name.split('.')
- top = None
+ parent = None
for i in range(1, len(split)-1):
name = '.'.join(split[i:])
domain = Domain.objects.filter(name=name)
if domain:
- top = domain.get()
- return top
+ parent = domain.get()
+ if not top:
+ return parent
+ return parent
@property
def origin(self):
@@ -57,7 +60,7 @@ class Domain(models.Model):
""" create top relation """
update = False
if not self.pk:
- top = self.get_top()
+ top = self.get_parent(top=True)
if top:
self.top = top
self.account_id = self.account_id or top.account_id
@@ -90,8 +93,8 @@ class Domain(models.Model):
""" proxy method, needed for input validation, see helpers.domain_for_validation """
return self.origin.subdomain_set.all().prefetch_related('records')
- def get_top(self):
- return type(self).get_top_domain(self.name)
+ def get_parent(self, top=False):
+ return type(self).get_parent_domain(self.name, top=top)
def render_zone(self):
origin = self.origin
diff --git a/orchestra/apps/domains/serializers.py b/orchestra/apps/domains/serializers.py
index 2e092647..1efaf6a1 100644
--- a/orchestra/apps/domains/serializers.py
+++ b/orchestra/apps/domains/serializers.py
@@ -30,7 +30,7 @@ class DomainSerializer(AccountSerializerMixin, HyperlinkedModelSerializer):
def clean_name(self, attrs, source):
""" prevent users creating subdomains of other users domains """
name = attrs[source]
- top = Domain.get_top_domain(name)
+ top = Domain.get_parent_domain(name)
if top and top.account != self.account:
raise ValidationError(_("Can not create subdomains of other users domains"))
return attrs
diff --git a/orchestra/apps/issues/models.py b/orchestra/apps/issues/models.py
index 89945693..7f608991 100644
--- a/orchestra/apps/issues/models.py
+++ b/orchestra/apps/issues/models.py
@@ -4,6 +4,7 @@ from django.utils.translation import ugettext_lazy as _
from orchestra.apps.contacts import settings as contacts_settings
from orchestra.apps.contacts.models import Contact
+from orchestra.core.translations import ModelTranslation
from orchestra.models.fields import MultiSelectField
from orchestra.utils import send_email_template
@@ -12,6 +13,7 @@ from . import settings
class Queue(models.Model):
name = models.CharField(_("name"), max_length=128, unique=True)
+ verbose_name = models.CharField(_("verbose_name"), max_length=128, blank=True)
default = models.BooleanField(_("default"), default=False)
notify = MultiSelectField(_("notify"), max_length=256, blank=True,
choices=Contact.EMAIL_USAGES,
@@ -19,7 +21,7 @@ class Queue(models.Model):
help_text=_("Contacts to notify by email"))
def __unicode__(self):
- return self.name
+ return self.verbose_name or self.name
def save(self, *args, **kwargs):
""" mark as default queue if needed """
@@ -190,3 +192,6 @@ class TicketTracker(models.Model):
unique_together = (
('ticket', 'user'),
)
+
+
+ModelTranslation.register(Queue, ('verbose_name',))
diff --git a/orchestra/apps/miscellaneous/models.py b/orchestra/apps/miscellaneous/models.py
index fe7e9fda..db5171f5 100644
--- a/orchestra/apps/miscellaneous/models.py
+++ b/orchestra/apps/miscellaneous/models.py
@@ -3,6 +3,7 @@ from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from orchestra.core import services
+from orchestra.core.translations import ModelTranslation
from orchestra.core.validators import validate_name
from orchestra.models.fields import NullableCharField
@@ -74,3 +75,5 @@ class Miscellaneous(models.Model):
services.register(Miscellaneous)
+
+ModelTranslation.register(MiscService, ('verbose_name',))
diff --git a/orchestra/apps/orchestration/admin.py b/orchestra/apps/orchestration/admin.py
index 470e800e..cf4248c3 100644
--- a/orchestra/apps/orchestration/admin.py
+++ b/orchestra/apps/orchestration/admin.py
@@ -40,7 +40,7 @@ class RouteAdmin(admin.ModelAdmin):
def display_model(self, route):
try:
- return escape(route.backend_class().model)
+ return escape(route.backend_class.model)
except KeyError:
return "NOT AVAILABLE"
display_model.short_description = _("model")
@@ -48,7 +48,7 @@ class RouteAdmin(admin.ModelAdmin):
def display_actions(self, route):
try:
- return '
'.join(route.backend_class().get_actions())
+ return '
'.join(route.backend_class.get_actions())
except KeyError:
return "NOT AVAILABLE"
display_actions.short_description = _("actions")
diff --git a/orchestra/apps/orchestration/models.py b/orchestra/apps/orchestration/models.py
index 65ee36e7..bae49d01 100644
--- a/orchestra/apps/orchestration/models.py
+++ b/orchestra/apps/orchestration/models.py
@@ -186,11 +186,9 @@ class Route(models.Model):
def __unicode__(self):
return "%s@%s" % (self.backend, self.host)
-# def clean(self):
-# backend, method = self.get_backend_class(), self.get_method_class()
-# if not backend.type in method.types:
-# msg = _("%s backend is not compatible with %s method")
-# raise ValidationError(msg % (self.backend, self.method)
+ @property
+ def backend_class(self):
+ return ServiceBackend.get_backend(self.backend)
@classmethod
def get_servers(cls, operation, **kwargs):
@@ -215,6 +213,22 @@ class Route(models.Model):
servers.append(route.host)
return servers
+ def clean(self):
+ if not self.match:
+ self.match = 'True'
+ if self.backend:
+ backend_model = self.backend_class.model
+ try:
+ obj = backend_model.objects.all()[0]
+ except IndexError:
+ return
+ try:
+ bool(self.matches(obj))
+ except Exception, exception:
+ name = type(exception).__name__
+ message = exception.message
+ raise ValidationError(': '.join((name, message)))
+
def matches(self, instance):
safe_locals = {
'instance': instance,
@@ -223,15 +237,6 @@ class Route(models.Model):
}
return eval(self.match, safe_locals)
- def backend_class(self):
- return ServiceBackend.get_backend(self.backend)
-
-# def method_class(self):
-# for method in MethodBackend.get_backends():
-# if method.get_name() == self.method:
-# return method
-# raise ValueError('This method is not registered')
-
def enable(self):
self.is_active = True
self.save()
diff --git a/orchestra/apps/orders/helpers.py b/orchestra/apps/orders/helpers.py
index 4ee9513a..dd40a3f0 100644
--- a/orchestra/apps/orders/helpers.py
+++ b/orchestra/apps/orders/helpers.py
@@ -4,7 +4,7 @@ from orchestra.apps.accounts.models import Account
from orchestra.core import services
-def get_related_objects(origin, max_depth=2):
+def get_related_object(origin, max_depth=2):
"""
Introspects origin object and return the first related service object
diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py
index 1638cd04..73b72a7d 100644
--- a/orchestra/apps/orders/models.py
+++ b/orchestra/apps/orders/models.py
@@ -41,7 +41,7 @@ class OrderQuerySet(models.QuerySet):
order.old_billed_until = order.billed_until
lines = service.handler.generate_bill_lines(orders, account, **options)
bill_lines.extend(lines)
- # TODO make this consistent always returning the same fucking objects
+ # TODO make this consistent always returning the same fucking types
if commit:
bills += bill_backend.create_bills(account, bill_lines, **options)
else:
@@ -257,7 +257,8 @@ class MetricStorage(models.Model):
except cls.DoesNotExist:
cls.objects.create(order=order, value=value, updated_on=now)
else:
- if metric.value != value:
+ threshold = decimal.Decimal(settings.ORDERS_METRIC_THRESHOLD)
+ if metric.value*(1+threshold) > value or metric.value*threshold < value:
cls.objects.create(order=order, value=value, updated_on=now)
else:
metric.updated_on = now
@@ -276,7 +277,7 @@ def cancel_orders(sender, **kwargs):
for order in Order.objects.by_object(instance).active():
order.cancel()
elif not hasattr(instance, 'account'):
- related = helpers.get_related_objects(instance)
+ related = helpers.get_related_object(instance)
if related and related != instance:
Order.update_orders(related)
@@ -287,6 +288,6 @@ def update_orders(sender, **kwargs):
if type(instance) in services:
Order.update_orders(instance)
elif not hasattr(instance, 'account'):
- related = helpers.get_related_objects(instance)
+ related = helpers.get_related_object(instance)
if related and related != instance:
Order.update_orders(related)
diff --git a/orchestra/apps/orders/settings.py b/orchestra/apps/orders/settings.py
index 3d0bf74b..cc6a2fe9 100644
--- a/orchestra/apps/orders/settings.py
+++ b/orchestra/apps/orders/settings.py
@@ -1,13 +1,16 @@
from django.conf import settings
+# Pluggable backend for bill generation.
ORDERS_BILLING_BACKEND = getattr(settings, 'ORDERS_BILLING_BACKEND',
'orchestra.apps.orders.billing.BillsBackend')
+# Pluggable service class
ORDERS_SERVICE_MODEL = getattr(settings, 'ORDERS_SERVICE_MODEL', 'services.Service')
+# Prevent inspecting these apps for service accounting
ORDERS_EXCLUDED_APPS = getattr(settings, 'ORDERS_EXCLUDED_APPS', (
'orders',
'admin',
@@ -19,3 +22,8 @@ ORDERS_EXCLUDED_APPS = getattr(settings, 'ORDERS_EXCLUDED_APPS', (
'bills',
'services',
))
+
+
+# Only account for significative changes
+# metric_storage new value: lastvalue*(1+threshold) > currentvalue or lastvalue*threshold < currentvalue
+ORDERS_METRIC_THRESHOLD = getattr(settings, 'ORDERS_METRIC_THRESHOLD', 0.4)
diff --git a/orchestra/apps/payments/models.py b/orchestra/apps/payments/models.py
index 5ae3b7d9..bb9d0323 100644
--- a/orchestra/apps/payments/models.py
+++ b/orchestra/apps/payments/models.py
@@ -21,7 +21,7 @@ class PaymentSource(models.Model):
related_name='paymentsources')
method = models.CharField(_("method"), max_length=32,
choices=PaymentMethod.get_plugin_choices())
- data = JSONField(_("data"))
+ data = JSONField(_("data"), default={})
is_active = models.BooleanField(_("active"), default=True)
objects = PaymentSourcesQueryset.as_manager()
diff --git a/orchestra/apps/plans/models.py b/orchestra/apps/plans/models.py
index c79b5061..44ccc706 100644
--- a/orchestra/apps/plans/models.py
+++ b/orchestra/apps/plans/models.py
@@ -6,6 +6,7 @@ from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from orchestra.core import services, accounts
+from orchestra.core.translations import ModelTranslation
from orchestra.core.validators import validate_name
from orchestra.models import queryset
@@ -89,3 +90,5 @@ class Rate(models.Model):
accounts.register(ContractedPlan)
services.register(ContractedPlan, menu=False)
+
+ModelTranslation.register(Plan, ('verbose_name',))
diff --git a/orchestra/apps/saas/backends/phplist.py b/orchestra/apps/saas/backends/phplist.py
index d19223ba..2b072eff 100644
--- a/orchestra/apps/saas/backends/phplist.py
+++ b/orchestra/apps/saas/backends/phplist.py
@@ -15,7 +15,7 @@ class PhpListSaaSBackend(ServiceController):
default_route_match = "saas.service == 'phplist'"
block = True
- def initialize_database(self, saas, server):
+ def _save(self, saas, server):
base_domain = settings.SAAS_PHPLIST_BASE_DOMAIN
admin_link = 'http://%s/admin/' % saas.get_site_domain()
admin_content = requests.get(admin_link).content
@@ -25,21 +25,21 @@ class PhpListSaaSBackend(ServiceController):
if install:
if not hasattr(saas, 'password'):
raise RuntimeError("Password is missing")
- install = install.groups()[0]
- install_link = admin_link + install[1:]
+ install_path = install.groups()[0]
+ install_link = admin_link + install_path[1:]
post = {
'adminname': saas.name,
'orgname': saas.account.username,
'adminemail': saas.account.username,
'adminpassword': saas.password,
}
- print json.dumps(post, indent=4)
response = requests.post(install_link, data=post)
print response.content
if response.status_code != 200:
raise RuntimeError("Bad status code %i" % response.status_code)
- elif hasattr(saas, 'password'):
- raise NotImplementedError
+ else:
+ raise NotImplementedError("Change password not implemented")
def save(self, saas):
- self.append(self.initialize_database, saas)
+ if hasattr(saas, 'password'):
+ self.append(self._save, saas)
diff --git a/orchestra/apps/services/models.py b/orchestra/apps/services/models.py
index e9bec3fc..bcb419d4 100644
--- a/orchestra/apps/services/models.py
+++ b/orchestra/apps/services/models.py
@@ -10,6 +10,7 @@ from django.utils.module_loading import autodiscover_modules
from django.utils.translation import ugettext_lazy as _
from orchestra.core import caches, validators
+from orchestra.core.translations import ModelTranslation
from orchestra.core.validators import validate_name
from orchestra.models import queryset
@@ -240,3 +241,6 @@ class Service(models.Model):
for instance in related_model.objects.all().select_related('account'):
updates += order_model.update_orders(instance, service=self, commit=commit)
return updates
+
+
+ModelTranslation.register(Service, ('description',))
diff --git a/orchestra/apps/services/tests/functional_tests/test_domain.py b/orchestra/apps/services/tests/functional_tests/test_domain.py
index 5d2e3bc1..7811681d 100644
--- a/orchestra/apps/services/tests/functional_tests/test_domain.py
+++ b/orchestra/apps/services/tests/functional_tests/test_domain.py
@@ -1,9 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from orchestra.apps.miscellaneous.models import MiscService, Miscellaneous
+from orchestra.apps.plans.models import Plan
from orchestra.utils.tests import random_ascii
-from ...models import Service, Plan
+from ...models import Service
from . import BaseBillingTest
@@ -19,7 +20,7 @@ class DomainBillingTest(BaseBillingTest):
is_fee=False,
metric='',
pricing_period=Service.BILLING_PERIOD,
- rate_algorithm=Service.STEP_PRICE,
+ rate_algorithm='STEP_PRICE',
on_cancel=Service.NOTHING,
payment_style=Service.PREPAY,
tax=0,
diff --git a/orchestra/apps/services/tests/functional_tests/test_ftp.py b/orchestra/apps/services/tests/functional_tests/test_ftp.py
index b44789c2..d1fa1346 100644
--- a/orchestra/apps/services/tests/functional_tests/test_ftp.py
+++ b/orchestra/apps/services/tests/functional_tests/test_ftp.py
@@ -25,7 +25,7 @@ class FTPBillingTest(BaseBillingTest):
is_fee=False,
metric='',
pricing_period=Service.NEVER,
- rate_algorithm=Service.STEP_PRICE,
+ rate_algorithm='STEP_PRICE',
on_cancel=Service.COMPENSATE,
payment_style=Service.PREPAY,
tax=0,
diff --git a/orchestra/apps/services/tests/functional_tests/test_job.py b/orchestra/apps/services/tests/functional_tests/test_job.py
index 47f4249b..be24433c 100644
--- a/orchestra/apps/services/tests/functional_tests/test_job.py
+++ b/orchestra/apps/services/tests/functional_tests/test_job.py
@@ -1,9 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from orchestra.apps.miscellaneous.models import MiscService, Miscellaneous
+from orchestra.apps.plans.models import Plan
from orchestra.utils.tests import random_ascii
-from ...models import Service, Plan
+from ...models import Service
from . import BaseBillingTest
@@ -19,7 +20,7 @@ class JobBillingTest(BaseBillingTest):
is_fee=False,
metric='miscellaneous.amount',
pricing_period=Service.BILLING_PERIOD,
- rate_algorithm=Service.MATCH_PRICE,
+ rate_algorithm='MATCH_PRICE',
on_cancel=Service.NOTHING,
payment_style=Service.POSTPAY,
tax=0,
diff --git a/orchestra/apps/services/tests/functional_tests/test_mailbox.py b/orchestra/apps/services/tests/functional_tests/test_mailbox.py
index 70504726..bb3f0bf3 100644
--- a/orchestra/apps/services/tests/functional_tests/test_mailbox.py
+++ b/orchestra/apps/services/tests/functional_tests/test_mailbox.py
@@ -4,10 +4,11 @@ from django.utils import timezone
from freezegun import freeze_time
from orchestra.apps.mailboxes.models import Mailbox
+from orchestra.apps.plans.models import Plan
from orchestra.apps.resources.models import Resource, ResourceData
from orchestra.utils.tests import random_ascii
-from ...models import Service, Plan
+from ...models import Service
from . import BaseBillingTest
@@ -23,7 +24,7 @@ class MailboxBillingTest(BaseBillingTest):
is_fee=False,
metric='',
pricing_period=Service.NEVER,
- rate_algorithm=Service.STEP_PRICE,
+ rate_algorithm='STEP_PRICE',
on_cancel=Service.COMPENSATE,
payment_style=Service.PREPAY,
tax=0,
@@ -44,7 +45,7 @@ class MailboxBillingTest(BaseBillingTest):
is_fee=False,
metric='max((mailbox.resources.disk.allocated or 0) -1, 0)',
pricing_period=Service.NEVER,
- rate_algorithm=Service.STEP_PRICE,
+ rate_algorithm='STEP_PRICE',
on_cancel=Service.DISCOUNT,
payment_style=Service.PREPAY,
tax=0,
diff --git a/orchestra/apps/services/tests/functional_tests/test_plan.py b/orchestra/apps/services/tests/functional_tests/test_plan.py
index bc7ab566..a8d14599 100644
--- a/orchestra/apps/services/tests/functional_tests/test_plan.py
+++ b/orchestra/apps/services/tests/functional_tests/test_plan.py
@@ -1,6 +1,8 @@
from django.contrib.contenttypes.models import ContentType
-from ...models import Service, Plan, ContractedPlan
+from orchestra.apps.plans.models import Plan, ContractedPlan
+
+from ...models import Service
from . import BaseBillingTest
@@ -16,7 +18,7 @@ class PlanBillingTest(BaseBillingTest):
is_fee=True,
metric='',
pricing_period=Service.BILLING_PERIOD,
- rate_algorithm=Service.STEP_PRICE,
+ rate_algorithm='STEP_PRICE',
on_cancel=Service.DISCOUNT,
payment_style=Service.PREPAY,
tax=0,
diff --git a/orchestra/apps/services/tests/functional_tests/test_traffic.py b/orchestra/apps/services/tests/functional_tests/test_traffic.py
index 30740214..8602ba5b 100644
--- a/orchestra/apps/services/tests/functional_tests/test_traffic.py
+++ b/orchestra/apps/services/tests/functional_tests/test_traffic.py
@@ -5,9 +5,10 @@ from freezegun import freeze_time
from orchestra.apps.accounts.models import Account
from orchestra.apps.miscellaneous.models import MiscService, Miscellaneous
+from orchestra.apps.plans.models import Plan
from orchestra.apps.resources.models import Resource, ResourceData, MonitorData
-from ...models import Service, Plan
+from ...models import Service
from . import BaseBillingTest
@@ -25,7 +26,7 @@ class BaseTrafficBillingTest(BaseBillingTest):
is_fee=False,
metric=self.TRAFFIC_METRIC,
pricing_period=Service.BILLING_PERIOD,
- rate_algorithm=Service.STEP_PRICE,
+ rate_algorithm='STEP_PRICE',
on_cancel=Service.NOTHING,
payment_style=Service.POSTPAY,
tax=0,
@@ -107,7 +108,7 @@ class TrafficPrepayBillingTest(BaseTrafficBillingTest):
is_fee=False,
metric="miscellaneous.amount",
pricing_period=Service.NEVER,
- rate_algorithm=Service.STEP_PRICE,
+ rate_algorithm='STEP_PRICE',
on_cancel=Service.NOTHING,
payment_style=Service.PREPAY,
tax=0,
diff --git a/orchestra/apps/services/tests/test_handler.py b/orchestra/apps/services/tests/test_handler.py
index af3207ed..eee34e65 100644
--- a/orchestra/apps/services/tests/test_handler.py
+++ b/orchestra/apps/services/tests/test_handler.py
@@ -41,7 +41,7 @@ class HandlerTests(BaseTestCase):
is_fee=False,
metric='',
pricing_period=Service.NEVER,
- rate_algorithm=Service.STEP_PRICE,
+ rate_algorithm='STEP_PRICE',
on_cancel=Service.DISCOUNT,
payment_style=Service.PREPAY,
tax=0,
diff --git a/orchestra/apps/systemusers/backends.py b/orchestra/apps/systemusers/backends.py
index 46056f9d..12214b71 100644
--- a/orchestra/apps/systemusers/backends.py
+++ b/orchestra/apps/systemusers/backends.py
@@ -208,10 +208,7 @@ class Exim4Traffic(ServiceMonitor):
with open(mainlog, 'r') as mainlog:
for line in mainlog.readlines():
if ' <= ' in line and 'P=local' in line:
- username = user_regex.search(line)
- if not username:
- continue
- username = username.groups()[0]
+ username = user_regex.search(line).groups()[0]
try:
sender = users[username]
except KeyError:
@@ -299,7 +296,7 @@ class FTPTraffic(ServiceMonitor):
users[username] = [ini_date, object_id, 0]
def monitor(users, end_date, months, vsftplogs):
- user_regex = re.compile(r'\] \[([^ ]+)\] OK ')
+ user_regex = re.compile(r'\] \[([^ ]+)\] (OK|FAIL) ')
bytes_regex = re.compile(r', ([0-9]+) bytes, ')
for vsftplog in vsftplogs:
try:
diff --git a/orchestra/apps/websites/backends/apache.py b/orchestra/apps/websites/backends/apache.py
index 7be86747..219130dc 100644
--- a/orchestra/apps/websites/backends/apache.py
+++ b/orchestra/apps/websites/backends/apache.py
@@ -97,7 +97,7 @@ class Apache2Backend(ServiceController):
def delete(self, site):
context = self.get_context(site)
self.append("a2dissite %(site_unique_name)s.conf && UPDATED=1" % context)
- self.append("rm -fr %(sites_available)s" % context)
+ self.append("rm -f %(sites_available)s" % context)
def commit(self):
""" reload Apache2 if necessary """
diff --git a/orchestra/apps/websites/models.py b/orchestra/apps/websites/models.py
index 5cc57622..2e298ce4 100644
--- a/orchestra/apps/websites/models.py
+++ b/orchestra/apps/websites/models.py
@@ -11,7 +11,6 @@ from orchestra.utils.functional import cached
from . import settings
from .directives import SiteDirective
-from .utils import normurlpath
class Website(models.Model):
@@ -141,8 +140,8 @@ class Content(models.Model):
return self.path
def clean(self):
- # TODO do it on the field?
- self.path = normurlpath(self.path)
+ if not self.path:
+ self.path = '/'
def get_absolute_url(self):
domain = self.website.domains.first()
diff --git a/orchestra/bin/orchestra-admin b/orchestra/bin/orchestra-admin
index 4279baaa..ad35a522 100755
--- a/orchestra/bin/orchestra-admin
+++ b/orchestra/bin/orchestra-admin
@@ -131,9 +131,10 @@ function install_requirements () {
libxslt1-dev \
wkhtmltopdf \
xvfb \
- ca-certificates"
+ ca-certificates \
+ gettext"
- PIP="django==1.7.1 \
+ PIP="django==1.7.7 \
django-celery-email==1.0.4 \
django-fluent-dashboard==0.3.5 \
https://bitbucket.org/izi/django-admin-tools/get/a0abfffd76a0.zip \
@@ -158,7 +159,8 @@ function install_requirements () {
requests \
phonenumbers \
django-countries \
- django-localflavor"
+ django-localflavor \
+ pip==6.0.8"
if $testing; then
APT="${APT} \
diff --git a/orchestra/conf/project_template/locale/.gitignore b/orchestra/conf/project_template/locale/.gitignore
new file mode 100644
index 00000000..e69de29b
diff --git a/orchestra/conf/project_template/project_name/settings.py b/orchestra/conf/project_template/project_name/settings.py
index 1e18b9a0..8a4a06ed 100644
--- a/orchestra/conf/project_template/project_name/settings.py
+++ b/orchestra/conf/project_template/project_name/settings.py
@@ -55,6 +55,11 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'static')
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+# Path used for database translations files
+LOCALE_PATHS = (
+ os.path.join(BASE_DIR, 'locale'),
+)
+
# EMAIL_HOST = 'smtp.yourhost.eu'
# EMAIL_PORT = ''
# EMAIL_HOST_USER = ''
diff --git a/orchestra/core/translations.py b/orchestra/core/translations.py
new file mode 100644
index 00000000..6bc7ebb6
--- /dev/null
+++ b/orchestra/core/translations.py
@@ -0,0 +1,13 @@
+class ModelTranslation(object):
+ """
+ Collects all model fields that would be translated
+
+ using 'makemessages --domain database' management command
+ """
+ _registry = {}
+
+ @classmethod
+ def register(cls, model, fields):
+ if model in cls._registry:
+ raise ValueError("Model %s already registered." % model.__name__)
+ cls._registry[model] = fields
diff --git a/orchestra/management/commands/makemessages.py b/orchestra/management/commands/makemessages.py
new file mode 100644
index 00000000..65a7bf0a
--- /dev/null
+++ b/orchestra/management/commands/makemessages.py
@@ -0,0 +1,53 @@
+import os
+
+from django.core.management.commands import makemessages
+
+from orchestra.core.translations import ModelTranslation
+from orchestra.utils.paths import get_site_root
+
+
+class Command(makemessages.Command):
+ """ Provides database translations support """
+
+ def handle(self, *args, **options):
+ do_database = os.getcwd() == get_site_root()
+ self.generated_database_files = []
+ if do_database:
+ self.project_locale_path = get_site_root()
+ self.generate_database_files()
+ super(Command, self).handle(*args, **options)
+ self.remove_database_files()
+
+ def get_contents(self):
+ for model, fields in ModelTranslation._registry.iteritems():
+ contents = []
+ for field in fields:
+ for content in model.objects.values_list('id', field):
+ pk, value = content
+ contents.append(
+ (pk, u"_(u'%s')" % value)
+ )
+ yield ('_'.join((model._meta.db_table, field)), contents)
+
+ def generate_database_files(self):
+ """ tmp files are generated because of having a nice gettext location """
+ for name, contents in self.get_contents():
+ name = unicode(name)
+ maximum = None
+ content = {}
+ for pk, value in contents:
+ if not maximum or pk > maximum:
+ maximum = pk
+ content[pk] = value
+ tmpcontent = []
+ for ix in xrange(maximum+1):
+ tmpcontent.append(content.get(ix, ''))
+ tmpcontent = u'\n'.join(tmpcontent) + '\n'
+ filepath = os.path.join(self.project_locale_path, 'database_%s.sql.py' % name)
+ self.generated_database_files.append(filepath)
+ with open(filepath, 'w') as tmpfile:
+ tmpfile.write(tmpcontent.encode('utf-8'))
+
+ def remove_database_files(self):
+ for path in self.generated_database_files:
+ os.unlink(path)