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."))