Added database translations support

This commit is contained in:
Marc Aymerich 2015-03-29 16:10:07 +00:00
parent 124124da6c
commit bcfc453a95
35 changed files with 689 additions and 83 deletions

55
TODO.md
View file

@ -83,7 +83,8 @@
* print open invoices as proforma? * 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 * ForeignKey.swappable
@ -222,13 +223,55 @@ require_once(/etc/moodles/.$moodle_host.config.php);``` moodle/drupl
* autoexpand mailbox.filter according to filtering options * autoexpand mailbox.filter according to filtering options
* allow empty metric pack for default rates? changes on rating algo * 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 or quantity 0 ?
* 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?
* Improve performance of admin change lists with debug toolbar and prefech_related * Improve performance of admin change lists with debug toolbar and prefech_related
* and miscellaneous.service.name == 'domini-registre' * and miscellaneous.service.name == 'domini-registre'
* DOMINI REGISTRE MIGRATION SCRIPTS * 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, and make margin wider
* lines too long on invoice, double lines or cut * 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")

View file

@ -111,3 +111,68 @@ def send_bills(modeladmin, request, queryset):
modeladmin.log_change(request, bill, 'Sent') modeladmin.log_change(request, bill, 'Sent')
send_bills.verbose_name = lambda bill: _("Resend" if getattr(bill, 'is_sent', False) else "Send") send_bills.verbose_name = lambda bill: _("Resend" if getattr(bill, 'is_sent', False) else "Send")
send_bills.url_name = '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

View file

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.conf.urls import patterns, url
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.utils import unquote from django.contrib.admin.utils import unquote
from django.core.urlresolvers import reverse 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.apps.accounts.admin import AccountAdminMixin, AccountAdmin
from orchestra.forms.widgets import paddingCheckboxSelectMultiple from orchestra.forms.widgets import paddingCheckboxSelectMultiple
from . import settings from . import settings, actions
from .actions import download_bills, view_bill, close_bills, send_bills, validate_contact
from .filters import BillTypeListFilter, HasBillContactListFilter from .filters import BillTypeListFilter, HasBillContactListFilter
from .models import Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForma, BillLine, BillContact from .models import Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForma, BillLine, BillContact
@ -84,6 +84,36 @@ class ClosedBillLineInline(BillLineInline):
return False 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 = '<a href="%s">%s</a>' % (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): class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
list_display = ( list_display = (
'number', 'type_link', 'account_link', 'created_on_display', 'number', 'type_link', 'account_link', 'created_on_display',
@ -101,8 +131,10 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
'fields': ('html',), 'fields': ('html',),
}), }),
) )
actions = [download_bills, close_bills, send_bills] change_view_actions = [
change_view_actions = [view_bill, download_bills, send_bills, close_bills] 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') change_readonly_fields = ('account_link', 'type', 'is_open')
readonly_fields = ('number', 'display_total', 'is_sent', 'display_payment_state') readonly_fields = ('number', 'display_total', 'is_sent', 'display_payment_state')
inlines = [BillLineInline, ClosedBillLineInline] inlines = [BillLineInline, ClosedBillLineInline]
@ -144,6 +176,17 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
display_payment_state.allow_tags = True display_payment_state.allow_tags = True
display_payment_state.short_description = _("Payment") 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): def get_readonly_fields(self, request, obj=None):
fields = super(BillAdmin, self).get_readonly_fields(request, obj) fields = super(BillAdmin, self).get_readonly_fields(request, obj)
if obj and not obj.is_open: if obj and not obj.is_open:
@ -187,7 +230,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
def change_view(self, request, object_id, **kwargs): def change_view(self, request, object_id, **kwargs):
# TODO raise404, here and everywhere # TODO raise404, here and everywhere
bill = self.get_object(request, unquote(object_id)) 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) return super(BillAdmin, self).change_view(request, object_id, **kwargs)

View file

@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 "<a href=\"%s\">One related transaction</a> has been created"
msgstr ""
#: actions.py:81
#, python-format
msgid "<a href=\"%s\">%i related transactions</a> 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.</p><p>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 <a href=\"{url}#invoicecontact-group\">provide one</a>"
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 ""

View file

@ -274,8 +274,11 @@ class BillLine(models.Model):
subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2) subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2)
tax = models.PositiveIntegerField(_("tax")) tax = models.PositiveIntegerField(_("tax"))
# Undo # Undo
# initial = models.DateTimeField(null=True)
# end = models.DateTimeField(null=True)
order = models.ForeignKey(settings.BILLS_ORDER_MODEL, null=True, blank=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_on = models.DateField(_("order billed"), null=True, blank=True)
order_billed_until = models.DateField(_("order billed until"), 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) created_on = models.DateField(_("created"), auto_now_add=True)

View file

@ -30,34 +30,33 @@ class BatchDomainCreationAdminForm(forms.ModelForm):
return target return target
def clean(self): def clean(self):
""" inherit related top domain account, when exists """ """ inherit related parent domain account, when exists """
cleaned_data = super(BatchDomainCreationAdminForm, self).clean() cleaned_data = super(BatchDomainCreationAdminForm, self).clean()
if not cleaned_data['account']: if not cleaned_data['account']:
account = None account = None
for name in [cleaned_data['name']] + self.extra_names: for name in [cleaned_data['name']] + self.extra_names:
domain = Domain(name=name) domain = Domain(name=name)
top = domain.get_top() parent = domain.get_parent()
if not top: if not parent:
# Fake an account to make django validation happy # Fake an account to make django validation happy
account_model = self.fields['account']._queryset.model account_model = self.fields['account']._queryset.model
cleaned_data['account'] = account_model() cleaned_data['account'] = account_model()
raise ValidationError({ raise ValidationError({
'account': _("An account should be provided for top domain names."), '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 # Fake an account to make django validation happy
account_model = self.fields['account']._queryset.model account_model = self.fields['account']._queryset.model
cleaned_data['account'] = account_model() cleaned_data['account'] = account_model()
raise ValidationError({ raise ValidationError({
'account': _("Provided domain names belong to different accounts."), 'account': _("Provided domain names belong to different accounts."),
}) })
account = top.account account = parent.account
cleaned_data['account'] = account cleaned_data['account'] = account
return cleaned_data return cleaned_data
class RecordInlineFormSet(forms.models.BaseInlineFormSet): class RecordInlineFormSet(forms.models.BaseInlineFormSet):
# TODO
def clean(self): def clean(self):
""" Checks if everything is consistent """ """ Checks if everything is consistent """
if any(self.errors): if any(self.errors):

View file

@ -17,7 +17,7 @@ def domain_for_validation(instance, records):
if not domain.pk: if not domain.pk:
# top domain lookup for new domains # top domain lookup for new domains
domain.top = domain.get_top() domain.top = domain.get_parent(top=True)
if domain.top: if domain.top:
# is a subdomain # is a subdomain
subdomains = [sub for sub in domain.top.subdomains.all() if sub.pk != domain.pk] subdomains = [sub for sub in domain.top.subdomains.all() if sub.pk != domain.pk]

View file

@ -27,15 +27,18 @@ class Domain(models.Model):
return self.name return self.name
@classmethod @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('.') split = name.split('.')
top = None parent = None
for i in range(1, len(split)-1): for i in range(1, len(split)-1):
name = '.'.join(split[i:]) name = '.'.join(split[i:])
domain = Domain.objects.filter(name=name) domain = Domain.objects.filter(name=name)
if domain: if domain:
top = domain.get() parent = domain.get()
return top if not top:
return parent
return parent
@property @property
def origin(self): def origin(self):
@ -57,7 +60,7 @@ class Domain(models.Model):
""" create top relation """ """ create top relation """
update = False update = False
if not self.pk: if not self.pk:
top = self.get_top() top = self.get_parent(top=True)
if top: if top:
self.top = top self.top = top
self.account_id = self.account_id or top.account_id 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 """ """ proxy method, needed for input validation, see helpers.domain_for_validation """
return self.origin.subdomain_set.all().prefetch_related('records') return self.origin.subdomain_set.all().prefetch_related('records')
def get_top(self): def get_parent(self, top=False):
return type(self).get_top_domain(self.name) return type(self).get_parent_domain(self.name, top=top)
def render_zone(self): def render_zone(self):
origin = self.origin origin = self.origin

View file

@ -30,7 +30,7 @@ class DomainSerializer(AccountSerializerMixin, HyperlinkedModelSerializer):
def clean_name(self, attrs, source): def clean_name(self, attrs, source):
""" prevent users creating subdomains of other users domains """ """ prevent users creating subdomains of other users domains """
name = attrs[source] name = attrs[source]
top = Domain.get_top_domain(name) top = Domain.get_parent_domain(name)
if top and top.account != self.account: if top and top.account != self.account:
raise ValidationError(_("Can not create subdomains of other users domains")) raise ValidationError(_("Can not create subdomains of other users domains"))
return attrs return attrs

View file

@ -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 import settings as contacts_settings
from orchestra.apps.contacts.models import Contact from orchestra.apps.contacts.models import Contact
from orchestra.core.translations import ModelTranslation
from orchestra.models.fields import MultiSelectField from orchestra.models.fields import MultiSelectField
from orchestra.utils import send_email_template from orchestra.utils import send_email_template
@ -12,6 +13,7 @@ from . import settings
class Queue(models.Model): class Queue(models.Model):
name = models.CharField(_("name"), max_length=128, unique=True) 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) default = models.BooleanField(_("default"), default=False)
notify = MultiSelectField(_("notify"), max_length=256, blank=True, notify = MultiSelectField(_("notify"), max_length=256, blank=True,
choices=Contact.EMAIL_USAGES, choices=Contact.EMAIL_USAGES,
@ -19,7 +21,7 @@ class Queue(models.Model):
help_text=_("Contacts to notify by email")) help_text=_("Contacts to notify by email"))
def __unicode__(self): def __unicode__(self):
return self.name return self.verbose_name or self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" mark as default queue if needed """ """ mark as default queue if needed """
@ -190,3 +192,6 @@ class TicketTracker(models.Model):
unique_together = ( unique_together = (
('ticket', 'user'), ('ticket', 'user'),
) )
ModelTranslation.register(Queue, ('verbose_name',))

View file

@ -3,6 +3,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 orchestra.core import services from orchestra.core import services
from orchestra.core.translations import ModelTranslation
from orchestra.core.validators import validate_name from orchestra.core.validators import validate_name
from orchestra.models.fields import NullableCharField from orchestra.models.fields import NullableCharField
@ -74,3 +75,5 @@ class Miscellaneous(models.Model):
services.register(Miscellaneous) services.register(Miscellaneous)
ModelTranslation.register(MiscService, ('verbose_name',))

View file

@ -40,7 +40,7 @@ class RouteAdmin(admin.ModelAdmin):
def display_model(self, route): def display_model(self, route):
try: try:
return escape(route.backend_class().model) return escape(route.backend_class.model)
except KeyError: except KeyError:
return "<span style='color: red;'>NOT AVAILABLE</span>" return "<span style='color: red;'>NOT AVAILABLE</span>"
display_model.short_description = _("model") display_model.short_description = _("model")
@ -48,7 +48,7 @@ class RouteAdmin(admin.ModelAdmin):
def display_actions(self, route): def display_actions(self, route):
try: try:
return '<br>'.join(route.backend_class().get_actions()) return '<br>'.join(route.backend_class.get_actions())
except KeyError: except KeyError:
return "<span style='color: red;'>NOT AVAILABLE</span>" return "<span style='color: red;'>NOT AVAILABLE</span>"
display_actions.short_description = _("actions") display_actions.short_description = _("actions")

View file

@ -186,11 +186,9 @@ class Route(models.Model):
def __unicode__(self): def __unicode__(self):
return "%s@%s" % (self.backend, self.host) return "%s@%s" % (self.backend, self.host)
# def clean(self): @property
# backend, method = self.get_backend_class(), self.get_method_class() def backend_class(self):
# if not backend.type in method.types: return ServiceBackend.get_backend(self.backend)
# msg = _("%s backend is not compatible with %s method")
# raise ValidationError(msg % (self.backend, self.method)
@classmethod @classmethod
def get_servers(cls, operation, **kwargs): def get_servers(cls, operation, **kwargs):
@ -215,6 +213,22 @@ class Route(models.Model):
servers.append(route.host) servers.append(route.host)
return servers 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): def matches(self, instance):
safe_locals = { safe_locals = {
'instance': instance, 'instance': instance,
@ -223,15 +237,6 @@ class Route(models.Model):
} }
return eval(self.match, safe_locals) 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): def enable(self):
self.is_active = True self.is_active = True
self.save() self.save()

View file

@ -4,7 +4,7 @@ from orchestra.apps.accounts.models import Account
from orchestra.core import services 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 Introspects origin object and return the first related service object

View file

@ -41,7 +41,7 @@ class OrderQuerySet(models.QuerySet):
order.old_billed_until = order.billed_until order.old_billed_until = order.billed_until
lines = service.handler.generate_bill_lines(orders, account, **options) lines = service.handler.generate_bill_lines(orders, account, **options)
bill_lines.extend(lines) 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: if commit:
bills += bill_backend.create_bills(account, bill_lines, **options) bills += bill_backend.create_bills(account, bill_lines, **options)
else: else:
@ -257,7 +257,8 @@ class MetricStorage(models.Model):
except cls.DoesNotExist: except cls.DoesNotExist:
cls.objects.create(order=order, value=value, updated_on=now) cls.objects.create(order=order, value=value, updated_on=now)
else: 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) cls.objects.create(order=order, value=value, updated_on=now)
else: else:
metric.updated_on = now metric.updated_on = now
@ -276,7 +277,7 @@ def cancel_orders(sender, **kwargs):
for order in Order.objects.by_object(instance).active(): for order in Order.objects.by_object(instance).active():
order.cancel() order.cancel()
elif not hasattr(instance, 'account'): elif not hasattr(instance, 'account'):
related = helpers.get_related_objects(instance) related = helpers.get_related_object(instance)
if related and related != instance: if related and related != instance:
Order.update_orders(related) Order.update_orders(related)
@ -287,6 +288,6 @@ def update_orders(sender, **kwargs):
if type(instance) in services: if type(instance) in services:
Order.update_orders(instance) Order.update_orders(instance)
elif not hasattr(instance, 'account'): elif not hasattr(instance, 'account'):
related = helpers.get_related_objects(instance) related = helpers.get_related_object(instance)
if related and related != instance: if related and related != instance:
Order.update_orders(related) Order.update_orders(related)

View file

@ -1,13 +1,16 @@
from django.conf import settings from django.conf import settings
# Pluggable backend for bill generation.
ORDERS_BILLING_BACKEND = getattr(settings, 'ORDERS_BILLING_BACKEND', ORDERS_BILLING_BACKEND = getattr(settings, 'ORDERS_BILLING_BACKEND',
'orchestra.apps.orders.billing.BillsBackend') 'orchestra.apps.orders.billing.BillsBackend')
# Pluggable service class
ORDERS_SERVICE_MODEL = getattr(settings, 'ORDERS_SERVICE_MODEL', 'services.Service') 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_EXCLUDED_APPS = getattr(settings, 'ORDERS_EXCLUDED_APPS', (
'orders', 'orders',
'admin', 'admin',
@ -19,3 +22,8 @@ ORDERS_EXCLUDED_APPS = getattr(settings, 'ORDERS_EXCLUDED_APPS', (
'bills', 'bills',
'services', '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)

View file

@ -21,7 +21,7 @@ class PaymentSource(models.Model):
related_name='paymentsources') related_name='paymentsources')
method = models.CharField(_("method"), max_length=32, method = models.CharField(_("method"), max_length=32,
choices=PaymentMethod.get_plugin_choices()) choices=PaymentMethod.get_plugin_choices())
data = JSONField(_("data")) data = JSONField(_("data"), default={})
is_active = models.BooleanField(_("active"), default=True) is_active = models.BooleanField(_("active"), default=True)
objects = PaymentSourcesQueryset.as_manager() objects = PaymentSourcesQueryset.as_manager()

View file

@ -6,6 +6,7 @@ from django.db.models import Q
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.core import services, accounts from orchestra.core import services, accounts
from orchestra.core.translations import ModelTranslation
from orchestra.core.validators import validate_name from orchestra.core.validators import validate_name
from orchestra.models import queryset from orchestra.models import queryset
@ -89,3 +90,5 @@ class Rate(models.Model):
accounts.register(ContractedPlan) accounts.register(ContractedPlan)
services.register(ContractedPlan, menu=False) services.register(ContractedPlan, menu=False)
ModelTranslation.register(Plan, ('verbose_name',))

View file

@ -15,7 +15,7 @@ class PhpListSaaSBackend(ServiceController):
default_route_match = "saas.service == 'phplist'" default_route_match = "saas.service == 'phplist'"
block = True block = True
def initialize_database(self, saas, server): def _save(self, saas, server):
base_domain = settings.SAAS_PHPLIST_BASE_DOMAIN base_domain = settings.SAAS_PHPLIST_BASE_DOMAIN
admin_link = 'http://%s/admin/' % saas.get_site_domain() admin_link = 'http://%s/admin/' % saas.get_site_domain()
admin_content = requests.get(admin_link).content admin_content = requests.get(admin_link).content
@ -25,21 +25,21 @@ class PhpListSaaSBackend(ServiceController):
if install: if install:
if not hasattr(saas, 'password'): if not hasattr(saas, 'password'):
raise RuntimeError("Password is missing") raise RuntimeError("Password is missing")
install = install.groups()[0] install_path = install.groups()[0]
install_link = admin_link + install[1:] install_link = admin_link + install_path[1:]
post = { post = {
'adminname': saas.name, 'adminname': saas.name,
'orgname': saas.account.username, 'orgname': saas.account.username,
'adminemail': saas.account.username, 'adminemail': saas.account.username,
'adminpassword': saas.password, 'adminpassword': saas.password,
} }
print json.dumps(post, indent=4)
response = requests.post(install_link, data=post) response = requests.post(install_link, data=post)
print response.content print response.content
if response.status_code != 200: if response.status_code != 200:
raise RuntimeError("Bad status code %i" % response.status_code) raise RuntimeError("Bad status code %i" % response.status_code)
elif hasattr(saas, 'password'): else:
raise NotImplementedError raise NotImplementedError("Change password not implemented")
def save(self, saas): def save(self, saas):
self.append(self.initialize_database, saas) if hasattr(saas, 'password'):
self.append(self._save, saas)

View file

@ -10,6 +10,7 @@ from django.utils.module_loading import autodiscover_modules
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.core import caches, validators from orchestra.core import caches, validators
from orchestra.core.translations import ModelTranslation
from orchestra.core.validators import validate_name from orchestra.core.validators import validate_name
from orchestra.models import queryset from orchestra.models import queryset
@ -240,3 +241,6 @@ class Service(models.Model):
for instance in related_model.objects.all().select_related('account'): for instance in related_model.objects.all().select_related('account'):
updates += order_model.update_orders(instance, service=self, commit=commit) updates += order_model.update_orders(instance, service=self, commit=commit)
return updates return updates
ModelTranslation.register(Service, ('description',))

View file

@ -1,9 +1,10 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from orchestra.apps.miscellaneous.models import MiscService, Miscellaneous from orchestra.apps.miscellaneous.models import MiscService, Miscellaneous
from orchestra.apps.plans.models import Plan
from orchestra.utils.tests import random_ascii from orchestra.utils.tests import random_ascii
from ...models import Service, Plan from ...models import Service
from . import BaseBillingTest from . import BaseBillingTest
@ -19,7 +20,7 @@ class DomainBillingTest(BaseBillingTest):
is_fee=False, is_fee=False,
metric='', metric='',
pricing_period=Service.BILLING_PERIOD, pricing_period=Service.BILLING_PERIOD,
rate_algorithm=Service.STEP_PRICE, rate_algorithm='STEP_PRICE',
on_cancel=Service.NOTHING, on_cancel=Service.NOTHING,
payment_style=Service.PREPAY, payment_style=Service.PREPAY,
tax=0, tax=0,

View file

@ -25,7 +25,7 @@ class FTPBillingTest(BaseBillingTest):
is_fee=False, is_fee=False,
metric='', metric='',
pricing_period=Service.NEVER, pricing_period=Service.NEVER,
rate_algorithm=Service.STEP_PRICE, rate_algorithm='STEP_PRICE',
on_cancel=Service.COMPENSATE, on_cancel=Service.COMPENSATE,
payment_style=Service.PREPAY, payment_style=Service.PREPAY,
tax=0, tax=0,

View file

@ -1,9 +1,10 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from orchestra.apps.miscellaneous.models import MiscService, Miscellaneous from orchestra.apps.miscellaneous.models import MiscService, Miscellaneous
from orchestra.apps.plans.models import Plan
from orchestra.utils.tests import random_ascii from orchestra.utils.tests import random_ascii
from ...models import Service, Plan from ...models import Service
from . import BaseBillingTest from . import BaseBillingTest
@ -19,7 +20,7 @@ class JobBillingTest(BaseBillingTest):
is_fee=False, is_fee=False,
metric='miscellaneous.amount', metric='miscellaneous.amount',
pricing_period=Service.BILLING_PERIOD, pricing_period=Service.BILLING_PERIOD,
rate_algorithm=Service.MATCH_PRICE, rate_algorithm='MATCH_PRICE',
on_cancel=Service.NOTHING, on_cancel=Service.NOTHING,
payment_style=Service.POSTPAY, payment_style=Service.POSTPAY,
tax=0, tax=0,

View file

@ -4,10 +4,11 @@ from django.utils import timezone
from freezegun import freeze_time from freezegun import freeze_time
from orchestra.apps.mailboxes.models import Mailbox from orchestra.apps.mailboxes.models import Mailbox
from orchestra.apps.plans.models import Plan
from orchestra.apps.resources.models import Resource, ResourceData from orchestra.apps.resources.models import Resource, ResourceData
from orchestra.utils.tests import random_ascii from orchestra.utils.tests import random_ascii
from ...models import Service, Plan from ...models import Service
from . import BaseBillingTest from . import BaseBillingTest
@ -23,7 +24,7 @@ class MailboxBillingTest(BaseBillingTest):
is_fee=False, is_fee=False,
metric='', metric='',
pricing_period=Service.NEVER, pricing_period=Service.NEVER,
rate_algorithm=Service.STEP_PRICE, rate_algorithm='STEP_PRICE',
on_cancel=Service.COMPENSATE, on_cancel=Service.COMPENSATE,
payment_style=Service.PREPAY, payment_style=Service.PREPAY,
tax=0, tax=0,
@ -44,7 +45,7 @@ class MailboxBillingTest(BaseBillingTest):
is_fee=False, is_fee=False,
metric='max((mailbox.resources.disk.allocated or 0) -1, 0)', metric='max((mailbox.resources.disk.allocated or 0) -1, 0)',
pricing_period=Service.NEVER, pricing_period=Service.NEVER,
rate_algorithm=Service.STEP_PRICE, rate_algorithm='STEP_PRICE',
on_cancel=Service.DISCOUNT, on_cancel=Service.DISCOUNT,
payment_style=Service.PREPAY, payment_style=Service.PREPAY,
tax=0, tax=0,

View file

@ -1,6 +1,8 @@
from django.contrib.contenttypes.models import ContentType 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 from . import BaseBillingTest
@ -16,7 +18,7 @@ class PlanBillingTest(BaseBillingTest):
is_fee=True, is_fee=True,
metric='', metric='',
pricing_period=Service.BILLING_PERIOD, pricing_period=Service.BILLING_PERIOD,
rate_algorithm=Service.STEP_PRICE, rate_algorithm='STEP_PRICE',
on_cancel=Service.DISCOUNT, on_cancel=Service.DISCOUNT,
payment_style=Service.PREPAY, payment_style=Service.PREPAY,
tax=0, tax=0,

View file

@ -5,9 +5,10 @@ from freezegun import freeze_time
from orchestra.apps.accounts.models import Account from orchestra.apps.accounts.models import Account
from orchestra.apps.miscellaneous.models import MiscService, Miscellaneous 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 orchestra.apps.resources.models import Resource, ResourceData, MonitorData
from ...models import Service, Plan from ...models import Service
from . import BaseBillingTest from . import BaseBillingTest
@ -25,7 +26,7 @@ class BaseTrafficBillingTest(BaseBillingTest):
is_fee=False, is_fee=False,
metric=self.TRAFFIC_METRIC, metric=self.TRAFFIC_METRIC,
pricing_period=Service.BILLING_PERIOD, pricing_period=Service.BILLING_PERIOD,
rate_algorithm=Service.STEP_PRICE, rate_algorithm='STEP_PRICE',
on_cancel=Service.NOTHING, on_cancel=Service.NOTHING,
payment_style=Service.POSTPAY, payment_style=Service.POSTPAY,
tax=0, tax=0,
@ -107,7 +108,7 @@ class TrafficPrepayBillingTest(BaseTrafficBillingTest):
is_fee=False, is_fee=False,
metric="miscellaneous.amount", metric="miscellaneous.amount",
pricing_period=Service.NEVER, pricing_period=Service.NEVER,
rate_algorithm=Service.STEP_PRICE, rate_algorithm='STEP_PRICE',
on_cancel=Service.NOTHING, on_cancel=Service.NOTHING,
payment_style=Service.PREPAY, payment_style=Service.PREPAY,
tax=0, tax=0,

View file

@ -41,7 +41,7 @@ class HandlerTests(BaseTestCase):
is_fee=False, is_fee=False,
metric='', metric='',
pricing_period=Service.NEVER, pricing_period=Service.NEVER,
rate_algorithm=Service.STEP_PRICE, rate_algorithm='STEP_PRICE',
on_cancel=Service.DISCOUNT, on_cancel=Service.DISCOUNT,
payment_style=Service.PREPAY, payment_style=Service.PREPAY,
tax=0, tax=0,

View file

@ -208,10 +208,7 @@ class Exim4Traffic(ServiceMonitor):
with open(mainlog, 'r') as mainlog: with open(mainlog, 'r') as mainlog:
for line in mainlog.readlines(): for line in mainlog.readlines():
if ' <= ' in line and 'P=local' in line: if ' <= ' in line and 'P=local' in line:
username = user_regex.search(line) username = user_regex.search(line).groups()[0]
if not username:
continue
username = username.groups()[0]
try: try:
sender = users[username] sender = users[username]
except KeyError: except KeyError:
@ -299,7 +296,7 @@ class FTPTraffic(ServiceMonitor):
users[username] = [ini_date, object_id, 0] users[username] = [ini_date, object_id, 0]
def monitor(users, end_date, months, vsftplogs): 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, ') bytes_regex = re.compile(r', ([0-9]+) bytes, ')
for vsftplog in vsftplogs: for vsftplog in vsftplogs:
try: try:

View file

@ -97,7 +97,7 @@ class Apache2Backend(ServiceController):
def delete(self, site): def delete(self, site):
context = self.get_context(site) context = self.get_context(site)
self.append("a2dissite %(site_unique_name)s.conf && UPDATED=1" % context) 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): def commit(self):
""" reload Apache2 if necessary """ """ reload Apache2 if necessary """

View file

@ -11,7 +11,6 @@ from orchestra.utils.functional import cached
from . import settings from . import settings
from .directives import SiteDirective from .directives import SiteDirective
from .utils import normurlpath
class Website(models.Model): class Website(models.Model):
@ -141,8 +140,8 @@ class Content(models.Model):
return self.path return self.path
def clean(self): def clean(self):
# TODO do it on the field? if not self.path:
self.path = normurlpath(self.path) self.path = '/'
def get_absolute_url(self): def get_absolute_url(self):
domain = self.website.domains.first() domain = self.website.domains.first()

View file

@ -131,9 +131,10 @@ function install_requirements () {
libxslt1-dev \ libxslt1-dev \
wkhtmltopdf \ wkhtmltopdf \
xvfb \ xvfb \
ca-certificates" ca-certificates \
gettext"
PIP="django==1.7.1 \ PIP="django==1.7.7 \
django-celery-email==1.0.4 \ django-celery-email==1.0.4 \
django-fluent-dashboard==0.3.5 \ django-fluent-dashboard==0.3.5 \
https://bitbucket.org/izi/django-admin-tools/get/a0abfffd76a0.zip \ https://bitbucket.org/izi/django-admin-tools/get/a0abfffd76a0.zip \
@ -158,7 +159,8 @@ function install_requirements () {
requests \ requests \
phonenumbers \ phonenumbers \
django-countries \ django-countries \
django-localflavor" django-localflavor \
pip==6.0.8"
if $testing; then if $testing; then
APT="${APT} \ APT="${APT} \

View file

View file

@ -55,6 +55,11 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'static')
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') 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_HOST = 'smtp.yourhost.eu'
# EMAIL_PORT = '' # EMAIL_PORT = ''
# EMAIL_HOST_USER = '' # EMAIL_HOST_USER = ''

View file

@ -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

View file

@ -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)