Added support for SaaS service custom URL

This commit is contained in:
Marc Aymerich 2015-10-01 16:02:26 +00:00
parent 0f603181ff
commit 95a6a0c37d
26 changed files with 518 additions and 53 deletions

21
TODO.md
View File

@ -387,11 +387,6 @@ Case
# Modsecurity rules template by cms (wordpress, joomla, dokuwiki (973337 973338 973347 958057), ... # 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 --dev
deploy.sh and deploy-dev.sh autoupgrade deploy.sh and deploy-dev.sh autoupgrade
@ -401,7 +396,6 @@ orchestra home autocomplete
short URLS: https://github.com/rsvp/gitio short URLS: https://github.com/rsvp/gitio
link backend help text variables to settings/#var_name link backend help text variables to settings/#var_name
saas changelist domain: add <br>custom domain<img>
$ sudo python manage.py startservices $ sudo python manage.py startservices
Traceback (most recent call last): Traceback (most recent call last):
@ -409,3 +403,18 @@ Traceback (most recent call last):
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
ImportError: No module named django.core.management 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:
<div style="display: none;">
<input type="text" id="PreventChromeAutocomplete" name="PreventChromeAutocomplete" autocomplete="address-level4" />
</div>
Once I did this, all of my "autocomplete=off" elements were respected by Chrome.
<input type="password" name="password" value="" style="display: none" />
http://makandracards.com/makandra/24933-chrome-34+-firefox-38+-ie11+-ignore-autocomplete-off

View File

@ -180,7 +180,7 @@ class AccountAdminMixin(object):
def account_link(self, instance): def account_link(self, instance):
account = instance.account if instance.pk else self.account account = instance.account if instance.pk else self.account
url = change_url(account) url = change_url(account)
return '<a href="%s">%s</a>' % (url, str(account)) return '<a href="%s">%s</a>' % (url, account)
account_link.short_description = _("account") account_link.short_description = _("account")
account_link.allow_tags = True account_link.allow_tags = True
account_link.admin_order_field = 'account__username' account_link.admin_order_field = 'account__username'

View File

@ -1,7 +1,6 @@
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.templatetags.static import static
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin 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.actions import list_accounts
from orchestra.contrib.accounts.admin import AccountAdminMixin from orchestra.contrib.accounts.admin import AccountAdminMixin
from orchestra.utils import apps from orchestra.utils import apps
from orchestra.utils.html import get_on_site_link
from .actions import view_zone, edit_records, set_soa from .actions import view_zone, edit_records, set_soa
from .filters import TopDomainListFilter from .filters import TopDomainListFilter
@ -84,22 +84,12 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
if websites: if websites:
links = [] links = []
for website in websites: for website in websites:
context = { site_link = get_on_site_link(website.get_absolute_url())
'title': _("View on site"),
'url': website.get_absolute_url(),
'image': '<img src="%s"></img>' % static('orchestra/images/view-on-site.png'),
}
site_link = '<a href="%(url)s" title="%(title)s">%(image)s</a>' % context
admin_url = change_url(website) admin_url = change_url(website)
link = '<a href="%s">%s %s</a>' % (admin_url, website.name, site_link) link = '<a href="%s">%s %s</a>' % (admin_url, website.name, site_link)
links.append(link) links.append(link)
return '<br>'.join(links) return '<br>'.join(links)
context = { site_link = get_on_site_link('http://%s' % domain.name)
'title': _("View on site"),
'url': 'http://%s' % domain.name,
'image': '<img src="%s"></img>' % static('orchestra/images/view-on-site.png'),
}
site_link = '<a href="%(url)s" title="%(title)s">%(image)s</a>' % context
return _("No website %s") % site_link return _("No website %s") % site_link
display_websites.admin_order_field = 'websites__name' display_websites.admin_order_field = 'websites__name'
display_websites.short_description = _("Websites") display_websites.short_description = _("Websites")

View File

