diff --git a/TODO.md b/TODO.md index 7af04f4d..7fc67f7c 100644 --- a/TODO.md +++ b/TODO.md @@ -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]]) - [, ] - 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 .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 diff --git a/orchestra/admin/options.py b/orchestra/admin/options.py index 6f56f219..fd95132f 100644 --- a/orchestra/admin/options.py +++ b/orchestra/admin/options.py @@ -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 diff --git a/orchestra/apps/accounts/admin.py b/orchestra/apps/accounts/admin.py index 3ae363a3..ce5d13a8 100644 --- a/orchestra/apps/accounts/admin.py +++ b/orchestra/apps/accounts/admin.py @@ -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 += "
This user's account is dissabled" 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 """ diff --git a/orchestra/apps/databases/backends.py b/orchestra/apps/databases/backends.py index 171701bd..90bfe136 100644 --- a/orchestra/apps/databases/backends.py +++ b/orchestra/apps/databases/backends.py @@ -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 { diff --git a/orchestra/apps/domains/settings.py b/orchestra/apps/domains/settings.py index 766a7a1d..9096e129 100644 --- a/orchestra/apps/domains/settings.py +++ b/orchestra/apps/domains/settings.py @@ -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') diff --git a/orchestra/apps/domains/validators.py b/orchestra/apps/domains/validators.py index 2e116ad4..b38f1a84 100644 --- a/orchestra/apps/domains/validators.py +++ b/orchestra/apps/domains/validators.py @@ -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)) diff --git a/orchestra/apps/miscellaneous/admin.py b/orchestra/apps/miscellaneous/admin.py index a221079e..050379e2 100644 --- a/orchestra/apps/miscellaneous/admin.py +++ b/orchestra/apps/miscellaneous/admin.py @@ -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) diff --git a/orchestra/apps/miscellaneous/models.py b/orchestra/apps/miscellaneous/models.py index f3efe473..fe7e9fda 100644 --- a/orchestra/apps/miscellaneous/models.py +++ b/orchestra/apps/miscellaneous/models.py @@ -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() diff --git a/orchestra/apps/orders/admin.py b/orchestra/apps/orders/admin.py index bc77ec53..9e84e529 100644 --- a/orchestra/apps/orders/admin.py +++ b/orchestra/apps/orders/admin.py @@ -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, (See all)') + 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') diff --git a/orchestra/apps/orders/forms.py b/orchestra/apps/orders/forms.py index c96da35a..e7e71bdc 100644 --- a/orchestra/apps/orders/forms.py +++ b/orchestra/apps/orders/forms.py @@ -29,8 +29,8 @@ class BillSelectedOptionsForm(AdminFormMixin, forms.Form): def selected_related_choices(queryset): for order in queryset: - verbose = '{description} ' - verbose += '' + verbose = u'{description} ' + verbose += u'' verbose = verbose.format( order_url=change_url(order), description=order.description, account_url=change_url(order.account), account=str(order.account) diff --git a/orchestra/apps/plans/models.py b/orchestra/apps/plans/models.py index f0d1a57e..c79b5061 100644 --- a/orchestra/apps/plans/models.py +++ b/orchestra/apps/plans/models.py @@ -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.") diff --git a/orchestra/apps/saas/backends/phplist.py b/orchestra/apps/saas/backends/phplist.py index bfc242db..d19223ba 100644 --- a/orchestra/apps/saas/backends/phplist.py +++ b/orchestra/apps/saas/backends/phplist.py @@ -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): diff --git a/orchestra/apps/saas/fields.py b/orchestra/apps/saas/fields.py new file mode 100644 index 00000000..b1bbde7f --- /dev/null +++ b/orchestra/apps/saas/fields.py @@ -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) diff --git a/orchestra/apps/saas/models.py b/orchestra/apps/saas/models.py index d58ee537..d622abfa 100644 --- a/orchestra/apps/saas/models.py +++ b/orchestra/apps/saas/models.py @@ -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" diff --git a/orchestra/apps/saas/services/moodle.py b/orchestra/apps/saas/services/moodle.py index d2c69995..08461ead 100644 --- a/orchestra/apps/saas/services/moodle.py +++ b/orchestra/apps/saas/services/moodle.py @@ -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")) diff --git a/orchestra/apps/saas/services/options.py b/orchestra/apps/saas/services/options.py index f3468c6f..57dc8fa7 100644 --- a/orchestra/apps/saas/services/options.py +++ b/orchestra/apps/saas/services/options.py @@ -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) diff --git a/orchestra/apps/saas/services/phplist.py b/orchestra/apps/saas/services/phplist.py index 03e71ef4..10ec6239 100644 --- a/orchestra/apps/saas/services/phplist.py +++ b/orchestra/apps/saas/services/phplist.py @@ -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 {0}").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('%s' % (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 diff --git a/orchestra/apps/services/models.py b/orchestra/apps/services/models.py index 037ea1b5..e9bec3fc 100644 --- a/orchestra/apps/services/models.py +++ b/orchestra/apps/services/models.py @@ -53,7 +53,7 @@ class Service(models.Model): "Related instance can be instantiated with instance keyword or " "content_type.model_name.
" " databaseuser.type == 'MYSQL'
" - " miscellaneous.active and miscellaneous.identifier.endswith(('.org', '.net', '.com'))
" + " miscellaneous.active and str(miscellaneous.identifier).endswith(('.org', '.net', '.com'))
" " contractedplan.plan.name == 'association_fee''
" " instance.active")) 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")), diff --git a/orchestra/apps/systemusers/backends.py b/orchestra/apps/systemusers/backends.py index ed48f059..46056f9d 100644 --- a/orchestra/apps/systemusers/backends.py +++ b/orchestra/apps/systemusers/backends.py @@ -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: diff --git a/orchestra/apps/webapps/admin.py b/orchestra/apps/webapps/admin.py index 95ad696d..9056b6a3 100644 --- a/orchestra/apps/webapps/admin.py +++ b/orchestra/apps/webapps/admin.py @@ -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('%s' % (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 = '+' websites.append('%s%s' % (add_url, plus, ugettext("Add website"))) diff --git a/orchestra/apps/webapps/backends/__init__.py b/orchestra/apps/webapps/backends/__init__.py index ea0e2213..8a7e3c48 100644 --- a/orchestra/apps/webapps/backends/__init__.py +++ b/orchestra/apps/webapps/backends/__init__.py @@ -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(), } diff --git a/orchestra/apps/webapps/backends/php.py b/orchestra/apps/webapps/backends/php.py index 9cdbe5e6..5f181cf4 100644 --- a/orchestra/apps/webapps/backends/php.py +++ b/orchestra/apps/webapps/backends/php.py @@ -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) diff --git a/orchestra/apps/webapps/backends/wordpress.py b/orchestra/apps/webapps/backends/wordpress.py index b1cdb9a0..b84fad76 100644 --- a/orchestra/apps/webapps/backends/wordpress.py +++ b/orchestra/apps/webapps/backends/wordpress.py @@ -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("""\ + 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, '

Success!

') === 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 diff --git a/orchestra/apps/webapps/fields.py b/orchestra/apps/webapps/fields.py new file mode 100644 index 00000000..cca540f3 --- /dev/null +++ b/orchestra/apps/webapps/fields.py @@ -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) diff --git a/orchestra/apps/webapps/models.py b/orchestra/apps/webapps/models.py index 0372f3dc..10c3d6f7 100644 --- a/orchestra/apps/webapps/models.py +++ b/orchestra/apps/webapps/models.py @@ -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") diff --git a/orchestra/apps/webapps/types/__init__.py b/orchestra/apps/webapps/types/__init__.py index 3b210320..8ad6bccd 100644 --- a/orchestra/apps/webapps/types/__init__.py +++ b/orchestra/apps/webapps/types/__init__.py @@ -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, diff --git a/orchestra/apps/webapps/types/cms.py b/orchestra/apps/webapps/types/cms.py new file mode 100644 index 00000000..e9538cac --- /dev/null +++ b/orchestra/apps/webapps/types/cms.py @@ -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.
" + "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('%s' % (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('%s' % (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, + } diff --git a/orchestra/apps/webapps/types/wordpress.py b/orchestra/apps/webapps/types/wordpress.py index b0cfbe7b..20394a5e 100644 --- a/orchestra/apps/webapps/types/wordpress.py +++ b/orchestra/apps/webapps/types/wordpress.py @@ -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://<domain.lan>/wp-admin/install.php to finish the installation.") + help_text = _( + "Visit http://<domain.lan>/wp-admin/install.php to finish the installation.
" + "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 diff --git a/orchestra/apps/websites/admin.py b/orchestra/apps/websites/admin.py index a4500553..98906dbc 100644 --- a/orchestra/apps/websites/admin.py +++ b/orchestra/apps/websites/admin.py @@ -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('%s' % (url, name)) return '
'.join(webapps) display_webapps.allow_tags = True diff --git a/orchestra/apps/websites/backends/apache.py b/orchestra/apps/websites/backends/apache.py index ce43301e..7be86747 100644 --- a/orchestra/apps/websites/backends/apache.py +++ b/orchestra/apps/websites/backends/apache.py @@ -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]) diff --git a/orchestra/apps/websites/models.py b/orchestra/apps/websites/models.py index 84557dc6..5cc57622 100644 --- a/orchestra/apps/websites/models.py +++ b/orchestra/apps/websites/models.py @@ -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): diff --git a/orchestra/apps/websites/settings.py b/orchestra/apps/websites/settings.py index 88afb601..c9f86c39 100644 --- a/orchestra/apps/websites/settings.py +++ b/orchestra/apps/websites/settings.py @@ -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', ( + # (, ), + # ('/cgi-bin/', 'ScriptAlias /cgi-bin/ %(home)s/cgi-bin/'), +)) diff --git a/orchestra/plugins/forms.py b/orchestra/plugins/forms.py index 529f2564..a747dc61 100644 --- a/orchestra/plugins/forms.py +++ b/orchestra/plugins/forms.py @@ -13,11 +13,10 @@ class PluginDataForm(forms.ModelForm): display = '%s change' % 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 diff --git a/orchestra/plugins/options.py b/orchestra/plugins/options.py index 82cf4d1f..5d0a9803 100644 --- a/orchestra/plugins/options.py +++ b/orchestra/plugins/options.py @@ -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):