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 , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-03-29 10:17+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: actions.py:35 +msgid "Download" +msgstr "Blod" + +#: actions.py:45 +msgid "View" +msgstr "" + +#: actions.py:53 +msgid "Selected bills should be in open state" +msgstr "" + +#: actions.py:71 +msgid "Selected bills have been closed" +msgstr "" + +#: actions.py:80 +#, python-format +msgid "One related transaction has been created" +msgstr "" + +#: actions.py:81 +#, python-format +msgid "%i related transactions have been created" +msgstr "" + +#: actions.py:87 +msgid "Are you sure about closing the following bills?" +msgstr "" + +#: actions.py:88 +msgid "" +"Once a bill is closed it can not be further modified.

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)