@ -1,19 +1,24 @@
from django.contrib import admin from django.contrib import admin
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin
from orchestra.admin.actions import disable 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.actions import list_accounts
from orchestra.contrib.accounts.admin import AccountAdminMixin from orchestra.contrib.accounts.admin import AccountAdminMixin
from orchestra.plugins.admin import SelectPluginAdminMixin 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 .models import SaaS
from .services import SoftwareService from .services import SoftwareService
class SaaSAdmin(SelectPluginAdminMixin, ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin): class SaaSAdmin(SelectPluginAdminMixin, ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin):
list_display = ('name', 'service', 'display_site_domain', 'account_link', 'is_active') list_display = ('name', 'service', 'display_url', 'account_link', 'is_active')
list_filter = ('service', 'is_active') list_filter = ('service', 'is_active', CustomURLListFilter)
search_fields = ('name', 'account__username') search_fields = ('name', 'account__username')
change_readonly_fields = ('service',) change_readonly_fields = ('service',)
plugin = SoftwareService plugin = SoftwareService
@ -21,12 +26,33 @@ class SaaSAdmin(SelectPluginAdminMixin, ChangePasswordAdminMixin, AccountAdminMi
plugin_title = 'Software as a Service' plugin_title = 'Software as a Service'
actions = (disable, list_accounts) actions = (disable, list_accounts)
def display_site_domain(self, saas): def display_url(self, saas):
site_domain = saas.get_site_domain() site_domain = saas.get_site_domain()
return '<a href="http://%s">%s</a>' % (site_domain, site_domain) site_link = '<a href="http://%s">%s</a>' % (site_domain, site_domain)
display_site_domain.short_description = _("Site domain") links = [site_link]
display_site_domain.allow_tags = True if saas.custom_url and isinstalled('orchestra.contrib.websites'):
display_site_domain.admin_order_field = 'name' try:
website = saas.service_instance.get_website()
except ObjectDoesNotExist:
warning = _("Related website directive does not exist for this custom URL.")
link = '<span style="color:red" title="%s">%s</span>' % (warning, saas.custom_url)
else:
website_link = get_on_site_link(saas.custom_url)
admin_url = change_url(website)
link = '<a title="Edit website" href="%s">%s %s</a>' % (
admin_url, saas.custom_url, website_link
)
links.append(link)
return '<br>'.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) admin.site.register(SaaS, SaaSAdmin)

View File

