diff --git a/TODO.md b/TODO.md
index 12634b9c..37fddd0a 100644
--- a/TODO.md
+++ b/TODO.md
@@ -379,7 +379,7 @@ Case
# Don't enforce one contact per account? remove account.email in favour of contacts?
# Mailer: mark as sent
-
+# Mailer: download attachments
# Deprecate orchestra start/stop/restart services management commands?
@@ -387,3 +387,4 @@ Case
# Modsecurity rules template by cms (wordpress, joomla, dokuwiki (973337 973338 973347 958057), ...
+
diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py
index 0fa7aee5..c073a15b 100644
--- a/orchestra/admin/utils.py
+++ b/orchestra/admin/utils.py
@@ -90,9 +90,11 @@ def action_to_view(action, modeladmin):
def change_url(obj):
- opts = obj._meta
- view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
- return reverse(view_name, args=(obj.pk,))
+ if obj is not None:
+ opts = obj._meta
+ view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
+ return reverse(view_name, args=(obj.pk,))
+ raise NoReverseMatch
@admin_field
diff --git a/orchestra/contrib/databases/backends.py b/orchestra/contrib/databases/backends.py
index ad646d0f..1cd9f3dd 100644
--- a/orchestra/contrib/databases/backends.py
+++ b/orchestra/contrib/databases/backends.py
@@ -85,7 +85,7 @@ class MySQLUserBackend(ServiceController):
context = self.get_context(user)
self.append(textwrap.dedent("""\
# Create user %(username)s
- mysql -e 'CREATE USER "%(username)s"@"%(host)s";' || true
+ mysql -e 'CREATE USER "%(username)s"@"%(host)s";' || true # User already exists
mysql -e 'UPDATE mysql.user SET Password="%(password)s" WHERE User="%(username)s";'\
""") % context
)
diff --git a/orchestra/contrib/databases/forms.py b/orchestra/contrib/databases/forms.py
index 41a13f84..1504366a 100644
--- a/orchestra/contrib/databases/forms.py
+++ b/orchestra/contrib/databases/forms.py
@@ -12,7 +12,8 @@ from .models import DatabaseUser, Database
class DatabaseUserCreationForm(forms.ModelForm):
password1 = forms.CharField(label=_("Password"), required=False,
- widget=forms.PasswordInput, validators=[validate_password])
+ widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
+ validators=[validate_password])
password2 = forms.CharField(label=_("Password confirmation"), required=False,
widget=forms.PasswordInput,
help_text=_("Enter the same password as above, for verification."))
@@ -57,6 +58,7 @@ class DatabaseCreationForm(DatabaseUserCreationForm):
username = self.cleaned_data.get('username')
if DatabaseUser.objects.filter(username=username).exists():
raise ValidationError("Provided username already exists.")
+ return username
def clean_password2(self):
username = self.cleaned_data.get('username')
@@ -79,7 +81,7 @@ class DatabaseCreationForm(DatabaseUserCreationForm):
def clean(self):
cleaned_data = super(DatabaseCreationForm, self).clean()
if 'user' in cleaned_data and 'username' in cleaned_data:
- msg = _("Use existing user or create a new one?")
+ msg = _("Use existing user or create a new one? you have provided both.")
if cleaned_data['user'] and self.cleaned_data['username']:
raise ValidationError(msg)
elif not (cleaned_data['username'] or cleaned_data['user']):
diff --git a/orchestra/contrib/orders/admin.py b/orchestra/contrib/orders/admin.py
index 89b8d001..77e6d27e 100644
--- a/orchestra/contrib/orders/admin.py
+++ b/orchestra/contrib/orders/admin.py
@@ -49,7 +49,7 @@ class MetricStorageInline(admin.TabularInline):
class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin):
list_display = (
- 'id', 'service_link', 'account_link', 'content_object_link',
+ 'display_description', 'service_link', 'account_link', 'content_object_link',
'display_registered_on', 'display_billed_until', 'display_cancelled_on',
'display_metric'
)
@@ -78,6 +78,12 @@ class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin):
display_registered_on = admin_date('registered_on')
display_cancelled_on = admin_date('cancelled_on')
+ def display_description(self, order):
+ return order.description[:64]
+ display_description.short_description = _("Description")
+ display_description.allow_tags = True
+ display_description.admin_order_field = 'description'
+
def content_object_link(self, order):
if order.content_object:
try:
diff --git a/orchestra/contrib/payments/admin.py b/orchestra/contrib/payments/admin.py
index e71e34bc..07231a67 100644
--- a/orchestra/contrib/payments/admin.py
+++ b/orchestra/contrib/payments/admin.py
@@ -151,9 +151,10 @@ class TransactionProcessAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
lines = []
counter = 0
# Because of values_list this query doesn't benefit from prefetch_related
- tx_ids = process.transactions.values_list('id', flat=True)
- for tx_id in tx_ids:
- ids.append(str(tx_id))
+ for trans in process.transactions.only('id', 'state'):
+ color = STATE_COLORS.get(trans.state, 'black')
+ state = trans.get_state_display()
+ ids.append('%i' % (color, state, trans.id))
counter += 1
if counter > 10:
counter = 0
diff --git a/orchestra/contrib/saas/backends/moodle.py b/orchestra/contrib/saas/backends/moodle.py
new file mode 100644
index 00000000..aa781068
--- /dev/null
+++ b/orchestra/contrib/saas/backends/moodle.py
@@ -0,0 +1,136 @@
+import os
+import textwrap
+
+from django.utils.translation import ugettext_lazy as _
+
+from orchestra.contrib.orchestration import ServiceController, replace
+
+from .. import settings
+
+
+class MoodleMuBackend(ServiceController):
+ """
+ Creates a Moodle site on a Moodle multisite installation
+
+ // config.php
+ $site_map = array(
+ // "" => ["", ""],
+ );
+
+ wwwroot = "https://{$site}-courses.pangea.org";
+ $site = getenv("SITE");
+ if ( $site == '' ) {
+ http_host = $_SERVER['HTTP_HOST'];
+ if (array_key_exists($http_host, $site_map)) {
+ $site = $site_map[$http_host][0];
+ $wwwroot = $site_map[$http_host][1];
+ } elseif (strpos($http_host, '-courses.') !== false) {
+ $site = array_shift((explode("-courses.", $http_host)));
+ } else {
+ $site = array_shift((explode(".", $http_host)));
+ }
+ }
+ $CFG->prefix = "${site}_";
+ $CFG->wwwroot = $wwwroot;
+ $CFG->dataroot = "/home/pangea/moodledata/{$site}/";
+ """
+ verbose_name = _("Moodle multisite")
+ model = 'saas.SaaS'
+ default_route_match = "saas.service == 'moodle'"
+
+ def save(self, webapp):
+ context = self.get_context(webapp)
+ self.append(textwrap.dedent("""\
+ mkdir -p %(moodledata_path)s
+ chown %(user)s:%(user)s %(moodledata_path)s
+ export SITE=%(site_name)s
+ CHANGE_PASSWORD=0
+ php %(moodle_path)s/admin/cli/install_database.php \\
+ --fullname="%(site_name)s" \\
+ --shortname="%(site_name)s" \\
+ --adminpass="%(password)s" \\
+ --adminemail="%(email)s" \\
+ --non-interactive \\
+ --agree-license \\
+ --allow-unstable || CHANGE_PASSWORD=1
+ """) % context
+ )
+ if context['password']:
+ self.append(textwrap.dedent("""\
+ mysql \\
+ --host="%(db_host)s" \\
+ --user="%(db_user)s" \\
+ --password="%(db_pass)s" \\
+ --execute='UPDATE %(site_name)s_user
+ SET password=MD5("%(password)s")
+ WHERE username="admin"' \\
+ %(db_name)s
+ """) % context
+ )
+ if context['crontab']:
+ context['escaped_crontab'] = context['crontab'].replace('$', '\\$')
+ self.append(textwrap.dedent("""\
+ # Configuring Moodle crontabs
+ if ! crontab -u %(user)s -l | grep 'Moodle:"%(site_name)s"' > /dev/null; then
+ cat << EOF | su %(user)s --shell /bin/bash -c 'crontab'
+ $(crontab -u %(user)s -l)
+
+ # %(banner)s - Moodle:"%(site_name)s"
+ %(escaped_crontab)s
+ EOF
+ fi""") % context
+ )
+
+ def delete(self, saas):
+ context = self.get_context(saas)
+ self.append(textwrap.dedent("""
+ rm -rf %(moodledata_path)s
+ # Delete tables with prefix %(site_name)s
+ mysql -Nrs \\
+ --host="%(db_host)s" \\
+ --user="%(db_user)s" \\
+ --password="%(db_pass)s" \\
+ --execute='SET GROUP_CONCAT_MAX_LEN=10000;
+ SET @tbls = (SELECT GROUP_CONCAT(TABLE_NAME)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = "%(db_name)s"
+ AND TABLE_NAME LIKE "%(site_name)s_%%");
+ SET @delStmt = CONCAT("DROP TABLE ", @tbls);
+ -- SELECT @delStmt;
+ PREPARE stmt FROM @delStmt;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;' \\
+ %(db_name)s
+ """) % context
+ )
+ if context['crontab']:
+ context['crontab_regex'] = '\\|'.join(context['crontab'].splitlines())
+ context['crontab_regex'] = context['crontab_regex'].replace('*', '\\*')
+ self.append(textwrap.dedent("""\
+ crontab -u %(user)s -l \\
+ | grep -v 'Moodle:"%(site_name)s"\\|%(crontab_regex)s' \\
+ | su %(user)s --shell /bin/bash -c 'crontab'
+ """) % context
+ )
+
+ def get_context(self, saas):
+ context = {
+ 'banner': self.get_banner(),
+ 'name': saas.name,
+ 'site_name': saas.name,
+ 'full_name': "%s course" % saas.name.capitalize(),
+ 'moodle_path': settings.SAAS_MOODLE_PATH,
+ 'user': settings.SAAS_MOODLE_SYSTEMUSER,
+ 'db_user': settings.SAAS_MOODLE_DB_USER,
+ 'db_pass': settings.SAAS_MOODLE_DB_PASS,
+ 'db_name': settings.SAAS_MOODLE_DB_NAME,
+ 'db_host': settings.SAAS_MOODLE_DB_HOST,
+ 'email': saas.account.email,
+ 'password': getattr(saas, 'password', None),
+ }
+ context.update({
+ 'crontab': settings.SAAS_MOODLE_CRONTAB % context,
+ 'db_name': context['db_name'] % context,
+ 'moodledata_path': settings.SAAS_MOODLE_DATA_PATH % context,
+ })
+ return context
diff --git a/orchestra/contrib/saas/backends/phplist.py b/orchestra/contrib/saas/backends/phplist.py
index d35cc2b0..8cf09582 100644
--- a/orchestra/contrib/saas/backends/phplist.py
+++ b/orchestra/contrib/saas/backends/phplist.py
@@ -19,6 +19,9 @@ class PhpListSaaSBackend(ServiceController):
The site is created by means of creating a new database per phpList site,
but all sites share the same code.
+ Different databases are used instead of prefixes because php-list reacts by launching
+ the installation process.
+
// config/config.php
$site = getenv("SITE");
if ( $site == '' ) {
diff --git a/orchestra/contrib/saas/forms.py b/orchestra/contrib/saas/forms.py
index 14533d88..de6fe887 100644
--- a/orchestra/contrib/saas/forms.py
+++ b/orchestra/contrib/saas/forms.py
@@ -49,7 +49,7 @@ class SaaSPasswordForm(SaaSBaseForm):
"service's password, but you can change the password using "
"this form."))
password1 = forms.CharField(label=_("Password"), validators=[validators.validate_password],
- widget=forms.PasswordInput)
+ widget=forms.PasswordInput(attrs={'autocomplete': 'off'}))
password2 = forms.CharField(label=_("Password confirmation"),
widget=forms.PasswordInput,
help_text=_("Enter the same password as above, for verification."))
diff --git a/orchestra/contrib/saas/services/moodle.py b/orchestra/contrib/saas/services/moodle.py
index 88f5e623..adfed1f3 100644
--- a/orchestra/contrib/saas/services/moodle.py
+++ b/orchestra/contrib/saas/services/moodle.py
@@ -1,16 +1,24 @@
from django import forms
from django.utils.translation import ugettext_lazy as _
+from orchestra.forms.widgets import SpanWidget
+
+from .. import settings
from ..forms import SaaSPasswordForm
from .options import SoftwareService
class MoodleForm(SaaSPasswordForm):
- email = forms.EmailField(label=_("Email"))
+ admin_username = forms.CharField(label=_("Admin username"), required=False,
+ widget=SpanWidget(display='admin'))
class MoodleService(SoftwareService):
+ name = 'moodle'
verbose_name = "Moodle"
form = MoodleForm
description_field = 'site_name'
icon = 'orchestra/icons/apps/Moodle.png'
+ site_domain = settings.SAAS_MOODLE_DOMAIN
+ 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 8a95abe4..de67a402 100644
--- a/orchestra/contrib/saas/services/options.py
+++ b/orchestra/contrib/saas/services/options.py
@@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError
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.utils.functional import cached
from orchestra.utils.python import import_class
@@ -64,3 +65,58 @@ class SoftwareService(plugins.Plugin):
def get_related(self):
return []
+
+
+class DBSoftwareService(SoftwareService):
+ db_name = None
+ db_user = None
+
+ def get_db_name(self):
+ context = {
+ 'name': self.instance.name,
+ 'site_name': self.instance.name,
+ }
+ db_name = self.db_name % context
+ # Limit for mysql database names
+ return db_name[:65]
+
+ def get_db_user(self):
+ return self.db_user
+
+ @cached
+ def get_account(self):
+ account_model = self.instance._meta.get_field_by_name('account')[0]
+ return account_model.rel.to.objects.get_main()
+
+ def validate(self):
+ super(DBSoftwareService, self).validate()
+ create = not self.instance.pk
+ if create:
+ account = self.get_account()
+ # Validated Database
+ db_user = self.get_db_user()
+ try:
+ DatabaseUser.objects.get(username=db_user)
+ except DatabaseUser.DoesNotExist:
+ raise ValidationError(
+ _("Global database user for PHPList '%(db_user)s' does not exists.") % {
+ 'db_user': db_user
+ }
+ )
+ db = Database(name=self.get_db_name(), account=account)
+ try:
+ db.full_clean()
+ except ValidationError as e:
+ raise ValidationError({
+ 'name': e.messages,
+ })
+
+ def save(self):
+ account = self.get_account()
+ # Database
+ db_name = self.get_db_name()
+ db_user = self.get_db_user()
+ db, db_created = account.databases.get_or_create(name=db_name, type=Database.MYSQL)
+ user = DatabaseUser.objects.get(username=db_user)
+ db.users.add(user)
+ self.instance.database_id = db.pk
diff --git a/orchestra/contrib/saas/services/phplist.py b/orchestra/contrib/saas/services/phplist.py
index 5a5e1417..8e619c10 100644
--- a/orchestra/contrib/saas/services/phplist.py
+++ b/orchestra/contrib/saas/services/phplist.py
@@ -5,13 +5,12 @@ from django.db.models import Q
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
-from orchestra.contrib.databases.models import Database, DatabaseUser
from orchestra.contrib.mailboxes.models import Mailbox
from orchestra.forms.widgets import SpanWidget
from .. import settings
from ..forms import SaaSPasswordForm
-from .options import SoftwareService
+from .options import DBSoftwareService
class PHPListForm(SaaSPasswordForm):
@@ -64,26 +63,15 @@ class PHPListChangeForm(PHPListForm):
original=mailbox.name, display=mailbox_link)
-class PHPListService(SoftwareService):
+class PHPListService(DBSoftwareService):
name = 'phplist'
verbose_name = "phpList"
form = PHPListForm
change_form = PHPListChangeForm
icon = 'orchestra/icons/apps/Phplist.png'
site_domain = settings.SAAS_PHPLIST_DOMAIN
-
- def get_db_name(self):
- context = {
- 'name': self.instance.name,
- 'site_name': self.instance.name,
- }
- return settings.SAAS_PHPLIST_DB_NAME % context
- db_name = 'phplist_mu_%s' % self.instance.name
- # Limit for mysql database names
- return db_name[:65]
-
- def get_db_user(self):
- return settings.SAAS_PHPLIST_DB_USER
+ db_name = settings.SAAS_PHPLIST_DB_NAME
+ db_user = settings.SAAS_PHPLIST_DB_USER
def get_mailbox_name(self):
context = {
@@ -92,32 +80,11 @@ class PHPListService(SoftwareService):
}
return settings.SAAS_PHPLIST_BOUNCES_MAILBOX_NAME % context
- def get_account(self):
- account_model = self.instance._meta.get_field_by_name('account')[0]
- return account_model.rel.to.objects.get_main()
-
def validate(self):
super(PHPListService, self).validate()
create = not self.instance.pk
if create:
account = self.get_account()
- # Validated Database
- db_user = self.get_db_user()
- try:
- DatabaseUser.objects.get(username=db_user)
- except DatabaseUser.DoesNotExist:
- raise ValidationError(
- _("Global database user for PHPList '%(db_user)s' does not exists.") % {
- 'db_user': db_user
- }
- )
- db = Database(name=self.get_db_name(), account=account)
- try:
- db.full_clean()
- except ValidationError as e:
- raise ValidationError({
- 'name': e.messages,
- })
# Validate mailbox
mailbox = Mailbox(name=self.get_mailbox_name(), account=account)
try:
@@ -129,13 +96,6 @@ class PHPListService(SoftwareService):
def save(self):
account = self.get_account()
- # Database
- db_name = self.get_db_name()
- db_user = self.get_db_user()
- db, db_created = account.databases.get_or_create(name=db_name, type=Database.MYSQL)
- user = DatabaseUser.objects.get(username=db_user)
- db.users.add(user)
- self.instance.database_id = db.pk
# Mailbox
mailbox_name = self.get_mailbox_name()
mailbox, mb_created = account.mailboxes.get_or_create(name=mailbox_name)
diff --git a/orchestra/contrib/saas/settings.py b/orchestra/contrib/saas/settings.py
index 10fe55b5..bf6b0097 100644
--- a/orchestra/contrib/saas/settings.py
+++ b/orchestra/contrib/saas/settings.py
@@ -197,3 +197,54 @@ SAAS_GITLAB_DOMAIN = Setting('SAAS_GITLAB_DOMAIN',
'gitlab.{}'.format(ORCHESTRA_BASE_DOMAIN),
help_text="Uses ORCHESTRA_BASE_DOMAIN by default.",
)
+
+
+# Moodle
+
+SAAS_MOODLE_DB_USER = Setting('SAAS_MOODLE_DB_USER',
+ 'moodle_mu',
+ help_text=_("Needed for password changing support."),
+)
+
+SAAS_MOODLE_DB_PASS = Setting('SAAS_MOODLE_DB_PASS',
+ 'secret',
+ help_text=_("Needed for password changing support."),
+)
+
+SAAS_MOODLE_DB_NAME = Setting('SAAS_MOODLE_DB_NAME',
+ 'moodle_mu',
+ help_text=_("Needed for password changing support."),
+)
+
+SAAS_MOODLE_DB_HOST = Setting('SAAS_MOODLE_DB_HOST',
+ 'loclahost',
+ help_text=_("Needed for password changing support."),
+)
+
+SAAS_MOODLE_DOMAIN = Setting('SAAS_MOODLE_DOMAIN',
+ '%(site_name)s.courses.{}'.format(ORCHESTRA_BASE_DOMAIN),
+ help_text="Uses ORCHESTRA_BASE_DOMAIN by default.",
+)
+
+SAAS_MOODLE_PATH = Setting('SAAS_MOODLE_PATH',
+ '/var/www/moodle-mu',
+ help_text=_("Filesystem path to the Moodle source code installed on the server. "
+ "Used by SAAS_MOODLE_CRONTAB.")
+)
+
+SAAS_MOODLE_DATA_PATH = Setting('SAAS_MOODLE_DATA_PATH',
+ '/var/moodledata/%(site_name)s',
+ help_text=_("Filesystem path to the Moodle source code installed on the server. "
+ "Used by SAAS_MOODLE_CRONTAB.")
+)
+
+SAAS_MOODLE_SYSTEMUSER = Setting('SAAS_MOODLE_SYSTEMUSER',
+ 'root',
+ help_text=_("System user running Moodle on the server."
+ "Used by SAAS_MOODLE_CRONTAB.")
+)
+
+SAAS_MOODLE_CRONTAB = Setting('SAAS_MOODLE_CRONTAB',
+ '*/15 * * * * export SITE="%(site_name)s"; php %(moodle_path)s/admin/cli/cron.php >/dev/null',
+ help_text=_("Left blank if you don't want crontab to be configured")
+)
diff --git a/orchestra/forms/options.py b/orchestra/forms/options.py
index afa0f6ec..36719af8 100644
--- a/orchestra/forms/options.py
+++ b/orchestra/forms/options.py
@@ -20,7 +20,8 @@ class UserCreationForm(forms.ModelForm):
'duplicate_username': _("A user with that username already exists."),
}
password1 = forms.CharField(label=_("Password"),
- widget=forms.PasswordInput, validators=[validate_password])
+ widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
+ validators=[validate_password])
password2 = forms.CharField(label=_("Password confirmation"),
widget=forms.PasswordInput,
help_text=_("Enter the same password as above, for verification."))