Refactored saas and webapps

This commit is contained in:
Marc Aymerich 2014-11-27 19:17:26 +00:00
parent 02b8e24f45
commit f3551bb13e
34 changed files with 367 additions and 270 deletions

30
TODO.md
View File

@ -161,23 +161,23 @@
* Rename apache logs ending on .log in order to logrotate easily * Rename apache logs ending on .log in order to logrotate easily
* SaaS wordpress multiple blogs per user? separate users from sites? SaaSUser SaaSSite models * multitenant webapps modeled on WepApp -> name unique for all accounts
* Custom domains for SaaS apps (wordpress Vhost) SaaSSite.domain ?
* webapp compat webapp-options
* webapps modeled on classes instead of settings?
* Change account and orders * Change account and orders
* Mix webapps type with backends (two for the price of one)
==== SaaS ==== * Webapp options and type compatibility
Wordpress
---------
* site_name
* email
* site_title
* site_domain (optional)
BSCW Multi-tenant WebApps
---- --------------------
* email * SaaS - Those apps that can't use custom domain
* username * WebApp - Those apps that can use custom domain
* quota
* password (optional) * Howto upgrade webapp PHP version? <FilesMatch \.php$> SetHandler php54-cgi</FilesMatch> ? or create a new app
* prevent @pangea.org email addresses on contacts

View File

@ -38,7 +38,7 @@ class SendEmail(object):
raise PermissionDenied raise PermissionDenied
initial={ initial={
'email_from': self.default_from, 'email_from': self.default_from,
'to': ' '.join(self.queryset.values_list('email', flat=True)) 'to': ' '.join(self.get_queryset_emails())
} }
form = self.form(initial=initial) form = self.form(initial=initial)
if request.POST.get('post'): if request.POST.get('post'):
@ -63,8 +63,10 @@ class SendEmail(object):
# Display confirmation page # Display confirmation page
return render(request, self.template, self.context) return render(request, self.template, self.context)
def get_queryset_emails(self):
return self.queryset.value_list('email', flat=True)
def confirm_email(self, request, **options): def confirm_email(self, request, **options):
num = len(self.queryset)
email_from = options['email_from'] email_from = options['email_from']
extra_to = options['extra_to'] extra_to = options['extra_to']
subject = options['subject'] subject = options['subject']
@ -72,13 +74,15 @@ class SendEmail(object):
# The user has already confirmed # The user has already confirmed
if request.POST.get('post') == 'email_confirmation': if request.POST.get('post') == 'email_confirmation':
emails = [] emails = []
for contact in self.queryset.all(): num = 0
emails.append((subject, message, email_from, [contact.email])) for email in self.get_queryset_emails():
emails.append((subject, message, email_from, [email]))
num += 1
if extra_to: if extra_to:
emails.append((subject, message, email_from, extra_to)) emails.append((subject, message, email_from, extra_to))
send_mass_mail(emails) send_mass_mail(emails, fail_silently=False)
msg = ungettext( msg = ungettext(
_("Message has been sent to %s.") % str(contact), _("Message has been sent to one %s.") % self.opts.verbose_name_plural,
_("Message has been sent to %i %s.") % (num, self.opts.verbose_name_plural), _("Message has been sent to %i %s.") % (num, self.opts.verbose_name_plural),
num num
) )

View File

@ -8,6 +8,8 @@ from django.utils.translation import ungettext, ugettext_lazy as _
from orchestra.admin.decorators import action_with_confirmation from orchestra.admin.decorators import action_with_confirmation
from orchestra.core import services from orchestra.core import services
from . import settings
@transaction.atomic @transaction.atomic
@action_with_confirmation() @action_with_confirmation()
@ -38,6 +40,7 @@ list_contacts.verbose_name = _("List contacts")
def service_report(modeladmin, request, queryset): def service_report(modeladmin, request, queryset):
# TODO resources
accounts = [] accounts = []
fields = [] fields = []
# First we get related manager names to fire a prefetch related # First we get related manager names to fire a prefetch related
@ -59,4 +62,4 @@ def service_report(modeladmin, request, queryset):
'accounts': accounts, 'accounts': accounts,
'date': timezone.now().today() 'date': timezone.now().today()
} }
return render(request, 'admin/accounts/account/service_report.html', context) return render(request, settings.ACCOUNTS_SERVICE_REPORT_TEMPLATE, context)

View File

@ -47,3 +47,7 @@ ACCOUNTS_CREATE_RELATED = getattr(settings, 'ACCOUNTS_CREATE_RELATED', (
_("Designates whether to creates a related subdomain &lt;username&gt;.orchestra.lan or not."), _("Designates whether to creates a related subdomain &lt;username&gt;.orchestra.lan or not."),
), ),
)) ))
ACCOUNTS_SERVICE_REPORT_TEMPLATE = getattr(settings, 'ACCOUNTS_SERVICE_REPORT_TEMPLATE',
'admin/accounts/account/service_report.html')

View File

@ -54,7 +54,10 @@
<li class="item-title">{{ opts.verbose_name_plural|capfirst }}</li> <li class="item-title">{{ opts.verbose_name_plural|capfirst }}</li>
<ul> <ul>
{% for obj in related %} {% for obj in related %}
<li class="related"><a href="{{ obj|admin_url }}">{{ obj }}</a>{% if not obj|isactive %} ({% trans "disabled" %}){% endif %}</li> <li class="related"><a href="{{ obj|admin_url }}">{{ obj }}</a>
{% if not obj|isactive %} ({% trans "disabled" %}){% endif %}
{{ obj.get_description|capfirst }}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endfor %} {% endfor %}

View File

@ -159,7 +159,8 @@ class Bill(models.Model):
return now + relativedelta(months=1) return now + relativedelta(months=1)
def close(self, payment=False): def close(self, payment=False):
assert self.is_open, "Bill not in Open state" if not self.is_open:
raise TypeError("Bill not in Open state.")
if payment is False: if payment is False:
payment = self.account.paymentsources.get_default() payment = self.account.paymentsources.get_default()
if not self.due_on: if not self.due_on:

View File