@ -1,6 +1,7 @@
import crypt import crypt
import os import os
import textwrap import textwrap
from urllib.parse import urlparse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -43,20 +44,57 @@ class DokuWikiMuBackend(ServiceController):
echo 'admin:%(password)s:admin:%(email)s:admin,user' >> %(users_path)s echo 'admin:%(password)s:admin:%(email)s:admin,user' >> %(users_path)s
fi""") % context 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): def delete(self, saas):
context = self.get_context(saas) context = self.get_context(saas)
self.append("rm -fr %(app_path)s" % context) 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): def get_context(self, saas):
context = super(DokuWikiMuBackend, self).get_context(saas) context = super(DokuWikiMuBackend, self).get_context(saas)
domain = saas.get_site_domain()
context.update({ context.update({
'template': settings.SAAS_DOKUWIKI_TEMPLATE_PATH, 'template': settings.SAAS_DOKUWIKI_TEMPLATE_PATH,
'farm_path': settings.SAAS_DOKUWIKI_FARM_PATH, 'farm_path': os.path.normpath(settings.SAAS_DOKUWIKI_FARM_PATH),
'app_path': os.path.join(settings.SAAS_DOKUWIKI_FARM_PATH, saas.get_site_domain()), 'app_path': os.path.join(settings.SAAS_DOKUWIKI_FARM_PATH, domain),
'user': settings.SAAS_DOKUWIKI_USER, 'user': settings.SAAS_DOKUWIKI_USER,
'group': settings.SAAS_DOKUWIKI_GROUP, 'group': settings.SAAS_DOKUWIKI_GROUP,
'email': saas.account.email, '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) password = getattr(saas, 'password', None)
salt = random_ascii(8) salt = random_ascii(8)

View File

@ -1,5 +1,6 @@
import os import os
import textwrap import textwrap
from urllib.parse import urlparse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -80,6 +81,23 @@ class MoodleMuBackend(ServiceController):
EOF EOF
fi""") % context 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): def delete(self, saas):
context = self.get_context(saas) context = self.get_context(saas)
@ -112,6 +130,7 @@ class MoodleMuBackend(ServiceController):
| su %(user)s --shell /bin/bash -c 'crontab' | su %(user)s --shell /bin/bash -c 'crontab'
""") % context """) % context
) )
self.delete_site_map(context)
def get_context(self, saas): def get_context(self, saas):
context = { context = {
@ -127,6 +146,8 @@ class MoodleMuBackend(ServiceController):
'db_host': settings.SAAS_MOODLE_DB_HOST, 'db_host': settings.SAAS_MOODLE_DB_HOST,
'email': saas.account.email, 'email': saas.account.email,
'password': getattr(saas, 'password', None), '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({ context.update({
'crontab': settings.SAAS_MOODLE_CRONTAB % context, 'crontab': settings.SAAS_MOODLE_CRONTAB % context,

View File

@ -1,4 +1,6 @@
import re import re
import textwrap
from urllib.parse import urlparse
import requests import requests
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -12,6 +14,8 @@ from .. import settings
class WordpressMuBackend(ServiceController): class WordpressMuBackend(ServiceController):
""" """
Creates a wordpress site on a WordPress MultiSite installation. Creates a wordpress site on a WordPress MultiSite installation.
You should point it to the database server
""" """
verbose_name = _("Wordpress multisite") verbose_name = _("Wordpress multisite")
model = 'saas.SaaS' model = 'saas.SaaS'
@ -117,10 +121,58 @@ class WordpressMuBackend(ServiceController):
def save(self, saas): def save(self, saas):
self.append(self.create_blog, 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): def delete(self, saas):
self.append(self.delete_blog, 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): class WordpressMuTraffic(ApacheTrafficByHost):
__doc__ = ApacheTrafficByHost.__doc__ __doc__ = ApacheTrafficByHost.__doc__

View File

@ -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

View File

@ -1,7 +1,9 @@
from django import forms from django import forms
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin.utils import change_url
from orchestra.core import validators from orchestra.core import validators
from orchestra.forms.widgets import SpanWidget from orchestra.forms.widgets import SpanWidget
from orchestra.plugins.forms import PluginDataForm from orchestra.plugins.forms import PluginDataForm
@ -20,6 +22,17 @@ class SaaSBaseForm(PluginDataForm):
self.is_change = bool(self.instance and self.instance.pk) self.is_change = bool(self.instance and self.instance.pk)
if self.is_change: if self.is_change:
site_domain = self.instance.get_site_domain() site_domain = self.instance.get_site_domain()
if self.instance.custom_url:
try:
website = self.instance.service_instance.get_website()
except ObjectDoesNotExist:
link = ('<br><span style="color:red"><b>Warning:</b> '
'Related website directive does not exist for %s URL !</span>' %
self.instance.custom_url)
else:
url = change_url(website)
link = '<br>Related website: <a href="%s">%s</a>' % (url, website.name)
self.fields['custom_url'].help_text += link
else: else:
site_domain = self.plugin.site_domain site_domain = self.plugin.site_domain
context = { context = {

View File

@ -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),
),
]

View File

@ -32,6 +32,10 @@ class SaaS(models.Model):
help_text=_("Designates whether this service should be treated as active. ")) help_text=_("Designates whether this service should be treated as active. "))
data = JSONField(_("data"), default={}, data = JSONField(_("data"), default={},
help_text=_("Extra information dependent of each service.")) 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. <tt>https://wiki.mydomain/doku/</tt><br>"
"A related website will be automatically configured if needed."))
database = models.ForeignKey('databases.Database', null=True, blank=True) 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 # 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): def clean(self):
if not self.pk: if not self.pk:
self.name = self.name.lower() self.name = self.name.lower()
self.service_instance.clean()
self.data = self.service_instance.clean_data() self.data = self.service_instance.clean_data()
def get_site_domain(self): def get_site_domain(self):

View File

@ -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 .options import SoftwareService
from .. import settings from .. import settings
@ -7,3 +12,13 @@ class DokuWikiService(SoftwareService):
verbose_name = "Dowkuwiki" verbose_name = "Dowkuwiki"
icon = 'orchestra/icons/apps/Dokuwiki.png' icon = 'orchestra/icons/apps/Dokuwiki.png'
site_domain = settings.SAAS_DOKUWIKI_DOMAIN 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()

View File

@ -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)

View File

@ -20,5 +20,6 @@ class MoodleService(SoftwareService):
description_field = 'site_name' description_field = 'site_name'
icon = 'orchestra/icons/apps/Moodle.png' icon = 'orchestra/icons/apps/Moodle.png'
site_domain = settings.SAAS_MOODLE_DOMAIN site_domain = settings.SAAS_MOODLE_DOMAIN
allow_custom_url = settings.SAAS_MOODLE_ALLOW_CUSTOM_URL
db_name = settings.SAAS_MOODLE_DB_NAME db_name = settings.SAAS_MOODLE_DB_NAME
db_user = settings.SAAS_MOODLE_DB_USER db_user = settings.SAAS_MOODLE_DB_USER

View File

