Refactores webapps and SaaS

This commit is contained in:
Marc Aymerich 2015-03-23 15:36:51 +00:00
parent 2d3e925c36
commit 40930a480e
42 changed files with 618 additions and 279 deletions

37
TODO.md
View File

@ -147,7 +147,6 @@
* Resource graph for each related object
* Rename apache logs ending on .log in order to logrotate easily
* multitenant webapps modeled on WepApp -> name unique for all accounts
@ -193,9 +192,6 @@ Php binaries should have this format: /usr/bin/php5.2-cgi
* <IfModule security2_module> and other IfModule on backend SecRule
* monitor in batches doesnt work!!!
* Orchestra global search box on the header, based https://github.com/django/django/blob/master/django/contrib/admin/options.py#L866 and iterating over all registered services and inspectin its admin.search_fields
@ -214,17 +210,10 @@ Php binaries should have this format: /usr/bin/php5.2-cgi
* Display admin.is_active (disabled account/order by)
* show details data on webapp changelist
* lock resource monitoring
* Optimize backends like mail backend (log files single read), single "/var/log/vsftpd.log{,.1}" on ftp traffic
* -EXecCGI in common CMS upload locations /wp-upload/upload/uploads
* cgi user / pervent shell access
* merge php wrapper configuration to optimize process classes
* prevent stderr when users exists on backend i.e. mysql user create
@ -235,3 +224,29 @@ Php binaries should have this format: /usr/bin/php5.2-cgi
* php-fpm disable execCGI
* SuexecUserGroup needs to be per app othewise wrapper/fpm user can't be correct
* wprdess-mu saas app that create a Website object????
* tags = GenericRelation(TaggedItem, related_query_name='bookmarks')
* make home for all systemusers (/home/username) and fix monitors
* user provided crons
* ```<?php
$moodle_host = $SERVER[HTTP_HOST];
require_once(/etc/moodles/.$moodle_host.config.php);``` moodle/drupla/php-list multi-tenancy
* make account available on all admin forms
* WPMU blog traffic
* normurlpath '' returns '/'
* rename webapps.type to something more generic
* initial configuration of multisite sas apps with password stored in DATA
* websites links on webpaps ans saas
* /var/lib/fcgid/wrappers/ rm write permissions

View File

@ -118,10 +118,13 @@ class ChangeAddFieldsMixin(object):
return super(ChangeAddFieldsMixin, self).get_prepopulated_fields(request, obj)
return {}
def get_change_readonly_fields(self, request, obj=None):
return self.change_readonly_fields
def get_readonly_fields(self, request, obj=None):
fields = super(ChangeAddFieldsMixin, self).get_readonly_fields(request, obj)
if obj:
return fields + self.change_readonly_fields
return fields + self.get_change_readonly_fields(request, obj=obj)
return fields
def get_fieldsets(self, request, obj=None):

View File

@ -205,6 +205,13 @@ class AccountAdminMixin(object):
formfield.initial = 1
return formfield
def get_formset(self, request, obj=None, **kwargs):
""" provides form.account for convinience """
formset = super(AccountAdminMixin, self).get_formset(request, obj=obj, **kwargs)
formset.form.account = self.account
formset.account = self.account
return formset
def get_account_from_preserve_filters(self, request):
preserved_filters = self.get_preserved_filters(request)
preserved_filters = dict(parse_qsl(preserved_filters))

View File

@ -281,7 +281,7 @@ class MaildirDisk(ServiceMonitor):
current_date = self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z")
self.append(textwrap.dedent("""\
function monitor () {
awk 'NR>1 {s+=$1} END {print s}' $1 || echo 0
awk 'BEGIN { size = 0 } NR > 1 { size += $1 } END { print size }' $1 || echo 0
}"""))
def monitor(self, mailbox):

View File

@ -35,6 +35,7 @@ class ServiceBackend(plugins.Plugin):
ignore_fields = []
actions = []
default_route_match = 'True'
block = False # Force the backend manager to block in multiple backend executions and execute them synchronously
__metaclass__ = ServiceMount

View File

@ -1,6 +1,7 @@
import logging
import threading
import traceback
from collections import OrderedDict
from django import db
from django.core.mail import mail_admins
@ -51,8 +52,9 @@ def close_connection(execute):
def execute(operations, async=False):
""" generates and executes the operations on the servers """
scripts = {}
scripts = OrderedDict()
cache = {}
block = False
# Generate scripts per server+backend
for operation in operations:
logger.debug("Queued %s" % str(operation))
@ -77,6 +79,8 @@ def execute(operations, async=False):
pre_action.send(**kwargs)
method(operation.instance)
post_action.send(**kwargs)
if backend.block:
block = True
# Execute scripts on each server
threads = []
executions = []
@ -88,8 +92,11 @@ def execute(operations, async=False):
execute = close_connection(execute)
# DEBUG: substitute all thread related stuff for this function
#execute(server, async=async)
logger.debug('%s is going to be executed on %s' % (backend, server))
thread = threading.Thread(target=execute, args=(server,), kwargs={'async': async})
thread.start()
if block:
thread.join()
threads.append(thread)
executions.append((execute, operations))
[ thread.join() for thread in threads ]

View File