@ -1,6 +1,6 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ungettext, ugettext_lazy as _
from orchestra.core import services from orchestra.core import services
from orchestra.core.validators import validate_ipv4_address, validate_ipv6_address, validate_ascii from orchestra.core.validators import validate_ipv4_address, validate_ipv6_address, validate_ascii
@ -50,6 +50,15 @@ class Domain(models.Model):
def subdomains(self): def subdomains(self):
return Domain.objects.filter(name__regex='\.%s$' % self.name) return Domain.objects.filter(name__regex='\.%s$' % self.name)
def get_description(self):
if self.is_top:
num = self.subdomains.count()
return ungettext(
_("top domain with one subdomain"),
_("top domain with %d subdomains") % num,
num)
return _("subdomain")
def get_absolute_url(self): def get_absolute_url(self):
return 'http://%s' % self.name return 'http://%s' % self.name

View File

@ -37,8 +37,8 @@ class MailmanBackend(ServiceController):
[[ $(grep "^\s*%(address_domain)s\s*$" %(virtual_alias_domains)s) ]] || { [[ $(grep "^\s*%(address_domain)s\s*$" %(virtual_alias_domains)s) ]] || {
echo "%(address_domain)s" >> %(virtual_alias_domains)s echo "%(address_domain)s" >> %(virtual_alias_domains)s
UPDATED_VIRTUAL_ALIAS_DOMAINS=1 UPDATED_VIRTUAL_ALIAS_DOMAINS=1
}""" % context }""") % context
)) )
def exclude_virtual_alias_domain(self, context): def exclude_virtual_alias_domain(self, context):
address_domain = context['address_domain'] address_domain = context['address_domain']
@ -58,13 +58,11 @@ class MailmanBackend(ServiceController):
self.append(textwrap.dedent("""\ self.append(textwrap.dedent("""\
[[ ! -e %(mailman_root)s/lists/%(name)s ]] && { [[ ! -e %(mailman_root)s/lists/%(name)s ]] && {
newlist --quiet --emailhost='%(domain)s' '%(name)s' '%(admin)s' '%(password)s' newlist --quiet --emailhost='%(domain)s' '%(name)s' '%(admin)s' '%(password)s'
}""" % context)) }""") % context)
# Custom domain # Custom domain
if mail_list.address: if mail_list.address:
aliases = self.get_virtual_aliases(context) context['aliases'] = self.get_virtual_aliases(context)
# Preserve indentation # Preserve indentation
spaces = ' '*4
context['aliases'] = spaces + aliases.replace('\n', '\n'+spaces)
self.append(textwrap.dedent("""\ self.append(textwrap.dedent("""\
if [[ ! $(grep '\s\s*%(name)s\s*$' %(virtual_alias)s) ]]; then if [[ ! $(grep '\s\s*%(name)s\s*$' %(virtual_alias)s) ]]; then
echo '# %(banner)s\n%(aliases)s echo '# %(banner)s\n%(aliases)s
@ -78,23 +76,28 @@ class MailmanBackend(ServiceController):
' >> %(virtual_alias)s ' >> %(virtual_alias)s
UPDATED_VIRTUAL_ALIAS=1 UPDATED_VIRTUAL_ALIAS=1
fi fi
fi""" % context fi""") % context
)) )
self.append('echo "require_explicit_destination = 0" | ' self.append(
'%(mailman_root)s/bin/config_list -i /dev/stdin %(name)s' % context) 'echo "require_explicit_destination = 0" | '
'%(mailman_root)s/bin/config_list -i /dev/stdin %(name)s' % context
)
self.append(textwrap.dedent("""\ self.append(textwrap.dedent("""\
echo "host_name = '%(address_domain)s'" | \ echo "host_name = '%(address_domain)s'" | \
%(mailman_root)s/bin/config_list -i /dev/stdin %(name)s""" % context)) %(mailman_root)s/bin/config_list -i /dev/stdin %(name)s""") % context
)
else: else:
# Cleanup shit # Cleanup shit
self.append(textwrap.dedent("""\ self.append(textwrap.dedent("""\
if [[ ! $(grep '\s\s*%(name)s\s*$' %(virtual_alias)s) ]]; then if [[ ! $(grep '\s\s*%(name)s\s*$' %(virtual_alias)s) ]]; then
sed -i "/^.*\s%(name)s\s*$/d" %(virtual_alias)s sed -i "/^.*\s%(name)s\s*$/d" %(virtual_alias)s
fi""" % context fi""") % context
)) )
# Update # Update
if context['password'] is not None: if context['password'] is not None:
self.append('%(mailman_root)s/bin/change_pw --listname="%(name)s" --password="%(password)s"' % context) self.append(
'%(mailman_root)s/bin/change_pw --listname="%(name)s" --password="%(password)s"' % context
)
self.include_virtual_alias_domain(context) self.include_virtual_alias_domain(context)
def delete(self, mail_list): def delete(self, mail_list):
@ -102,8 +105,8 @@ class MailmanBackend(ServiceController):
self.exclude_virtual_alias_domain(context) self.exclude_virtual_alias_domain(context)
self.append(textwrap.dedent("""\ self.append(textwrap.dedent("""\
sed -i -e '/^.*\s%(name)s\(%(address_regex)s\)\s*$/d' \\ sed -i -e '/^.*\s%(name)s\(%(address_regex)s\)\s*$/d' \\
-e 'N; /^\s*\\n\s*$/d; P; D' %(virtual_alias)s""" % context -e 'N; /^\s*\\n\s*$/d; P; D' %(virtual_alias)s""") % context
)) )
self.append("rmlist -a %(name)s" % context) self.append("rmlist -a %(name)s" % context)
def commit(self): def commit(self):
@ -111,8 +114,8 @@ class MailmanBackend(ServiceController):
self.append(textwrap.dedent(""" self.append(textwrap.dedent("""
[[ $UPDATED_VIRTUAL_ALIAS == 1 ]] && { postmap %(virtual_alias)s; } [[ $UPDATED_VIRTUAL_ALIAS == 1 ]] && { postmap %(virtual_alias)s; }
[[ $UPDATED_VIRTUAL_ALIAS_DOMAINS == 1 ]] && { /etc/init.d/postfix reload; } [[ $UPDATED_VIRTUAL_ALIAS_DOMAINS == 1 ]] && { /etc/init.d/postfix reload; }
""" % context """) % context
)) )
def get_context_files(self): def get_context_files(self):
return { return {
@ -163,7 +166,7 @@ class MailmanTraffic(ServiceMonitor):
| tr '\\n' '+' \\ | tr '\\n' '+' \\
| xargs -i echo {} ) | xargs -i echo {} )
echo ${OBJECT_ID} $(( ${SIZE}*${SUBSCRIBERS} )) echo ${OBJECT_ID} $(( ${SIZE}*${SUBSCRIBERS} ))
}""" % current_date)) }""") % current_date)
def monitor(self, mail_list): def monitor(self, mail_list):
context = self.get_context(mail_list) context = self.get_context(mail_list)