@ -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 django.utils.translation import ugettext_lazy as _
from orchestra import plugins from orchestra import plugins
from orchestra.contrib.databases.models import Database, DatabaseUser from orchestra.contrib.databases.models import Database, DatabaseUser
from orchestra.contrib.orchestration import Operation 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.functional import cached
from orchestra.utils.python import import_class from orchestra.utils.python import import_class
from . import helpers
from .. import settings from .. import settings
from ..forms import SaaSPasswordForm from ..forms import SaaSPasswordForm
class SoftwareService(plugins.Plugin): 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 form = SaaSPasswordForm
site_domain = None site_domain = None
has_custom_domain = False has_custom_domain = False
icon = 'orchestra/icons/apps.png' icon = 'orchestra/icons/apps.png'
class_verbose_name = _("Software as a Service") class_verbose_name = _("Software as a Service")
plugin_field = 'service' plugin_field = 'service'
allow_custom_url = False
@classmethod @classmethod
@cached @cached
@ -38,6 +51,16 @@ class SoftwareService(plugins.Plugin):
} }
return self.site_domain % context 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): def clean_data(self):
data = super(SoftwareService, self).clean_data() data = super(SoftwareService, self).clean_data()
if not self.instance.pk: if not self.instance.pk:
@ -57,11 +80,58 @@ class SoftwareService(plugins.Plugin):
raise ValidationError(errors) raise ValidationError(errors)
return data return data
def save(self): 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 pass
else:
directive.delete()
def save(self):
# 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): def delete(self):
pass if isinstalled('orchestra.contrib.websites'):
self.delete_directive()
def get_related(self): def get_related(self):
return [] return []
@ -112,6 +182,7 @@ class DBSoftwareService(SoftwareService):
}) })
def save(self): def save(self):
super(DBSoftwareService, self).save()
account = self.get_account() account = self.get_account()
# Database # Database
db_name = self.get_db_name() db_name = self.get_db_name()

View File

@ -70,6 +70,7 @@ class PHPListService(DBSoftwareService):
change_form = PHPListChangeForm change_form = PHPListChangeForm
icon = 'orchestra/icons/apps/Phplist.png' icon = 'orchestra/icons/apps/Phplist.png'
site_domain = settings.SAAS_PHPLIST_DOMAIN site_domain = settings.SAAS_PHPLIST_DOMAIN
allow_custom_url = settings.SAAS_PHPLIST_ALLOW_CUSTOM_URL
db_name = settings.SAAS_PHPLIST_DB_NAME db_name = settings.SAAS_PHPLIST_DB_NAME
db_user = settings.SAAS_PHPLIST_DB_USER db_user = settings.SAAS_PHPLIST_DB_USER
@ -95,6 +96,7 @@ class PHPListService(DBSoftwareService):
}) })
def save(self): def save(self):
super(PHPListService, self).save()
account = self.get_account() account = self.get_account()
# Mailbox # Mailbox
mailbox_name = self.get_mailbox_name() mailbox_name = self.get_mailbox_name()
@ -108,6 +110,7 @@ class PHPListService(DBSoftwareService):
}) })
def delete(self): def delete(self):
super(PHPListService, self).save()
account = self.get_account() account = self.get_account()
# delete Mailbox (database will be deleted by ORM's cascade behaviour # delete Mailbox (database will be deleted by ORM's cascade behaviour
mailbox_name = self.instance.data.get('mailbox_name') or self.get_mailbox_name() mailbox_name = self.instance.data.get('mailbox_name') or self.get_mailbox_name()

View File

@ -33,3 +33,4 @@ class WordPressService(SoftwareService):
icon = 'orchestra/icons/apps/WordPress.png' icon = 'orchestra/icons/apps/WordPress.png'
change_readonly_fileds = ('email',) change_readonly_fileds = ('email',)
site_domain = settings.SAAS_WORDPRESS_DOMAIN site_domain = settings.SAAS_WORDPRESS_DOMAIN
allow_custom_url = settings.SAAS_WORDPRESS_ALLOW_CUSTOM_URL

View File

