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