@ -10,6 +10,8 @@ import paramiko
from celery.datastructures import ExceptionInfo
from django.conf import settings as djsettings
from orchestra.utils.python import CaptureStdout
from . import settings
@ -37,7 +39,6 @@ def SSH(backend, log, server, cmds, async=False):
channel = None
ssh = None
try:
logger.debug('%s is going to be executed on %s' % (backend, server))
# Avoid "Argument list too long" on large scripts by genereting a file
# and scping it to the remote server
with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, 0600), 'w') as handle:
@ -129,15 +130,19 @@ def Python(backend, log, server, cmds, async=False):
log.save(update_fields=['script'])
try:
for cmd in cmds:
with CaptureStdout() as stdout:
result = cmd(server)
log.stdout += str(result)
for line in stdout:
log.stdout += unicode(line, errors='replace') + '\n'
if async:
log.save(update_fields=['stdout'])
except:
log.exit_code = 1
log.state = log.FAILURE
log.traceback = ExceptionInfo(sys.exc_info()).traceback
logger.error('Exception while executing %s on %s' % (backend, server))
else:
log.exit_code = 0
log.state = log.SUCCESS
logger.debug('%s execution state on %s is %s' % (backend, server, log.state))
log.save()

View File

@ -13,9 +13,8 @@ class PaymentMethod(plugins.Plugin):
label_field = 'label'
number_field = 'number'
process_credit = False
form = None
serializer = None
due_delta = relativedelta.relativedelta(months=1)
plugin_field = 'method'
@classmethod
@cached
@ -25,24 +24,6 @@ class PaymentMethod(plugins.Plugin):
plugins.append(import_class(cls))
return plugins
@classmethod
def clean_data(cls):
""" model clean, uses cls.serializer by default """
serializer = cls.serializer(data=self.instance.data)
if not serializer.is_valid():
serializer.errors.pop('non_field_errors', None)
raise ValidationError(serializer.errors)
return serializer.data
def get_form(self):
self.form.plugin = self
self.form.plugin_field = 'method'
return self.form
def get_serializer(self):
self.serializer.plugin = self
return self.serializer
def get_label(self):
return self.instance.data[self.label_field]

View File

@ -182,15 +182,16 @@ def resource_inline_factory(resources):
return len(resources)
def get_queryset(self):
""" Filter disabled resources """
queryset = super(ResourceInlineFormSet, self).get_queryset()
return queryset.order_by('-id').filter(resource__is_active=True)
return queryset.filter(resource__is_active=True)
@cached_property
def forms(self, resources=resources):
forms = []
resources_copy = list(resources)
# Remove queryset disabled objects
queryset = [data for data in self.queryset if data.resource in resources]
queryset = [data for data in self.get_queryset() if data.resource in resources]
if self.instance.pk:
# Create missing resource data
queryset_resources = [data.resource for data in queryset]

View File

@ -170,6 +170,7 @@ class ResourceData(models.Model):
updated_at = models.DateTimeField(_("updated"), null=True, editable=False)
allocated = models.DecimalField(_("allocated"), max_digits=8, decimal_places=2,
null=True, blank=True)
content_object = GenericForeignKey()
class Meta:
@ -326,9 +327,9 @@ def create_resource_relation():
field for field in related._meta.virtual_fields if field.rel.to != ResourceData
]
relation = GenericRelation('resources.ResourceData')
for ct, resources in Resource.objects.group_by('content_type').iteritems():
model = ct.model_class()
relation = GenericRelation('resources.ResourceData')
model.add_to_class('resource_set', relation)
model.resources = ResourceHandler()
Resource._related.add(model)

View File

@ -1,6 +1,8 @@
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext, ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
from orchestra.apps.accounts.admin import AccountAdminMixin
from orchestra.plugins.admin import SelectPluginAdminMixin
@ -8,7 +10,7 @@ from .models import SaaS
from .services import SoftwareService
class SaaSAdmin(SelectPluginAdminMixin, AccountAdminMixin, admin.ModelAdmin):
class SaaSAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedModelAdmin):
list_display = ('username', 'service', 'display_site_name', 'account_link')
list_filter = ('service',)
plugin = SoftwareService

View File

@ -0,0 +1,45 @@
import json
import re
import requests
from django.utils.translation import ugettext_lazy as _
from orchestra.apps.orchestration import ServiceController
from .. import settings
class PhpListSaaSBackend(ServiceController):
verbose_name = _("phpList SaaS")
model = 'saas.SaaS'
default_route_match = "saas.service == 'phplist'"
block = True
def initialize_database(self, saas, server):
base_domain = settings.SAAS_PHPLIST_BASE_DOMAIN
admin_link = 'http://%s.%s/admin/' % (saas.get_site_name(), base_domain)
admin_content = requests.get(admin_link).content
if admin_content.startswith('Cannot connect to Database'):
raise RuntimeError("Database is not yet configured")
install = re.search(r'([^"]+firstinstall[^"]+)', admin_content)
if install:
if not saas.password:
raise RuntimeError("Password is missing")
install = install.groups()[0]
install_link = admin_link + install[1:]
post = {
'adminname': saas.username,
'orgname': saas.account.username,
'adminemail': saas.account.username,
'adminpassword': saas.password,
}
print json.dumps(post, indent=4)
response = requests.post(install_link, data=post)
print response.content
if response.status_code != 200:
raise RuntimeError("Bad status code %i" % response.status_code)
elif saas.password:
raise NotImplementedError
def save(self, saas):
self.append(self.initialize_database, saas)

View File

@ -0,0 +1,123 @@
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'
default_route_match = "webapp.type == 'wordpress-mu'"
@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):
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