View File

@ -0,0 +1,7 @@
from orchestra.admin.actions import SendEmail
class SendMailboxEmail(SendEmail):
def get_queryset_emails(self):
for mailbox in self.queryset.all():
yield mailbox.get_local_address()

View File

@ -11,6 +11,8 @@ from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin
from orchestra.admin.utils import admin_link, change_url from orchestra.admin.utils import admin_link, change_url
from orchestra.apps.accounts.admin import SelectAccountAdminMixin, AccountAdminMixin from orchestra.apps.accounts.admin import SelectAccountAdminMixin, AccountAdminMixin
from . import settings
from .actions import SendMailboxEmail
from .filters import HasMailboxListFilter, HasForwardListFilter, HasAddressListFilter from .filters import HasMailboxListFilter, HasForwardListFilter, HasAddressListFilter
from .forms import MailboxCreationForm, MailboxChangeForm, AddressForm from .forms import MailboxCreationForm, MailboxChangeForm, AddressForm
from .models import Mailbox, Address, Autoresponse from .models import Mailbox, Address, Autoresponse
@ -71,6 +73,13 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
display_addresses.short_description = _("Addresses") display_addresses.short_description = _("Addresses")
display_addresses.allow_tags = True display_addresses.allow_tags = True
def get_actions(self, request):
if settings.MAILBOXES_LOCAL_ADDRESS_DOMAIN:
self.actions = (SendMailboxEmail(),)
else:
self.actions = ()
return super(MailboxAdmin, self).get_actions(request)
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
fieldsets = super(MailboxAdmin, self).get_fieldsets(request, obj=obj) fieldsets = super(MailboxAdmin, self).get_fieldsets(request, obj=obj)
if obj and obj.filtering == obj.CUSTOM: if obj and obj.filtering == obj.CUSTOM:

View File

