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
* 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? <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
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
)

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

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."),
),
))
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>
<ul>
{% 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 %}
</ul>
{% endfor %}

View File

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

View File

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

View File

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

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.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:

View File

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

View File

@ -78,6 +78,11 @@ class Mailbox(models.Model):
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):
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_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:
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()

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <a href='%s'>scheduled for monitoring</a>.") % link,
_("%s selected resource have been <a href='%s'>scheduled for monitoring</a>.") % (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 <a href='%s'>been monitored</a>.") % 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 <a href='%s'>been executed</a>.") % link
elif num >= 1:
link = reverse('admin:orchestration_backendlog_changelist')
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:
msg = _("No related monitors have been executed.")
modeladmin.message_user(request, mark_safe(msg))

View File

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

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', (
'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')

View File

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

View File

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

View File

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

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

View File

@ -59,6 +59,26 @@ WEBAPPS_TYPES = getattr(settings, 'WEBAPPS_TYPES', {
'help_text': _("This creates a Webalizer application under "
"~/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'^[^ ]+$'
),
})
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
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 += "</Directory>\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

View File

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

View File

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