@ -1,4 +1,6 @@
from django.db import models
from django.db.models.signals import pre_save, pre_delete
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField
@ -12,20 +14,21 @@ from .services import SoftwareService
class SaaS(models.Model):
service = models.CharField(_("service"), max_length=32,
choices=SoftwareService.get_plugin_choices())
username = models.CharField(_("username"), max_length=64,
username = models.CharField(_("name"), max_length=64,
help_text=_("Required. 64 characters or fewer. Letters, digits and ./-/_ only."),
validators=[validators.validate_username])
site_name = NullableCharField(_("site name"), max_length=32, null=True)
# site_name = NullableCharField(_("site name"), max_length=32, null=True)
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
related_name='saas')
data = JSONField(_("data"), help_text=_("Extra information dependent of each service."))
data = JSONField(_("data"), default={},
help_text=_("Extra information dependent of each service."))
class Meta:
verbose_name = "SaaS"
verbose_name_plural = "SaaS"
unique_together = (
('username', 'service'),
('site_name', 'service'),
# ('site_name', 'service'),
)
def __unicode__(self):
@ -49,4 +52,22 @@ class SaaS(models.Model):
def set_password(self, password):
self.password = password
services.register(SaaS)
# Admin bulk deletion doesn't call model.delete()
# So, signals are used instead of model method overriding
@receiver(pre_save, sender=SaaS, dispatch_uid='saas.service.save')
def type_save(sender, *args, **kwargs):
instance = kwargs['instance']
instance.service_instance.save()
@receiver(pre_delete, sender=SaaS, dispatch_uid='saas.service.delete')
def type_delete(sender, *args, **kwargs):
instance = kwargs['instance']
try:
instance.service_instance.delete()
except KeyError:
pass

View File

@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from .. import settings
from .options import SoftwareService, SoftwareServiceForm
@ -19,10 +20,11 @@ class BSCWDataSerializer(serializers.Serializer):
class BSCWService(SoftwareService):
name = 'bscw'
verbose_name = "BSCW"
form = BSCWForm
serializer = BSCWDataSerializer
icon = 'orchestra/icons/apps/BSCW.png'
# TODO override from settings
site_name = 'bascw.orchestra.lan'
site_name = settings.SAAS_BSCW_DOMAIN
change_readonly_fileds = ('email',)

View File

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

View File

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

View File