@ -89,10 +89,10 @@ class PasswdVirtualUserBackend(ServiceController):
context = { context = {
'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH 'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH
} }
self.append( self.append(textwrap.dedent("""\
"[[ $UPDATED_VIRTUAL_MAILBOX_MAPS == 1 ]] && { postmap %(virtual_mailbox_maps)s; }" [[ $UPDATED_VIRTUAL_MAILBOX_MAPS == 1 ]] && {
% context postmap %(virtual_mailbox_maps)s
) }""" % context))
def get_context(self, mailbox): def get_context(self, mailbox):
context = { context = {
@ -123,8 +123,7 @@ class PostfixAddressBackend(ServiceController):
[[ $(grep "^\s*%(domain)s\s*$" %(virtual_alias_domains)s) ]] || { [[ $(grep "^\s*%(domain)s\s*$" %(virtual_alias_domains)s) ]] || {
echo "%(domain)s" >> %(virtual_alias_domains)s echo "%(domain)s" >> %(virtual_alias_domains)s
UPDATED_VIRTUAL_ALIAS_DOMAINS=1 UPDATED_VIRTUAL_ALIAS_DOMAINS=1
}""" % context }""") % context)
))
def exclude_virtual_alias_domain(self, context): def exclude_virtual_alias_domain(self, context):
domain = context['domain'] domain = context['domain']
@ -151,8 +150,7 @@ class PostfixAddressBackend(ServiceController):
sed -i "s/^%(email)s\s.*$/${LINE}/" %(virtual_alias_maps)s sed -i "s/^%(email)s\s.*$/${LINE}/" %(virtual_alias_maps)s
UPDATED_VIRTUAL_ALIAS_MAPS=1 UPDATED_VIRTUAL_ALIAS_MAPS=1
fi fi
fi""" % context fi""") % context)
))
else: else:
logger.warning("Address %i is empty" % address.pk) logger.warning("Address %i is empty" % address.pk)
self.append('sed -i "/^%(email)s\s/d" %(virtual_alias_maps)s') self.append('sed -i "/^%(email)s\s/d" %(virtual_alias_maps)s')
@ -163,8 +161,7 @@ class PostfixAddressBackend(ServiceController):
if [[ $(grep "^%(email)s\s" %(virtual_alias_maps)s) ]]; then if [[ $(grep "^%(email)s\s" %(virtual_alias_maps)s) ]]; then
sed -i "/^%(email)s\s.*$/d" %(virtual_alias_maps)s sed -i "/^%(email)s\s.*$/d" %(virtual_alias_maps)s
UPDATED_VIRTUAL_ALIAS_MAPS=1 UPDATED_VIRTUAL_ALIAS_MAPS=1
fi""" % context fi""") % context)
))
def save(self, address): def save(self, address):
context = self.get_context(address) context = self.get_context(address)
@ -181,8 +178,8 @@ class PostfixAddressBackend(ServiceController):
self.append(textwrap.dedent(""" self.append(textwrap.dedent("""
[[ $UPDATED_VIRTUAL_ALIAS_MAPS == 1 ]] && { postmap %(virtual_alias_maps)s; } [[ $UPDATED_VIRTUAL_ALIAS_MAPS == 1 ]] && { postmap %(virtual_alias_maps)s; }
[[ $UPDATED_VIRTUAL_ALIAS_DOMAINS == 1 ]] && { /etc/init.d/postfix reload; } [[ $UPDATED_VIRTUAL_ALIAS_DOMAINS == 1 ]] && { /etc/init.d/postfix reload; }
""" % context """) % context
)) )
def get_context_files(self): def get_context_files(self):
return { return {

View File

@ -78,6 +78,11 @@ class Mailbox(models.Model):
else: else:
address.save() address.save()
def get_local_address(self):
if not settings.MAILBOXES_LOCAL_ADDRESS_DOMAIN:
raise AttributeError("Mailboxes do not have a defined local address domain")
return '@'.join((self.name, settings.MAILBOXES_LOCAL_ADDRESS_DOMAIN))
class Address(models.Model): class Address(models.Model):
name = models.CharField(_("name"), max_length=64, blank=True, name = models.CharField(_("name"), max_length=64, blank=True,

View File

@ -61,7 +61,12 @@ MAILBOXES_MAILBOX_FILTERINGS = getattr(settings, 'MAILBOXES_MAILBOX_FILTERINGS',
}) })
MAILBOXES_MAILBOX_DEFAULT_FILTERING = getattr(settings, 'MAILBOXES_MAILBOX_DEFAULT_FILTERING', 'REDIRECT') MAILBOXES_MAILBOX_DEFAULT_FILTERING = getattr(settings, 'MAILBOXES_MAILBOX_DEFAULT_FILTERING',
'REDIRECT')
MAILBOXES_MAILDIRSIZE_PATH = getattr(settings, 'MAILBOXES_MAILDIRSIZE_PATH', '%(home)s/Maildir/maildirsize') MAILBOXES_MAILDIRSIZE_PATH = getattr(settings, 'MAILBOXES_MAILDIRSIZE_PATH', '%(home)s/Maildir/maildirsize')
MAILBOXES_LOCAL_ADDRESS_DOMAIN = getattr(settings, 'MAILBOXES_LOCAL_ADDRESS_DOMAIN',
'orchestra.lan')

View File

@ -60,6 +60,9 @@ class Miscellaneous(models.Model):
except type(self).account.field.rel.to.DoesNotExist: except type(self).account.field.rel.to.DoesNotExist:
return self.is_active return self.is_active
def get_description(self):
return ' '.join((str(self.amount), self.service.description or self.service.verbose_name))
def clean(self): def clean(self):
if self.identifier: if self.identifier:
self.identifier = self.identifier.strip() self.identifier = self.identifier.strip()

View File

@ -10,6 +10,14 @@ from orchestra import plugins
from . import methods from . import methods
class ServiceMount(plugins.PluginMount):
def __init__(cls, name, bases, attrs):
# Make sure backends specify a model attribute
if not (attrs.get('abstract', False) or name == 'ServiceBackend' or cls.model):
raise AttributeError("'%s' does not have a defined model attribute." % cls)
super(ServiceMount, cls).__init__(name, bases, attrs)
class ServiceBackend(plugins.Plugin): class ServiceBackend(plugins.Plugin):
""" """
Service management backend base class Service management backend base class
@ -27,7 +35,7 @@ class ServiceBackend(plugins.Plugin):
ignore_fields = [] ignore_fields = []
actions = [] actions = []
__metaclass__ = plugins.PluginMount __metaclass__ = ServiceMount
def __unicode__(self): def __unicode__(self):
return type(self).__name__ return type(self).__name__

View File

@ -50,10 +50,11 @@ def SSH(backend, log, server, cmds, async=False):
key = settings.ORCHESTRATION_SSH_KEY_PATH key = settings.ORCHESTRATION_SSH_KEY_PATH
try: try:
ssh.connect(addr, username='root', key_filename=key, timeout=10) ssh.connect(addr, username='root', key_filename=key, timeout=10)
except socket.error: except socket.error, e:
logger.error('%s timed out on %s' % (backend, server)) logger.error('%s timed out on %s' % (backend, addr))
log.state = log.TIMEOUT log.state = log.TIMEOUT
log.save(update_fields=['state']) log.stderr = str(e)
log.save(update_fields=['state', 'stderr'])
return return
transport = ssh.get_transport() transport = ssh.get_transport()

View File

@ -24,6 +24,7 @@ STATE_COLORS = {
class PaymentSourceAdmin(SelectPluginAdminMixin, AccountAdminMixin, admin.ModelAdmin): class PaymentSourceAdmin(SelectPluginAdminMixin, AccountAdminMixin, admin.ModelAdmin):
list_display = ('label', 'method', 'number', 'account_link', 'is_active') list_display = ('label', 'method', 'number', 'account_link', 'is_active')
list_filter = ('method', 'is_active') list_filter = ('method', 'is_active')
search_fields = ('account__username', 'account__full_name', 'data')
plugin = PaymentMethod plugin = PaymentMethod
plugin_field = 'method' plugin_field = 'method'

View File

@ -119,23 +119,27 @@ class Transaction(models.Model):
if amount >= self.bill.total: if amount >= self.bill.total:
raise ValidationError(_("New transactions can not be allocated for this bill.")) raise ValidationError(_("New transactions can not be allocated for this bill."))
def check_state(*args):
if self.state not in args:
raise TypeError("Transaction not in %s" % ' or '.join(args))
def mark_as_processed(self): def mark_as_processed(self):
assert self.state == self.WAITTING_PROCESSING self.check_state(self.WAITTING_PROCESSING)
self.state = self.WAITTING_EXECUTION self.state = self.WAITTING_EXECUTION
self.save(update_fields=['state']) self.save(update_fields=['state'])
def mark_as_executed(self): def mark_as_executed(self):
assert self.state == self.WAITTING_EXECUTION self.check_state(self.WAITTING_EXECUTION)
self.state = self.EXECUTED self.state = self.EXECUTED
self.save(update_fields=['state']) self.save(update_fields=['state'])
def mark_as_secured(self): def mark_as_secured(self):
assert self.state == self.EXECUTED self.check_state(self.EXECUTED)
self.state = self.SECURED self.state = self.SECURED
self.save(update_fields=['state']) self.save(update_fields=['state'])
def mark_as_rejected(self): def mark_as_rejected(self):
assert self.state == self.EXECUTED self.check_state(self.EXECUTED)
self.state = self.REJECTED self.state = self.REJECTED
self.save(update_fields=['state']) self.save(update_fields=['state'])
@ -167,22 +171,26 @@ class TransactionProcess(models.Model):
def __unicode__(self): def __unicode__(self):
return '#%i' % self.id return '#%i' % self.id
def check_state(*args):
if self.state not in args:
raise TypeError("Transaction process not in %s" % ' or '.join(args))
def mark_as_executed(self): def mark_as_executed(self):
assert self.state == self.CREATED self.check_state(self.CREATED)
self.state = self.EXECUTED self.state = self.EXECUTED
for transaction in self.transactions.all(): for transaction in self.transactions.all():
transaction.mark_as_executed() transaction.mark_as_executed()
self.save(update_fields=['state']) self.save(update_fields=['state'])
def abort(self): def abort(self):
assert self.state in [self.CREATED, self.EXCECUTED] self.check_state(self.CREATED, self.EXCECUTED)
self.state = self.ABORTED self.state = self.ABORTED
for transaction in self.transaction.all(): for transaction in self.transaction.all():
transaction.mark_as_aborted() transaction.mark_as_aborted()
self.save(update_fields=['state']) self.save(update_fields=['state'])
def commit(self): def commit(self):
assert self.state in [self.CREATED, self.EXECUTED] self.check_state(self.CREATED, self.EXECUTED)
self.state = self.COMMITED self.state = self.COMMITED
for transaction in self.transactions.processing(): for transaction in self.transactions.processing():
transaction.mark_as_secured() transaction.mark_as_secured()

View File

@ -9,33 +9,32 @@ from django.utils.translation import ungettext, ugettext_lazy as _
def run_monitor(modeladmin, request, queryset): def run_monitor(modeladmin, request, queryset):
""" Resource and ResourceData run monitors """ """ Resource and ResourceData run monitors """
referer = request.META.get('HTTP_REFERER') referer = request.META.get('HTTP_REFERER')
if not queryset:
modeladmin.message_user(request, _("No resource has been selected,"))
return redirect(referer)
async = modeladmin.model.monitor.func_defaults[0] async = modeladmin.model.monitor.func_defaults[0]
results = [] logs = set()
for resource in queryset: for resource in queryset:
result = resource.monitor() results = resource.monitor()
if not async: if not async:
results += result for result in results:
if hasattr(result, 'log'):
logs.add(result.log.pk)
modeladmin.log_change(request, resource, _("Run monitors")) modeladmin.log_change(request, resource, _("Run monitors"))
num = len(queryset)
if async: if async:
num = len(queryset)
link = reverse('admin:djcelery_taskstate_changelist') link = reverse('admin:djcelery_taskstate_changelist')
msg = ungettext( msg = ungettext(
_("One selected resource has been <a href='%s'>scheduled for monitoring</a>.") % link, _("One selected resource has been <a href='%s'>scheduled for monitoring</a>.") % link,
_("%s selected resource have been <a href='%s'>scheduled for monitoring</a>.") % (num, link), _("%s selected resource have been <a href='%s'>scheduled for monitoring</a>.") % (num, link),
num) num)
else: else:
if len(results) == 1: num = len(logs)
log = results[0].log if num == 1:
link = reverse('admin:orchestration_backendlog_change', args=(log.pk,)) log = logs.pop()
msg = _("One selected resource has <a href='%s'>been monitored</a>.") % link link = reverse('admin:orchestration_backendlog_change', args=(log,))
elif len(results) >= 1: msg = _("One related monitor has <a href='%s'>been executed</a>.") % link
logs = [str(result.log.pk) for result in results] elif num >= 1:
link = reverse('admin:orchestration_backendlog_changelist') link = reverse('admin:orchestration_backendlog_changelist')
link += '?id__in=%s' % ','.join(logs) link += '?id__in=%s' % ','.join(logs)
msg = _("%s selected resources have <a href='%s'>been monitored</a>.") % (num, link) msg = _("%s related monitors have <a href='%s'>been executed</a>.") % (num, link)
else: else:
msg = _("No related monitors have been executed.") msg = _("No related monitors have been executed.")
modeladmin.message_user(request, mark_safe(msg)) modeladmin.message_user(request, mark_safe(msg))

View File

@ -119,8 +119,7 @@ class Resource(models.Model):
def get_model_path(self, monitor): def get_model_path(self, monitor):
""" returns a model path between self.content_type and monitor.model """ """ returns a model path between self.content_type and monitor.model """
resource_model = self.content_type.model_class() resource_model = self.content_type.model_class()
model_path = ServiceMonitor.get_backend(monitor).model monitor_model = ServiceMonitor.get_backend(monitor).model_class()
monitor_model = get_model(model_path)
return get_model_field_path(monitor_model, resource_model) return get_model_field_path(monitor_model, resource_model)
def sync_periodic_task(self): def sync_periodic_task(self):
@ -223,6 +222,7 @@ class ResourceData(models.Model):
) )
else: else:
fields = '__'.join(path) fields = '__'.join(path)
monitor_model = ServiceMonitor.get_backend(monitor).model_class()
objects = monitor_model.objects.filter(**{fields: self.object_id}) objects = monitor_model.objects.filter(**{fields: self.object_id})
pks = objects.values_list('id', flat=True) pks = objects.values_list('id', flat=True)
ct = ContentType.objects.get_for_model(monitor_model) ct = ContentType.objects.get_for_model(monitor_model)