@ -33,6 +33,11 @@ SAAS_TRAFFIC_IGNORE_HOSTS = Setting('SAAS_TRAFFIC_IGNORE_HOSTS',
# WordPress # 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', SAAS_WORDPRESS_LOG_PATH = Setting('SAAS_WORDPRESS_LOG_PATH',
'', '',
help_text=_('Filesystem path for the webserver access logs.<br>' help_text=_('Filesystem path for the webserver access logs.<br>'
@ -52,9 +57,19 @@ SAAS_WORDPRESS_DOMAIN = Setting('SAAS_WORDPRESS_DOMAIN',
'%(site_name)s.blogs.{}'.format(ORCHESTRA_BASE_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 <tt>SAAS_WORDPRESS_ALLOW_CUSTOM_URL</tt> is enabled."),
)
# DokuWiki # 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', SAAS_DOKUWIKI_TEMPLATE_PATH = Setting('SAAS_DOKUWIKI_TEMPLATE_PATH',
'/home/httpd/htdocs/wikifarm/template.tar.gz' '/home/httpd/htdocs/wikifarm/template.tar.gz'
) )
@ -90,6 +105,11 @@ SAAS_DOKUWIKI_LOG_PATH = Setting('SAAS_DOKUWIKI_LOG_PATH',
# Drupal # 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', SAAS_DRUPAL_SITES_PATH = Setting('WEBSITES_DRUPAL_SITES_PATH',
'/home/httpd/htdocs/drupal-mu/sites/%(site_name)s', '/home/httpd/htdocs/drupal-mu/sites/%(site_name)s',
) )
@ -97,6 +117,11 @@ SAAS_DRUPAL_SITES_PATH = Setting('WEBSITES_DRUPAL_SITES_PATH',
# PhpList # 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', SAAS_PHPLIST_DB_USER = Setting('SAAS_PHPLIST_DB_USER',
'phplist_mu', 'phplist_mu',
help_text=_("Needed for password changing support."), help_text=_("Needed for password changing support."),
@ -201,6 +226,11 @@ SAAS_GITLAB_DOMAIN = Setting('SAAS_GITLAB_DOMAIN',
# Moodle # 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', SAAS_MOODLE_DB_USER = Setting('SAAS_MOODLE_DB_USER',
'moodle_mu', 'moodle_mu',
help_text=_("Needed for password changing support."), help_text=_("Needed for password changing support."),

View File

@ -1,7 +1,6 @@
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.templatetags.static import static
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.utils.translation import ugettext, ugettext_lazy as _ 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.contrib.accounts.admin import AccountAdminMixin
from orchestra.forms.widgets import DynamicHelpTextSelect from orchestra.forms.widgets import DynamicHelpTextSelect
from orchestra.plugins.admin import SelectPluginAdminMixin from orchestra.plugins.admin import SelectPluginAdminMixin
from orchestra.utils.html import get_on_site_link
from .filters import HasWebsiteListFilter, PHPVersionListFilter from .filters import HasWebsiteListFilter, PHPVersionListFilter
from .models import WebApp, WebAppOption from .models import WebApp, WebAppOption
@ -65,12 +65,7 @@ class WebAppAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedModelAdmin)
def display_websites(self, webapp): def display_websites(self, webapp):
websites = [] websites = []
for content in webapp.content_set.all(): for content in webapp.content_set.all():
context = { site_link = get_on_site_link(content.get_absolute_url())
'title': _("View on site"),
'url': content.get_absolute_url(),
'image': '<img src="%s"></img>' % static('orchestra/images/view-on-site.png'),
}
site_link = '<a href="%(url)s" title="%(title)s">%(image)s</a>' % context
website = content.website website = content.website
admin_url = change_url(website) admin_url = change_url(website)
name = "%s on %s" % (website.name, content.path) name = "%s on %s" % (website.name, content.path)

View File

@ -5,7 +5,7 @@ from . import settings
class HasWebsiteListFilter(SimpleListFilter): class HasWebsiteListFilter(SimpleListFilter):
title = _("Has website") title = _("website")
parameter_name = 'has_website' parameter_name = 'has_website'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):

View File

@ -95,7 +95,8 @@ class WebApp(models.Model):
class WebAppOption(models.Model): class WebAppOption(models.Model):
webapp = models.ForeignKey(WebApp, verbose_name=_("Web application"), webapp = models.ForeignKey(WebApp, verbose_name=_("Web application"),
related_name='options') 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) value = models.CharField(_("value"), max_length=256)
class Meta: class Meta:

View File

@ -175,7 +175,7 @@ class SecEngine(SecRuleRemove):
class WordPressSaaS(SiteDirective): class WordPressSaaS(SiteDirective):
name = 'wordpress-saas' name = 'wordpress-saas'
verbose_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 group = SiteDirective.SAAS
regex = r'^/[^ ]*$' regex = r'^/[^ ]*$'
unique_value = True unique_value = True
@ -185,10 +185,16 @@ class WordPressSaaS(SiteDirective):
class DokuWikiSaaS(WordPressSaaS): class DokuWikiSaaS(WordPressSaaS):
name = 'dokuwiki-saas' name = 'dokuwiki-saas'
verbose_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): class DrupalSaaS(WordPressSaaS):
name = 'drupal-saas' name = 'drupal-saas'
verbose_name = "Drupdal 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.")