@ -14,9 +14,10 @@ from .. import settings
class SoftwareServiceForm(PluginDataForm):
site_name = forms.CharField(widget=widgets.ShowTextWidget, required=False)
password = forms.CharField(label=_("Password"), required=False,
widget=widgets.ReadOnlyWidget('<strong>Unknown password</strong>'),
help_text=_("Servide passwords are not stored, so there is no way to see this "
help_text=_("Passwords are not stored, so there is no way to see this "
"service's password, but you can change the password using "
"<a href=\"password/\">this form</a>."))
password1 = forms.CharField(label=_("Password"), validators=[validators.validate_password],
@ -38,13 +39,16 @@ class SoftwareServiceForm(PluginDataForm):
self.fields['password'].widget = forms.HiddenInput()
site_name = self.plugin.site_name
if site_name:
link = '<a href="http://%s">%s</a>' % (site_name, site_name)
self.fields['site_name'].widget = widgets.ReadOnlyWidget(site_name, mark_safe(link))
self.fields['site_name'].required = False
site_name_link = '<a href="http://%s">%s</a>' % (site_name, site_name)
else:
base_name = self.plugin.site_name_base_domain
help_text = _("The final URL would be &lt;site_name&gt;.%s") % base_name
self.fields['site_name'].help_text = help_text
site_name_link = '&lt;name&gt;.%s' % self.plugin.site_name_base_domain
self.fields['site_name'].initial = site_name_link
## self.fields['site_name'].widget = widgets.ReadOnlyWidget(site_name, mark_safe(link))
## self.fields['site_name'].required = False
# else:
# base_name = self.plugin.site_name_base_domain
# help_text = _("The final URL would be &lt;site_name&gt;.%s") % base_name
# self.fields['site_name'].help_text = help_text
def clean_password2(self):
if not self.is_change:
@ -69,12 +73,13 @@ class SoftwareServiceForm(PluginDataForm):
class SoftwareService(plugins.Plugin):
form = SoftwareServiceForm
serializer = None
site_name = None
site_name_base_domain = 'orchestra.lan'
has_custom_domain = False
icon = 'orchestra/icons/apps.png'
change_readonly_fileds = ('username',)
change_readonly_fileds = ('site_name',)
class_verbose_name = _("Software as a Service")
plugin_field = 'service'
@classmethod
@cached
@ -84,27 +89,18 @@ class SoftwareService(plugins.Plugin):
plugins.append(import_class(cls))
return plugins
def clean_data(cls):
""" model clean, uses cls.serizlier by default """
serializer = cls.serializer(data=self.instance.data)
if not serializer.is_valid():
raise ValidationError(serializer.errors)
return serializer.data
@classmethod
def get_change_readonly_fileds(cls):
return cls.change_readonly_fileds + ('username',)
fields = super(SoftwareService, cls).get_change_readonly_fileds()
return fields + ('username',)
def get_site_name(self):
return self.site_name or '.'.join(
(self.instance.site_name, self.site_name_base_domain)
(self.instance.username, self.site_name_base_domain)
)
def get_form(self):
self.form.plugin = self
self.form.plugin_field = 'service'
return self.form
def save(self):
pass
def get_serializer(self):
self.serializer.plugin = self
return self.serializer
def delete(self):
pass

View File

@ -1,14 +1,100 @@
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 orchestra.apps.databases.models import Database, DatabaseUser
from orchestra.forms import widgets
from orchestra.plugins.forms import PluginDataForm
from .. import settings
from .options import SoftwareService, SoftwareServiceForm
class PHPListForm(SoftwareServiceForm):
email = forms.EmailField(label=_("Email"), widget=forms.TextInput(attrs={'size':'40'}))
admin_username = forms.CharField(label=_("Admin username"), required=False,
widget=widgets.ReadOnlyWidget('admin'))
def __init__(self, *args, **kwargs):
super(PHPListForm, self).__init__(*args, **kwargs)
self.fields['username'].label = _("Name")
base_domain = self.plugin.site_name_base_domain
help_text = _("Admin URL http://&lt;name&gt;.{}/admin/").format(base_domain)
self.fields['site_name'].help_text = help_text
class PHPListChangeForm(PHPListForm):
# site_name = forms.CharField(widget=widgets.ShowTextWidget, required=False)
db_name = forms.CharField(label=_("Database name"),
help_text=_("Database used for this webapp."))
def __init__(self, *args, **kwargs):
super(PHPListChangeForm, self).__init__(*args, **kwargs)
site_name = self.instance.get_site_name()
admin_url = "http://%s/admin/" % site_name
help_text = _("Admin URL <a href={0}>{0}</a>").format(admin_url)
self.fields['site_name'].help_text = help_text
class PHPListSerializer(serializers.Serializer):
db_name = serializers.CharField(label=_("Database name"), required=False)
class PHPListService(SoftwareService):
name = 'phplist'
verbose_name = "phpList"
form = PHPListForm
change_form = PHPListChangeForm
change_readonly_fileds = ('db_name',)
serializer = PHPListSerializer
icon = 'orchestra/icons/apps/Phplist.png'
site_name_base_domain = settings.SAAS_PHPLIST_BASE_DOMAIN
def get_db_name(self):
db_name = 'phplist_mu_%s' % self.instance.username
# Limit for mysql database names
return db_name[:65]
def get_db_user(self):
return settings.SAAS_PHPLIST_DB_NAME
def validate(self):
super(PHPListService, self).validate()
create = not self.instance.pk
if create:
db = Database(name=self.get_db_name(), account=self.instance.account)
try:
db.full_clean()
except ValidationError as e:
raise ValidationError({
'name': e.messages,
})
def save(self):
db_name = self.get_db_name()
db_user = self.get_db_user()
db, db_created = Database.objects.get_or_create(name=db_name, account=self.instance.account)
user = DatabaseUser.objects.get(username=db_user)
db.users.add(user)
self.instance.data = {
'db_name': db_name,
}
if not db_created:
# Trigger related backends
for related in self.get_related():
related.save(update_fields=[])
def delete(self):
for related in self.get_related():
related.delete()
def get_related(self):
related = []
account = self.instance.account
try:
db = account.databases.get(name=self.instance.data.get('db_name'))
except Database.DoesNotExist:
pass
else:
related.append(db)
return related

View File

@ -0,0 +1,23 @@
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 = 'orchestra/icons/apps/WordPress.png'
site_name_base_domain = 'blogs.orchestra.lan'
change_readonly_fileds = ('email',)

View File

@ -6,4 +6,44 @@ SAAS_ENABLED_SERVICES = getattr(settings, 'SAAS_ENABLED_SERVICES', (
'orchestra.apps.saas.services.bscw.BSCWService',
'orchestra.apps.saas.services.gitlab.GitLabService',
'orchestra.apps.saas.services.phplist.PHPListService',
'orchestra.apps.saas.services.wordpress.WordPressService',
'orchestra.apps.saas.services.dokuwiki.DokuWikiService',
'orchestra.apps.saas.services.drupal.DrupalService',
))
SAAS_WORDPRESS_ADMIN_PASSWORD = getattr(settings, 'SAAS_WORDPRESSMU_ADMIN_PASSWORD',
'secret'
)
SAAS_WORDPRESS_BASE_URL = getattr(settings, 'SAAS_WORDPRESS_BASE_URL',
'http://blogs.orchestra.lan/'
)
SAAS_DOKUWIKI_TEMPLATE_PATH = getattr(settings, 'SAAS_DOKUWIKI_TEMPLATE_PATH',
'/home/httpd/htdocs/wikifarm/template.tar.gz')
SAAS_DOKUWIKI_FARM_PATH = getattr(settings, 'WEBSITES_DOKUWIKI_FARM_PATH',
'/home/httpd/htdocs/wikifarm/farm'
)
SAAS_DRUPAL_SITES_PATH = getattr(settings, 'WEBSITES_DRUPAL_SITES_PATH',
'/home/httpd/htdocs/drupal-mu/sites/%(site_name)s'
)
SAAS_PHPLIST_DB_NAME = getattr(settings, 'SAAS_PHPLIST_DB_NAME',
'phplist_mu'
)
SAAS_PHPLIST_BASE_DOMAIN = getattr(settings, 'SAAS_PHPLIST_BASE_DOMAIN',
'lists.orchestra.lan'
)
SAAS_BSCW_DOMAIN = getattr(settings, 'SAAS_BSCW_DOMAIN',
'bscw.orchestra.lan'
)

View File

@ -48,21 +48,15 @@ class WebAppOptionInline(admin.TabularInline):
class WebAppAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedModelAdmin):
list_display = ('name', 'type', 'display_detail', 'display_websites', 'account_link')
list_filter = ('type',)
# add_fields = ('account', 'name', 'type')
# fields = ('account_link', 'name', 'type')
inlines = [WebAppOptionInline]
readonly_fields = ('account_link', )
change_readonly_fields = ('name', 'type')
search_fuelds = ('name', 'account__username')
change_readonly_fields = ('name', 'type', 'display_websites')
search_fields = ('name', 'account__username', 'data', 'website__domains__name')
list_prefetch_related = ('content_set__website',)
plugin = AppType
plugin_field = 'type'
plugin_title = _("Web application type")
# TYPE_HELP_TEXT = {
# app.get_name(): str(unicode(app.help_text)) for app in App.get_plugins()
# }
def display_websites(self, webapp):
websites = []
for content in webapp.content_set.all():

View File

@ -13,6 +13,7 @@ from .. import settings
class PHPBackend(WebAppServiceMixin, ServiceController):
verbose_name = _("PHP FPM/FCGID")
default_route_match = "webapp.type == 'php'"
MERGE = settings.WEBAPPS_MERGE_PHP_WEBAPPS
def save(self, webapp):
context = self.get_context(webapp)
@ -89,8 +90,9 @@ class PHPBackend(WebAppServiceMixin, ServiceController):
)
def get_fpm_config(self, webapp, context):
merge = settings.WEBAPPS_MERGE_PHP_WEBAPPS
context.update({
'init_vars': webapp.type_instance.get_php_init_vars(),
'init_vars': webapp.type_instance.get_php_init_vars(merge=self.MERGE),
'max_children': webapp.get_options().get('processes', False),
'request_terminate_timeout': webapp.get_options().get('timeout', False),
})
@ -116,7 +118,7 @@ class PHPBackend(WebAppServiceMixin, ServiceController):
def get_fcgid_wrapper(self, webapp, context):
opt = webapp.type_instance
# Format PHP init vars
init_vars = opt.get_php_init_vars()
init_vars = opt.get_php_init_vars(merge=self.MERGE)
if init_vars:
init_vars = [ '-d %s="%s"' % (k,v) for k,v in init_vars.iteritems() ]
init_vars = ', '.join(init_vars)