View File

@ -1,101 +0,0 @@
import re
import requests
from django.utils.translation import ugettext_lazy as _
from orchestra.apps.orchestration import ServiceController
from . import SaaSServiceMixin
from .. import settings
class WordpressMuBackend(SaaSServiceMixin, ServiceController):
verbose_name = _("Wordpress multisite")
@property
def script(self):
return self.cmds
def login(self, session):
base_url = self.get_base_url()
login_url = base_url + '/wp-login.php'
login_data = {
'log': 'admin',
'pwd': settings.WEBAPPS_WORDPRESSMU_ADMIN_PASSWORD,
'redirect_to': '/wp-admin/'
}
response = session.post(login_url, data=login_data)
if response.url != base_url + '/wp-admin/':
raise IOError("Failure login to remote application")
def get_base_url(self):
base_url = settings.WEBAPPS_WORDPRESSMU_BASE_URL
if base_url.endswith('/'):
base_url = base_url[:-1]
return base_url
def create_blog(self, webapp, server):
emails = webapp.account.contacts.filter(email_usage__contains='')
email = emails.values_list('email', flat=True).first()
base_url = self.get_base_url()
session = requests.Session()
self.login(session)
url = base_url + '/wp-admin/network/site-new.php'
content = session.get(url).content
wpnonce = re.compile('name="_wpnonce_add-blog"\s+value="([^"]*)"')
wpnonce = wpnonce.search(content).groups()[0]
url += '?action=add-site'
data = {
'blog[domain]': webapp.name,
'blog[title]': webapp.name,
'blog[email]': email,
'_wpnonce_add-blog': wpnonce,
}
# TODO validate response
response = session.post(url, data=data)
def delete_blog(self, webapp, server):
# OH, I've enjoied so much coding this methods that I want to thanks
# the wordpress team for the excellent software they are producing
context = self.get_context(webapp)
session = requests.Session()
self.login(session)
base_url = self.get_base_url()
search = base_url + '/wp-admin/network/sites.php?s=%(name)s&action=blogs' % context
regex = re.compile(
'<a href="http://[\.\-\w]+/wp-admin/network/site-info\.php\?id=([0-9]+)"\s+'
'class="edit">%(name)s</a>' % context
)
content = session.get(search).content
ids = regex.search(content).groups()
if len(ids) > 1:
raise ValueError("Multiple matches")
delete = re.compile('<span class="delete">(.*)</span>')
content = delete.search(content).groups()[0]
wpnonce = re.compile('_wpnonce=([^"]*)"')
wpnonce = wpnonce.search(content).groups()[0]
delete = '/wp-admin/network/sites.php?action=confirm&action2=deleteblog'
delete += '&id=%d&_wpnonce=%d' % (ids[0], wpnonce)
content = session.get(delete).content
wpnonce = re.compile('name="_wpnonce"\s+value="([^"]*)"')
wpnonce = wpnonce.search(content).groups()[0]
data = {
'action': 'deleteblog',
'id': ids[0],
'_wpnonce': wpnonce,
'_wp_http_referer': '/wp-admin/network/sites.php',
}
delete = base_url + '/wp-admin/network/sites.php?action=deleteblog'
session.post(delete, data=data)
def save(self, webapp):
self.append(self.create_blog, webapp)
def delete(self, webapp):
self.append(self.delete_blog, webapp)

