diff --git a/orchestra/admin/forms.py b/orchestra/admin/forms.py index ed675457..0eb2f397 100644 --- a/orchestra/admin/forms.py +++ b/orchestra/admin/forms.py @@ -1,6 +1,12 @@ +from functools import partial + +from django import forms from django.contrib.admin import helpers from django.forms.models import modelformset_factory, BaseModelFormSet from django.template import Template, Context +from django.utils.translation import ugettext_lazy as _ + +from ..core.validators import validate_password class AdminFormMixin(object): @@ -42,3 +48,84 @@ def adminmodelformset_factory(modeladmin, form, formset=AdminFormSet, **kwargs): **kwargs) formset.modeladmin = modeladmin return formset + + +class AdminPasswordChangeForm(forms.Form): + """ + A form used to change the password of a user in the admin interface. + """ + error_messages = { + 'password_mismatch': _("The two password fields didn't match."), + 'password_missing': _("No password has been provided."), + } + required_css_class = 'required' + password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput, + required=False, validators=[validate_password]) + password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput, + required=False) + + def __init__(self, user, *args, **kwargs): + self.related = kwargs.pop('related', []) + self.user = user + super(AdminPasswordChangeForm, self).__init__(*args, **kwargs) + for ix, rel in enumerate(self.related): + self.fields['password1_%i' % ix] = forms.CharField( + label=_("Password"), widget=forms.PasswordInput, required=False) + self.fields['password2_%i' % ix] = forms.CharField( + label=_("Password (again)"), widget=forms.PasswordInput, required=False) + setattr(self, 'clean_password2_%i' % ix, partial(self.clean_password2, ix=ix)) + + def clean_password2(self, ix=''): + if ix != '': + ix = '_%i' % ix + password1 = self.cleaned_data.get('password1%s' % ix) + password2 = self.cleaned_data.get('password2%s' % ix) + if password1 and password2: + if password1 != password2: + raise forms.ValidationError( + self.error_messages['password_mismatch'], + code='password_mismatch', + ) + elif password1 or password2: + raise forms.ValidationError( + self.error_messages['password_mismatch'], + code='password_mismatch', + ) + return password2 + + def clean(self): + cleaned_data = super(AdminPasswordChangeForm, self).clean() + for data in cleaned_data.values(): + if data: + return + raise forms.ValidationError( + self.error_messages['password_missing'], + code='password_missing', + ) + + def save(self, commit=True): + """ + Saves the new password. + """ + password = self.cleaned_data["password1"] + if password: + self.user.set_password(password) + if commit: + self.user.save() + for ix, rel in enumerate(self.related): + password = self.cleaned_data['password1_%s' % ix] + if password: + set_password = getattr(rel, 'set_password') + set_password(password) + if commit: + rel.save() + return self.user + + def _get_changed_data(self): + data = super(AdminPasswordChangeForm, self).changed_data + for name in self.fields.keys(): + if name not in data: + return [] + return ['password'] + changed_data = property(_get_changed_data) + diff --git a/orchestra/admin/options.py b/orchestra/admin/options.py index cee4510c..8d3c71d4 100644 --- a/orchestra/admin/options.py +++ b/orchestra/admin/options.py @@ -1,15 +1,28 @@ from django import forms from django.conf.urls import patterns, url -from django.contrib import admin +from django.contrib import admin, messages +from django.contrib.admin.options import IS_POPUP_VAR from django.contrib.admin.utils import unquote +from django.contrib.auth import update_session_auth_hash +from django.core.exceptions import PermissionDenied +from django.http import HttpResponseRedirect, Http404 from django.forms.models import BaseInlineFormSet -from django.shortcuts import render, redirect +from django.shortcuts import render, redirect, get_object_or_404 +from django.template.response import TemplateResponse +from django.utils.decorators import method_decorator +from django.utils.html import escape from django.utils.text import camel_case_to_spaces from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.debug import sensitive_post_parameters +from .forms import AdminPasswordChangeForm +#from django.contrib.auth.forms import AdminPasswordChangeForm from .utils import set_url_query, action_to_view, wrap_admin_view +sensitive_post_parameters_m = method_decorator(sensitive_post_parameters()) + + class ChangeListDefaultFilter(object): """ Enables support for default filtering on admin change list pages @@ -200,3 +213,94 @@ class SelectPluginAdminMixin(object): setattr(obj, self.plugin_field, self.plugin_value) obj.save() + +class ChangePasswordAdminMixin(object): + change_password_form = AdminPasswordChangeForm + change_user_password_template = 'admin/orchestra/change_password.html' + + def get_urls(self): + opts = self.model._meta + info = opts.app_label, opts.model_name + return patterns('', + url(r'^(\d+)/password/$', + self.admin_site.admin_view(self.change_password), + name='%s_%s_change_password' % info), + ) + super(ChangePasswordAdminMixin, self).get_urls() + + @sensitive_post_parameters_m + def change_password(self, request, id, form_url=''): + if not self.has_change_permission(request): + raise PermissionDenied + # TODO use this insetad of self.get_object() + user = get_object_or_404(self.get_queryset(request), pk=id) + + related = [] + try: + # don't know why getattr(user, 'username', user.name) doesn't work + username = user.username + except AttributeError: + username = user.name + if hasattr(user, 'account'): + account = user.account + if user.account.username == username: + related.append(user.account) + else: + account = user + # TODO plugability + if user._meta.model_name != 'systemuser': + rel = account.systemusers.filter(username=username).first() + if rel: + related.append(rel) + if user._meta.model_name != 'mailbox': + rel = account.mailboxes.filter(name=username).first() + if rel: + related.append(rel) + + if request.method == 'POST': + form = self.change_password_form(user, request.POST, related=related) + if form.is_valid(): + form.save() + change_message = self.construct_change_message(request, form, None) + self.log_change(request, user, change_message) + msg = _('Password changed successfully.') + messages.success(request, msg) + update_session_auth_hash(request, form.user) # This is safe + return HttpResponseRedirect('..') + else: + form = self.change_password_form(user, related=related) + + fieldsets = [ + (user._meta.verbose_name.capitalize(), { + 'classes': ('wide',), + 'fields': ('password1', 'password2') + }), + ] + for ix, rel in enumerate(related): + fieldsets.append((rel._meta.verbose_name.capitalize(), { + 'classes': ('wide',), + 'fields': ('password1_%i' % ix, 'password2_%i' % ix) + })) + + adminForm = admin.helpers.AdminForm(form, fieldsets, {}) + + context = { + 'title': _('Change password: %s') % escape(username), + 'adminform': adminForm, + 'errors': admin.helpers.AdminErrorList(form, []), + 'form_url': form_url, + 'is_popup': (IS_POPUP_VAR in request.POST or + IS_POPUP_VAR in request.GET), + 'add': True, + 'change': False, + 'has_delete_permission': False, + 'has_change_permission': True, + 'has_absolute_url': False, + 'opts': self.model._meta, + 'original': user, + 'save_as': False, + 'show_save': True, + } + context.update(admin.site.each_context()) + return TemplateResponse(request, + self.change_user_password_template, + context, current_app=self.admin_site.name) diff --git a/orchestra/api/options.py b/orchestra/api/options.py index d27aeb9e..19b72a2f 100644 --- a/orchestra/api/options.py +++ b/orchestra/api/options.py @@ -99,12 +99,13 @@ class LinkHeaderRouter(DefaultRouter): def insert(self, prefix_or_model, name, field, **kwargs): """ Dynamically add new fields to an existing serializer """ viewset = self.get_viewset(prefix_or_model) + setattr(viewset, 'inserted', getattr(viewset, 'inserted', [])) if viewset.serializer_class is None: viewset.serializer_class = viewset().get_serializer_class() - viewset.serializer_class.Meta.fields += (name,) viewset.serializer_class.base_fields.update({name: field(**kwargs)}) - setattr(viewset, 'inserted', getattr(viewset, 'inserted', [])) - viewset.inserted.append(name) + if not name in viewset.inserted: + viewset.serializer_class.Meta.fields += (name,) + viewset.inserted.append(name) # Create a router and register our viewsets with it. diff --git a/orchestra/apps/accounts/admin.py b/orchestra/apps/accounts/admin.py index e58e021e..65f1592b 100644 --- a/orchestra/apps/accounts/admin.py +++ b/orchestra/apps/accounts/admin.py @@ -8,16 +8,17 @@ from django.utils.safestring import mark_safe from django.utils.six.moves.urllib.parse import parse_qsl from django.utils.translation import ugettext_lazy as _ -from orchestra.admin import ExtendedModelAdmin +from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin from orchestra.admin.utils import wrap_admin_view, admin_link, set_url_query, change_url from orchestra.core import services, accounts +from orchestra.forms import UserChangeForm from .filters import HasMainUserListFilter -from .forms import AccountCreationForm, AccountChangeForm +from .forms import AccountCreationForm from .models import Account -class AccountAdmin(auth.UserAdmin, ExtendedModelAdmin): +class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin): list_display = ('username', 'type', 'is_active') list_filter = ( 'type', 'is_active', HasMainUserListFilter @@ -50,7 +51,7 @@ class AccountAdmin(auth.UserAdmin, ExtendedModelAdmin): ) search_fields = ('username',) add_form = AccountCreationForm - form = AccountChangeForm + form = UserChangeForm filter_horizontal = () change_readonly_fields = ('username',) change_form_template = 'admin/accounts/account/change_form.html' diff --git a/orchestra/apps/accounts/forms.py b/orchestra/apps/accounts/forms.py index 24005b7d..0a012e19 100644 --- a/orchestra/apps/accounts/forms.py +++ b/orchestra/apps/accounts/forms.py @@ -3,14 +3,12 @@ from django.contrib import auth from django.utils.translation import ugettext_lazy as _ from orchestra.core.validators import validate_password +from orchestra.forms import UserCreationForm from orchestra.forms.widgets import ReadOnlyWidget -class AccountCreationForm(auth.forms.UserCreationForm): - def __init__(self, *args, **kwargs): - super(AccountCreationForm, self).__init__(*args, **kwargs) - self.fields['password1'].validators.append(validate_password) - + +class AccountCreationForm(UserCreationForm): def clean_username(self): # Since model.clean() will check this, this is redundant, # but it sets a nicer error message than the ORM and avoids conflicts with contrib.auth @@ -21,16 +19,3 @@ class AccountCreationForm(auth.forms.UserCreationForm): if systemuser_model.objects.filter(username=username).exists(): raise forms.ValidationError(self.error_messages['duplicate_username']) return username - - -class AccountChangeForm(forms.ModelForm): - password = auth.forms.ReadOnlyPasswordHashField(label=_("Password"), - help_text=_("Raw passwords are not stored, so there is no way to see " - "this user's password, but you can change the password " - "using this form.")) - - def clean_password(self): - # Regardless of what the user provides, return the initial value. - # This is done here, rather than on the field, because the - # field does not have access to the initial value - return self.initial["password"] diff --git a/orchestra/apps/domains/tests/functional_tests/tests.py b/orchestra/apps/domains/tests/functional_tests/tests.py index 9762c7a9..2f159866 100644 --- a/orchestra/apps/domains/tests/functional_tests/tests.py +++ b/orchestra/apps/domains/tests/functional_tests/tests.py @@ -42,11 +42,11 @@ class DomainTestMixin(object): ) self.ns1_name = 'ns1.%s' % self.domain_name self.ns1_records = ( - (Record.A, '%s' % self.SLAVE_SERVER_ADDR), + (Record.A, self.SLAVE_SERVER_ADDR), ) self.ns2_name = 'ns2.%s' % self.domain_name self.ns2_records = ( - (Record.A, '%s' % self.MASTER_SERVER_ADDR), + (Record.A, self.MASTER_SERVER_ADDR), ) self.www_name = 'www.%s' % self.domain_name self.www_records = ( diff --git a/orchestra/apps/mails/admin.py b/orchestra/apps/mails/admin.py index e92b71fb..9237aa7c 100644 --- a/orchestra/apps/mails/admin.py +++ b/orchestra/apps/mails/admin.py @@ -6,11 +6,13 @@ from django.core.urlresolvers import reverse from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ -from orchestra.admin import ExtendedModelAdmin +from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin from orchestra.admin.utils import admin_link, change_url from orchestra.apps.accounts.admin import SelectAccountAdminMixin, AccountAdminMixin +from orchestra.forms import UserCreationForm, UserChangeForm from .filters import HasMailboxListFilter, HasForwardListFilter, HasAddressListFilter +from .forms import MailboxCreationForm, AddressForm from .models import Mailbox, Address, Autoresponse @@ -24,14 +26,14 @@ class AutoresponseInline(admin.StackedInline): return super(AutoresponseInline, self).formfield_for_dbfield(db_field, **kwargs) -class MailboxAdmin(AccountAdminMixin, ExtendedModelAdmin): +class MailboxAdmin(ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin): list_display = ( 'name', 'account_link', 'uses_custom_filtering', 'display_addresses' ) list_filter = (HasAddressListFilter,) add_fieldsets = ( (None, { - 'fields': ('account', 'name', 'password'), + 'fields': ('account', 'name', 'password1', 'password2'), }), (_("Filtering"), { 'classes': ('collapse',), @@ -41,7 +43,7 @@ class MailboxAdmin(AccountAdminMixin, ExtendedModelAdmin): fieldsets = ( (None, { 'classes': ('wide',), - 'fields': ('account_link', 'name'), + 'fields': ('account_link', 'name', 'password'), }), (_("Filtering"), { 'classes': ('collapse',), @@ -53,6 +55,9 @@ class MailboxAdmin(AccountAdminMixin, ExtendedModelAdmin): }), ) readonly_fields = ('account_link', 'display_addresses', 'addresses_field') + change_readonly_fields = ('name',) + add_form = MailboxCreationForm + form = UserChangeForm def display_addresses(self, mailbox): addresses = [] @@ -108,6 +113,7 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): readonly_fields = ('account_link', 'domain_link', 'email_link') filter_by_account_fields = ('domain', 'mailboxes') filter_horizontal = ['mailboxes'] + form = AddressForm domain_link = admin_link('domain', order='domain__name') diff --git a/orchestra/apps/mails/backends.py b/orchestra/apps/mails/backends.py index 70f7dfc2..b6418d3a 100644 --- a/orchestra/apps/mails/backends.py +++ b/orchestra/apps/mails/backends.py @@ -1,6 +1,7 @@ import textwrap import os +from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from django.utils.translation import ugettext_lazy as _ @@ -10,24 +11,30 @@ from orchestra.apps.resources import ServiceMonitor from . import settings from .models import Address +# TODO http://wiki2.dovecot.org/HowTo/SimpleVirtualInstall +# TODO http://wiki2.dovecot.org/HowTo/VirtualUserFlatFilesPostfix +# TODO Set first/last_valid_uid/gid settings to contain only the range actually used by mail processes +# TODO Insert "/./" inside the returned home directory, eg.: home=/home/./user to chroot into /home, or home=/home/user/./ to chroot into /home/user. +# TODO mount the filesystem with "nosuid" option -class MailSystemUserBackend(ServiceController): - verbose_name = _("Mail system user") + +class PasswdVirtualUserBackend(ServiceController): + verbose_name = _("Mail virtual user (passwd-file)") model = 'mails.Mailbox' # TODO related_models = ('resources__content_type') ?? needed for updating disk usage from resource.data DEFAULT_GROUP = 'postfix' - def create_user(self, context): - self.append(textwrap(""" - if [[ $( id %(username)s ) ]]; then - usermod -p '%(password)s' %(username)s + def set_user(self, context): + self.append(textwrap.dedent(""" + if [[ $( grep "^%(username)s:" %(passwd_path)s ) ]]; then + sed -i "s/^%(username)s:.*/%(passwd)s/" %(passwd_path)s else - useradd %(username)s --password '%(password)s' --shell /dev/null + echo '%(passwd)s' >> %(passwd_path)s fi""" % context )) self.append("mkdir -p %(home)s" % context) - self.append("chown %(username)s.%(group)s %(home)s" % context) + self.append("chown %(uid)s.%(gid)s %(home)s" % context) def generate_filter(self, mailbox, context): now = timezone.now().strftime("%B %d, %Y, %H:%M") @@ -38,7 +45,7 @@ class MailSystemUserBackend(ServiceController): if mailbox.custom_filtering: context['filtering'] += mailbox.custom_filtering else: - context['filtering'] += settings.EMAILS_DEFAUL_FILTERING + context['filtering'] += settings.MAILS_DEFAUL_FILTERING context['filter_path'] = os.path.join(context['home'], '.orchestra.sieve') self.append("echo '%(filtering)s' > %(filter_path)s" % context) @@ -51,7 +58,7 @@ class MailSystemUserBackend(ServiceController): 'quota': mailbox.resources.disk.allocated*1000*1000, }) self.append("mkdir -p %(maildir_path)s" % context) - self.append(textwrap(""" + self.append(textwrap.dedent(""" sed -i '1s/.*/%(quota)s,S/' %(maildirsize_path)s || { echo '%(quota)s,S' > %(maildirsize_path)s && chown %(username)s %(maildirsize_path)s; @@ -60,25 +67,42 @@ class MailSystemUserBackend(ServiceController): def save(self, mailbox): context = self.get_context(mailbox) - self.create_user(context) - self.set_quota(mailbox, context) + self.set_user(context) self.generate_filter(mailbox, context) def delete(self, mailbox): context = self.get_context(mailbox) - self.append("{ sleep 2 && killall -u %(username)s -s KILL; } &" % context) - self.append("killall -u %(username)s" % context) - self.append("userdel %(username)s" % context) + self.append("{ sleep 2 && killall -u %(uid)s -s KILL; } &" % context) + self.append("killall -u %(uid)s" % context) + self.append("sed -i '/^%(username)s:.*/d' %(passwd_path)s" % context) self.append("rm -fr %(home)s" % context) + def get_extra_fields(self, mailbox, context): + context['quota'] = self.get_quota(mailbox) + return 'userdb_mail=maildir:~/Maildir {quota}'.format(**context) + + def get_quota(self, mailbox): + try: + quota = mailbox.resources.disk.allocated + except (AttributeError, ObjectDoesNotExist): + return '' + unit = mailbox.resources.disk.unit[0].upper() + return 'userdb_quota_rule=*:bytes=%i%s' % (quota, unit) + def get_context(self, mailbox): context = { 'name': mailbox.name, 'username': mailbox.name, - 'password': mailbox.password if mailbox.is_active else '*%s' % mailbox.password, - 'group': self.DEFAULT_GROUP + 'password': mailbox.password if mailbox.active else '*%s' % mailbox.password, + 'uid': 10000 + mailbox.pk, + 'gid': 10000 + mailbox.pk, + 'group': self.DEFAULT_GROUP, + 'quota': self.get_quota(mailbox), + 'passwd_path': settings.MAILS_PASSWD_PATH, + 'home': mailbox.get_home(), } - context['home'] = settings.EMAILS_HOME % context + context['extra_fields'] = self.get_extra_fields(mailbox, context) + context['passwd'] = '{username}:{password}:{uid}:{gid}:,,,:{home}:{extra_fields}'.format(**context) return context @@ -89,7 +113,7 @@ class PostfixAddressBackend(ServiceController): def include_virtdomain(self, context): self.append( '[[ $(grep "^\s*%(domain)s\s*$" %(virtdomains)s) ]]' - ' || { echo "%(domain)s" >> %(virtdomains)s; UPDATED=1; }' % context + ' || { echo "%(domain)s" >> %(virtdomains)s; UPDATED_VIRTDOMAINS=1; }' % context ) def exclude_virtdomain(self, context): @@ -98,21 +122,21 @@ class PostfixAddressBackend(ServiceController): self.append('sed -i "s/^%(domain)s//" %(virtdomains)s' % context) def update_virtusertable(self, context): - self.append(textwrap(""" + self.append(textwrap.dedent(""" LINE="%(email)s\t%(destination)s" if [[ ! $(grep "^%(email)s\s" %(virtusertable)s) ]]; then - echo "$LINE" >> %(virtusertable)s - UPDATED=1 + echo "${LINE}" >> %(virtusertable)s + UPDATED_VIRTUSERTABLE=1 else if [[ ! $(grep "^${LINE}$" %(virtusertable)s) ]]; then sed -i "s/^%(email)s\s.*$/${LINE}/" %(virtusertable)s - UPDATED=1 + UPDATED_VIRTUSERTABLE=1 fi fi""" % context )) def exclude_virtusertable(self, context): - self.append(textwrap(""" + self.append(textwrap.dedent(""" if [[ $(grep "^%(email)s\s") ]]; then sed -i "s/^%(email)s\s.*$//" %(virtusertable)s UPDATED=1 @@ -131,17 +155,17 @@ class PostfixAddressBackend(ServiceController): def commit(self): context = self.get_context_files() - self.append(textwrap(""" - [[ $UPDATED == 1 ]] && { - postmap %(virtdomains)s - postmap %(virtusertable)s - }""" % context + self.append(textwrap.dedent(""" + [[ $UPDATED_VIRTUSERTABLE == 1 ]] && { postmap %(virtusertable)s; } + # TODO not sure if always needed + [[ $UPDATED_VIRTDOMAINS == 1 ]] && { /etc/init.d/postfix reload; } + """ % context )) def get_context_files(self): return { - 'virtdomains': settings.EMAILS_VIRTDOMAINS_PATH, - 'virtusertable': settings.EMAILS_VIRTUSERTABLE_PATH, + 'virtdomains': settings.MAILS_VIRTDOMAINS_PATH, + 'virtusertable': settings.MAILS_VIRTUSERTABLE_PATH, } def get_context(self, address): @@ -173,7 +197,8 @@ class MaildirDisk(ServiceMonitor): def get_context(self, mailbox): context = MailSystemUserBackend().get_context(mailbox) - context['home'] = settings.EMAILS_HOME % context - context['rr_path'] = os.path.join(context['home'], 'Maildir/maildirsize') - context['object_id'] = mailbox.pk + context.update({ + 'rr_path': os.path.join(context['home'], 'Maildir/maildirsize'), + 'object_id': mailbox.pk + }) return context diff --git a/orchestra/apps/mails/forms.py b/orchestra/apps/mails/forms.py new file mode 100644 index 00000000..3a500c03 --- /dev/null +++ b/orchestra/apps/mails/forms.py @@ -0,0 +1,22 @@ +from django import forms + +from orchestra.forms import UserCreationForm + + +class MailboxCreationForm(UserCreationForm): + def clean_name(self): + # Since model.clean() will check this, this is redundant, + # but it sets a nicer error message than the ORM and avoids conflicts with contrib.auth + name = self.cleaned_data["name"] + try: + self._meta.model._default_manager.get(name=name) + except self._meta.model.DoesNotExist: + return name + raise forms.ValidationError(self.error_messages['duplicate_name']) + + +class AddressForm(forms.ModelForm): + def clean(self): + cleaned_data = super(AddressForm, self).clean() + if not cleaned_data['mailboxes'] and not cleaned_data['forward']: + raise forms.ValidationError(_("Mailboxes or forward address should be provided")) diff --git a/orchestra/apps/mails/models.py b/orchestra/apps/mails/models.py index b9ecf593..8e2e1bb5 100644 --- a/orchestra/apps/mails/models.py +++ b/orchestra/apps/mails/models.py @@ -1,3 +1,4 @@ +from django.contrib.auth.hashers import make_password from django.core.validators import RegexValidator from django.db import models from django.utils.functional import cached_property @@ -7,6 +8,7 @@ from orchestra.core import services from . import validators, settings +# TODO rename app to mailboxes class Mailbox(models.Model): name = models.CharField(_("name"), max_length=64, unique=True, @@ -35,19 +37,30 @@ class Mailbox(models.Model): @cached_property def active(self): return self.is_active and self.account.is_active + + def set_password(self, raw_password): + self.password = make_password(raw_password) + + def get_home(self): + context = { + 'name': self.name, + 'username': self.name, + } + home = settings.MAILS_HOME % context + return home.rstrip('/') class Address(models.Model): name = models.CharField(_("name"), max_length=64, validators=[validators.validate_emailname]) - domain = models.ForeignKey(settings.EMAILS_DOMAIN_MODEL, + domain = models.ForeignKey(settings.MAILS_DOMAIN_MODEL, verbose_name=_("domain"), related_name='addresses') mailboxes = models.ManyToManyField(Mailbox, verbose_name=_("mailboxes"), related_name='addresses', blank=True) forward = models.CharField(_("forward"), max_length=256, blank=True, - validators=[validators.validate_forward]) + validators=[validators.validate_forward], help_text=_("Space separated email addresses")) account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), related_name='addresses') @@ -61,6 +74,13 @@ class Address(models.Model): @property def email(self): return "%s@%s" % (self.name, self.domain) + + @property + def destination(self): + destinations = list(self.mailboxes.values_list('name', flat=True)) + if self.forward: + destinations.append(self.forward) + return ' '.join(destinations) class Autoresponse(models.Model): diff --git a/orchestra/apps/mails/serializers.py b/orchestra/apps/mails/serializers.py index c7516de7..e0435f9f 100644 --- a/orchestra/apps/mails/serializers.py +++ b/orchestra/apps/mails/serializers.py @@ -8,7 +8,23 @@ from .models import Mailbox, Address class MailboxSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): class Meta: model = Mailbox - fields = ('url', 'name', 'use_custom_filtering', 'custom_filtering', 'addresses') + # TODO 'use_custom_filtering', + fields = ('url', 'name', 'password', 'custom_filtering', 'addresses') + + def validate_password(self, attrs, source): + """ POST only password """ + if self.object: + if 'password' in attrs: + raise serializers.ValidationError(_("Can not set password")) + elif 'password' not in attrs: + raise serializers.ValidationError(_("Password required")) + return attrs + + def save_object(self, obj, **kwargs): + # FIXME this method will be called when saving nested serializers :( + if not obj.pk: + obj.set_password(obj.password) + super(MailboxSerializer, self).save_object(obj, **kwargs) class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): @@ -25,3 +41,9 @@ class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSeri domain = fields['domain'].queryset fields['domain'].queryset = domain.filter(account=account) return fields + + def validate(self, attrs): + if not attrs['mailboxes'] and not attrs['forward']: + raise serializers.ValidationError("mailboxes or forward should be provided") + return attrs + diff --git a/orchestra/apps/mails/settings.py b/orchestra/apps/mails/settings.py index fcca1e32..73b30774 100644 --- a/orchestra/apps/mails/settings.py +++ b/orchestra/apps/mails/settings.py @@ -1,25 +1,32 @@ from django.conf import settings -EMAILS_DOMAIN_MODEL = getattr(settings, 'EMAILS_DOMAIN_MODEL', 'domains.Domain') +MAILS_DOMAIN_MODEL = getattr(settings, 'MAILS_DOMAIN_MODEL', 'domains.Domain') -EMAILS_HOME = getattr(settings, 'EMAILS_HOME', '/home/%(username)s/') -EMAILS_SIEVETEST_PATH = getattr(settings, 'EMAILS_SIEVETEST_PATH', '/dev/shm') +MAILS_HOME = getattr(settings, 'MAILS_HOME', '/home/%(name)s/') -EMAILS_SIEVETEST_BIN_PATH = getattr(settings, 'EMAILS_SIEVETEST_BIN_PATH', + +MAILS_SIEVETEST_PATH = getattr(settings, 'MAILS_SIEVETEST_PATH', '/dev/shm') + + +MAILS_SIEVETEST_BIN_PATH = getattr(settings, 'MAILS_SIEVETEST_BIN_PATH', '%(orchestra_root)s/bin/sieve-test') -EMAILS_VIRTUSERTABLE_PATH = getattr(settings, 'EMAILS_VIRTUSERTABLE_PATH', +MAILS_VIRTUSERTABLE_PATH = getattr(settings, 'MAILS_VIRTUSERTABLE_PATH', '/etc/postfix/virtusertable') -EMAILS_VIRTDOMAINS_PATH = getattr(settings, 'EMAILS_VIRTDOMAINS_PATH', +MAILS_VIRTDOMAINS_PATH = getattr(settings, 'MAILS_VIRTDOMAINS_PATH', '/etc/postfix/virtdomains') -EMAILS_DEFAUL_FILTERING = getattr(settings, 'EMAILS_DEFAULT_FILTERING', +MAILS_PASSWD_PATH = getattr(settings, 'MAILS_PASSWD_PATH', + '/etc/dovecot/virtual_users') + + +MAILS_DEFAUL_FILTERING = getattr(settings, 'MAILS_DEFAULT_FILTERING', 'require ["fileinto","regex","envelope","vacation","reject","relational","comparator-i;ascii-numeric"];\n' '\n' 'if header :value "ge" :comparator "i;ascii-numeric" "X-Spam-Score" "5" {\n' diff --git a/orchestra/apps/mails/tests/__init__.py b/orchestra/apps/mails/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/mails/tests/functional_tests/__init__.py b/orchestra/apps/mails/tests/functional_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/mails/tests/functional_tests/tests.py b/orchestra/apps/mails/tests/functional_tests/tests.py index 4a8448c5..89cac724 100644 --- a/orchestra/apps/mails/tests/functional_tests/tests.py +++ b/orchestra/apps/mails/tests/functional_tests/tests.py @@ -1,8 +1,25 @@ -#import imaplib -#mail = imaplib.IMAP4_SSL('localhost') -#mail.login('rata', '3') -#('OK', ['Logged in']) +import email.utils +import imaplib +import os +import poplib +import smtplib +import time +from email.mime.text import MIMEText +from django.conf import settings as djsettings +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import CommandError +from django.core.urlresolvers import reverse +from selenium.webdriver.support.select import Select + +from orchestra.apps.accounts.models import Account +from orchestra.apps.orchestration.models import Server, Route +from orchestra.apps.resources.models import Resource +from orchestra.utils.system import run, sshrun +from orchestra.utils.tests import BaseLiveServerTestCase, random_ascii, snapshot_on_error, save_response_on_error + +from ... import backends, settings +from ...models import Mailbox #>>> mail.list() #('OK', ['(\\HasNoChildren) "." INBOX']) #>>> mail.select('INBOX') @@ -17,7 +34,7 @@ #('OK', ['Close completed.']) -#import poplib + #pop = poplib.POP3('localhost') #pop.user('rata') #pop.pass_('3') @@ -26,3 +43,287 @@ #>>> pop.quit() #'+OK Logging out.' + +# FIXME django load production database at the begining of tests +class MailboxMixin(object): + MASTER_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost') + DEPENDENCIES = ( + 'orchestra.apps.orchestration', + 'orchestra.apps.mails', + 'orchestra.apps.resources', + ) + + def setUp(self): + super(MailboxMixin, self).setUp() + self.add_route() +# apps.get_app_config('resources').reload_relations() doesn't work + djsettings.DEBUG = True + + def add_route(self): + server = Server.objects.create(name=self.MASTER_SERVER) + backend = backends.PasswdVirtualUserBackend.get_name() + Route.objects.create(backend=backend, match=True, host=server) + backend = backends.PostfixAddressBackend.get_name() + Route.objects.create(backend=backend, match=True, host=server) + + def add_quota_resource(self): + Resource.objects.create( + name='disk', + content_type=ContentType.objects.get_for_model(Mailbox), + period=Resource.LAST, + verbose_name='Mail quota', + unit='MB', + scale=10**6, + on_demand=False, + default_allocation=2000 + ) + + def save(self): + raise NotImplementedError + + def add(self): + raise NotImplementedError + + def delete(self): + raise NotImplementedError + + def update(self): + raise NotImplementedError + + def disable(self): + raise NotImplementedError + + def add_group(self, username, groupname): + raise NotImplementedError + + def validate_user(self, username): + idcmd = sshr(self.MASTER_SERVER, "id %s" % username) + self.assertEqual(0, idcmd.return_code) + user = SystemUser.objects.get(username=username) + groups = list(user.groups.values_list('username', flat=True)) + groups.append(user.username) + idgroups = idcmd.stdout.strip().split(' ')[2] + idgroups = re.findall(r'\d+\((\w+)\)', idgroups) + self.assertEqual(set(groups), set(idgroups)) + + def validate_delete(self, username): + self.assertRaises(SystemUser.DoesNotExist, SystemUser.objects.get, username=username) + self.assertRaises(CommandError, + sshrun, self.MASTER_SERVER,'id %s' % username, display=False) + self.assertRaises(CommandError, + sshrun, self.MASTER_SERVER, 'grep "^%s:" /etc/groups' % username, display=False) + self.assertRaises(CommandError, + sshrun, self.MASTER_SERVER, 'grep "^%s:" /etc/passwd' % username, display=False) + self.assertRaises(CommandError, + sshrun, self.MASTER_SERVER, 'grep "^%s:" /etc/shadow' % username, display=False) + + def login_imap(self, username, password): + mail = imaplib.IMAP4_SSL(self.MASTER_SERVER) + status, msg = mail.login(username, password) + self.assertEqual('OK', status) + self.assertEqual(['Logged in'], msg) + return mail + + def login_pop3(self, username, password): + pop = poplib.POP3(self.MASTER_SERVER) + pop.user(username) + pop.pass_(password) + return pop + + def send_email(self, to, token): + msg = MIMEText(token) + msg['To'] = to + msg['From'] = 'orchestra@test.orchestra.lan' + msg['Subject'] = 'test' + server = smtplib.SMTP(self.MASTER_SERVER, 25) + try: + server.ehlo() + server.starttls() + server.ehlo() + server.sendmail(msg['From'], msg['To'], msg.as_string()) + finally: + server.quit() + + def validate_email(self, username, token): + home = Mailbox.objects.get(name=username).get_home() + sshrun(self.MASTER_SERVER, "grep '%s' %s/Maildir/new/*" % (token, home), display=False) + + def test_add(self): + username = '%s_mailbox' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(partial(self.delete, username)) + imap = self.login_imap(username, password) + + def test_change_password(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(partial(self.delete, username)) + imap = self.login_imap(username, password) + new_password = '@!?%spppP001' % random_ascii(5) + self.change_password(username, new_password) + imap = self.login_imap(username, new_password) + + def test_quota(self): + username = '%s_mailbox' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add_quota_resource() + quota = 100 + self.add(username, password, quota=quota) + self.addCleanup(partial(self.delete, username)) + get_quota = "doveadm quota get -u %s 2>&1|grep STORAGE|awk {'print $5'}" % username + stdout = sshrun(self.MASTER_SERVER, get_quota, display=False).stdout + self.assertEqual(quota*1024, int(stdout)) + imap = self.login_imap(username, password) + imap_quota = int(imap.getquotaroot("INBOX")[1][1][0].split(' ')[-1].split(')')[0]) + self.assertEqual(quota*1024, imap_quota) + + def test_send_email(self): + username = '%s_mailbox' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(partial(self.delete, username)) + msg = MIMEText("Hola bishuns") + msg['To'] = 'noexists@example.com' + msg['From'] = '%s@%s' % (username, self.MASTER_SERVER) + msg['Subject'] = "test" + server = smtplib.SMTP(self.MASTER_SERVER, 25) + server.login(username, password) + try: + server.sendmail(msg['From'], msg['To'], msg.as_string()) + finally: + server.quit() + + def test_address(self): + username = '%s_mailbox' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(partial(self.delete, username)) + domain = '%s_domain.lan' % random_ascii(5) + name = '%s_name' % random_ascii(5) + domain = self.account.domains.create(name=domain) + self.add_address(username, name, domain) + token = random_ascii(100) + self.send_email("%s@%s" % (name, domain), token) + self.validate_email(username, token) + + +class RESTMailboxMixin(MailboxMixin): + def setUp(self): + super(RESTMailboxMixin, self).setUp() + self.rest_login() + + @save_response_on_error + def add(self, username, password, quota=None): + extra = {} + if quota: + extra = { + "resources": [ + { + "name": "disk", + "allocated": quota + }, + ] + } + self.rest.mailboxes.create(name=username, password=password, **extra) + + @save_response_on_error + def delete(self, username): + mailbox = self.rest.mailboxes.retrieve(name=username).get() + mailbox.delete() + + @save_response_on_error + def change_password(self, username, password): + mailbox = self.rest.mailboxes.retrieve(name=username).get() + mailbox.change_password(password) + + @save_response_on_error + def add_address(self, username, name, domain): + mailbox = self.rest.mailboxes.retrieve(name=username).get() + domain = self.rest.domains.retrieve(name=domain.name).get() + self.rest.addresses.create(name=name, domain=domain, mailboxes=[mailbox]) + + + +class AdminMailboxMixin(MailboxMixin): + def setUp(self): + super(AdminMailboxMixin, self).setUp() + self.admin_login() + + @snapshot_on_error + def add(self, username, password, quota=None): + url = self.live_server_url + reverse('admin:mails_mailbox_add') + self.selenium.get(url) + + account_input = self.selenium.find_element_by_id('id_account') + account_select = Select(account_input) + account_select.select_by_value(str(self.account.pk)) + + name_field = self.selenium.find_element_by_id('id_name') + name_field.send_keys(username) + + password_field = self.selenium.find_element_by_id('id_password1') + password_field.send_keys(password) + password_field = self.selenium.find_element_by_id('id_password2') + password_field.send_keys(password) + + if quota is not None: + quota_field = self.selenium.find_element_by_id( + 'id_resources-resourcedata-content_type-object_id-0-allocated') + quota_field.clear() + quota_field.send_keys(quota) + + name_field.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def delete(self, username): + mailbox = Mailbox.objects.get(name=username) + delete = reverse('admin:mails_mailbox_delete', args=(mailbox.pk,)) + url = self.live_server_url + delete + self.selenium.get(url) + confirmation = self.selenium.find_element_by_name('post') + confirmation.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def change_password(self, username, password): + mailbox = Mailbox.objects.get(name=username) + change_password = reverse('admin:mails_mailbox_change_password', args=(mailbox.pk,)) + url = self.live_server_url + change_password + self.selenium.get(url) + + password_field = self.selenium.find_element_by_id('id_password1') + password_field.send_keys(password) + password_field = self.selenium.find_element_by_id('id_password2') + password_field.send_keys(password) + password_field.submit() + + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def add_address(self, username, name, domain): + url = self.live_server_url + reverse('admin:mails_address_add') + self.selenium.get(url) + + name_field = self.selenium.find_element_by_id('id_name') + name_field.send_keys(name) + + domain_input = self.selenium.find_element_by_id('id_domain') + domain_select = Select(domain_input) + domain_select.select_by_value(str(domain.pk)) + + mailboxes = self.selenium.find_element_by_id('id_mailboxes_add_all_link') + mailboxes.click() + time.sleep(0.5) + name_field.submit() + + self.assertNotEqual(url, self.selenium.current_url) + +class RESTMailboxTest(RESTMailboxMixin, BaseLiveServerTestCase): + pass + + +class AdminMailboxTest(AdminMailboxMixin, BaseLiveServerTestCase): + pass diff --git a/orchestra/apps/resources/admin.py b/orchestra/apps/resources/admin.py index bcd06461..7ff83d40 100644 --- a/orchestra/apps/resources/admin.py +++ b/orchestra/apps/resources/admin.py @@ -140,4 +140,15 @@ def insert_resource_inlines(): for ct, resources in Resource.objects.group_by('content_type').iteritems(): inline = resource_inline_factory(resources) model = ct.model_class() - insertattr(model, 'inlines', inline) + modeladmin = get_modeladmin(model) + inserted = False + inlines = [] + for existing in getattr(modeladmin, 'inlines', []): + if type(inline) == type(existing): + existing = inline + inserted = True + inlines.append(existing) + if inserted: + modeladmin.inlines = inlines + else: + insertattr(model, 'inlines', inline) diff --git a/orchestra/apps/resources/apps.py b/orchestra/apps/resources/apps.py index 87b624f5..c2408b54 100644 --- a/orchestra/apps/resources/apps.py +++ b/orchestra/apps/resources/apps.py @@ -13,3 +13,11 @@ class ResourcesConfig(AppConfig): from .models import create_resource_relation create_resource_relation() insert_resource_inlines() + + def reload_relations(self): + from .admin import insert_resource_inlines + from .models import create_resource_relation + from .serializers import insert_resource_serializers + create_resource_relation() + insert_resource_inlines() + insert_resource_serializers() diff --git a/orchestra/apps/resources/models.py b/orchestra/apps/resources/models.py index 384a79d3..9cd01f40 100644 --- a/orchestra/apps/resources/models.py +++ b/orchestra/apps/resources/models.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType +from django.apps import apps from django.core import validators from django.db import models from django.utils import timezone @@ -101,7 +102,7 @@ class Resource(models.Model): task.save(update_fields=['crontab']) if created: # This only work on tests because of multiprocessing used on real deployments - create_resource_relation() + apps.get_app_config('resources').reload_relations() def delete(self, *args, **kwargs): super(Resource, self).delete(*args, **kwargs) @@ -132,12 +133,15 @@ class ResourceData(models.Model): def get_or_create(cls, obj, resource): ct = ContentType.objects.get_for_model(type(obj)) try: - return cls.objects.get(content_type=ct, object_id=obj.pk, - resource=resource) + return cls.objects.get(content_type=ct, object_id=obj.pk, resource=resource) except cls.DoesNotExist: return cls.objects.create(content_object=obj, resource=resource, allocated=resource.default_allocation) + @property + def unit(self): + return self.resource.unit + def get_used(self): return helpers.compute_resource_usage(self) @@ -177,8 +181,10 @@ def create_resource_relation(): data = self.obj.resource_set.get(resource__name=attr) except ResourceData.DoesNotExist: model = self.obj._meta.model_name - resource = Resource.objects.get(content_type__model=model, name=attr, is_active=True) - data = ResourceData(content_object=self.obj, resource=resource) + resource = Resource.objects.get(content_type__model=model, name=attr, + is_active=True) + data = ResourceData(content_object=self.obj, resource=resource, + allocated=resource.default_allocation) return data def __get__(self, obj, cls): diff --git a/orchestra/apps/resources/serializers.py b/orchestra/apps/resources/serializers.py index d2c7fa1d..1205fcb5 100644 --- a/orchestra/apps/resources/serializers.py +++ b/orchestra/apps/resources/serializers.py @@ -14,6 +14,12 @@ class ResourceSerializer(serializers.ModelSerializer): fields = ('name', 'used', 'allocated') read_only_fields = ('used',) + def from_native(self, raw_data, files=None): + data = super(ResourceSerializer, self).from_native(raw_data, files=files) + if not data.resource_id: + data.resource = Resource.objects.get(name=raw_data['name']) + return data + def get_name(self, instance): return instance.resource.name @@ -23,8 +29,7 @@ class ResourceSerializer(serializers.ModelSerializer): # Monkey-patching section -if database_ready(): - # TODO why this is even loaded during syncdb? +def insert_resource_serializers(): # Create nested serializers on target models for ct, resources in Resource.objects.group_by('content_type').iteritems(): model = ct.model_class() @@ -32,7 +37,7 @@ if database_ready(): router.insert(model, 'resources', ResourceSerializer, required=False, many=True, source='resource_set') except KeyError: continue - + # TODO this is a fucking workaround, reimplement this on the proper place def validate_resources(self, attrs, source, _resources=resources): """ Creates missing resources """ posted = attrs.get(source, []) @@ -70,3 +75,6 @@ if database_ready(): ] return ret viewset.metadata = metadata + +if database_ready(): + insert_resource_serializers() diff --git a/orchestra/apps/systemusers/admin.py b/orchestra/apps/systemusers/admin.py index a3b640e6..ef3d3efe 100644 --- a/orchestra/apps/systemusers/admin.py +++ b/orchestra/apps/systemusers/admin.py @@ -2,17 +2,18 @@ from django.conf.urls import patterns, url from django.core.urlresolvers import reverse from django.contrib import admin from django.contrib.admin.util import unquote +from django.contrib.auth.admin import UserAdmin from django.utils.translation import ugettext, ugettext_lazy as _ -from orchestra.admin import ExtendedModelAdmin +from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin from orchestra.admin.utils import wrap_admin_view from orchestra.apps.accounts.admin import SelectAccountAdminMixin +from orchestra.forms import UserCreationForm, UserChangeForm -from .forms import UserCreationForm, UserChangeForm from .models import SystemUser -class SystemUserAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): +class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin): list_display = ('username', 'account_link', 'shell', 'home', 'is_active',) list_filter = ('is_active', 'shell') fieldsets = ( diff --git a/orchestra/apps/systemusers/forms.py b/orchestra/apps/systemusers/forms.py deleted file mode 100644 index 0ffd8dce..00000000 --- a/orchestra/apps/systemusers/forms.py +++ /dev/null @@ -1,46 +0,0 @@ -from django import forms -from django.contrib import auth -from django.utils.translation import ugettext, ugettext_lazy as _ - -from orchestra.apps.accounts.models import Account -from orchestra.core.validators import validate_password - -from .models import SystemUser - - -# TODO orchestra.UserCretionForm -class UserCreationForm(auth.forms.UserCreationForm): - def __init__(self, *args, **kwargs): - super(UserCreationForm, self).__init__(*args, **kwargs) - self.fields['password1'].validators.append(validate_password) - - def clean_username(self): - # Since model.clean() will check this, this is redundant, - # but it sets a nicer error message than the ORM and avoids conflicts with contrib.auth - username = self.cleaned_data["username"] - try: - SystemUser._default_manager.get(username=username) - except SystemUser.DoesNotExist: - return username - raise forms.ValidationError(self.error_messages['duplicate_username']) - - - -# TODO orchestra.UserCretionForm -class UserChangeForm(forms.ModelForm): - password = auth.forms.ReadOnlyPasswordHashField(label=_("Password"), - help_text=_("Raw passwords are not stored, so there is no way to see " - "this user's password, but you can change the password " - "using this form.")) - - def __init__(self, *args, **kwargs): - super(UserChangeForm, self).__init__(*args, **kwargs) - f = self.fields.get('user_permissions', None) - if f is not None: - f.queryset = f.queryset.select_related('content_type') - - def clean_password(self): - # Regardless of what the user provides, return the initial value. - # This is done here, rather than on the field, because the - # field does not have access to the initial value - return self.initial["password"] diff --git a/orchestra/apps/systemusers/models.py b/orchestra/apps/systemusers/models.py index e43e68d0..5c68e1d2 100644 --- a/orchestra/apps/systemusers/models.py +++ b/orchestra/apps/systemusers/models.py @@ -46,9 +46,6 @@ class SystemUser(models.Model): def __unicode__(self): return self.username - def set_password(self, raw_password): - self.password = make_password(raw_password) - @cached_property def active(self): a = type(self).account.field.model @@ -57,6 +54,9 @@ class SystemUser(models.Model): except type(self).account.field.rel.to.DoesNotExist: return self.is_active + def set_password(self, raw_password): + self.password = make_password(raw_password) + def get_home(self): if self.is_main: context = { diff --git a/orchestra/apps/systemusers/tests/functional_tests/tests.py b/orchestra/apps/systemusers/tests/functional_tests/tests.py index a94e0209..f5016baf 100644 --- a/orchestra/apps/systemusers/tests/functional_tests/tests.py +++ b/orchestra/apps/systemusers/tests/functional_tests/tests.py @@ -1,6 +1,7 @@ import ftplib import os import re +import time from functools import partial import paramiko @@ -100,7 +101,7 @@ class SystemUserMixin(object): self.assertEqual(0, channel.recv_exit_status()) channel.close() - def test_create(self): + def test_add(self): username = '%s_systemuser' % random_ascii(10) password = '@!?%spppP001' % random_ascii(5) self.add(username, password) @@ -166,9 +167,14 @@ class SystemUserMixin(object): self.assertRaises(ftplib.error_perm, self.validate_ftp, username, password) def test_change_password(self): - pass - # TODO - + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(partial(self.delete, username)) + self.validate_ftp(username, password) + new_password = '@!?%spppP001' % random_ascii(5) + self.change_password(username, new_password) + self.validate_ftp(username, new_password) # TODO test resources @@ -191,7 +197,7 @@ class RESTSystemUserMixin(SystemUserMixin): def add_group(self, username, groupname): user = self.rest.systemusers.retrieve(username=username).get() group = self.rest.systemusers.retrieve(username=groupname).get() - user.groups.append(group) # TODO how to do it with the api? + user.groups.append(group) # TODO user.save() def disable(self, username): @@ -202,6 +208,10 @@ class RESTSystemUserMixin(SystemUserMixin): def save(self, username): user = self.rest.systemusers.retrieve(username=username).get() user.save() + + def change_password(self, username, password): + user = self.rest.systemusers.retrieve(username=username).get() + user.change_password(password) class AdminSystemUserMixin(SystemUserMixin): @@ -266,6 +276,7 @@ class AdminSystemUserMixin(SystemUserMixin): self.selenium.get(url) groups = self.selenium.find_element_by_id('id_groups_add_all_link') groups.click() + time.sleep(0.5) save = self.selenium.find_element_by_name('_save') save.submit() self.assertNotEqual(url, self.selenium.current_url) @@ -279,7 +290,21 @@ class AdminSystemUserMixin(SystemUserMixin): save = self.selenium.find_element_by_name('_save') save.submit() self.assertNotEqual(url, self.selenium.current_url) - + + @snapshot_on_error + def change_password(self, username, password): + user = SystemUser.objects.get(username=username) + change_password = reverse('admin:systemusers_systemuser_change_password', args=(user.pk,)) + url = self.live_server_url + change_password + self.selenium.get(url) + + password_field = self.selenium.find_element_by_id('id_password1') + password_field.send_keys(password) + password_field = self.selenium.find_element_by_id('id_password2') + password_field.send_keys(password) + password_field.submit() + + self.assertNotEqual(url, self.selenium.current_url) class RESTSystemUserTest(RESTSystemUserMixin, BaseLiveServerTestCase): pass diff --git a/orchestra/forms/options.py b/orchestra/forms/options.py index 796317d5..acfe64d7 100644 --- a/orchestra/forms/options.py +++ b/orchestra/forms/options.py @@ -1,4 +1,8 @@ from django import forms +from django.contrib.auth import forms as auth_forms +from django.utils.translation import ugettext, ugettext_lazy as _ + +from ..core.validators import validate_password class PluginDataForm(forms.ModelForm): @@ -25,3 +29,62 @@ class PluginDataForm(forms.ModelForm): field: self.cleaned_data[field] for field in self.declared_fields } return super(PluginDataForm, self).save(commit=commit) + + +class UserCreationForm(forms.ModelForm): + """ + A form that creates a user, with no privileges, from the given username and + password. + """ + error_messages = { + 'password_mismatch': _("The two password fields didn't match."), + } + password1 = forms.CharField(label=_("Password"), + widget=forms.PasswordInput, validators=[validate_password]) + password2 = forms.CharField(label=_("Password confirmation"), + widget=forms.PasswordInput, + help_text=_("Enter the same password as above, for verification.")) + +# def __init__(self, *args, **kwargs): +# super(UserCreationForm, self).__init__(*args, **kwargs) +# self.fields['password1'].validators.append(validate_password) + + def clean_password2(self): + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise forms.ValidationError( + self.error_messages['password_mismatch'], + code='password_mismatch', + ) + return password2 + + def clean_username(self): + # Since model.clean() will check this, this is redundant, + # but it sets a nicer error message than the ORM and avoids conflicts with contrib.auth + username = self.cleaned_data["username"] + try: + self._meta.model._default_manager.get(username=username) + except self._meta.model.DoesNotExist: + return username + raise forms.ValidationError(self.error_messages['duplicate_username']) + + def save(self, commit=True): + user = super(UserCreationForm, self).save(commit=False) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.save() + return user + + +class UserChangeForm(forms.ModelForm): + password = auth_forms.ReadOnlyPasswordHashField(label=_("Password"), + help_text=_("Raw passwords are not stored, so there is no way to see " + "this user's password, but you can change the password " + "using this form.")) + + def clean_password(self): + # Regardless of what the user provides, return the initial value. + # This is done here, rather than on the field, because the + # field does not have access to the initial value + return self.initial["password"] diff --git a/orchestra/templates/admin/orchestra/change_password.html b/orchestra/templates/admin/orchestra/change_password.html new file mode 100644 index 00000000..dc5abddd --- /dev/null +++ b/orchestra/templates/admin/orchestra/change_password.html @@ -0,0 +1,33 @@ +{% extends 'admin/auth/user/change_password.html' %} +{% load i18n %} + +{% block content %}