View File

@ -10,8 +10,8 @@ from .. import settings
class WordpressMuBackend(ServiceController):
verbose_name = _("Wordpress multisite")
model = 'webapps.WebApp'
default_route_match = "webapp.type == 'wordpress-mu'"
model = 'saas.SaaS'
default_route_match = "saas.service == 'wordpress-mu'"
@property
def script(self):
@ -22,7 +22,7 @@ class WordpressMuBackend(ServiceController):
login_url = base_url + '/wp-login.php'
login_data = {
'log': 'admin',
'pwd': settings.WEBAPPS_WORDPRESSMU_ADMIN_PASSWORD,
'pwd': settings.WEBSITES_WORDPRESSMU_ADMIN_PASSWORD,
'redirect_to': '/wp-admin/'
}
response = session.post(login_url, data=login_data)
@ -30,7 +30,7 @@ class WordpressMuBackend(ServiceController):
raise IOError("Failure login to remote application")
def get_base_url(self):
base_url = settings.WEBAPPS_WORDPRESSMU_BASE_URL
base_url = settings.WEBSITES_WORDPRESSMU_BASE_URL
return base_url.rstrip('/')
def validate_response(self, response):
@ -86,8 +86,6 @@ class WordpressMuBackend(ServiceController):
self.validate_response(response)
def delete_blog(self, webapp, server):
# OH, I've enjoied so much coding this methods that I want to thank
# the wordpress team for the excellent software they are producing
session = requests.Session()
self.login(session)

View File