View File

@ -1,6 +0,0 @@
from .options import SoftwareService
class DokuwikiService(SoftwareService):
verbose_name = "Dowkuwiki"
icon = 'saas/icons/Dokuwiki.png'

View File

@ -1,6 +0,0 @@
from .options import SoftwareService
class DrupalService(SoftwareService):
verbose_name = "Drupal"
icon = 'saas/icons/Drupal.png'

View File

@ -1,23 +0,0 @@
from django import forms
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from .options import SoftwareService, SoftwareServiceForm
class WordPressForm(SoftwareServiceForm):
email = forms.EmailField(label=_("Email"), widget=forms.TextInput(attrs={'size':'40'}))
class WordPressDataSerializer(serializers.Serializer):
email = serializers.EmailField(label=_("Email"))
class WordPressService(SoftwareService):
verbose_name = "WordPress"
form = WordPressForm
serializer = WordPressDataSerializer
icon = 'saas/icons/WordPress.png'
site_name_base_domain = 'blogs.orchestra.lan'
change_readonly_fileds = ('email',)

View File

@ -2,31 +2,8 @@ from django.conf import settings
SAAS_ENABLED_SERVICES = getattr(settings, 'SAAS_ENABLED_SERVICES', ( SAAS_ENABLED_SERVICES = getattr(settings, 'SAAS_ENABLED_SERVICES', (
'orchestra.apps.saas.services.wordpress.WordPressService',
'orchestra.apps.saas.services.drupal.DrupalService',
'orchestra.apps.saas.services.dokuwiki.DokuwikiService',
'orchestra.apps.saas.services.moodle.MoodleService', 'orchestra.apps.saas.services.moodle.MoodleService',
'orchestra.apps.saas.services.bscw.BSCWService', 'orchestra.apps.saas.services.bscw.BSCWService',
'orchestra.apps.saas.services.gitlab.GitLabService', 'orchestra.apps.saas.services.gitlab.GitLabService',
'orchestra.apps.saas.services.phplist.PHPListService', 'orchestra.apps.saas.services.phplist.PHPListService',
)) ))
SAAS_WORDPRESSMU_BASE_URL = getattr(settings, 'SAAS_WORDPRESSMU_BASE_URL',
'http://%(site_name)s.example.com')
SAAS_WORDPRESSMU_ADMIN_PASSWORD = getattr(settings, 'SAAS_WORDPRESSMU_ADMIN_PASSWORD',
'secret')
SAAS_DOKUWIKIMU_TEMPLATE_PATH = setattr(settings, 'SAAS_DOKUWIKIMU_TEMPLATE_PATH',
'/home/httpd/htdocs/wikifarm/template.tar.gz')
SAAS_DOKUWIKIMU_FARM_PATH = getattr(settings, 'SAAS_DOKUWIKIMU_FARM_PATH',
'/home/httpd/htdocs/wikifarm/farm')
SAAS_DRUPAL_SITES_PATH = getattr(settings, 'SAAS_DRUPAL_SITES_PATH',
'/home/httpd/htdocs/drupal-mu/sites/%(site_name)s')

View File

@ -66,12 +66,16 @@ class SystemUser(models.Model):
def has_shell(self): def has_shell(self):
return self.shell not in settings.SYSTEMUSERS_DISABLED_SHELLS return self.shell not in settings.SYSTEMUSERS_DISABLED_SHELLS
def get_description(self):
return self.get_shell_display()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.home: if not self.home:
self.home = self.get_base_home() self.home = self.get_base_home()
super(SystemUser, self).save(*args, **kwargs) super(SystemUser, self).save(*args, **kwargs)
def clean(self): def clean(self):
if self.home:
self.home = os.path.normpath(self.home) self.home = os.path.normpath(self.home)
if self.directory: if self.directory:
directory_error = None directory_error = None
@ -119,10 +123,7 @@ class SystemUser(models.Model):
return os.path.normpath(settings.SYSTEMUSERS_HOME % context) return os.path.normpath(settings.SYSTEMUSERS_HOME % context)
def get_home(self): def get_home(self):
return os.path.join( return os.path.join(self.home, self.directory)
self.home or self.get_base_home(),
self.directory
)
services.register(SystemUser) services.register(SystemUser)

View File

@ -4,12 +4,12 @@ from django.utils.translation import ugettext_lazy as _
from orchestra.apps.orchestration import ServiceController from orchestra.apps.orchestration import ServiceController
from . import SaaSServiceMixin
from .. import settings from .. import settings
class DokuWikiMuBackend(SaaSServiceMixin, ServiceController): class DokuWikiMuBackend(ServiceController):
verbose_name = _("DokuWiki multisite") verbose_name = _("DokuWiki multisite")
model = 'webapps.WebApp'
def save(self, webapp): def save(self, webapp):
context = self.get_context(webapp) context = self.get_context(webapp)