View File

@ -36,15 +36,14 @@ class WebsiteDirectiveInlineFormSet(forms.models.BaseInlineFormSet):
location = form.cleaned_data.get('path') location = form.cleaned_data.get('path')
if location is not None: if location is not None:
locations.add(normurlpath(location)) locations.add(normurlpath(location))
directives = []
values = defaultdict(list) values = defaultdict(list)
for form in self.forms: for form in self.forms:
website = form.instance wdirective = form.instance
directive = form.cleaned_data directive = form.cleaned_data
if directive.get('name') is not None: if directive.get('name') is not None:
try: try:
website.directive_instance.validate_uniqueness(directive, values, locations) wdirective.directive_instance.validate_uniqueness(directive, values, locations)
except ValidationError as err: except ValidationError as err:
for k,v in err.error_dict.items(): for k,v in err.error_dict.items():
form.add_error(k, v) form.add_error(k, v)

View File

@ -58,6 +58,7 @@ WEBSITES_ENABLED_DIRECTIVES = Setting('WEBSITES_ENABLED_DIRECTIVES',
'orchestra.contrib.websites.directives.WordPressSaaS', 'orchestra.contrib.websites.directives.WordPressSaaS',
'orchestra.contrib.websites.directives.DokuWikiSaaS', 'orchestra.contrib.websites.directives.DokuWikiSaaS',
'orchestra.contrib.websites.directives.DrupalSaaS', 'orchestra.contrib.websites.directives.DrupalSaaS',
'orchestra.contrib.websites.directives.MoodleSaaS',
), ),
# lazy loading # lazy loading
choices=lambda : ((d.get_class_path(), d.get_class_path()) for d in websites.directives.SiteDirective.get_plugins()), choices=lambda : ((d.get_class_path(), d.get_class_path()) for d in websites.directives.SiteDirective.get_plugins()),

View File

@ -20,6 +20,7 @@ class SelectPluginAdminMixin(object):
else: else:
plugin = self.plugin.get(self.plugin_value)() plugin = self.plugin.get(self.plugin_value)()
self.form = plugin.get_form() self.form = plugin.get_form()
self.plugin_instance = plugin
return super(SelectPluginAdminMixin, self).get_form(request, obj, **kwargs) return super(SelectPluginAdminMixin, self).get_form(request, obj, **kwargs)
def get_fields(self, request, obj=None): def get_fields(self, request, obj=None):
@ -83,8 +84,8 @@ class SelectPluginAdminMixin(object):
'title': _("Add new %s") % plugin.verbose_name, 'title': _("Add new %s") % plugin.verbose_name,
} }
context.update(extra_context or {}) context.update(extra_context or {})
return super(SelectPluginAdminMixin, self).add_view(request, form_url=form_url, return super(SelectPluginAdminMixin, self).add_view(
extra_context=context) request, form_url=form_url, extra_context=context)
return redirect('./select-plugin/?%s' % request.META['QUERY_STRING']) return redirect('./select-plugin/?%s' % request.META['QUERY_STRING'])
def change_view(self, request, object_id, form_url='', extra_context=None): 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, 'title': _("Change %s") % plugin.verbose_name,
} }
context.update(extra_context or {}) context.update(extra_context or {})
return super(SelectPluginAdminMixin, self).change_view(request, object_id, return super(SelectPluginAdminMixin, self).change_view(
form_url=form_url, extra_context=context) request, object_id, form_url=form_url, extra_context=context)
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if not change: if not change:

View File

@ -1,5 +1,8 @@
import textwrap import textwrap
from django.templatetags.static import static
from django.utils.translation import ugettext_lazy as _
from orchestra.utils.sys import run from orchestra.utils.sys import run
@ -22,3 +25,12 @@ def html_to_pdf(html, pagination=False):
--margin-top 20 - - \ --margin-top 20 - - \
""") % context """) % context
return run(cmd, stdin=html.encode('utf-8')).stdout return run(cmd, stdin=html.encode('utf-8')).stdout
def get_on_site_link(url):
context = {
'title': _("View on site"),
'url': url,
'image': '<img src="%s"></img>' % static('orchestra/images/view-on-site.png'),
}
return '<a href="%(url)s" title="%(title)s">%(image)s</a>' % context