@ -24,7 +24,7 @@ class WebApp(models.Model):
choices=AppType.get_plugin_choices())
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='webapps')
data = JSONField(_("data"), blank=True,
data = JSONField(_("data"), blank=True, default={},
help_text=_("Extra information dependent of each service."))
class Meta:

View File

@ -38,9 +38,6 @@ WEBAPPS_TYPES = getattr(settings, 'WEBAPPS_TYPES', (
'orchestra.apps.webapps.types.php.PHPApp',
'orchestra.apps.webapps.types.misc.StaticApp',
'orchestra.apps.webapps.types.misc.WebalizerApp',
'orchestra.apps.webapps.types.saas.WordPressMuApp',
'orchestra.apps.webapps.types.saas.DokuWikiMuApp',
'orchestra.apps.webapps.types.saas.DrupalMuApp',
'orchestra.apps.webapps.types.misc.SymbolicLinkApp',
'orchestra.apps.webapps.types.wordpress.WordPressApp',
))
@ -152,40 +149,5 @@ WEBAPPS_ENABLED_OPTIONS = getattr(settings, 'WEBAPPS_ENABLED_OPTIONS', (
))
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_WORDPRESSMU_LISTEN = getattr(settings, 'WEBAPPS_WORDPRESSMU_LISTEN',
'/opt/php/5.4/socks/wordpress-mu.sock'
)
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_DOKUWIKIMU_LISTEN = getattr(settings, 'WEBAPPS_DOKUWIKIMU_LISTEN',
'/opt/php/5.4/socks/dokuwiki-mu.sock'
)
WEBAPPS_DRUPALMU_SITES_PATH = getattr(settings, 'WEBAPPS_DRUPALMU_SITES_PATH',
'/home/httpd/htdocs/drupal-mu/sites/%(site_name)s')
WEBAPPS_DRUPALMU_LISTEN = getattr(settings, 'WEBAPPS_DRUPALMU_LISTEN',
'/opt/php/5.4/socks/drupal-mu.sock'
)
WEBAPPS_MOODLEMU_LISTEN = getattr(settings, 'WEBAPPS_MOODLEMU_LISTEN',
'/opt/php/5.4/socks/moodle-mu.sock'
)
WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST = getattr(settings, 'WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST',
'mysql.orchestra.lan')

View File

@ -14,11 +14,10 @@ class AppType(plugins.Plugin):
verbose_name = ""
help_text= ""
form = PluginDataForm
change_form = None
serializer = None
icon = 'orchestra/icons/apps.png'
unique_name = False
option_groups = (AppOption.FILESYSTEM, AppOption.PROCESS, AppOption.PHP)
plugin_field = 'type'
# TODO generic name like 'execution' ?
@classmethod
@ -29,33 +28,6 @@ class AppType(plugins.Plugin):
plugins.append(import_class(cls))
return plugins
def clean_data(self):
""" model clean, uses cls.serizlier by default """
if self.serializer:
serializer = self.serializer(data=self.instance.data)
if not serializer.is_valid():
raise ValidationError(serializer.errors)
return serializer.data
return {}
def get_directive(self):
raise NotImplementedError
def get_form(self):
self.form.plugin = self
self.form.plugin_field = 'type'
return self.form
def get_change_form(self):
form = self.change_form or self.form
form.plugin = self
form.plugin_field = 'type'
return form
def get_serializer(self):
self.serializer.plugin = self
return self.serializer
def validate(self):
""" Unique name validation """
if self.unique_name:

View File

@ -66,15 +66,21 @@ class PHPApp(AppType):
'app_name': self.instance.name,
}
def get_php_init_vars(self, per_account=False):
def get_php_init_vars(self, merge=False):
"""
process php options for inclusion on php.ini
per_account=True merges all (account, webapp.type) options
"""
init_vars = {}
options = self.instance.options.all()
if per_account:
options = self.instance.account.webapps.filter(webapp_type=self.instance.type)
if merge:
# Get options from the same account and php_version webapps
options = []
php_version = self.get_php_version()
webapps = self.instance.account.webapps.filter(webapp_type=self.instance.type)
for webapp in webapps:
if webapp.type_instance.get_php_version == php_version:
options += list(webapp.options.all())
php_options = [option.name for option in type(self).get_php_options()]
for opt in options:
if opt.name in php_options:
@ -97,11 +103,8 @@ class PHPApp(AppType):
def get_directive(self):
context = self.get_directive_context()
if self.is_fpm:
socket_type = 'unix'
if ':' in self.FPM_LISTEN:
socket_type = 'tcp'
socket = self.FPM_LISTEN % context
return ('fpm', socket_type, socket, self.instance.get_path())
return ('fpm', socket, self.instance.get_path())
elif self.is_fcgid:
wrapper_path = os.path.normpath(self.FCGID_WRAPPER_PATH % context)
return ('fcgid', self.instance.get_path(), wrapper_path)

View File

@ -1,54 +0,0 @@
from django.utils.translation import ugettext_lazy as _
from . import AppType
from .. import settings
class WordPressMuApp(AppType):
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.<br>"
"By default this blog is accessible via &lt;app_name&gt;.blogs.orchestra.lan")
icon = 'orchestra/icons/apps/WordPressMu.png'
unique_name = True
option_groups = ()
fpm_listen = settings.WEBAPPS_WORDPRESSMU_LISTEN
class DokuWikiMuApp(AppType):
name = '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")
icon = 'orchestra/icons/apps/DokuWikiMu.png'
unique_name = True
option_groups = ()
fpm_listen = settings.WEBAPPS_DOKUWIKIMU_LISTEN
class MoodleMuApp(AppType):
name = 'moodle-mu'
verbose_name = "Moodle (SaaS)"
directive = ('alias', '/home/httpd/wikifarm/farm/')
help_text = _("This create a Moodle site into a shared Moodle server.<br>"
"By default this wiki is accessible via &lt;app_name&gt;.moodle.orchestra.lan")
icon = 'orchestra/icons/apps/MoodleMu.png'
unique_name = True
option_groups = ()
fpm_listen = settings.WEBAPPS_MOODLEMU_LISTEN
class DrupalMuApp(AppType):
name = '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")
icon = 'orchestra/icons/apps/DrupalMu.png'
unique_name = True
option_groups = ()
fpm_listen = settings.WEBAPPS_DRUPALMU_LISTEN

View File

@ -64,12 +64,11 @@ class WordPressApp(PHPApp):
})
def save(self):
create = not self.instance.pk
if create:
db_name = self.get_db_name()
db_user = self.get_db_user()
db_pass = self.get_db_pass()
db = Database.objects.create(name=db_name, account=self.instance.account)
db, db_created = Database.objects.get_or_create(name=db_name, account=self.instance.account)
if db_created:
user = DatabaseUser(username=db_user, account=self.instance.account)
user.set_password(db_pass)
user.save()
@ -82,7 +81,7 @@ class WordPressApp(PHPApp):
else:
# Trigger related backends
for related in self.get_related():
related.save(updated_fields=[])
related.save(update_fields=[])
def delete(self):
for related in self.get_related():

View File

@ -24,11 +24,6 @@ class WebsiteDirectiveInline(admin.TabularInline):
op.name: str(unicode(op.help_text)) for op in SiteDirective.get_plugins()
}
# class Media:
# css = {
# 'all': ('orchestra/css/hide-inline-id.css',)
# }
def formfield_for_dbfield(self, db_field, **kwargs):
if db_field.name == 'value':
kwargs['widget'] = forms.TextInput(attrs={'size':'100'})

View File

@ -0,0 +1,17 @@
from django.apps import AppConfig
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from orchestra.utils import database_ready
class WebsiteConfig(AppConfig):
name = 'orchestra.apps.websites'
def ready(self):
if database_ready():
from .models import Content
qset = Content.content_type.field.get_limit_choices_to()
for ct in ContentType.objects.filter(qset):
relation = GenericRelation('websites.Content')
ct.model_class().add_to_class('content_set', relation)