View File

@ -1,30 +1,32 @@
import os import os
import textwrap
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.apps.orchestration import ServiceController from orchestra.apps.orchestration import ServiceController
from . import SaaSServiceMixin
from .. import settings from .. import settings
class DrupalMuBackend(SaaSServiceMixin, ServiceController): class DrupalMuBackend(ServiceController):
verbose_name = _("Drupal multisite") verbose_name = _("Drupal multisite")
model = 'webapps.WebApp'
def save(self, webapp): def save(self, webapp):
context = self.get_context(webapp) context = self.get_context(webapp)
self.append("mkdir %(drupal_path)s" % context) self.append(textwrap.dedent("""\
self.append("chown -R www-data %(drupal_path)s" % context) mkdir %(drupal_path)s
self.append( chown -R www-data %(drupal_path)s
"# the following assumes settings.php to be previously configured\n"
"REGEX='^\s*$databases\[.default.\]\[.default.\]\[.prefix.\]'\n" # the following assumes settings.php to be previously configured
"CONFIG='$databases[\'default\'][\'default\'][\'prefix\'] = \'%(app_name)s_\';'\n" REGEX='^\s*$databases\[.default.\]\[.default.\]\[.prefix.\]'
"if [[ ! $(grep $REGEX %(drupal_settings)s) ]]; then\n" CONFIG='$databases[\'default\'][\'default\'][\'prefix\'] = \'%(app_name)s_\';'
" echo $CONFIG >> %(drupal_settings)s\n" if [[ ! $(grep $REGEX %(drupal_settings)s) ]]; then
"fi" % context echo $CONFIG >> %(drupal_settings)s
fi""") % context
) )
def selete(self, webapp): def delete(self, webapp):
context = self.get_context(webapp) context = self.get_context(webapp)
self.append("rm -fr %(app_path)s" % context) self.append("rm -fr %(app_path)s" % context)

View File

@ -0,0 +1,124 @@
import re
import requests
from django.utils.translation import ugettext_lazy as _
from orchestra.apps.orchestration import ServiceController
from .. import settings
class WordpressMuBackend(ServiceController):
verbose_name = _("Wordpress multisite")
model = 'webapps.WebApp'
@property
def script(self):
return self.cmds
def login(self, session):
base_url = self.get_base_url()
login_url = base_url + '/wp-login.php'
login_data = {
'log': 'admin',
'pwd': settings.WEBAPPS_WORDPRESSMU_ADMIN_PASSWORD,
'redirect_to': '/wp-admin/'
}
response = session.post(login_url, data=login_data)
if response.url != base_url + '/wp-admin/':
raise IOError("Failure login to remote application")
def get_base_url(self):
base_url = settings.WEBAPPS_WORDPRESSMU_BASE_URL
return base_url.rstrip('/')
def validate_response(self, response):
if response.status_code != 200:
errors = re.findall(r'<body id="error-page">\n\t<p>(.*)</p></body>', response.content)
raise RuntimeError(errors[0] if errors else 'Unknown %i error' % response.status_code)
def get_id(self, session, webapp):
search = self.get_base_url()
search += '/wp-admin/network/sites.php?s=%s&action=blogs' % webapp.name
regex = re.compile(
'<a href="http://[\.\-\w]+/wp-admin/network/site-info\.php\?id=([0-9]+)"\s+'
'class="edit">%s</a>' % webapp.name
)
content = session.get(search).content
# Get id
ids = regex.search(content)
if not ids:
raise RuntimeError("Blog '%s' not found" % webapp.name)
ids = ids.groups()
if len(ids) > 1:
raise ValueError("Multiple matches")
# Get wpnonce
wpnonce = re.search(r'<span class="delete">(.*)</span>', content).groups()[0]
wpnonce = re.search(r'_wpnonce=([^"]*)"', wpnonce).groups()[0]
return int(ids[0]), wpnonce
def create_blog(self, webapp, server):
session = requests.Session()
self.login(session)
# Check if blog already exists
try:
self.get_id(session, webapp)
except RuntimeError:
url = self.get_base_url()
url += '/wp-admin/network/site-new.php'
content = session.get(url).content
wpnonce = re.compile('name="_wpnonce_add-blog"\s+value="([^"]*)"')
wpnonce = wpnonce.search(content).groups()[0]
url += '?action=add-site'
data = {
'blog[domain]': webapp.name,
'blog[title]': webapp.name,
'blog[email]': webapp.account.email,
'_wpnonce_add-blog': wpnonce,
}
# Validate response
response = session.post(url, data=data)
self.validate_response(response)
def delete_blog(self, webapp, server):
# OH, I've enjoied so much coding this methods that I want to thanks
# the wordpress team for the excellent software they are producing
session = requests.Session()
self.login(session)
try:
id, wpnonce = self.get_id(session, webapp)
except RuntimeError:
pass
else:
delete = self.get_base_url()
delete += '/wp-admin/network/sites.php?action=confirm&action2=deleteblog'
delete += '&id=%d&_wpnonce=%s' % (id, wpnonce)
content = session.get(delete).content
wpnonce = re.compile('name="_wpnonce"\s+value="([^"]*)"')
wpnonce = wpnonce.search(content).groups()[0]
data = {
'action': 'deleteblog',
'id': id,
'_wpnonce': wpnonce,
'_wp_http_referer': '/wp-admin/network/sites.php',
}
delete = self.get_base_url()
delete += '/wp-admin/network/sites.php?action=deleteblog'
response = session.post(delete, data=data)
self.validate_response(response)
def save(self, webapp):
if webapp.type != 'wordpress-mu':
return
self.append(self.create_blog, webapp)
def delete(self, webapp):
if webapp.type != 'wordpress-mu':
return
self.append(self.delete_blog, webapp)

View File

