Random fixes

This commit is contained in:
Marc Aymerich 2015-03-27 19:50:54 +00:00
parent 882c03a416
commit 124124da6c
34 changed files with 432 additions and 238 deletions

51
TODO.md
View File

@ -203,39 +203,32 @@ require_once(/etc/moodles/.$moodle_host.config.php);``` moodle/drupl
* normurlpath '' return '/'
* initial configuration of multisite sas apps with password stored in DATA ?? Dsign decission: initial pwds vs eventual consistency vs externa service vs backend raise exception?
* webapps installation complete, passowrd protected
* saas.initial_password autogenerated (ok because its random and not user provided) vs saas.password /change_Form provided + send email with initial_password
* more robust backend error handling, continue executing but exit code > 0 if failure, replace exit_code=0; do_sometging || exit_code=1
* automaitcally set passwords and email users?
* more robust backend error handling, continue executing but exit code > 0 if failure: failing_cmd || exit_code=1 and don't forget to call super.commit()!!
* website directives uniquenes validation on serializers
+ is_Active custom filter with support for instance.account.is_Active
+ is_Active custom filter with support for instance.account.is_Active annotate with F() needed (django 1.8)
* django virtual field for saas and webapps related objects (db) to show on delete confirmation
if only extra related objects are databases and user databases why not make them first class relations?????
* >>> Account._meta.virtual_fields[0].bulk_related_objects([Account.objects.all()[0]])
[<ResourceData: account-disk: entrep>, <ResourceData: account-traffic: entrep>]
https://github.com/django/django/blob/master/django/db/models/deletion.py#L232
https://github.com/django/django/blob/master/django/contrib/contenttypes/fields.py#L282
from django.contrib.contenttypes.fields import GenericRelation
from django.db import DEFAULT_DB_ALIAS
from orchestra.apps.databases.models import Database
class VirtualRelation(GenericRelation):
def bulk_related_objects(self, objs, using=DEFAULT_DB_ALIAS):
return []
# return Database.objects.filter(name__in=
# obj.service_instance.get_related() for obj in objs
## return self.remote_field.model._base_manager.db_manager(using).all()
relation = VirtualRelation('databases.Database')
SaaS.add_to_class('databases', relation)
* delete apache logs and php logs
* one to one relation deleteion on both sides??
* document service help things: discount/refound/compensation effect and metric table
* Document metric interpretation help_text
* document plugin serialization, data_serializer?
* bill line managemente, remove, undo (only when possible), move, copy, paste
* budgets: no undo feature
* webapps/saas delete related db by id not name !! type!=Mysql
* Autocomplete admin fields like <site_name>.phplist... with js
* autoexpand mailbox.filter according to filtering options
* allow empty metric pack for default rates? changes on rating algo
* rates plan verbose name!"!
* IMPORTANT make sure no order is created for mailboxes that include disk? or just don't produce lines with cost == 0
* IMPORTANT maildis updae and metric storage ?? threshold ? or what?
* Improve performance of admin change lists with debug toolbar and prefech_related
* and miscellaneous.service.name == 'domini-registre'
* DOMINI REGISTRE MIGRATION SCRIPTS
* detect subdomains accounts correctly with subdomains: i.e. www.marcay.pangea.org
* lines too long on invoice, double lines or cut

View File

@ -111,7 +111,7 @@ class ChangeAddFieldsMixin(object):
add_form = None
add_prepopulated_fields = {}
change_readonly_fields = ()
add_inlines = ()
add_inlines = None
def get_prepopulated_fields(self, request, obj=None):
if not obj:
@ -140,7 +140,7 @@ class ChangeAddFieldsMixin(object):
if obj:
self.inlines = type(self).inlines
else:
self.inlines = self.add_inlines or self.inlines
self.inlines = self.inlines if self.add_inlines is None else self.add_inlines
inlines = super(ChangeAddFieldsMixin, self).get_inline_instances(request, obj)
for inline in inlines:
inline.parent_object = obj

View File

@ -172,10 +172,11 @@ class AccountAdminMixin(object):
account_link.allow_tags = True
account_link.admin_order_field = 'account__username'
def render_change_form(self, request, context, *args, **kwargs):
def get_form(self, request, obj=None, **kwargs):
""" Warns user when object's account is disabled """
form = super(AccountAdminMixin, self).get_form(request, obj, **kwargs)
try:
field = context['adminform'].form.fields['is_active']
field = form.base_fields['is_active']
except KeyError:
pass
else:
@ -183,11 +184,10 @@ class AccountAdminMixin(object):
"Designates whether this account should be treated as active. "
"Unselect this instead of deleting accounts."
)
obj = kwargs.get('obj')
if obj and not obj.account.is_active:
help_text += "<br><b style='color:red;'>This user's account is dissabled</b>"
field.help_text = _(help_text)
return super(AccountAdminMixin, self).render_change_form(request, context, *args, **kwargs)
return form
def get_fields(self, request, obj=None):
""" remove account or account_link depending on the case """

View File

@ -42,8 +42,8 @@ class MySQLBackend(ServiceController):
self.append("mysql mysql -e 'DELETE FROM db WHERE db = \"%(database)s\";'" % context)
def commit(self):
super(MySQLBackend, self).commit()
self.append("mysql -e 'FLUSH PRIVILEGES;'")
super(MySQLBackend, self).commit()
def get_context(self, database):
return {

View File

@ -37,6 +37,9 @@ DOMAINS_CHECKZONE_BIN_PATH = getattr(settings, 'DOMAINS_CHECKZONE_BIN_PATH',
'/usr/sbin/named-checkzone -i local -k fail -n fail')
DOMAINS_CHECKZONE_PATH = getattr(settings, 'DOMAINS_CHECKZONE_PATH', '/dev/shm')
DOMAINS_DEFAULT_A = getattr(settings, 'DOMAINS_DEFAULT_A', '10.0.3.13')

View File

@ -108,9 +108,12 @@ def validate_soa_record(value):
def validate_zone(zone):
""" Ultimate zone file validation using named-checkzone """
zone_name = zone.split()[0][:-1]
path = os.path.join(settings.DOMAINS_CHECKZONE_PATH, zone_name)
with open(path, 'wb') as f:
f.write(zone)
# Don't use /dev/stdin becuase the 'argument list is too long' error
checkzone = settings.DOMAINS_CHECKZONE_BIN_PATH
cmd = ' '.join(["echo -e '%s'" % zone, '|', checkzone, zone_name, '/dev/stdin'])
check = run(cmd, error_codes=[0, 1], display=False)
check = run(' '.join([checkzone, zone_name, path]), error_codes=[0,1], display=False)
if check.return_code == 1:
errors = re.compile(r'zone.*: (.*)').findall(check.stdout)[:-1]
raise ValidationError(', '.join(errors))

View File

@ -72,7 +72,7 @@ class MiscellaneousAdmin(AccountAdminMixin, SelectPluginAdminMixin, admin.ModelA
def get_service(self, obj):
if obj is None:
return self.plugin.get_plugin(self.plugin_value)().instance
return self.plugin.get_plugin(self.plugin_value).related_instance
else:
return obj.service
@ -105,7 +105,15 @@ class MiscellaneousAdmin(AccountAdminMixin, SelectPluginAdminMixin, admin.ModelA
if db_field.name == 'description':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4})
return super(MiscellaneousAdmin, self).formfield_for_dbfield(db_field, **kwargs)
def save_model(self, request, obj, form, change):
if not change:
plugin = self.plugin
kwargs = {
plugin.name_field: self.plugin_value
}
setattr(obj, self.plugin_field, plugin.model.objects.get(**kwargs))
obj.save()
admin.site.register(MiscService, MiscServiceAdmin)
admin.site.register(Miscellaneous, MiscellaneousAdmin)

View File

@ -63,6 +63,10 @@ class Miscellaneous(models.Model):
def get_description(self):
return ' '.join((str(self.amount), self.service.description or self.service.verbose_name))
@cached_property
def service_class(self):
return self.service
def clean(self):
if self.identifier:
self.identifier = self.identifier.strip()

View File

@ -1,9 +1,11 @@
from django.contrib import admin
from django.core.urlresolvers import reverse
from django.utils import timezone
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ChangeListDefaultFilter
from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import admin_link, admin_date
from orchestra.apps.accounts.admin import AccountAdminMixin
from orchestra.utils.humanize import naturaldate
@ -13,17 +15,50 @@ from .filters import IgnoreOrderListFilter, ActiveOrderListFilter, BilledOrderLi
from .models import Order, MetricStorage
class OrderAdmin(ChangeListDefaultFilter, AccountAdminMixin, admin.ModelAdmin):
class MetricStorageInline(admin.TabularInline):
model = MetricStorage
readonly_fields = ('value', 'updated_on')
extra = 0
def has_add_permission(self, request, obj=None):
return False
def get_fieldsets(self, request, obj=None):
if obj:
url = reverse('admin:orders_metricstorage_changelist')
url += '?order=%i' % obj.pk
title = _('Metric storage, last 10 entries, <a href="%s">(See all)</a>')
self.verbose_name_plural = mark_safe(title % url)
return super(MetricStorageInline, self).get_fieldsets(request, obj)
def get_queryset(self, request):
qs = super(MetricStorageInline, self).get_queryset(request)
if self.parent_object and self.parent_object.pk:
qs = qs.filter(order=self.parent_object.pk).order_by('-id')
try:
tenth_id = qs.values_list('id', flat=True)[10]
except IndexError:
pass
else:
return qs.filter(pk__lte=tenth_id)
return qs
class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin):
list_display = (
'id', 'service_link', 'account_link', 'content_object_link',
'display_registered_on', 'display_billed_until', 'display_cancelled_on'
'display_registered_on', 'display_billed_until', 'display_cancelled_on', 'display_metric'
)
list_filter = (ActiveOrderListFilter, BilledOrderListFilter, IgnoreOrderListFilter, 'service',)
default_changelist_filters = (
('ignore', '0'),
)
actions = (BillSelectedOrders(), mark_as_ignored, mark_as_not_ignored)
change_view_actions = (BillSelectedOrders(), mark_as_ignored, mark_as_not_ignored)
date_hierarchy = 'registered_on'
inlines = (MetricStorageInline,)
add_inlines = ()
search_fields = ('account__username', 'description')
service_link = admin_link('service')
content_object_link = admin_link('content_object', order=False)
@ -42,6 +77,11 @@ class OrderAdmin(ChangeListDefaultFilter, AccountAdminMixin, admin.ModelAdmin):
display_billed_until.allow_tags = True
display_billed_until.admin_order_field = 'billed_until'
def display_metric(self, order):
metric = order.metrics.latest()
return metric.value if metric else ''
display_metric.short_description = _("Metric")
def get_queryset(self, request):
qs = super(OrderAdmin, self).get_queryset(request)
return qs.select_related('service').prefetch_related('content_object')

View File

@ -29,8 +29,8 @@ class BillSelectedOptionsForm(AdminFormMixin, forms.Form):
def selected_related_choices(queryset):
for order in queryset:
verbose = '<a href="{order_url}">{description}</a> '
verbose += '<a class="account" href="{account_url}">{account}</a>'
verbose = u'<a href="{order_url}">{description}</a> '
verbose += u'<a class="account" href="{account_url}">{account}</a>'
verbose = verbose.format(
order_url=change_url(order), description=order.description,
account_url=change_url(order.account), account=str(order.account)

View File

@ -2,6 +2,7 @@ import decimal
from django.core.validators import ValidationError
from django.db import models
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from orchestra.core import services, accounts
@ -22,7 +23,7 @@ class Plan(models.Model):
help_text=_("Designates whether this plan allow for multiple contractions."))
def __unicode__(self):
return self.name
return self.get_verbose_name()
def clean(self):
self.verbose_name = self.verbose_name.strip()
@ -43,7 +44,7 @@ class ContractedPlan(models.Model):
return str(self.plan)
def clean(self):
if not self.pk and not self.plan.allow_multiples:
if not self.pk and not self.plan.allow_multiple:
if ContractedPlan.objects.filter(plan=self.plan, account=self.account).exists():
raise ValidationError("A contracted plan for this account already exists.")

View File

@ -23,7 +23,7 @@ class PhpListSaaSBackend(ServiceController):
raise RuntimeError("Database is not yet configured")
install = re.search(r'([^"]+firstinstall[^"]+)', admin_content)
if install:
if not saas.password:
if not hasattr(saas, 'password'):
raise RuntimeError("Password is missing")
install = install.groups()[0]
install_link = admin_link + install[1:]
@ -38,7 +38,7 @@ class PhpListSaaSBackend(ServiceController):
print response.content
if response.status_code != 200:
raise RuntimeError("Bad status code %i" % response.status_code)
elif saas.password:
elif hasattr(saas, 'password'):
raise NotImplementedError
def save(self, saas):

View File

@ -0,0 +1,17 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.db import DEFAULT_DB_ALIAS
from orchestra.apps.databases.models import Database
class VirtualDatabaseRelation(GenericRelation):
""" Delete related databases if any """
def bulk_related_objects(self, objs, using=DEFAULT_DB_ALIAS):
pks = []
for obj in objs:
if obj.database_id:
pks.append(obj.database_id)
if not pks:
return []
# TODO renamed to self.remote_field in django 1.8
return self.rel.to._base_manager.db_manager(using).filter(pk__in=pks)

View File

@ -8,6 +8,7 @@ from jsonfield import JSONField
from orchestra.core import services, validators
from orchestra.models.fields import NullableCharField
from .fields import VirtualDatabaseRelation
from .services import SoftwareService
@ -23,6 +24,10 @@ class SaaS(models.Model):
help_text=_("Designates whether this service should be treated as active. "))
data = JSONField(_("data"), default={},
help_text=_("Extra information dependent of each service."))
database = models.ForeignKey('databases.Database', null=True, blank=True)
# Some SaaS sites may need a database, with this virtual field we tell the ORM to delete them
databases = VirtualDatabaseRelation('databases.Database')
class Meta:
verbose_name = "SaaS"

View File

@ -3,13 +3,10 @@ from django.utils.translation import ugettext_lazy as _
from orchestra.plugins.forms import PluginDataForm
from .options import SoftwareService
from .options import SoftwareService, SoftwareServiceForm
class MoodleForm(PluginDataForm):
username = forms.CharField(label=_("Username"), max_length=64)
password = forms.CharField(label=_("Password"), max_length=64)
site_name = forms.CharField(label=_("Site name"), max_length=64)
class MoodleForm(SoftwareServiceForm):
email = forms.EmailField(label=_("Email"))

View File

@ -26,6 +26,9 @@ class SoftwareServiceForm(PluginDataForm):
widget=forms.PasswordInput,
help_text=_("Enter the same password as above, for verification."))
class Meta:
exclude = ('database',)
def __init__(self, *args, **kwargs):
super(SoftwareServiceForm, self).__init__(*args, **kwargs)
self.is_change = bool(self.instance and self.instance.pk)

View File

@ -1,4 +1,5 @@
from django import forms
from django.core.urlresolvers import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
@ -24,7 +25,7 @@ class PHPListForm(SoftwareServiceForm):
class PHPListChangeForm(PHPListForm):
db_name = forms.CharField(label=_("Database name"),
database = forms.CharField(label=_("Database"), required=False,
help_text=_("Database used for this webapp."))
def __init__(self, *args, **kwargs):
@ -33,10 +34,11 @@ class PHPListChangeForm(PHPListForm):
admin_url = "http://%s/admin/" % site_domain
help_text = _("Admin URL <a href={0}>{0}</a>").format(admin_url)
self.fields['site_url'].help_text = help_text
class PHPListSerializer(serializers.Serializer):
db_name = serializers.CharField(label=_("Database name"), required=False)
# DB link
db = self.instance.database
db_url = reverse('admin:databases_database_change', args=(db.pk,))
db_link = mark_safe('<a href="%s">%s</a>' % (db_url, db.name))
self.fields['database'].widget = widgets.ReadOnlyWidget(db.name, db_link)
class PHPListService(SoftwareService):
@ -44,8 +46,6 @@ class PHPListService(SoftwareService):
verbose_name = "phpList"
form = PHPListForm
change_form = PHPListChangeForm
change_readonly_fileds = ('db_name',)
serializer = PHPListSerializer
icon = 'orchestra/icons/apps/Phplist.png'
site_base_domain = settings.SAAS_PHPLIST_BASE_DOMAIN
@ -77,29 +77,7 @@ class PHPListService(SoftwareService):
db_name = self.get_db_name()
db_user = self.get_db_user()
account = self.get_account()
db, db_created = account.databases.get_or_create(name=db_name)
db, db_created = account.databases.get_or_create(name=db_name, type=Database.MYSQL)
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.get_account()
db_name = self.instance.data.get('db_name')
try:
db = account.databases.get(name=db_name)
except Database.DoesNotExist:
pass
else:
related.append(db)
return related
self.instance.database_id = db.pk

View File

@ -53,7 +53,7 @@ class Service(models.Model):
"Related instance can be instantiated with <tt>instance</tt> keyword or "
"<tt>content_type.model_name</tt>.</br>"
"<tt>&nbsp;databaseuser.type == 'MYSQL'</tt><br>"
"<tt>&nbsp;miscellaneous.active and miscellaneous.identifier.endswith(('.org', '.net', '.com'))</tt><br>"
"<tt>&nbsp;miscellaneous.active and str(miscellaneous.identifier).endswith(('.org', '.net', '.com'))</tt><br>"
"<tt>&nbsp;contractedplan.plan.name == 'association_fee''</tt><br>"
"<tt>&nbsp;instance.active</tt>"))
handler_type = models.CharField(_("handler"), max_length=256, blank=True,
@ -117,9 +117,10 @@ class Service(models.Model):
decimal_places=2)
tax = models.PositiveIntegerField(_("tax"), choices=settings.SERVICES_SERVICE_TAXES,
default=settings.SERVICES_SERVICE_DEFAULT_TAX)
pricing_period = models.CharField(_("pricing period"), max_length=16,
pricing_period = models.CharField(_("pricing period"), max_length=16, blank=True,
help_text=_("Time period that is used for computing the rate metric."),
choices=(
(NEVER, _("Current value")),
(BILLING_PERIOD, _("Same as billing period")),
(MONTHLY, _("Monthly data")),
(ANUAL, _("Anual data")),

View File

@ -208,7 +208,10 @@ class Exim4Traffic(ServiceMonitor):
with open(mainlog, 'r') as mainlog:
for line in mainlog.readlines():
if ' <= ' in line and 'P=local' in line:
username = user_regex.search(line).groups()[0]
username = user_regex.search(line)
if not username:
continue
username = username.groups()[0]
try:
sender = users[username]
except KeyError:

View File

@ -39,9 +39,8 @@ class WebAppOptionInline(admin.TabularInline):
plugin = AppType.get_plugin(request.GET['type'])
kwargs['choices'] = plugin.get_options_choices()
# Help text based on select widget
kwargs['widget'] = DynamicHelpTextSelect(
'this.id.replace("name", "value")', self.OPTIONS_HELP_TEXT
)
target = 'this.id.replace("name", "value")'
kwargs['widget'] = DynamicHelpTextSelect(target, self.OPTIONS_HELP_TEXT)
return super(WebAppOptionInline, self).formfield_for_dbfield(db_field, **kwargs)
@ -66,7 +65,6 @@ class WebAppAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedModelAdmin)
websites.append('<a href="%s">%s</a>' % (url, name))
if not websites:
add_url = reverse('admin:websites_website_add')
# TODO support for preselecting related web app on website
add_url += '?account=%s' % webapp.account_id
plus = '<strong style="color:green; font-size:12px">+</strong>'
websites.append('<a href="%s">%s%s</a>' % (add_url, plus, ugettext("Add website")))

View File

@ -37,7 +37,8 @@ class WebAppServiceMixin(object):
'type': webapp.type,
'app_path': webapp.get_path().rstrip('/'),
'banner': self.get_banner(),
'under_construction_path': settings.settings.WEBAPPS_UNDER_CONSTRUCTION_PATH
'under_construction_path': settings.settings.WEBAPPS_UNDER_CONSTRUCTION_PATH,
'is_mounted': webapp.content_set.exists(),
}

View File

@ -12,7 +12,7 @@ from .. import settings
class PHPBackend(WebAppServiceMixin, ServiceController):
verbose_name = _("PHP FPM/FCGID")
default_route_match = "webapp.type == 'php'"
default_route_match = "webapp.type.endswith('php')"
MERGE = settings.WEBAPPS_MERGE_PHP_WEBAPPS
def save(self, webapp):
@ -34,7 +34,8 @@ class PHPBackend(WebAppServiceMixin, ServiceController):
} || {
echo -e "${fpm_config}" > %(fpm_path)s
UPDATEDFPM=1
}""") % context
}
""") % context
)
def save_fcgid(self, webapp, context):
@ -46,8 +47,10 @@ class PHPBackend(WebAppServiceMixin, ServiceController):
{
echo -e "${wrapper}" | diff -N -I'^\s*#' %(wrapper_path)s -
} || {
echo -e "${wrapper}" > %(wrapper_path)s; UPDATED_APACHE=1
}""") % context
echo -e "${wrapper}" > %(wrapper_path)s
[[ ${UPDATED_APACHE} -eq 0 ]] && UPDATED_APACHE=%(is_mounted)i
}
""") % context
)
self.append("chmod 550 %(wrapper_dir)s" % context)
self.append("chmod 550 %(wrapper_path)s" % context)
@ -58,8 +61,10 @@ class PHPBackend(WebAppServiceMixin, ServiceController):
{
echo -e "${cmd_options}" | diff -N -I'^\s*#' %(cmd_options_path)s -
} || {
echo -e "${cmd_options}" > %(cmd_options_path)s; UPDATED_APACHE=1
}""" ) % context
echo -e "${cmd_options}" > %(cmd_options_path)s
[[ ${UPDATED_APACHE} -eq 0 ]] && UPDATED_APACHE=%(is_mounted)i
}
""" ) % context
)
else:
self.append("rm -f %(cmd_options_path)s" % context)
@ -85,12 +90,14 @@ class PHPBackend(WebAppServiceMixin, ServiceController):
if [[ $UPDATEDFPM == 1 ]]; then
service php5-fpm reload
service php5-fpm start
fi""")
fi
""")
)
self.append(textwrap.dedent("""\
if [[ $UPDATED_APACHE == 1 ]]; then
service apache2 reload
fi""")
fi
""")
)
def get_fpm_config(self, webapp, context):
@ -149,7 +156,10 @@ class PHPBackend(WebAppServiceMixin, ServiceController):
if value:
cmd_options.append("%s %s" % (directive, value))
if cmd_options:
head = '# %(banner)s\nFcgidCmdOptions %(wrapper_path)s' % context
head = (
'# %(banner)s\n'
'FcgidCmdOptions %(wrapper_path)s'
) % context
cmd_options.insert(0, head)
return ' \\\n '.join(cmd_options)

View File

@ -9,40 +9,112 @@ from .. import settings
from . import WebAppServiceMixin
# Based on https://github.com/mtomic/wordpress-install/blob/master/wpinstall.php
class WordPressBackend(WebAppServiceMixin, ServiceController):
verbose_name = _("Wordpress")
model = 'webapps.WebApp'
default_route_match = "webapp.type == 'wordpress'"
default_route_match = "webapp.type == 'wordpress-php'"
script_executable = '/usr/bin/php'
def prepare(self):
self.append(textwrap.dedent("""\
<?php
function exc($cmd) {
passthru($cmd, $exit_code);
if ($exit_code != 0) {
echo "ERROR: execution returned non-zero code: $exit_code. cmd was:\\n$cmd\\n";
exit($exit_code);
}
}""")
)
def save(self, webapp):
context = self.get_context(webapp)
self.create_webapp_dir(context)
self.append(textwrap.dedent("""\
# Check if directory is empty befor doing anything
if [[ ! $(ls -A %(app_path)s) ]]; then
wget http://wordpress.org/latest.tar.gz -O - --no-check-certificate \\
| tar -xzvf - -C %(app_path)s --strip-components=1
cp %(app_path)s/wp-config-sample.php %(app_path)s/wp-config.php
sed -i "s/database_name_here/%(db_name)s/" %(app_path)s/wp-config.php
sed -i "s/username_here/%(db_user)s/" %(app_path)s/wp-config.php
sed -i "s/password_here/%(db_pass)s/" %(app_path)s/wp-config.php
sed -i "s/localhost/%(db_host)s/" %(app_path)s/wp-config.php
mkdir %(app_path)s/wp-content/uploads
chmod 750 %(app_path)s/wp-content/uploads
chown -R %(user)s:%(group)s %(app_path)s
fi""") % context
if (count(glob("%(app_path)s/*")) > 1) {
die("App directory not empty.");
}
exc('mkdir -p %(app_path)s');
exc('rm -f %(app_path)s/index.html');
exc('wget http://wordpress.org/latest.tar.gz -O - --no-check-certificate | tar -xzvf - -C %(app_path)s --strip-components=1');
exc('mkdir %(app_path)s/wp-content/uploads');
exc('chmod 750 %(app_path)s/wp-content/uploads');
exc('chown -R %(user)s:%(group)s %(app_path)s');
$config_file = file('%(app_path)s/' . 'wp-config-sample.php');
$secret_keys = file_get_contents('https://api.wordpress.org/secret-key/1.1/salt/');
$secret_keys = explode( "\\n", $secret_keys );
foreach ( $secret_keys as $k => $v ) {
$secret_keys[$k] = substr( $v, 28, 64 );
}
array_pop($secret_keys);
$config_file = str_replace('database_name_here', '%(db_name)s', $config_file);
$config_file = str_replace('username_here', '%(db_user)s', $config_file);
$config_file = str_replace('password_here', '%(password)s', $config_file);
$config_file = str_replace('localhost', '%(db_host)s', $config_file);
$config_file = str_replace("'AUTH_KEY', 'put your unique phrase here'", "'AUTH_KEY', '{$secret_keys[0]}'", $config_file);
$config_file = str_replace("'SECURE_AUTH_KEY', 'put your unique phrase here'", "'SECURE_AUTH_KEY', '{$secret_keys[1]}'", $config_file);
$config_file = str_replace("'LOGGED_IN_KEY', 'put your unique phrase here'", "'LOGGED_IN_KEY', '{$secret_keys[2]}'", $config_file);
$config_file = str_replace("'NONCE_KEY', 'put your unique phrase here'", "'NONCE_KEY', '{$secret_keys[3]}'", $config_file);
$config_file = str_replace("'AUTH_SALT', 'put your unique phrase here'", "'AUTH_SALT', '{$secret_keys[4]}'", $config_file);
$config_file = str_replace("'SECURE_AUTH_SALT', 'put your unique phrase here'", "'SECURE_AUTH_SALT', '{$secret_keys[5]}'", $config_file);
$config_file = str_replace("'LOGGED_IN_SALT', 'put your unique phrase here'", "'LOGGED_IN_SALT', '{$secret_keys[6]}'", $config_file);
$config_file = str_replace("'NONCE_SALT', 'put your unique phrase here'", "'NONCE_SALT', '{$secret_keys[7]}'", $config_file);
if(file_exists('%(app_path)s/' .'wp-config.php')) {
unlink('%(app_path)s/' .'wp-config.php');
}
$fw = fopen('%(app_path)s/' . 'wp-config.php', 'a');
foreach ( $config_file as $line_num => $line ) {
fwrite($fw, $line);
}
define('WP_CONTENT_DIR', 'wp-content/');
define('WP_LANG_DIR', WP_CONTENT_DIR . '/languages' );
define('WP_USE_THEMES', true);
define('DB_NAME', '%(db_name)s');
define('DB_USER', '%(db_user)s');
define('DB_PASSWORD', '%(password)s');
define('DB_HOST', '%(db_host)s');
$_GET['step'] = 2;
$_POST['weblog_title'] = "%(title)s";
$_POST['user_name'] = "admin";
$_POST['admin_email'] = "%(email)s";
$_POST['blog_public'] = true;
$_POST['admin_password'] = "%(password)s";
$_POST['admin_password2'] = "%(password)s";
function wp_new_blog_notification($blog_title, $blog_url, $user_id, $password){
// do nothing
}
ob_start();
require_once('%(app_path)s/wp-admin/install.php');
$response = ob_get_contents();
ob_end_clean();
if (strpos($response, '<h1>Success!</h1>') === false) {
echo "Error has occured during installation\\n";
echo $msg;
exit(1);
}""") % context
)
def commit(self):
self.append('?>')
def delete(self, webapp):
context = self.get_context(webapp)
self.delete_webapp_dir(context)
self.append("exc('rm -rf %(app_path)s');" % context)
def get_context(self, webapp):
context = super(WordPressBackend, self).get_context(webapp)
context.update({
'db_name': webapp.data['db_name'],
'db_user': webapp.data['db_user'],
'db_pass': webapp.data['db_pass'],
'password': webapp.data['password'],
'db_host': settings.WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST,
'title': "%s blog's" % webapp.account.get_full_name(),
'email': webapp.account.email,
})
return context

View File

@ -0,0 +1,32 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.db import DEFAULT_DB_ALIAS
from orchestra.apps.databases.models import Database, DatabaseUser
class VirtualDatabaseRelation(GenericRelation):
""" Delete related databases if any """
def bulk_related_objects(self, objs, using=DEFAULT_DB_ALIAS):
pks = []
for obj in objs:
db_id = obj.data.get('db_id')
if db_id:
pks.append(db_id)
if not pks:
return []
# TODO renamed to self.remote_field in django 1.8
return self.rel.to._base_manager.db_manager(using).filter(pk__in=pks)
class VirtualDatabaseUserRelation(GenericRelation):
""" Delete related databases if any """
def bulk_related_objects(self, objs, using=DEFAULT_DB_ALIAS):
pks = []
for obj in objs:
db_id = obj.data.get('db_user_id')
if db_id:
pks.append(db_id)
if not pks:
return []
# TODO renamed to self.remote_field in django 1.8
return self.rel.to._base_manager.db_manager(using).filter(pk__in=pks)

View File

@ -13,6 +13,7 @@ from orchestra.core import validators, services
from orchestra.utils.functional import cached
from . import settings
from .fields import VirtualDatabaseRelation, VirtualDatabaseUserRelation
from .options import AppOption
from .types import AppType
@ -27,6 +28,10 @@ class WebApp(models.Model):
data = JSONField(_("data"), blank=True, default={},
help_text=_("Extra information dependent of each service."))
# CMS webapps usually need a database and dbuser, with these virtual fields we tell the ORM to delete them
databases = VirtualDatabaseRelation('databases.Database')
databaseusers = VirtualDatabaseUserRelation('databases.DatabaseUser')
class Meta:
unique_together = ('name', 'account')
verbose_name = _("Web App")

View File

@ -71,9 +71,6 @@ class AppType(plugins.Plugin):
def delete(self):
pass
def get_related_objects(self):
pass
def get_directive_context(self):
return {
'app_id': self.instance.id,

View File

@ -0,0 +1,104 @@
from django import forms
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
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 orchestra.utils.python import random_ascii
from .. import settings
from .php import PHPApp, PHPAppForm, PHPAppSerializer
class CMSAppForm(PHPAppForm):
db_name = forms.CharField(label=_("Database name"),
help_text=_("Database exclusively used for this webapp."))
db_user = forms.CharField(label=_("Database user"),
help_text=_("Database user exclusively used for this webapp."))
password = forms.CharField(label=_("Password"),
help_text=_("Initial database and WordPress admin password.<br>"
"Subsequent changes to the admin password will not be reflected."))
def __init__(self, *args, **kwargs):
super(CMSAppForm, self).__init__(*args, **kwargs)
if self.instance:
data = self.instance.data
# DB link
db_name = data.get('db_name')
db_id = data.get('db_id')
db_url = reverse('admin:databases_database_change', args=(db_id,))
db_link = mark_safe('<a href="%s">%s</a>' % (db_url, db_name))
self.fields['db_name'].widget = widgets.ReadOnlyWidget(db_name, db_link)
# DB user link
db_user = data.get('db_user')
db_user_id = data.get('db_user_id')
db_user_url = reverse('admin:databases_databaseuser_change', args=(db_user_id,))
db_user_link = mark_safe('<a href="%s">%s</a>' % (db_user_url, db_user))
self.fields['db_user'].widget = widgets.ReadOnlyWidget(db_user, db_user_link)
class CMSAppSerializer(PHPAppSerializer):
db_name = serializers.CharField(label=_("Database name"), required=False)
db_user = serializers.CharField(label=_("Database user"), required=False)
password = serializers.CharField(label=_("Password"), required=False)
db_id = serializers.IntegerField(label=_("Database ID"), required=False)
db_user_id = serializers.IntegerField(label=_("Database user ID"), required=False)
class CMSApp(PHPApp):
""" Abstract AppType with common CMS functionality """
serializer = CMSAppSerializer
change_form = CMSAppForm
change_readonly_fileds = ('db_name', 'db_user', 'password',)
db_type = Database.MYSQL
def get_db_name(self):
db_name = 'wp_%s_%s' % (self.instance.name, self.instance.account)
# Limit for mysql database names
return db_name[:65]
def get_db_user(self):
db_name = self.get_db_name()
# Limit for mysql user names
return db_name[:16]
def get_password(self):
return random_ascii(10)
def validate(self):
super(CMSApp, self).validate()
create = not self.instance.pk
if create:
db = Database(name=self.get_db_name(), account=self.instance.account, type=self.db_type)
user = DatabaseUser(username=self.get_db_user(), password=self.get_password(),
account=self.instance.account, type=self.db_type)
for obj in (db, user):
try:
obj.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()
password = self.get_password()
db, db_created = self.instance.account.databases.get_or_create(name=db_name, type=self.db_type)
if db_created:
user = DatabaseUser(username=db_user, account=self.instance.account, type=self.db_type)
user.set_password(password)
user.save()
db.users.add(user)
self.instance.data = {
'db_name': db_name,
'db_user': db_user,
'password': password,
'db_id': db.id,
'db_user_id': user.id,
}

View File

@ -1,105 +1,13 @@
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from orchestra.apps.databases.models import Database, DatabaseUser
from orchestra.plugins.forms import PluginDataForm
from orchestra.utils.python import random_ascii
from .. import settings
from .php import PHPApp, PHPAppForm, PHPAppSerializer
from .cms import CMSApp
class WordPressAppForm(PHPAppForm):
db_name = forms.CharField(label=_("Database name"),
help_text=_("Database used for this webapp."))
db_user = forms.CharField(label=_("Database user"),)
db_pass = forms.CharField(label=_("Database user password"),
help_text=_("Initial database password."))
class WordPressAppSerializer(PHPAppSerializer):
db_name = serializers.CharField(label=_("Database name"), required=False)
db_user = serializers.CharField(label=_("Database user"), required=False)
db_pass = serializers.CharField(label=_("Database user password"), required=False)
class WordPressApp(PHPApp):
name = 'wordpress'
class WordPressApp(CMSApp):
name = 'wordpress-php'
verbose_name = "WordPress"
serializer = WordPressAppSerializer
change_form = WordPressAppForm
change_readonly_fileds = ('db_name', 'db_user', 'db_pass',)
help_text = _("Visit http://&lt;domain.lan&gt;/wp-admin/install.php to finish the installation.")
help_text = _(
"Visit http://&lt;domain.lan&gt;/wp-admin/install.php to finish the installation.<br>"
"A database and database user will automatically be created for this webapp."
)
icon = 'orchestra/icons/apps/WordPress.png'
def get_db_name(self):
db_name = 'wp_%s_%s' % (self.instance.name, self.instance.account)
# Limit for mysql database names
return db_name[:65]
def get_db_user(self):
db_name = self.get_db_name()
# Limit for mysql user names
return db_name[:16]
def get_db_pass(self):
return random_ascii(10)
def validate(self):
super(WordPressApp, self).validate()
create = not self.instance.pk
if create:
db = Database(name=self.get_db_name(), account=self.instance.account)
user = DatabaseUser(username=self.get_db_user(), password=self.get_db_pass(),
account=self.instance.account)
for obj in (db, user):
try:
obj.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_pass = self.get_db_pass()
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()
db.users.add(user)
self.instance.data = {
'db_name': db_name,
'db_user': db_user,
'db_pass': db_pass,
}
else:
# 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_user = account.databaseusers.get(username=self.instance.data.get('db_user'))
except DatabaseUser.DoesNotExist:
pass
else:
related.append(db_user)
try:
db = account.databases.get(name=self.instance.data.get('db_name'))
except Database.DoesNotExist:
pass
else:
related.append(db)
return related

View File

@ -30,9 +30,8 @@ class WebsiteDirectiveInline(admin.TabularInline):
kwargs['widget'] = forms.TextInput(attrs={'size':'100'})
if db_field.name == 'name':
# Help text based on select widget
kwargs['widget'] = DynamicHelpTextSelect(
'this.id.replace("name", "value")', self.DIRECTIVES_HELP_TEXT
)
target = 'this.id.replace("name", "value")'
kwargs['widget'] = DynamicHelpTextSelect(target, self.DIRECTIVES_HELP_TEXT)
return super(WebsiteDirectiveInline, self).formfield_for_dbfield(db_field, **kwargs)
@ -85,7 +84,7 @@ class WebsiteAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
for content in website.content_set.all():
webapp = content.webapp
url = change_url(webapp)
name = "%s on %s" % (webapp.get_type_display(), content.path)
name = "%s on %s" % (webapp.get_type_display(), content.path or '/')
webapps.append('<a href="%s">%s</a>' % (url, name))
return '<br>'.join(webapps)
display_webapps.allow_tags = True

View File

@ -32,6 +32,9 @@ class Apache2Backend(ServiceController):
extra_conf += self.get_redirects(directives)
extra_conf += self.get_proxies(directives)
extra_conf += self.get_saas(directives)
settings_context = site.get_settings_context()
for location, directive in settings.WEBSITES_VHOST_EXTRA_DIRECTIVES:
extra_conf.append((location, directive % settings_context))
# Order extra conf directives based on directives (longer first)
extra_conf = sorted(extra_conf, key=lambda a: len(a[0]), reverse=True)
context['extra_conf'] = '\n'.join([conf for location, conf in extra_conf])

View File

@ -93,12 +93,12 @@ class Website(models.Model):
def get_www_access_log_path(self):
context = self.get_settings_context()
path = settings.WEBSITES_WEBSITE_WWW_ACCESS_LOG_PATH % context
return os.path.normpath(path.replace('//', '/'))
return os.path.normpath(path)
def get_www_error_log_path(self):
context = self.get_settings_context()
path = settings.WEBSITES_WEBSITE_WWW_ERROR_LOG_PATH % context
return os.path.normpath(path.replace('//', '/'))
return os.path.normpath(path)
class WebsiteDirective(models.Model):

View File

@ -94,3 +94,8 @@ WEBSITES_DEFAULT_SSL_KEY = getattr(settings, 'WEBSITES_DEFAULT_SSL_KEY',
WEBSITES_DEFAULT_SSL_CA = getattr(settings, 'WEBSITES_DEFAULT_SSL_CA',
''
)
WEBSITES_VHOST_EXTRA_DIRECTIVES = getattr(settings, 'WEBSITES_VHOST_EXTRA_DIRECTIVES', (
# (<location>, <directive>),
# ('/cgi-bin/', 'ScriptAlias /cgi-bin/ %(home)s/cgi-bin/'),
))

View File

@ -13,11 +13,10 @@ class PluginDataForm(forms.ModelForm):
display = '%s <a href=".">change</a>' % unicode(self.plugin.verbose_name)
self.fields[self.plugin_field].widget = ReadOnlyWidget(value, display)
self.fields[self.plugin_field].help_text = getattr(self.plugin, 'help_text', '')
instance = kwargs.get('instance')
if instance:
if self.instance:
for field in self.declared_fields:
initial = self.fields[field].initial
self.fields[field].initial = instance.data.get(field, initial)
self.fields[field].initial = self.instance.data.get(field, initial)
if self.instance.pk:
for field in self.plugin.get_change_readonly_fileds():
value = getattr(self.instance, field, None) or self.instance.data[field]
@ -27,14 +26,12 @@ class PluginDataForm(forms.ModelForm):
display = foo_display()
self.fields[field].required = False
self.fields[field].widget = ReadOnlyWidget(value, display)
# self.fields[field].help_text = None
def clean(self):
# TODO clean all filed within data???
data = {}
for field in self.declared_fields:
for field, value in self.instance.data.iteritems():
try:
data[field] = self.cleaned_data[field]
except KeyError:
data[field] = self.data[field]
data[field] = value
self.cleaned_data['data'] = data

View File

@ -93,17 +93,24 @@ class PluginModelAdapter(Plugin):
@classmethod
def get_plugins(cls):
plugins = []
for instance in cls.model.objects.filter(is_active=True):
for related_instance in cls.model.objects.filter(is_active=True):
attributes = {
'instance': instance,
'verbose_name': instance.verbose_name
'related_instance': related_instance,
'verbose_name': related_instance.verbose_name
}
plugins.append(type('PluginAdapter', (cls,), attributes))
return plugins
@classmethod
def get_plugin(cls, name):
# don't cache, since models can change
for plugin in cls.get_plugins():
if name == plugin.get_name():
return plugin
@classmethod
def get_name(cls):
return getattr(cls.instance, cls.name_field)
return getattr(cls.related_instance, cls.name_field)
class PluginMount(type):