View File

@ -98,32 +98,38 @@ class Apache2Backend(ServiceController):
""" reload Apache2 if necessary """
self.append('if [[ $UPDATED == 1 ]]; then service apache2 reload; fi')
def get_directives(self, directive, context):
method, args = directive[0], directive[1:]
try:
method = getattr(self, 'get_%s_directives' % method)
except AttributeError:
raise AttributeError("%s does not has suport for '%s' directive." %
(self.__class__.__name__, method))
return method(context, *args)
def get_content_directives(self, site):
directives = []
for content in site.content_set.all():
directive = content.webapp.get_directive()
method, args = directive[0], directive[1:]
method = getattr(self, 'get_%s_directives' % method)
directives += method(content, *args)
context = self.get_content_context(content)
directives += self.get_directives(directive, context)
return directives
def get_static_directives(self, content, app_path):
context = self.get_content_context(content)
def get_static_directives(self, context, app_path):
context['app_path'] = app_path % context
location = "%(location)s/" % context
directive = "Alias %(location)s/ %(app_path)s/" % context
return [(location, directive)]
def get_fpm_directives(self, content, socket_type, socket, app_path):
if socket_type == 'unix':
target = 'unix:%(socket)s|fcgi://127.0.0.1%(app_path)s/'
if content.path != '/':
target = 'unix:%(socket)s|fcgi://127.0.0.1%(app_path)s/$1'
elif socket_type == 'tcp':
def get_fpm_directives(self, context, socket, app_path):
if ':' in socket:
# TCP socket
target = 'fcgi://%(socket)s%(app_path)s/$1'
else:
raise TypeError("%s socket not supported." % socket_type)
context = self.get_content_context(content)
# UNIX socket
target = 'unix:%(socket)s|fcgi://127.0.0.1%(app_path)s/'
if context['location'] != '/':
target = 'unix:%(socket)s|fcgi://127.0.0.1%(app_path)s/$1'
context.update({
'app_path': app_path,
'socket': socket,
@ -135,8 +141,7 @@ class Apache2Backend(ServiceController):
)
return [(location, directives)]
def get_fcgid_directives(self, content, app_path, wrapper_path):
context = self.get_content_context(content)
def get_fcgid_directives(self, context, app_path, wrapper_path):
context.update({
'app_path': app_path,
'wrapper_path': wrapper_path,
@ -203,6 +208,16 @@ class Apache2Backend(ServiceController):
proxies.append((location, proxy))
return proxies
def get_saas(self, directives):
saas = []
for name, value in directives.iteritems():
if name.endswith('-saas'):
context = {
'location': normurlpath(value),
}
directive = settings.WEBSITES_SAAS_DIRECTIVES[name]
saas += self.get_directive(context, directive)
return saas
# def get_protections(self, site):
# protections = ''
# context = self.get_context(site)

View File

@ -15,6 +15,7 @@ class SiteDirective(Plugin):
HTTPD = 'HTTPD'
SEC = 'ModSecurity'
SSL = 'SSL'
SAAS = 'SaaS'
help_text = ""
unique = True
@ -76,31 +77,6 @@ class Proxy(SiteDirective):
group = SiteDirective.HTTPD
class UserGroup(SiteDirective):
name = 'user_group'
verbose_name = _("SuexecUserGroup")
help_text = _("<tt>user [group]</tt>, username and optional groupname.")
regex = r'^[\w/_]+(\s[\w/_]+)*$'
group = SiteDirective.HTTPD
def validate(self, directive):
super(UserGroup, self).validate(directive)
options = directive.value.split()
systemusers = [options[0]]
if len(options) > 1:
systemusers.append(options[1])
# TODO not sure about this dependency maybe make it part of pangea only
from orchestra.apps.systemusers.models import SystemUser
errors = []
for user in systemusers:
if not SystemUser.objects.filter(username=user).exists():
erros.append("")
if errors:
raise ValidationError({
'value': errors
})
class ErrorDocument(SiteDirective):
name = 'error_document'
verbose_name = _("ErrorDocumentRoot")
@ -151,3 +127,30 @@ class SecEngine(SiteDirective):
help_text = _("URL location for disabling modsecurity engine.")
regex = r'^/[^ ]*$'
group = SiteDirective.SEC
class WordPressSaaS(SiteDirective):
name = 'wordpress-saas'
verbose_name = "WordPress"
help_text = _("URL location for mounting wordpress multisite.")
# fpm_listen = settings.WEBAPPS_WORDPRESSMU_LISTEN
group = SiteDirective.SAAS
regex = r'^/[^ ]*$'
class DokuWikiSaaS(SiteDirective):
name = 'dokuwiki-saas'
verbose_name = "DokuWiki"
help_text = _("URL location for mounting wordpress multisite.")
# fpm_listen = settings.WEBAPPS_DOKUWIKIMU_LISTEN
group = SiteDirective.SAAS
regex = r'^/[^ ]*$'
class DrupalSaaS(SiteDirective):
name = 'drupal-saas'
verbose_name = "Drupdal"
help_text = _("URL location for mounting wordpress multisite.")
# fpm_listen = settings.WEBAPPS_DRUPALMU_LISTEN
group = SiteDirective.SAAS
regex = r'^/[^ ]*$'

View File

@ -141,6 +141,7 @@ class Content(models.Model):
return self.path
def clean(self):
# TODO do it on the field?
self.path = normurlpath(self.path)
def get_absolute_url(self):

View File

@ -23,7 +23,7 @@ class RelatedDomainSerializer(AccountSerializerMixin, serializers.HyperlinkedMod
class RelatedWebAppSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
class Meta:
model = Content.webapp.field.rel.to
# model = Content.webapp.field.rel.to
fields = ('url', 'name', 'type')
def from_native(self, data, files=None):
@ -46,15 +46,15 @@ class WebsiteSerializer(AccountSerializerMixin, HyperlinkedModelSerializer):
domains = RelatedDomainSerializer(many=True, allow_add_remove=True, required=False)
contents = ContentSerializer(required=False, many=True, allow_add_remove=True,
source='content_set')
options = OptionField(required=False)
directives = OptionField(required=False)
class Meta:
model = Website
fields = ('url', 'name', 'port', 'domains', 'is_active', 'contents', 'options')
fields = ('url', 'name', 'protocol', 'domains', 'is_active', 'contents', 'directives')
postonly_fileds = ('name',)
def full_clean(self, instance):
""" Prevent multiples domains on the same port """
""" Prevent multiples domains on the same protocol """
for domain in instance._m2m_data['domains']:
try:
validate_domain_protocol(instance, domain, instance.protocol)

View File

@ -34,13 +34,15 @@ WEBSITES_DOMAIN_MODEL = getattr(settings, 'WEBSITES_DOMAIN_MODEL', 'domains.Doma
WEBSITES_ENABLED_DIRECTIVES = getattr(settings, 'WEBSITES_ENABLED_DIRECTIVES', (
'orchestra.apps.websites.directives.Redirect',
'orchestra.apps.websites.directives.Proxy',
'orchestra.apps.websites.directives.UserGroup',
'orchestra.apps.websites.directives.ErrorDocument',
'orchestra.apps.websites.directives.SSLCA',
'orchestra.apps.websites.directives.SSLCert',
'orchestra.apps.websites.directives.SSLKey',
'orchestra.apps.websites.directives.SecRuleRemove',
'orchestra.apps.websites.directives.SecEngine',
'orchestra.apps.websites.directives.WordPressSaaS',
'orchestra.apps.websites.directives.DokuWikiSaaS',
'orchestra.apps.websites.directives.DrupalSaaS',
))
@ -73,3 +75,14 @@ WEBSITES_TRAFFIC_IGNORE_HOSTS = getattr(settings, 'WEBSITES_TRAFFIC_IGNORE_HOSTS
#WEBSITES_DEFAULT_SSl_KEY = getattr(settings, 'WEBSITES_DEFAULT_SSl_KEY',
# '')
WEBAPPS_SAAS_DIRECTIVES = getattr(settings, 'WEBAPPS_SAAS_DIRECTIVES', {
'wordpress-saas': ('fpm', '/home/httpd/wordpress-mu/', '/opt/php/5.4/socks/wordpress-mu.sock'),
'drupal-saas': ('fpm', '/home/httpd/drupal-mu/', '/opt/php/5.4/socks/drupal-mu.sock'),
'dokuwiki-saas': ('fpm', '/home/httpd/moodle-mu/', '/opt/php/5.4/socks/moodle-mu.sock'),
# 'moodle-saas': ('fpm', '/home/httpd/moodle-mu/', '/opt/php/5.4/socks/moodle-mu.sock'),
})

View File

@ -21,8 +21,12 @@ class PluginDataForm(forms.ModelForm):
if self.instance.pk:
for field in self.plugin.get_change_readonly_fileds():
value = getattr(self.instance, field, None) or self.instance.data[field]
display = value
foo_display = getattr(self.instance, 'get_%s_display' % field, None)
if foo_display:
display = foo_display()
self.fields[field].required = False
self.fields[field].widget = ReadOnlyWidget(value)
self.fields[field].widget = ReadOnlyWidget(value, display)
# self.fields[field].help_text = None
def clean(self):

View File

@ -6,7 +6,11 @@ class Plugin(object):
# Used on select plugin view
class_verbose_name = None
icon = None
change_form = None
form = None
serializer = None
change_readonly_fileds = ()
plugin_field = None
def __init__(self, instance=None):
# Related model instance of this plugin
@ -49,7 +53,34 @@ class Plugin(object):
@classmethod
def get_change_readonly_fileds(cls):
return cls.change_readonly_fileds
return (cls.plugin_field,) + cls.change_readonly_fileds
def clean_data(self):
""" model clean, uses cls.serizlier by default """
if self.serializer:
serializer = self.serializer(data=self.instance.data)
if not serializer.is_valid():
raise ValidationError(serializer.errors)
return serializer.data
return {}
def get_directive(self):
raise NotImplementedError
def get_form(self):
self.form.plugin = self
self.form.plugin_field = self.plugin_field
return self.form
def get_change_form(self):
form = self.change_form or self.form
form.plugin = self
form.plugin_field = self.plugin_field
return form
def get_serializer(self):
self.serializer.plugin = self
return self.serializer
class PluginModelAdapter(Plugin):

View File

@ -1,6 +1,8 @@
import sys
import collections
import random
import string
from cStringIO import StringIO
def import_class(cls):
@ -76,3 +78,14 @@ class AttrDict(dict):
def __init__(self, *args, **kwargs):
super(AttrDict, self).__init__(*args, **kwargs)
self.__dict__ = self
class CaptureStdout(list):
def __enter__(self):
self._stdout = sys.stdout
sys.stdout = self._stringio = StringIO()
return self
def __exit__(self, *args):
self.extend(self._stringio.getvalue().splitlines())
sys.stdout = self._stdout