@ -29,10 +29,29 @@ class WebApp(models.Model):
def __unicode__(self): def __unicode__(self):
return self.get_name() return self.get_name()
def get_description(self):
return self.get_type_display()
def clean(self):
# Validate unique webapp names
if self.app_type.get('unique_name', False):
try:
webapp = WebApp.objects.exclude(id=self.pk).get(name=self.name, type=self.type)
except WebApp.DoesNotExist:
pass
else:
raise ValidationError({
'name': _("A webapp with this name already exists."),
})
@cached @cached
def get_options(self): def get_options(self):
return { opt.name: opt.value for opt in self.options.all() } return { opt.name: opt.value for opt in self.options.all() }
@property
def app_type(self):
return settings.WEBAPPS_TYPES[self.type]
def get_name(self): def get_name(self):
return self.name or settings.WEBAPPS_BLANK_NAME return self.name or settings.WEBAPPS_BLANK_NAME
@ -40,7 +59,7 @@ class WebApp(models.Model):
return settings.WEBAPPS_FPM_START_PORT + self.account_id return settings.WEBAPPS_FPM_START_PORT + self.account_id
def get_directive(self): def get_directive(self):
directive = settings.WEBAPPS_TYPES[self.type]['directive'] directive = self.app_type['directive']
args = directive[1:] if len(directive) > 1 else () args = directive[1:] if len(directive) > 1 else ()
return directive[0], args return directive[0], args

View File

@ -59,6 +59,26 @@ WEBAPPS_TYPES = getattr(settings, 'WEBAPPS_TYPES', {
'help_text': _("This creates a Webalizer application under " 'help_text': _("This creates a Webalizer application under "
"~/webapps/&lt;app_name&gt;-&lt;site_name&gt;") "~/webapps/&lt;app_name&gt;-&lt;site_name&gt;")
}, },
'wordpress-mu': {
'verbose_name': _("Wordpress (SaaS)"),
'directive': ('fpm', 'fcgi://127.0.0.1:8990/home/httpd/wordpress-mu/'),
'help_text': _("This creates a Wordpress site on a multi-tenant Wordpress server.<br>"
"By default this blog is accessible via &lt;app_name&gt;.blogs.orchestra.lan")
},
'dokuwiki-mu': {
'verbose_name': _("DokuWiki (SaaS)"),
'directive': ('alias', '/home/httpd/wikifarm/farm/'),
'help_text': _("This create a Dokuwiki wiki into a shared Dokuwiki server.<br>"
"By default this wiki is accessible via &lt;app_name&gt;.wikis.orchestra.lan")
},
'drupal-mu': {
'verbose_name': _("Drupdal (SaaS)"),
'directive': ('fpm', 'fcgi://127.0.0.1:8991/home/httpd/drupal-mu/'),
'help_text': _("This creates a Drupal site into a multi-tenant Drupal server.<br>"
"The installation will be completed after visiting "
"http://&lt;app_name&gt;.drupal.orchestra.lan/install.php?profile=standard<br>"
"By default this site will be accessible via &lt;app_name&gt;.drupal.orchestra.lan")
}
}) })
@ -282,3 +302,22 @@ WEBAPPS_OPTIONS = getattr(settings, 'WEBAPPS_OPTIONS', {
r'^[^ ]+$' r'^[^ ]+$'
), ),
}) })
WEBAPPS_WORDPRESSMU_ADMIN_PASSWORD = getattr(settings, 'WEBAPPS_WORDPRESSMU_ADMIN_PASSWORD',
'secret')
WEBAPPS_WORDPRESSMU_BASE_URL = getattr(settings, 'WEBAPPS_WORDPRESSMU_BASE_URL',
'http://blogs.orchestra.lan/')
WEBAPPS_DOKUWIKIMU_TEMPLATE_PATH = getattr(settings, 'WEBAPPS_DOKUWIKIMU_TEMPLATE_PATH',
'/home/httpd/htdocs/wikifarm/template.tar.gz')
WEBAPPS_DOKUWIKIMU_FARM_PATH = getattr(settings, 'WEBAPPS_DOKUWIKIMU_FARM_PATH',
'/home/httpd/htdocs/wikifarm/farm')
WEBAPPS_DRUPAL_SITES_PATH = getattr(settings, 'WEBAPPS_DRUPAL_SITES_PATH',
'/home/httpd/htdocs/drupal-mu/sites/%(site_name)s')

View File

@ -50,8 +50,8 @@ class Apache2Backend(ServiceController):
} || { } || {
echo -e '%(apache_conf)s' > %(sites_available)s echo -e '%(apache_conf)s' > %(sites_available)s
UPDATED=1 UPDATED=1
}""" % context }""") % context
)) )
self.enable_or_disable(site) self.enable_or_disable(site)
def delete(self, site): def delete(self, site):
@ -92,7 +92,7 @@ class Apache2Backend(ServiceController):
Options +ExecCGI Options +ExecCGI
AddHandler fcgid-script .php AddHandler fcgid-script .php
FcgidWrapper %(fcgid_path)s\ FcgidWrapper %(fcgid_path)s\
""" % context) """) % context
for option in content.webapp.options.filter(name__startswith='Fcgid'): for option in content.webapp.options.filter(name__startswith='Fcgid'):
fcgid += " %s %s\n" % (option.name, option.value) fcgid += " %s %s\n" % (option.name, option.value)
fcgid += "</Directory>\n" fcgid += "</Directory>\n"
@ -231,7 +231,7 @@ class Apache2Traffic(ServiceMonitor):
END_DATE=$(date '+%%Y%%m%%d%%H%%M%%S' -d '%(current_date)s') END_DATE=$(date '+%%Y%%m%%d%%H%%M%%S' -d '%(current_date)s')
LOG_FILE="$3" LOG_FILE="$3"
{ {
{ grep "%(ignore_hosts)s" "${LOG_FILE}" || echo '\\n'; } \\ { grep %(ignore_hosts)s "${LOG_FILE}" || echo '\\n'; } \\
| awk -v ini="${INI_DATE}" -v end="${END_DATE}" ' | awk -v ini="${INI_DATE}" -v end="${END_DATE}" '
BEGIN { BEGIN {
sum = 0 sum = 0

View File

@ -12,6 +12,7 @@ from . import settings
class Website(models.Model): class Website(models.Model):
""" Models a web site, also known as virtual host """
name = models.CharField(_("name"), max_length=128, unique=True, name = models.CharField(_("name"), max_length=128, unique=True,
validators=[validators.validate_name]) validators=[validators.validate_name])
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),

View File

@ -122,8 +122,8 @@ AUTHENTICATION_BACKENDS = [
] ]
#TODO Email config # Email config
#EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend' EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend'
################################# #################################