diff --git a/TODO.md b/TODO.md index 0dc0b51a..fcdfaba0 100644 --- a/TODO.md +++ b/TODO.md @@ -161,23 +161,23 @@ * Rename apache logs ending on .log in order to logrotate easily -* SaaS wordpress multiple blogs per user? separate users from sites? SaaSUser SaaSSite models -* Custom domains for SaaS apps (wordpress Vhost) SaaSSite.domain ? +* multitenant webapps modeled on WepApp -> name unique for all accounts + +* webapp compat webapp-options +* webapps modeled on classes instead of settings? * Change account and orders +* Mix webapps type with backends (two for the price of one) -==== SaaS ==== -Wordpress ---------- -* site_name -* email -* site_title -* site_domain (optional) +* Webapp options and type compatibility -BSCW ----- -* email -* username -* quota -* password (optional) +Multi-tenant WebApps +-------------------- +* SaaS - Those apps that can't use custom domain +* WebApp - Those apps that can use custom domain + +* Howto upgrade webapp PHP version? SetHandler php54-cgi ? or create a new app + + +* prevent @pangea.org email addresses on contacts diff --git a/orchestra/admin/actions.py b/orchestra/admin/actions.py index 75ac3d9e..9231b128 100644 --- a/orchestra/admin/actions.py +++ b/orchestra/admin/actions.py @@ -38,7 +38,7 @@ class SendEmail(object): raise PermissionDenied initial={ '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) if request.POST.get('post'): @@ -63,8 +63,10 @@ class SendEmail(object): # Display confirmation page 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): - num = len(self.queryset) email_from = options['email_from'] extra_to = options['extra_to'] subject = options['subject'] @@ -72,13 +74,15 @@ class SendEmail(object): # The user has already confirmed if request.POST.get('post') == 'email_confirmation': emails = [] - for contact in self.queryset.all(): - emails.append((subject, message, email_from, [contact.email])) + num = 0 + for email in self.get_queryset_emails(): + emails.append((subject, message, email_from, [email])) + num += 1 if extra_to: emails.append((subject, message, email_from, extra_to)) - send_mass_mail(emails) + send_mass_mail(emails, fail_silently=False) 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), num ) diff --git a/orchestra/apps/accounts/actions.py b/orchestra/apps/accounts/actions.py index 7a6bd349..fd583fd2 100644 --- a/orchestra/apps/accounts/actions.py +++ b/orchestra/apps/accounts/actions.py @@ -8,6 +8,8 @@ from django.utils.translation import ungettext, ugettext_lazy as _ from orchestra.admin.decorators import action_with_confirmation from orchestra.core import services +from . import settings + @transaction.atomic @action_with_confirmation() @@ -38,6 +40,7 @@ list_contacts.verbose_name = _("List contacts") def service_report(modeladmin, request, queryset): + # TODO resources accounts = [] fields = [] # First we get related manager names to fire a prefetch related @@ -59,4 +62,4 @@ def service_report(modeladmin, request, queryset): 'accounts': accounts, 'date': timezone.now().today() } - return render(request, 'admin/accounts/account/service_report.html', context) + return render(request, settings.ACCOUNTS_SERVICE_REPORT_TEMPLATE, context) diff --git a/orchestra/apps/accounts/settings.py b/orchestra/apps/accounts/settings.py index f2dd8847..7d840bb2 100644 --- a/orchestra/apps/accounts/settings.py +++ b/orchestra/apps/accounts/settings.py @@ -47,3 +47,7 @@ ACCOUNTS_CREATE_RELATED = getattr(settings, 'ACCOUNTS_CREATE_RELATED', ( _("Designates whether to creates a related subdomain <username>.orchestra.lan or not."), ), )) + + +ACCOUNTS_SERVICE_REPORT_TEMPLATE = getattr(settings, 'ACCOUNTS_SERVICE_REPORT_TEMPLATE', + 'admin/accounts/account/service_report.html') diff --git a/orchestra/apps/accounts/templates/admin/accounts/account/service_report.html b/orchestra/apps/accounts/templates/admin/accounts/account/service_report.html index 3e6c84d3..1ac7a5fb 100644 --- a/orchestra/apps/accounts/templates/admin/accounts/account/service_report.html +++ b/orchestra/apps/accounts/templates/admin/accounts/account/service_report.html @@ -54,7 +54,10 @@
  • {{ opts.verbose_name_plural|capfirst }}
  • {% endfor %} diff --git a/orchestra/apps/bills/models.py b/orchestra/apps/bills/models.py index 34f507e5..e9d4926e 100644 --- a/orchestra/apps/bills/models.py +++ b/orchestra/apps/bills/models.py @@ -159,7 +159,8 @@ class Bill(models.Model): return now + relativedelta(months=1) 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: payment = self.account.paymentsources.get_default() if not self.due_on: diff --git a/orchestra/apps/domains/models.py b/orchestra/apps/domains/models.py index 71c24043..da164077 100644 --- a/orchestra/apps/domains/models.py +++ b/orchestra/apps/domains/models.py @@ -1,6 +1,6 @@ from django.core.exceptions import ValidationError 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.validators import validate_ipv4_address, validate_ipv6_address, validate_ascii @@ -50,6 +50,15 @@ class Domain(models.Model): def subdomains(self): 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): return 'http://%s' % self.name diff --git a/orchestra/apps/lists/backends.py b/orchestra/apps/lists/backends.py index 3629dc9c..9e2d9c0f 100644 --- a/orchestra/apps/lists/backends.py +++ b/orchestra/apps/lists/backends.py @@ -37,8 +37,8 @@ class MailmanBackend(ServiceController): [[ $(grep "^\s*%(address_domain)s\s*$" %(virtual_alias_domains)s) ]] || { echo "%(address_domain)s" >> %(virtual_alias_domains)s UPDATED_VIRTUAL_ALIAS_DOMAINS=1 - }""" % context - )) + }""") % context + ) def exclude_virtual_alias_domain(self, context): address_domain = context['address_domain'] @@ -58,13 +58,11 @@ class MailmanBackend(ServiceController): self.append(textwrap.dedent("""\ [[ ! -e %(mailman_root)s/lists/%(name)s ]] && { newlist --quiet --emailhost='%(domain)s' '%(name)s' '%(admin)s' '%(password)s' - }""" % context)) + }""") % context) # Custom domain if mail_list.address: - aliases = self.get_virtual_aliases(context) + context['aliases'] = self.get_virtual_aliases(context) # Preserve indentation - spaces = ' '*4 - context['aliases'] = spaces + aliases.replace('\n', '\n'+spaces) self.append(textwrap.dedent("""\ if [[ ! $(grep '\s\s*%(name)s\s*$' %(virtual_alias)s) ]]; then echo '# %(banner)s\n%(aliases)s @@ -78,23 +76,28 @@ class MailmanBackend(ServiceController): ' >> %(virtual_alias)s UPDATED_VIRTUAL_ALIAS=1 fi - fi""" % context - )) - self.append('echo "require_explicit_destination = 0" | ' - '%(mailman_root)s/bin/config_list -i /dev/stdin %(name)s' % context) + fi""") % context + ) + self.append( + 'echo "require_explicit_destination = 0" | ' + '%(mailman_root)s/bin/config_list -i /dev/stdin %(name)s' % context + ) self.append(textwrap.dedent("""\ 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: # Cleanup shit self.append(textwrap.dedent("""\ if [[ ! $(grep '\s\s*%(name)s\s*$' %(virtual_alias)s) ]]; then sed -i "/^.*\s%(name)s\s*$/d" %(virtual_alias)s - fi""" % context - )) + fi""") % context + ) # Update 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) def delete(self, mail_list): @@ -102,8 +105,8 @@ class MailmanBackend(ServiceController): self.exclude_virtual_alias_domain(context) self.append(textwrap.dedent("""\ 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) def commit(self): @@ -111,8 +114,8 @@ class MailmanBackend(ServiceController): self.append(textwrap.dedent(""" [[ $UPDATED_VIRTUAL_ALIAS == 1 ]] && { postmap %(virtual_alias)s; } [[ $UPDATED_VIRTUAL_ALIAS_DOMAINS == 1 ]] && { /etc/init.d/postfix reload; } - """ % context - )) + """) % context + ) def get_context_files(self): return { @@ -163,7 +166,7 @@ class MailmanTraffic(ServiceMonitor): | tr '\\n' '+' \\ | xargs -i echo {} ) echo ${OBJECT_ID} $(( ${SIZE}*${SUBSCRIBERS} )) - }""" % current_date)) + }""") % current_date) def monitor(self, mail_list): context = self.get_context(mail_list) diff --git a/orchestra/apps/mailboxes/actions.py b/orchestra/apps/mailboxes/actions.py new file mode 100644 index 00000000..81b9150a --- /dev/null +++ b/orchestra/apps/mailboxes/actions.py @@ -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() diff --git a/orchestra/apps/mailboxes/admin.py b/orchestra/apps/mailboxes/admin.py index 7571a0e0..2ca53893 100644 --- a/orchestra/apps/mailboxes/admin.py +++ b/orchestra/apps/mailboxes/admin.py @@ -11,6 +11,8 @@ from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin from orchestra.admin.utils import admin_link, change_url from orchestra.apps.accounts.admin import SelectAccountAdminMixin, AccountAdminMixin +from . import settings +from .actions import SendMailboxEmail from .filters import HasMailboxListFilter, HasForwardListFilter, HasAddressListFilter from .forms import MailboxCreationForm, MailboxChangeForm, AddressForm from .models import Mailbox, Address, Autoresponse @@ -71,6 +73,13 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo display_addresses.short_description = _("Addresses") 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): fieldsets = super(MailboxAdmin, self).get_fieldsets(request, obj=obj) if obj and obj.filtering == obj.CUSTOM: diff --git a/orchestra/apps/mailboxes/backends.py b/orchestra/apps/mailboxes/backends.py index c01893bf..acaa541c 100644 --- a/orchestra/apps/mailboxes/backends.py +++ b/orchestra/apps/mailboxes/backends.py @@ -89,10 +89,10 @@ class PasswdVirtualUserBackend(ServiceController): context = { 'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH } - self.append( - "[[ $UPDATED_VIRTUAL_MAILBOX_MAPS == 1 ]] && { postmap %(virtual_mailbox_maps)s; }" - % context - ) + self.append(textwrap.dedent("""\ + [[ $UPDATED_VIRTUAL_MAILBOX_MAPS == 1 ]] && { + postmap %(virtual_mailbox_maps)s + }""" % context)) def get_context(self, mailbox): context = { @@ -123,8 +123,7 @@ class PostfixAddressBackend(ServiceController): [[ $(grep "^\s*%(domain)s\s*$" %(virtual_alias_domains)s) ]] || { echo "%(domain)s" >> %(virtual_alias_domains)s UPDATED_VIRTUAL_ALIAS_DOMAINS=1 - }""" % context - )) + }""") % context) def exclude_virtual_alias_domain(self, context): domain = context['domain'] @@ -151,8 +150,7 @@ class PostfixAddressBackend(ServiceController): sed -i "s/^%(email)s\s.*$/${LINE}/" %(virtual_alias_maps)s UPDATED_VIRTUAL_ALIAS_MAPS=1 fi - fi""" % context - )) + fi""") % context) else: logger.warning("Address %i is empty" % address.pk) 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 sed -i "/^%(email)s\s.*$/d" %(virtual_alias_maps)s UPDATED_VIRTUAL_ALIAS_MAPS=1 - fi""" % context - )) + fi""") % context) def save(self, address): context = self.get_context(address) @@ -181,8 +178,8 @@ class PostfixAddressBackend(ServiceController): self.append(textwrap.dedent(""" [[ $UPDATED_VIRTUAL_ALIAS_MAPS == 1 ]] && { postmap %(virtual_alias_maps)s; } [[ $UPDATED_VIRTUAL_ALIAS_DOMAINS == 1 ]] && { /etc/init.d/postfix reload; } - """ % context - )) + """) % context + ) def get_context_files(self): return { diff --git a/orchestra/apps/mailboxes/models.py b/orchestra/apps/mailboxes/models.py index 798c194e..0740def0 100644 --- a/orchestra/apps/mailboxes/models.py +++ b/orchestra/apps/mailboxes/models.py @@ -77,6 +77,11 @@ class Mailbox(models.Model): address.delete() else: 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): diff --git a/orchestra/apps/mailboxes/settings.py b/orchestra/apps/mailboxes/settings.py index 5ca32e30..d00ceb7d 100644 --- a/orchestra/apps/mailboxes/settings.py +++ b/orchestra/apps/mailboxes/settings.py @@ -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_LOCAL_ADDRESS_DOMAIN = getattr(settings, 'MAILBOXES_LOCAL_ADDRESS_DOMAIN', + 'orchestra.lan') diff --git a/orchestra/apps/miscellaneous/models.py b/orchestra/apps/miscellaneous/models.py index 51bfa759..f3efe473 100644 --- a/orchestra/apps/miscellaneous/models.py +++ b/orchestra/apps/miscellaneous/models.py @@ -60,6 +60,9 @@ class Miscellaneous(models.Model): except type(self).account.field.rel.to.DoesNotExist: return self.is_active + def get_description(self): + return ' '.join((str(self.amount), self.service.description or self.service.verbose_name)) + def clean(self): if self.identifier: self.identifier = self.identifier.strip() diff --git a/orchestra/apps/orchestration/backends.py b/orchestra/apps/orchestration/backends.py index e8a9c8b8..ba4bd87c 100644 --- a/orchestra/apps/orchestration/backends.py +++ b/orchestra/apps/orchestration/backends.py @@ -10,6 +10,14 @@ from orchestra import plugins 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): """ Service management backend base class @@ -27,7 +35,7 @@ class ServiceBackend(plugins.Plugin): ignore_fields = [] actions = [] - __metaclass__ = plugins.PluginMount + __metaclass__ = ServiceMount def __unicode__(self): return type(self).__name__ diff --git a/orchestra/apps/orchestration/methods.py b/orchestra/apps/orchestration/methods.py index abe69f08..c8b4f682 100644 --- a/orchestra/apps/orchestration/methods.py +++ b/orchestra/apps/orchestration/methods.py @@ -50,10 +50,11 @@ def SSH(backend, log, server, cmds, async=False): key = settings.ORCHESTRATION_SSH_KEY_PATH try: ssh.connect(addr, username='root', key_filename=key, timeout=10) - except socket.error: - logger.error('%s timed out on %s' % (backend, server)) + except socket.error, e: + logger.error('%s timed out on %s' % (backend, addr)) log.state = log.TIMEOUT - log.save(update_fields=['state']) + log.stderr = str(e) + log.save(update_fields=['state', 'stderr']) return transport = ssh.get_transport() diff --git a/orchestra/apps/payments/admin.py b/orchestra/apps/payments/admin.py index c343ff85..83f27029 100644 --- a/orchestra/apps/payments/admin.py +++ b/orchestra/apps/payments/admin.py @@ -24,6 +24,7 @@ STATE_COLORS = { class PaymentSourceAdmin(SelectPluginAdminMixin, AccountAdminMixin, admin.ModelAdmin): list_display = ('label', 'method', 'number', 'account_link', 'is_active') list_filter = ('method', 'is_active') + search_fields = ('account__username', 'account__full_name', 'data') plugin = PaymentMethod plugin_field = 'method' diff --git a/orchestra/apps/payments/models.py b/orchestra/apps/payments/models.py index 7ffcd236..86111dad 100644 --- a/orchestra/apps/payments/models.py +++ b/orchestra/apps/payments/models.py @@ -119,23 +119,27 @@ class Transaction(models.Model): if amount >= self.bill.total: 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): - assert self.state == self.WAITTING_PROCESSING + self.check_state(self.WAITTING_PROCESSING) self.state = self.WAITTING_EXECUTION self.save(update_fields=['state']) def mark_as_executed(self): - assert self.state == self.WAITTING_EXECUTION + self.check_state(self.WAITTING_EXECUTION) self.state = self.EXECUTED self.save(update_fields=['state']) def mark_as_secured(self): - assert self.state == self.EXECUTED + self.check_state(self.EXECUTED) self.state = self.SECURED self.save(update_fields=['state']) def mark_as_rejected(self): - assert self.state == self.EXECUTED + self.check_state(self.EXECUTED) self.state = self.REJECTED self.save(update_fields=['state']) @@ -167,22 +171,26 @@ class TransactionProcess(models.Model): def __unicode__(self): 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): - assert self.state == self.CREATED + self.check_state(self.CREATED) self.state = self.EXECUTED for transaction in self.transactions.all(): transaction.mark_as_executed() self.save(update_fields=['state']) def abort(self): - assert self.state in [self.CREATED, self.EXCECUTED] + self.check_state(self.CREATED, self.EXCECUTED) self.state = self.ABORTED for transaction in self.transaction.all(): transaction.mark_as_aborted() self.save(update_fields=['state']) def commit(self): - assert self.state in [self.CREATED, self.EXECUTED] + self.check_state(self.CREATED, self.EXECUTED) self.state = self.COMMITED for transaction in self.transactions.processing(): transaction.mark_as_secured() diff --git a/orchestra/apps/resources/actions.py b/orchestra/apps/resources/actions.py index 5a1f5865..db2c4aa4 100644 --- a/orchestra/apps/resources/actions.py +++ b/orchestra/apps/resources/actions.py @@ -9,33 +9,32 @@ from django.utils.translation import ungettext, ugettext_lazy as _ def run_monitor(modeladmin, request, queryset): """ Resource and ResourceData run monitors """ 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] - results = [] + logs = set() for resource in queryset: - result = resource.monitor() + results = resource.monitor() 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")) - num = len(queryset) if async: + num = len(queryset) link = reverse('admin:djcelery_taskstate_changelist') msg = ungettext( _("One selected resource has been scheduled for monitoring.") % link, _("%s selected resource have been scheduled for monitoring.") % (num, link), num) else: - if len(results) == 1: - log = results[0].log - link = reverse('admin:orchestration_backendlog_change', args=(log.pk,)) - msg = _("One selected resource has been monitored.") % link - elif len(results) >= 1: - logs = [str(result.log.pk) for result in results] + num = len(logs) + if num == 1: + log = logs.pop() + link = reverse('admin:orchestration_backendlog_change', args=(log,)) + msg = _("One related monitor has been executed.") % link + elif num >= 1: link = reverse('admin:orchestration_backendlog_changelist') link += '?id__in=%s' % ','.join(logs) - msg = _("%s selected resources have been monitored.") % (num, link) + msg = _("%s related monitors have been executed.") % (num, link) else: msg = _("No related monitors have been executed.") modeladmin.message_user(request, mark_safe(msg)) diff --git a/orchestra/apps/resources/models.py b/orchestra/apps/resources/models.py index cb4d1af2..525d7f25 100644 --- a/orchestra/apps/resources/models.py +++ b/orchestra/apps/resources/models.py @@ -119,8 +119,7 @@ class Resource(models.Model): def get_model_path(self, monitor): """ returns a model path between self.content_type and monitor.model """ resource_model = self.content_type.model_class() - model_path = ServiceMonitor.get_backend(monitor).model - monitor_model = get_model(model_path) + monitor_model = ServiceMonitor.get_backend(monitor).model_class() return get_model_field_path(monitor_model, resource_model) def sync_periodic_task(self): @@ -223,6 +222,7 @@ class ResourceData(models.Model): ) else: fields = '__'.join(path) + monitor_model = ServiceMonitor.get_backend(monitor).model_class() objects = monitor_model.objects.filter(**{fields: self.object_id}) pks = objects.values_list('id', flat=True) ct = ContentType.objects.get_for_model(monitor_model) diff --git a/orchestra/apps/saas/backends/wordpressmu.py b/orchestra/apps/saas/backends/wordpressmu.py deleted file mode 100644 index 6b187424..00000000 --- a/orchestra/apps/saas/backends/wordpressmu.py +++ /dev/null @@ -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( - '%(name)s' % context - ) - content = session.get(search).content - ids = regex.search(content).groups() - if len(ids) > 1: - raise ValueError("Multiple matches") - - delete = re.compile('(.*)') - 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) diff --git a/orchestra/apps/saas/services/dokuwiki.py b/orchestra/apps/saas/services/dokuwiki.py deleted file mode 100644 index 2ff53b91..00000000 --- a/orchestra/apps/saas/services/dokuwiki.py +++ /dev/null @@ -1,6 +0,0 @@ -from .options import SoftwareService - - -class DokuwikiService(SoftwareService): - verbose_name = "Dowkuwiki" - icon = 'saas/icons/Dokuwiki.png' diff --git a/orchestra/apps/saas/services/drupal.py b/orchestra/apps/saas/services/drupal.py deleted file mode 100644 index 65ae9e5e..00000000 --- a/orchestra/apps/saas/services/drupal.py +++ /dev/null @@ -1,6 +0,0 @@ -from .options import SoftwareService - - -class DrupalService(SoftwareService): - verbose_name = "Drupal" - icon = 'saas/icons/Drupal.png' diff --git a/orchestra/apps/saas/services/wordpress.py b/orchestra/apps/saas/services/wordpress.py deleted file mode 100644 index 978e2de5..00000000 --- a/orchestra/apps/saas/services/wordpress.py +++ /dev/null @@ -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',) diff --git a/orchestra/apps/saas/settings.py b/orchestra/apps/saas/settings.py index 6cc78283..ef5257e8 100644 --- a/orchestra/apps/saas/settings.py +++ b/orchestra/apps/saas/settings.py @@ -2,31 +2,8 @@ from django.conf import settings 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.bscw.BSCWService', 'orchestra.apps.saas.services.gitlab.GitLabService', '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') diff --git a/orchestra/apps/systemusers/models.py b/orchestra/apps/systemusers/models.py index cf53230c..0703fe03 100644 --- a/orchestra/apps/systemusers/models.py +++ b/orchestra/apps/systemusers/models.py @@ -66,13 +66,17 @@ class SystemUser(models.Model): def has_shell(self): return self.shell not in settings.SYSTEMUSERS_DISABLED_SHELLS + def get_description(self): + return self.get_shell_display() + def save(self, *args, **kwargs): if not self.home: self.home = self.get_base_home() super(SystemUser, self).save(*args, **kwargs) def clean(self): - self.home = os.path.normpath(self.home) + if self.home: + self.home = os.path.normpath(self.home) if self.directory: directory_error = None if self.has_shell: @@ -119,10 +123,7 @@ class SystemUser(models.Model): return os.path.normpath(settings.SYSTEMUSERS_HOME % context) def get_home(self): - return os.path.join( - self.home or self.get_base_home(), - self.directory - ) + return os.path.join(self.home, self.directory) services.register(SystemUser) diff --git a/orchestra/apps/saas/backends/dokuwikimu.py b/orchestra/apps/webapps/backends/dokuwikimu.py similarity index 91% rename from orchestra/apps/saas/backends/dokuwikimu.py rename to orchestra/apps/webapps/backends/dokuwikimu.py index 78d0d721..76eb1b51 100644 --- a/orchestra/apps/saas/backends/dokuwikimu.py +++ b/orchestra/apps/webapps/backends/dokuwikimu.py @@ -4,12 +4,12 @@ from django.utils.translation import ugettext_lazy as _ from orchestra.apps.orchestration import ServiceController -from . import SaaSServiceMixin from .. import settings -class DokuWikiMuBackend(SaaSServiceMixin, ServiceController): +class DokuWikiMuBackend(ServiceController): verbose_name = _("DokuWiki multisite") + model = 'webapps.WebApp' def save(self, webapp): context = self.get_context(webapp) diff --git a/orchestra/apps/saas/backends/drupalmu.py b/orchestra/apps/webapps/backends/drupalmu.py similarity index 50% rename from orchestra/apps/saas/backends/drupalmu.py rename to orchestra/apps/webapps/backends/drupalmu.py index 1e0b56fb..7f9caa71 100644 --- a/orchestra/apps/saas/backends/drupalmu.py +++ b/orchestra/apps/webapps/backends/drupalmu.py @@ -1,30 +1,32 @@ import os +import textwrap from django.utils.translation import ugettext_lazy as _ from orchestra.apps.orchestration import ServiceController -from . import SaaSServiceMixin from .. import settings -class DrupalMuBackend(SaaSServiceMixin, ServiceController): +class DrupalMuBackend(ServiceController): verbose_name = _("Drupal multisite") - + model = 'webapps.WebApp' + def save(self, webapp): context = self.get_context(webapp) - self.append("mkdir %(drupal_path)s" % context) - self.append("chown -R www-data %(drupal_path)s" % context) - self.append( - "# the following assumes settings.php to be previously configured\n" - "REGEX='^\s*$databases\[.default.\]\[.default.\]\[.prefix.\]'\n" - "CONFIG='$databases[\'default\'][\'default\'][\'prefix\'] = \'%(app_name)s_\';'\n" - "if [[ ! $(grep $REGEX %(drupal_settings)s) ]]; then\n" - " echo $CONFIG >> %(drupal_settings)s\n" - "fi" % context + self.append(textwrap.dedent("""\ + mkdir %(drupal_path)s + chown -R www-data %(drupal_path)s + + # the following assumes settings.php to be previously configured + REGEX='^\s*$databases\[.default.\]\[.default.\]\[.prefix.\]' + CONFIG='$databases[\'default\'][\'default\'][\'prefix\'] = \'%(app_name)s_\';' + if [[ ! $(grep $REGEX %(drupal_settings)s) ]]; then + echo $CONFIG >> %(drupal_settings)s + fi""") % context ) - def selete(self, webapp): + def delete(self, webapp): context = self.get_context(webapp) self.append("rm -fr %(app_path)s" % context) diff --git a/orchestra/apps/webapps/backends/wordpressmu.py b/orchestra/apps/webapps/backends/wordpressmu.py new file mode 100644 index 00000000..87a5786c --- /dev/null +++ b/orchestra/apps/webapps/backends/wordpressmu.py @@ -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'\n\t

    (.*)

    ', 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( + '%s' % 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'(.*)', 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) diff --git a/orchestra/apps/webapps/models.py b/orchestra/apps/webapps/models.py index 3faafe83..350ac20e 100644 --- a/orchestra/apps/webapps/models.py +++ b/orchestra/apps/webapps/models.py @@ -29,10 +29,29 @@ class WebApp(models.Model): def __unicode__(self): 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 def get_options(self): 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): 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 def get_directive(self): - directive = settings.WEBAPPS_TYPES[self.type]['directive'] + directive = self.app_type['directive'] args = directive[1:] if len(directive) > 1 else () return directive[0], args diff --git a/orchestra/apps/webapps/settings.py b/orchestra/apps/webapps/settings.py index ca1a318f..8ab5c8b7 100644 --- a/orchestra/apps/webapps/settings.py +++ b/orchestra/apps/webapps/settings.py @@ -59,6 +59,26 @@ WEBAPPS_TYPES = getattr(settings, 'WEBAPPS_TYPES', { 'help_text': _("This creates a Webalizer application under " "~/webapps/<app_name>-<site_name>") }, + '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.
    " + "By default this blog is accessible via <app_name>.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.
    " + "By default this wiki is accessible via <app_name>.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.
    " + "The installation will be completed after visiting " + "http://<app_name>.drupal.orchestra.lan/install.php?profile=standard
    " + "By default this site will be accessible via <app_name>.drupal.orchestra.lan") + } }) @@ -282,3 +302,22 @@ WEBAPPS_OPTIONS = getattr(settings, 'WEBAPPS_OPTIONS', { 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') diff --git a/orchestra/apps/websites/backends/apache.py b/orchestra/apps/websites/backends/apache.py index 8e7416e7..b2cce739 100644 --- a/orchestra/apps/websites/backends/apache.py +++ b/orchestra/apps/websites/backends/apache.py @@ -50,8 +50,8 @@ class Apache2Backend(ServiceController): } || { echo -e '%(apache_conf)s' > %(sites_available)s UPDATED=1 - }""" % context - )) + }""") % context + ) self.enable_or_disable(site) def delete(self, site): @@ -92,7 +92,7 @@ class Apache2Backend(ServiceController): Options +ExecCGI AddHandler fcgid-script .php FcgidWrapper %(fcgid_path)s\ - """ % context) + """) % context for option in content.webapp.options.filter(name__startswith='Fcgid'): fcgid += " %s %s\n" % (option.name, option.value) fcgid += "\n" @@ -231,7 +231,7 @@ class Apache2Traffic(ServiceMonitor): END_DATE=$(date '+%%Y%%m%%d%%H%%M%%S' -d '%(current_date)s') 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}" ' BEGIN { sum = 0 diff --git a/orchestra/apps/websites/models.py b/orchestra/apps/websites/models.py index 64377f51..3b91fd4d 100644 --- a/orchestra/apps/websites/models.py +++ b/orchestra/apps/websites/models.py @@ -12,6 +12,7 @@ from . import settings class Website(models.Model): + """ Models a web site, also known as virtual host """ name = models.CharField(_("name"), max_length=128, unique=True, validators=[validators.validate_name]) account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), diff --git a/orchestra/conf/base_settings.py b/orchestra/conf/base_settings.py index 9d607217..4c594510 100644 --- a/orchestra/conf/base_settings.py +++ b/orchestra/conf/base_settings.py @@ -122,8 +122,8 @@ AUTHENTICATION_BACKENDS = [ ] -#TODO Email config -#EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend' +# Email config +EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend' #################################