From 95a6a0c37daa4cd93f235327198a4706b1963fb3 Mon Sep 17 00:00:00 2001 From: Marc Aymerich Date: Thu, 1 Oct 2015 16:02:26 +0000 Subject: [PATCH] Added support for SaaS service custom URL --- TODO.md | 21 ++- orchestra/contrib/accounts/admin.py | 2 +- orchestra/contrib/domains/admin.py | 16 +-- orchestra/contrib/saas/admin.py | 40 +++++- orchestra/contrib/saas/backends/dokuwikimu.py | 42 +++++- orchestra/contrib/saas/backends/moodle.py | 21 +++ .../contrib/saas/backends/wordpressmu.py | 54 +++++++- orchestra/contrib/saas/filters.py | 21 +++ orchestra/contrib/saas/forms.py | 13 ++ .../migrations/0002_auto_20151001_0923.py | 24 ++++ orchestra/contrib/saas/models.py | 5 + orchestra/contrib/saas/services/dokuwiki.py | 15 ++ orchestra/contrib/saas/services/helpers.py | 130 ++++++++++++++++++ orchestra/contrib/saas/services/moodle.py | 1 + orchestra/contrib/saas/services/options.py | 77 ++++++++++- orchestra/contrib/saas/services/phplist.py | 3 + orchestra/contrib/saas/services/wordpress.py | 1 + orchestra/contrib/saas/settings.py | 30 ++++ orchestra/contrib/webapps/admin.py | 9 +- orchestra/contrib/webapps/filters.py | 2 +- orchestra/contrib/webapps/models.py | 3 +- orchestra/contrib/websites/directives.py | 12 +- orchestra/contrib/websites/forms.py | 5 +- orchestra/contrib/websites/settings.py | 1 + orchestra/plugins/admin.py | 11 +- orchestra/utils/html.py | 12 ++ 26 files changed, 518 insertions(+), 53 deletions(-) create mode 100644 orchestra/contrib/saas/filters.py create mode 100644 orchestra/contrib/saas/migrations/0002_auto_20151001_0923.py create mode 100644 orchestra/contrib/saas/services/helpers.py diff --git a/TODO.md b/TODO.md index 5e27b7cc..986f1f32 100644 --- a/TODO.md +++ b/TODO.md @@ -387,11 +387,6 @@ Case # Modsecurity rules template by cms (wordpress, joomla, dokuwiki (973337 973338 973347 958057), ... -# saas custom domains support (maybe a new form field with custom url? autoconfigure websites?) - custom_url form field and validate/create/delete related website - SAAS_PHPLIST_ALLOW_CUSTOM_URL = False - - deploy --dev deploy.sh and deploy-dev.sh autoupgrade @@ -401,7 +396,6 @@ orchestra home autocomplete short URLS: https://github.com/rsvp/gitio link backend help text variables to settings/#var_name -saas changelist domain: add
custom domain $ sudo python manage.py startservices Traceback (most recent call last): @@ -409,3 +403,18 @@ Traceback (most recent call last): from django.core.management import execute_from_command_line ImportError: No module named django.core.management + +autocomplete; on the form header and type="search" +To latest developers to post on this thread: I implemented the workaround I described in comment #14 nearly three months ago, and it has worked perfectly since then. While we would all prefer that "autocomplete=off" function properly at all times, it still functions properly if you include in your form an input element with any other autocomplete value. + +I simply added this code to my layout: + +
+ +
+ +Once I did this, all of my "autocomplete=off" elements were respected by Chrome. + +http://makandracards.com/makandra/24933-chrome-34+-firefox-38+-ie11+-ignore-autocomplete-off + + diff --git a/orchestra/contrib/accounts/admin.py b/orchestra/contrib/accounts/admin.py index 4cadc51b..05bfc961 100644 --- a/orchestra/contrib/accounts/admin.py +++ b/orchestra/contrib/accounts/admin.py @@ -180,7 +180,7 @@ class AccountAdminMixin(object): def account_link(self, instance): account = instance.account if instance.pk else self.account url = change_url(account) - return '%s' % (url, str(account)) + return '%s' % (url, account) account_link.short_description = _("account") account_link.allow_tags = True account_link.admin_order_field = 'account__username' diff --git a/orchestra/contrib/domains/admin.py b/orchestra/contrib/domains/admin.py index 4ad7d3d5..8b7645bc 100644 --- a/orchestra/contrib/domains/admin.py +++ b/orchestra/contrib/domains/admin.py @@ -1,7 +1,6 @@ from django import forms from django.contrib import admin from django.db.models.functions import Concat, Coalesce -from django.templatetags.static import static from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin @@ -9,6 +8,7 @@ from orchestra.admin.utils import admin_link, change_url from orchestra.contrib.accounts.actions import list_accounts from orchestra.contrib.accounts.admin import AccountAdminMixin from orchestra.utils import apps +from orchestra.utils.html import get_on_site_link from .actions import view_zone, edit_records, set_soa from .filters import TopDomainListFilter @@ -84,22 +84,12 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin): if websites: links = [] for website in websites: - context = { - 'title': _("View on site"), - 'url': website.get_absolute_url(), - 'image': '' % static('orchestra/images/view-on-site.png'), - } - site_link = '%(image)s' % context + site_link = get_on_site_link(website.get_absolute_url()) admin_url = change_url(website) link = '%s %s' % (admin_url, website.name, site_link) links.append(link) return '
'.join(links) - context = { - 'title': _("View on site"), - 'url': 'http://%s' % domain.name, - 'image': '' % static('orchestra/images/view-on-site.png'), - } - site_link = '%(image)s' % context + site_link = get_on_site_link('http://%s' % domain.name) return _("No website %s") % site_link display_websites.admin_order_field = 'websites__name' display_websites.short_description = _("Websites") diff --git a/orchestra/contrib/saas/admin.py b/orchestra/contrib/saas/admin.py index b5eedece..e10d6ecc 100644 --- a/orchestra/contrib/saas/admin.py +++ b/orchestra/contrib/saas/admin.py @@ -1,19 +1,24 @@ from django.contrib import admin +from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin from orchestra.admin.actions import disable +from orchestra.admin.utils import change_url from orchestra.contrib.accounts.actions import list_accounts from orchestra.contrib.accounts.admin import AccountAdminMixin from orchestra.plugins.admin import SelectPluginAdminMixin +from orchestra.utils.apps import isinstalled +from orchestra.utils.html import get_on_site_link +from .filters import CustomURLListFilter from .models import SaaS from .services import SoftwareService class SaaSAdmin(SelectPluginAdminMixin, ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin): - list_display = ('name', 'service', 'display_site_domain', 'account_link', 'is_active') - list_filter = ('service', 'is_active') + list_display = ('name', 'service', 'display_url', 'account_link', 'is_active') + list_filter = ('service', 'is_active', CustomURLListFilter) search_fields = ('name', 'account__username') change_readonly_fields = ('service',) plugin = SoftwareService @@ -21,12 +26,33 @@ class SaaSAdmin(SelectPluginAdminMixin, ChangePasswordAdminMixin, AccountAdminMi plugin_title = 'Software as a Service' actions = (disable, list_accounts) - def display_site_domain(self, saas): + def display_url(self, saas): site_domain = saas.get_site_domain() - return '%s' % (site_domain, site_domain) - display_site_domain.short_description = _("Site domain") - display_site_domain.allow_tags = True - display_site_domain.admin_order_field = 'name' + site_link = '%s' % (site_domain, site_domain) + links = [site_link] + if saas.custom_url and isinstalled('orchestra.contrib.websites'): + try: + website = saas.service_instance.get_website() + except ObjectDoesNotExist: + warning = _("Related website directive does not exist for this custom URL.") + link = '%s' % (warning, saas.custom_url) + else: + website_link = get_on_site_link(saas.custom_url) + admin_url = change_url(website) + link = '%s %s' % ( + admin_url, saas.custom_url, website_link + ) + links.append(link) + return '
'.join(links) + display_url.short_description = _("URL") + display_url.allow_tags = True + display_url.admin_order_field = 'name' + + def get_fields(self, *args, **kwargs): + fields = super(SaaSAdmin, self).get_fields(*args, **kwargs) + if not self.plugin_instance.allow_custom_url: + return [field for field in fields if field != 'custom_url'] + return fields admin.site.register(SaaS, SaaSAdmin) diff --git a/orchestra/contrib/saas/backends/dokuwikimu.py b/orchestra/contrib/saas/backends/dokuwikimu.py index 8ca85a11..ad3333e8 100644 --- a/orchestra/contrib/saas/backends/dokuwikimu.py +++ b/orchestra/contrib/saas/backends/dokuwikimu.py @@ -1,6 +1,7 @@ import crypt import os import textwrap +from urllib.parse import urlparse from django.utils.translation import ugettext_lazy as _ @@ -43,21 +44,58 @@ class DokuWikiMuBackend(ServiceController): echo 'admin:%(password)s:admin:%(email)s:admin,user' >> %(users_path)s fi""") % context ) + self.append(textwrap.dedent("""\ + # Update custom domain link + find %(farm_path)s \\ + -maxdepth 1 \\ + -type l \\ + -exec bash -c ' + if [[ $(readlink {}) == "%(domain)s" && $(basename {}) != "%(custom_domain)s" ]]; then + rm {} + fi' \;\ + """) % context + ) + if context['custom_domain']: + self.append(textwrap.dedent("""\ + if [[ ! -e %(farm_path)s/%(custom_domain)s ]]; then + ln -s %(domain)s %(farm_path)s/%(custom_domain)s + chown -h %(user)s:%(group) %(farm_path)s/%(custom_domain)s + fi""") % context + ) def delete(self, saas): context = self.get_context(saas) self.append("rm -fr %(app_path)s" % context) + self.append(textwrap.dedent("""\ + # Delete custom domain link + find %(farm_path)s \\ + -maxdepth 1 \\ + -type l \\ + -exec bash -c ' + if [[ $(readlink {}) == "%(domain)s" ]]; then + rm {} + fi' \;\ + """) % context + ) def get_context(self, saas): context = super(DokuWikiMuBackend, self).get_context(saas) + domain = saas.get_site_domain() context.update({ 'template': settings.SAAS_DOKUWIKI_TEMPLATE_PATH, - 'farm_path': settings.SAAS_DOKUWIKI_FARM_PATH, - 'app_path': os.path.join(settings.SAAS_DOKUWIKI_FARM_PATH, saas.get_site_domain()), + 'farm_path': os.path.normpath(settings.SAAS_DOKUWIKI_FARM_PATH), + 'app_path': os.path.join(settings.SAAS_DOKUWIKI_FARM_PATH, domain), 'user': settings.SAAS_DOKUWIKI_USER, 'group': settings.SAAS_DOKUWIKI_GROUP, 'email': saas.account.email, + 'custom_url': saas.custom_url, + 'domain': domain, }) + if saas.custom_url: + custom_url = urlparse(saas.custom_url) + context.update({ + 'custom_domain': custom_url.netloc, + }) password = getattr(saas, 'password', None) salt = random_ascii(8) context.update({ diff --git a/orchestra/contrib/saas/backends/moodle.py b/orchestra/contrib/saas/backends/moodle.py index aa781068..98ddafc9 100644 --- a/orchestra/contrib/saas/backends/moodle.py +++ b/orchestra/contrib/saas/backends/moodle.py @@ -1,5 +1,6 @@ import os import textwrap +from urllib.parse import urlparse from django.utils.translation import ugettext_lazy as _ @@ -80,6 +81,23 @@ class MoodleMuBackend(ServiceController): EOF fi""") % context ) + self.delete_site_map(context) + if context['custom_url']: + self.insert_site_map(context) + + def delete_site_map(self, context): + self.append(textwrap.dedent("""\ + sed -i '/^\s*"[^\s]*"\s*=>\s*\["%(site_name)s",\s*".*/d' %(moodle_path)s/config.php + """) % context + ) + + def insert_site_map(self, context): + self.append(textwrap.dedent("""\ + regex='\s*\$site_map\s+=\s+array\(' + newline=' "%(custom_domain)s" => ["%(site_name)s", "%(custom_url)s"], // %(banner)s' + sed -i -r "s#$regex#\$site_map = array(\\n$newline#" %(moodle_path)s/config.php + """) % context + ) def delete(self, saas): context = self.get_context(saas) @@ -112,6 +130,7 @@ class MoodleMuBackend(ServiceController): | su %(user)s --shell /bin/bash -c 'crontab' """) % context ) + self.delete_site_map(context) def get_context(self, saas): context = { @@ -127,6 +146,8 @@ class MoodleMuBackend(ServiceController): 'db_host': settings.SAAS_MOODLE_DB_HOST, 'email': saas.account.email, 'password': getattr(saas, 'password', None), + 'custom_url': saas.custom_url, + 'custom_domain': urlparse(saas.custom_url).netloc if saas.custom_url else None, } context.update({ 'crontab': settings.SAAS_MOODLE_CRONTAB % context, diff --git a/orchestra/contrib/saas/backends/wordpressmu.py b/orchestra/contrib/saas/backends/wordpressmu.py index 906f3a49..9b64f5f4 100644 --- a/orchestra/contrib/saas/backends/wordpressmu.py +++ b/orchestra/contrib/saas/backends/wordpressmu.py @@ -1,4 +1,6 @@ import re +import textwrap +from urllib.parse import urlparse import requests from django.utils.translation import ugettext_lazy as _ @@ -12,6 +14,8 @@ from .. import settings class WordpressMuBackend(ServiceController): """ Creates a wordpress site on a WordPress MultiSite installation. + + You should point it to the database server """ verbose_name = _("Wordpress multisite") model = 'saas.SaaS' @@ -117,10 +121,58 @@ class WordpressMuBackend(ServiceController): def save(self, saas): self.append(self.create_blog, saas) + context = self.get_context(saas) + self.append(textwrap.dedent(""" + # Update custom URL mapping + existing=( $(mysql -Nrs %(db_name)s --execute=' + SELECT b.blog_id, b.domain, m.domain, b.path + FROM wp_domain_mapping AS m, wp_blogs AS b + WHERE m.blog_id = b.blog_id AND m.active AND b.domain = "%(domain)s";') ) + if [[ ${existing[0]} != '' ]]; then + if [[ "%(custom_domain)s" == "" ]]; then + mysql %(db_name)s --execute=" + DELETE wp_domain_mapping AS m, wp_blogs AS b + WHERE m.blog_id = b.blog_id AND m.active AND b.domain = '%(domain)s'; + UPDATE wp_blogs + SET path='/' + WHERE blog_id=${existing[0]};" + elif [[ "${existing[2]}" != "%(custom_domain)s" || "${existing[3]}" != "%(custom_path)s" ]]; then + mysql %(db_name)s --execute=' + UPDATE wp_domain_mapping as m, wp_blogs as b + SET m.domain = "%(custom_domain)s", b.path = "%(custom_path)s" + WHERE m.blog_id = b.blog_id AND m.active AND b.domain = "%(domain)s";' + fi + else + blog=( $(mysql -Nrs %(db_name)s --execute=' + SELECT blog_id, path FROM wp_blogs WHERE domain = "%(domain)s";') ) + mysql %(db_name)s --execute=' + INSERT INTO wp_domain_mapping + VALUES (blog_id, domain, active) ($blog_id, "%(custom_domain)s", 1);' + if [[ "${blog[1]}" != "%(custom_path)s" ]]; then + mysql %(db_name)s --execute=" + UPDATE wp_blogs + SET path='%(custom_path)s' + WHERE blog_id=${blog[0]};" + fi + fi""") % context + ) def delete(self, saas): self.append(self.delete_blog, saas) - + + def get_context(self, saas): + domain = saas.get_site_domain() + context = { + 'db_name': settings.SAAS_WORDPRESS_DB_NAME, + 'domain': domain, + } + if saas.custom_url: + custom_url = urlparse(saas.custom_url) + context.update({ + 'custom_domain': custom_url.netloc, + 'custom_path': custom_url.path, + }) + return context class WordpressMuTraffic(ApacheTrafficByHost): __doc__ = ApacheTrafficByHost.__doc__ diff --git a/orchestra/contrib/saas/filters.py b/orchestra/contrib/saas/filters.py new file mode 100644 index 00000000..0354a081 --- /dev/null +++ b/orchestra/contrib/saas/filters.py @@ -0,0 +1,21 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import ugettext_lazy as _ + + +class CustomURLListFilter(SimpleListFilter): + title = _("custom URL") + parameter_name = 'has_custom_url' + + def lookups(self, request, model_admin): + return ( + ('True', _("True")), + ('False', _("False")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.exclude(custom_url='') + elif self.value() == 'False': + return queryset.filter(custom_url='') + return queryset + diff --git a/orchestra/contrib/saas/forms.py b/orchestra/contrib/saas/forms.py index ef6918d5..086a29e7 100644 --- a/orchestra/contrib/saas/forms.py +++ b/orchestra/contrib/saas/forms.py @@ -1,7 +1,9 @@ from django import forms +from django.core.exceptions import ObjectDoesNotExist from django.core.validators import RegexValidator from django.utils.translation import ugettext_lazy as _ +from orchestra.admin.utils import change_url from orchestra.core import validators from orchestra.forms.widgets import SpanWidget from orchestra.plugins.forms import PluginDataForm @@ -20,6 +22,17 @@ class SaaSBaseForm(PluginDataForm): self.is_change = bool(self.instance and self.instance.pk) if self.is_change: site_domain = self.instance.get_site_domain() + if self.instance.custom_url: + try: + website = self.instance.service_instance.get_website() + except ObjectDoesNotExist: + link = ('
Warning: ' + 'Related website directive does not exist for %s URL !' % + self.instance.custom_url) + else: + url = change_url(website) + link = '
Related website: %s' % (url, website.name) + self.fields['custom_url'].help_text += link else: site_domain = self.plugin.site_domain context = { diff --git a/orchestra/contrib/saas/migrations/0002_auto_20151001_0923.py b/orchestra/contrib/saas/migrations/0002_auto_20151001_0923.py new file mode 100644 index 00000000..e0ec276b --- /dev/null +++ b/orchestra/contrib/saas/migrations/0002_auto_20151001_0923.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('saas', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='saas', + name='custom_url', + field=models.URLField(verbose_name='custom URL', blank=True, help_text='Optional and alternative URL for accessing this service instance. A related website will be automatically configured if needed.'), + ), + migrations.AlterField( + model_name='saas', + name='service', + field=models.CharField(choices=[('bscw', 'BSCW'), ('dokuwiki', 'Dowkuwiki'), ('drupal', 'Drupal'), ('gitlab', 'GitLab'), ('moodle', 'Moodle'), ('seafile', 'SeaFile'), ('wordpress', 'WordPress'), ('phplist', 'phpList')], verbose_name='service', max_length=32), + ), + ] diff --git a/orchestra/contrib/saas/models.py b/orchestra/contrib/saas/models.py index b67a17ee..4aea6343 100644 --- a/orchestra/contrib/saas/models.py +++ b/orchestra/contrib/saas/models.py @@ -32,6 +32,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.")) + custom_url = models.URLField(_("custom URL"), blank=True, + help_text=_("Optional and alternative URL for accessing this service instance. " + "i.e. https://wiki.mydomain/doku/
" + "A related website will be automatically configured if needed.")) 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 @@ -68,6 +72,7 @@ class SaaS(models.Model): def clean(self): if not self.pk: self.name = self.name.lower() + self.service_instance.clean() self.data = self.service_instance.clean_data() def get_site_domain(self): diff --git a/orchestra/contrib/saas/services/dokuwiki.py b/orchestra/contrib/saas/services/dokuwiki.py index 3e387f1c..1235de84 100644 --- a/orchestra/contrib/saas/services/dokuwiki.py +++ b/orchestra/contrib/saas/services/dokuwiki.py @@ -1,3 +1,8 @@ +from urllib.parse import urlparse + +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + from .options import SoftwareService from .. import settings @@ -7,3 +12,13 @@ class DokuWikiService(SoftwareService): verbose_name = "Dowkuwiki" icon = 'orchestra/icons/apps/Dokuwiki.png' site_domain = settings.SAAS_DOKUWIKI_DOMAIN + allow_custom_url = settings.SAAS_DOKUWIKI_ALLOW_CUSTOM_URL + + def clean(self): + if self.allow_custom_url and self.instance.custom_url: + url = urlparse(self.instance.custom_url) + if url.path and url.path != '/': + raise ValidationError({ + 'custom_url': _("Support for specific URL paths (%s) is not implemented.") % url.path + }) + super(DokuWikiService, self).clean() diff --git a/orchestra/contrib/saas/services/helpers.py b/orchestra/contrib/saas/services/helpers.py new file mode 100644 index 00000000..7bebd7c4 --- /dev/null +++ b/orchestra/contrib/saas/services/helpers.py @@ -0,0 +1,130 @@ +from collections import defaultdict +from urllib.parse import urlparse + +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +from orchestra.contrib.websites.models import Website, WebsiteDirective, Content +from orchestra.contrib.websites.utils import normurlpath +from orchestra.contrib.websites.validators import validate_domain_protocol +from orchestra.utils.python import AttrDict + + +def full_clean(obj, exclude=None): + try: + obj.full_clean(exclude=exclude) + except ValidationError as e: + raise ValidationError({ + 'custom_url': _("Error validating related %s: %s") % (type(obj).__name__, e), + }) + + +def clean_custom_url(saas): + instance = saas.instance + instance.custom_url = instance.custom_url.strip() + url = urlparse(instance.custom_url) + if not url.path: + instance.custom_url += '/' + url = urlparse(instance.custom_url) + try: + protocol, valid_protocols = saas.PROTOCOL_MAP[url.scheme] + except KeyError: + raise ValidationError({ + 'custom_url': _("%s scheme not supported (http/https)") % url.scheme, + }) + account = instance.account + # get or create website + try: + website = Website.objects.get( + protocol__in=valid_protocols, + domains__name=url.netloc, + account=account, + ) + except Website.DoesNotExist: + # get or create domain + Domain = Website.domains.field.rel.to + try: + domain = Domain.objects.get(name=url.netloc) + except Domain.DoesNotExist: + raise ValidationError({ + 'custom_url': _("Domain %s does not exist.") % url.netloc, + }) + if domain.account != account: + raise ValidationError({ + 'custom_url': _("Domain %s does not belong to account %s, it's from %s.") % + (url.netloc, account, domain.account), + }) + # Create new website for custom_url + website = Website(name=url.netloc , protocol=protocol, account=account) + full_clean(website) + try: + validate_domain_protocol(website, domain, protocol) + except ValidationError as e: + raise ValidationError({ + 'custom_url': _("Error validating related %s: %s") % (type(website).__name__, e), + }) + # get or create directive + try: + directive = website.directives.get(name=saas.get_directive_name()) + except WebsiteDirective.DoesNotExist: + directive = WebsiteDirective(name=saas.get_directive_name(), value=url.path) + if not directive.pk or directive.value != url.path: + directive.value = url.path + if website.pk: + directive.website = website + full_clean(directive) + # Adaptation of orchestra.websites.forms.WebsiteDirectiveInlineFormSet.clean() + locations = set( + Content.objects.filter(website=website).values_list('path', flat=True) + ) + values = defaultdict(list) + for wdirective in WebsiteDirective.objects.filter(website=website).exclude(pk=directive.pk): + fdirective = AttrDict({ + 'name': wdirective.name, + 'value': wdirective.value + }) + try: + wdirective.directive_instance.validate_uniqueness(fdirective, values, locations) + except ValidationError as err: + raise ValidationError({ + 'custom_url': _("Another directive with this URL path exists (%s)." % err) + }) + else: + full_clean(directive, exclude=('website',)) + return directive + + +def create_or_update_directive(saas): + instance = saas.instance + url = urlparse(instance.custom_url) + protocol, valid_protocols = saas.PROTOCOL_MAP[url.scheme] + account = instance.account + # get or create website + try: + website = Website.objects.get( + protocol__in=valid_protocols, + domains__name=url.netloc, + account=account, + ) + except Website.DoesNotExist: + Domain = Website.domains.field.rel.to + domain = Domain.objects.get(name=url.netloc) + # Create new website for custom_url + website = Website(name=url.netloc , protocol=protocol, account=account) + website.save() + website.domains.add(domain) + # get or create directive + try: + directive = website.directives.get(name=saas.get_directive_name()) + except WebsiteDirective.DoesNotExist: + directive = WebsiteDirective(name=saas.get_directive_name(), value=url.path) + if not directive.pk or directive.value != url.path: + directive.value = url.path + directive.website = website + directive.save() + return directive + + +def update_directive(saas): + saas.instance.custom_url = saas.instance.custom_url.strip() + url = urlparse(saas.instance.custom_url) diff --git a/orchestra/contrib/saas/services/moodle.py b/orchestra/contrib/saas/services/moodle.py index adfed1f3..a059550b 100644 --- a/orchestra/contrib/saas/services/moodle.py +++ b/orchestra/contrib/saas/services/moodle.py @@ -20,5 +20,6 @@ class MoodleService(SoftwareService): description_field = 'site_name' icon = 'orchestra/icons/apps/Moodle.png' site_domain = settings.SAAS_MOODLE_DOMAIN + allow_custom_url = settings.SAAS_MOODLE_ALLOW_CUSTOM_URL db_name = settings.SAAS_MOODLE_DB_NAME db_user = settings.SAAS_MOODLE_DB_USER diff --git a/orchestra/contrib/saas/services/options.py b/orchestra/contrib/saas/services/options.py index de67a402..56e70035 100644 --- a/orchestra/contrib/saas/services/options.py +++ b/orchestra/contrib/saas/services/options.py @@ -1,23 +1,36 @@ -from django.core.exceptions import ValidationError +from urllib.parse import urlparse + +from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.utils.translation import ugettext_lazy as _ from orchestra import plugins from orchestra.contrib.databases.models import Database, DatabaseUser from orchestra.contrib.orchestration import Operation +from orchestra.contrib.websites.models import Website, WebsiteDirective +from orchestra.utils.apps import isinstalled from orchestra.utils.functional import cached from orchestra.utils.python import import_class +from . import helpers from .. import settings from ..forms import SaaSPasswordForm class SoftwareService(plugins.Plugin): + PROTOCOL_MAP = { + 'http': (Website.HTTP, (Website.HTTP, Website.HTTP_AND_HTTPS)), + 'https': (Website.HTTPS_ONLY, (Website.HTTPS, Website.HTTP_AND_HTTPS, Website.HTTPS_ONLY)), + } + + name = None + verbose_name = None form = SaaSPasswordForm site_domain = None has_custom_domain = False icon = 'orchestra/icons/apps.png' class_verbose_name = _("Software as a Service") plugin_field = 'service' + allow_custom_url = False @classmethod @cached @@ -38,6 +51,16 @@ class SoftwareService(plugins.Plugin): } return self.site_domain % context + def clean(self): + if self.allow_custom_url: + if self.instance.custom_url: + if isinstalled('orchestra.contrib.websites'): + helpers.clean_custom_url(self) + elif self.instance.custom_url: + raise ValidationError({ + 'custom_url': _("Custom URL not allowed for this service."), + }) + def clean_data(self): data = super(SoftwareService, self).clean_data() if not self.instance.pk: @@ -57,11 +80,58 @@ class SoftwareService(plugins.Plugin): raise ValidationError(errors) return data + def get_directive_name(self): + return '%s-saas' % self.name + + def get_directive(self, *args): + if not args: + instance = self.instance + else: + instance = args[0] + url = urlparse(instance.custom_url) + account = instance.account + return WebsiteDirective.objects.get( + name=self.get_directive_name(), + value=url.path, + website__protocol__in=self.PROTOCOL_MAP[url.scheme][1], + website__domains__name=url.netloc, + website__account=account, + ) + + def get_website(self): + url = urlparse(self.instance.custom_url) + account = self.instance.account + return Website.objects.get( + protocol__in=self.PROTOCOL_MAP[url.scheme][1], + domains__name=url.netloc, + account=account, + directives__name=self.get_directive_name(), + directives__value=url.path, + ) + + def create_or_update_directive(self): + return helpers.create_or_update_directive(self) + + def delete_directive(self): + try: + old = type(self.instance).objects.get(pk=self.instance.pk) + directive = self.get_directive(old) + except ObjectDoesNotExist: + pass + else: + directive.delete() + def save(self): - pass + # pre instance.save() + if isinstalled('orchestra.contrib.websites'): + if self.instance.custom_url: + self.create_or_update_directive() + elif self.instance.pk: + self.delete_directive() def delete(self): - pass + if isinstalled('orchestra.contrib.websites'): + self.delete_directive() def get_related(self): return [] @@ -112,6 +182,7 @@ class DBSoftwareService(SoftwareService): }) def save(self): + super(DBSoftwareService, self).save() account = self.get_account() # Database db_name = self.get_db_name() diff --git a/orchestra/contrib/saas/services/phplist.py b/orchestra/contrib/saas/services/phplist.py index 8e619c10..39116278 100644 --- a/orchestra/contrib/saas/services/phplist.py +++ b/orchestra/contrib/saas/services/phplist.py @@ -70,6 +70,7 @@ class PHPListService(DBSoftwareService): change_form = PHPListChangeForm icon = 'orchestra/icons/apps/Phplist.png' site_domain = settings.SAAS_PHPLIST_DOMAIN + allow_custom_url = settings.SAAS_PHPLIST_ALLOW_CUSTOM_URL db_name = settings.SAAS_PHPLIST_DB_NAME db_user = settings.SAAS_PHPLIST_DB_USER @@ -95,6 +96,7 @@ class PHPListService(DBSoftwareService): }) def save(self): + super(PHPListService, self).save() account = self.get_account() # Mailbox mailbox_name = self.get_mailbox_name() @@ -108,6 +110,7 @@ class PHPListService(DBSoftwareService): }) def delete(self): + super(PHPListService, self).save() account = self.get_account() # delete Mailbox (database will be deleted by ORM's cascade behaviour mailbox_name = self.instance.data.get('mailbox_name') or self.get_mailbox_name() diff --git a/orchestra/contrib/saas/services/wordpress.py b/orchestra/contrib/saas/services/wordpress.py index 4218f41c..3dd72853 100644 --- a/orchestra/contrib/saas/services/wordpress.py +++ b/orchestra/contrib/saas/services/wordpress.py @@ -33,3 +33,4 @@ class WordPressService(SoftwareService): icon = 'orchestra/icons/apps/WordPress.png' change_readonly_fileds = ('email',) site_domain = settings.SAAS_WORDPRESS_DOMAIN + allow_custom_url = settings.SAAS_WORDPRESS_ALLOW_CUSTOM_URL diff --git a/orchestra/contrib/saas/settings.py b/orchestra/contrib/saas/settings.py index bf6b0097..395064dd 100644 --- a/orchestra/contrib/saas/settings.py +++ b/orchestra/contrib/saas/settings.py @@ -33,6 +33,11 @@ SAAS_TRAFFIC_IGNORE_HOSTS = Setting('SAAS_TRAFFIC_IGNORE_HOSTS', # WordPress +SAAS_WORDPRESS_ALLOW_CUSTOM_URL = Setting('SAAS_WORDPRESS_ALLOW_CUSTOM_URL', + True, + help_text=_("Whether allow custom URL to be specified or not."), +) + SAAS_WORDPRESS_LOG_PATH = Setting('SAAS_WORDPRESS_LOG_PATH', '', help_text=_('Filesystem path for the webserver access logs.
' @@ -52,9 +57,19 @@ SAAS_WORDPRESS_DOMAIN = Setting('SAAS_WORDPRESS_DOMAIN', '%(site_name)s.blogs.{}'.format(ORCHESTRA_BASE_DOMAIN), ) +SAAS_WORDPRESS_DB_NAME = Setting('SAAS_WORDPRESS_DB_NAME', + 'wordpressmu', + help_text=_("Needed for domain mapping when SAAS_WORDPRESS_ALLOW_CUSTOM_URL is enabled."), +) + # DokuWiki +SAAS_DOKUWIKI_ALLOW_CUSTOM_URL = Setting('SAAS_DOKUWIKI_ALLOW_CUSTOM_URL', + True, + help_text=_("Whether allow custom URL to be specified or not."), +) + SAAS_DOKUWIKI_TEMPLATE_PATH = Setting('SAAS_DOKUWIKI_TEMPLATE_PATH', '/home/httpd/htdocs/wikifarm/template.tar.gz' ) @@ -90,6 +105,11 @@ SAAS_DOKUWIKI_LOG_PATH = Setting('SAAS_DOKUWIKI_LOG_PATH', # Drupal +SAAS_DRUPAL_ALLOW_CUSTOM_URL = Setting('SAAS_DRUPAL_ALLOW_CUSTOM_URL', + True, + help_text=_("Whether allow custom URL to be specified or not."), +) + SAAS_DRUPAL_SITES_PATH = Setting('WEBSITES_DRUPAL_SITES_PATH', '/home/httpd/htdocs/drupal-mu/sites/%(site_name)s', ) @@ -97,6 +117,11 @@ SAAS_DRUPAL_SITES_PATH = Setting('WEBSITES_DRUPAL_SITES_PATH', # PhpList +SAAS_PHPLIST_ALLOW_CUSTOM_URL = Setting('SAAS_PHPLIST_ALLOW_CUSTOM_URL', + False, + help_text=_("Whether allow custom URL to be specified or not."), +) + SAAS_PHPLIST_DB_USER = Setting('SAAS_PHPLIST_DB_USER', 'phplist_mu', help_text=_("Needed for password changing support."), @@ -201,6 +226,11 @@ SAAS_GITLAB_DOMAIN = Setting('SAAS_GITLAB_DOMAIN', # Moodle +SAAS_MOODLE_ALLOW_CUSTOM_URL = Setting('SAAS_MOODLE_ALLOW_CUSTOM_URL', + True, + help_text=_("Whether allow custom URL to be specified or not."), +) + SAAS_MOODLE_DB_USER = Setting('SAAS_MOODLE_DB_USER', 'moodle_mu', help_text=_("Needed for password changing support."), diff --git a/orchestra/contrib/webapps/admin.py b/orchestra/contrib/webapps/admin.py index 663798cc..bdfb1c55 100644 --- a/orchestra/contrib/webapps/admin.py +++ b/orchestra/contrib/webapps/admin.py @@ -1,7 +1,6 @@ from django import forms from django.contrib import admin from django.core.urlresolvers import reverse -from django.templatetags.static import static from django.utils.encoding import force_text from django.utils.translation import ugettext, ugettext_lazy as _ @@ -11,6 +10,7 @@ from orchestra.contrib.accounts.actions import list_accounts from orchestra.contrib.accounts.admin import AccountAdminMixin from orchestra.forms.widgets import DynamicHelpTextSelect from orchestra.plugins.admin import SelectPluginAdminMixin +from orchestra.utils.html import get_on_site_link from .filters import HasWebsiteListFilter, PHPVersionListFilter from .models import WebApp, WebAppOption @@ -65,12 +65,7 @@ class WebAppAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedModelAdmin) def display_websites(self, webapp): websites = [] for content in webapp.content_set.all(): - context = { - 'title': _("View on site"), - 'url': content.get_absolute_url(), - 'image': '' % static('orchestra/images/view-on-site.png'), - } - site_link = '%(image)s' % context + site_link = get_on_site_link(content.get_absolute_url()) website = content.website admin_url = change_url(website) name = "%s on %s" % (website.name, content.path) diff --git a/orchestra/contrib/webapps/filters.py b/orchestra/contrib/webapps/filters.py index 025f200f..fca86d6b 100644 --- a/orchestra/contrib/webapps/filters.py +++ b/orchestra/contrib/webapps/filters.py @@ -5,7 +5,7 @@ from . import settings class HasWebsiteListFilter(SimpleListFilter): - title = _("Has website") + title = _("website") parameter_name = 'has_website' def lookups(self, request, model_admin): diff --git a/orchestra/contrib/webapps/models.py b/orchestra/contrib/webapps/models.py index badee75d..7f2b51bc 100644 --- a/orchestra/contrib/webapps/models.py +++ b/orchestra/contrib/webapps/models.py @@ -95,7 +95,8 @@ class WebApp(models.Model): class WebAppOption(models.Model): webapp = models.ForeignKey(WebApp, verbose_name=_("Web application"), related_name='options') - name = models.CharField(_("name"), max_length=128, choices=AppType.get_group_options_choices()) + name = models.CharField(_("name"), max_length=128, + choices=AppType.get_group_options_choices()) value = models.CharField(_("value"), max_length=256) class Meta: diff --git a/orchestra/contrib/websites/directives.py b/orchestra/contrib/websites/directives.py index 475c5a15..518c7ec9 100644 --- a/orchestra/contrib/websites/directives.py +++ b/orchestra/contrib/websites/directives.py @@ -175,7 +175,7 @@ class SecEngine(SecRuleRemove): class WordPressSaaS(SiteDirective): name = 'wordpress-saas' verbose_name = "WordPress SaaS" - help_text = _("URL-path for mounting wordpress multisite.") + help_text = _("URL-path for mounting WordPress multisite.") group = SiteDirective.SAAS regex = r'^/[^ ]*$' unique_value = True @@ -185,10 +185,16 @@ class WordPressSaaS(SiteDirective): class DokuWikiSaaS(WordPressSaaS): name = 'dokuwiki-saas' verbose_name = "DokuWiki SaaS" - help_text = _("URL-path for mounting wordpress multisite.") + help_text = _("URL-path for mounting DokuWiki multisite.") class DrupalSaaS(WordPressSaaS): name = 'drupal-saas' verbose_name = "Drupdal SaaS" - help_text = _("URL-path for mounting wordpress multisite.") + help_text = _("URL-path for mounting Drupal multisite.") + + +class MoodleSaaS(WordPressSaaS): + name = 'moodle-saas' + verbose_name = "Moodle SaaS" + help_text = _("URL-path for mounting Moodle multisite.") diff --git a/orchestra/contrib/websites/forms.py b/orchestra/contrib/websites/forms.py index 982649b2..1700fa49 100644 --- a/orchestra/contrib/websites/forms.py +++ b/orchestra/contrib/websites/forms.py @@ -36,15 +36,14 @@ class WebsiteDirectiveInlineFormSet(forms.models.BaseInlineFormSet): location = form.cleaned_data.get('path') if location is not None: locations.add(normurlpath(location)) - directives = [] values = defaultdict(list) for form in self.forms: - website = form.instance + wdirective = form.instance directive = form.cleaned_data if directive.get('name') is not None: try: - website.directive_instance.validate_uniqueness(directive, values, locations) + wdirective.directive_instance.validate_uniqueness(directive, values, locations) except ValidationError as err: for k,v in err.error_dict.items(): form.add_error(k, v) diff --git a/orchestra/contrib/websites/settings.py b/orchestra/contrib/websites/settings.py index 022d36fa..ef2fe69c 100644 --- a/orchestra/contrib/websites/settings.py +++ b/orchestra/contrib/websites/settings.py @@ -58,6 +58,7 @@ WEBSITES_ENABLED_DIRECTIVES = Setting('WEBSITES_ENABLED_DIRECTIVES', 'orchestra.contrib.websites.directives.WordPressSaaS', 'orchestra.contrib.websites.directives.DokuWikiSaaS', 'orchestra.contrib.websites.directives.DrupalSaaS', + 'orchestra.contrib.websites.directives.MoodleSaaS', ), # lazy loading choices=lambda : ((d.get_class_path(), d.get_class_path()) for d in websites.directives.SiteDirective.get_plugins()), diff --git a/orchestra/plugins/admin.py b/orchestra/plugins/admin.py index 0932d51f..64341716 100644 --- a/orchestra/plugins/admin.py +++ b/orchestra/plugins/admin.py @@ -20,6 +20,7 @@ class SelectPluginAdminMixin(object): else: plugin = self.plugin.get(self.plugin_value)() self.form = plugin.get_form() + self.plugin_instance = plugin return super(SelectPluginAdminMixin, self).get_form(request, obj, **kwargs) def get_fields(self, request, obj=None): @@ -65,7 +66,7 @@ class SelectPluginAdminMixin(object): if not plugin_value and request.method == 'POST': # HACK baceuse django add_preserved_filters removes extising queryargs value = re.search(r"%s=([^&^']+)[&']" % self.plugin_field, - request.META.get('HTTP_REFERER', '')) + request.META.get('HTTP_REFERER', '')) if value: plugin_value = value.groups()[0] return plugin_value @@ -83,8 +84,8 @@ class SelectPluginAdminMixin(object): 'title': _("Add new %s") % plugin.verbose_name, } context.update(extra_context or {}) - return super(SelectPluginAdminMixin, self).add_view(request, form_url=form_url, - extra_context=context) + return super(SelectPluginAdminMixin, self).add_view( + request, form_url=form_url, extra_context=context) return redirect('./select-plugin/?%s' % request.META['QUERY_STRING']) def change_view(self, request, object_id, form_url='', extra_context=None): @@ -94,8 +95,8 @@ class SelectPluginAdminMixin(object): 'title': _("Change %s") % plugin.verbose_name, } context.update(extra_context or {}) - return super(SelectPluginAdminMixin, self).change_view(request, object_id, - form_url=form_url, extra_context=context) + return super(SelectPluginAdminMixin, self).change_view( + request, object_id, form_url=form_url, extra_context=context) def save_model(self, request, obj, form, change): if not change: diff --git a/orchestra/utils/html.py b/orchestra/utils/html.py index 6d6731d3..16b590bf 100644 --- a/orchestra/utils/html.py +++ b/orchestra/utils/html.py @@ -1,5 +1,8 @@ import textwrap +from django.templatetags.static import static +from django.utils.translation import ugettext_lazy as _ + from orchestra.utils.sys import run @@ -22,3 +25,12 @@ def html_to_pdf(html, pagination=False): --margin-top 20 - - \ """) % context return run(cmd, stdin=html.encode('utf-8')).stdout + + +def get_on_site_link(url): + context = { + 'title': _("View on site"), + 'url': url, + 'image': '' % static('orchestra/images/view-on-site.png'), + } + return '%(image)s' % context