mailboxes functional tests passing

This commit is contained in:
Marc 2014-10-06 14:57:02 +00:00
parent 5786132ca8
commit 6240fa3139
27 changed files with 865 additions and 154 deletions

View file

@ -1,6 +1,12 @@
from functools import partial
from django import forms
from django.contrib.admin import helpers from django.contrib.admin import helpers
from django.forms.models import modelformset_factory, BaseModelFormSet from django.forms.models import modelformset_factory, BaseModelFormSet
from django.template import Template, Context from django.template import Template, Context
from django.utils.translation import ugettext_lazy as _
from ..core.validators import validate_password
class AdminFormMixin(object): class AdminFormMixin(object):
@ -42,3 +48,84 @@ def adminmodelformset_factory(modeladmin, form, formset=AdminFormSet, **kwargs):
**kwargs) **kwargs)
formset.modeladmin = modeladmin formset.modeladmin = modeladmin
return formset 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)

View file

@ -1,15 +1,28 @@
from django import forms from django import forms
from django.conf.urls import patterns, url 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.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.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.text import camel_case_to_spaces
from django.utils.translation import ugettext_lazy as _ 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 from .utils import set_url_query, action_to_view, wrap_admin_view
sensitive_post_parameters_m = method_decorator(sensitive_post_parameters())
class ChangeListDefaultFilter(object): class ChangeListDefaultFilter(object):
""" """
Enables support for default filtering on admin change list pages 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) setattr(obj, self.plugin_field, self.plugin_value)
obj.save() 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)

View file

@ -99,12 +99,13 @@ class LinkHeaderRouter(DefaultRouter):
def insert(self, prefix_or_model, name, field, **kwargs): def insert(self, prefix_or_model, name, field, **kwargs):
""" Dynamically add new fields to an existing serializer """ """ Dynamically add new fields to an existing serializer """
viewset = self.get_viewset(prefix_or_model) viewset = self.get_viewset(prefix_or_model)
setattr(viewset, 'inserted', getattr(viewset, 'inserted', []))
if viewset.serializer_class is None: if viewset.serializer_class is None:
viewset.serializer_class = viewset().get_serializer_class() viewset.serializer_class = viewset().get_serializer_class()
viewset.serializer_class.Meta.fields += (name,)
viewset.serializer_class.base_fields.update({name: field(**kwargs)}) viewset.serializer_class.base_fields.update({name: field(**kwargs)})
setattr(viewset, 'inserted', getattr(viewset, 'inserted', [])) if not name in viewset.inserted:
viewset.inserted.append(name) viewset.serializer_class.Meta.fields += (name,)
viewset.inserted.append(name)
# Create a router and register our viewsets with it. # Create a router and register our viewsets with it.

View file

@ -8,16 +8,17 @@ from django.utils.safestring import mark_safe
from django.utils.six.moves.urllib.parse import parse_qsl from django.utils.six.moves.urllib.parse import parse_qsl
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, ChangePasswordAdminMixin
from orchestra.admin.utils import wrap_admin_view, admin_link, set_url_query, change_url from orchestra.admin.utils import wrap_admin_view, admin_link, set_url_query, change_url
from orchestra.core import services, accounts from orchestra.core import services, accounts
from orchestra.forms import UserChangeForm
from .filters import HasMainUserListFilter from .filters import HasMainUserListFilter
from .forms import AccountCreationForm, AccountChangeForm from .forms import AccountCreationForm
from .models import Account from .models import Account
class AccountAdmin(auth.UserAdmin, ExtendedModelAdmin): class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin):
list_display = ('username', 'type', 'is_active') list_display = ('username', 'type', 'is_active')
list_filter = ( list_filter = (
'type', 'is_active', HasMainUserListFilter 'type', 'is_active', HasMainUserListFilter
@ -50,7 +51,7 @@ class AccountAdmin(auth.UserAdmin, ExtendedModelAdmin):
) )
search_fields = ('username',) search_fields = ('username',)
add_form = AccountCreationForm add_form = AccountCreationForm
form = AccountChangeForm form = UserChangeForm
filter_horizontal = () filter_horizontal = ()
change_readonly_fields = ('username',) change_readonly_fields = ('username',)
change_form_template = 'admin/accounts/account/change_form.html' change_form_template = 'admin/accounts/account/change_form.html'

View file

@ -3,14 +3,12 @@ from django.contrib import auth
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.core.validators import validate_password from orchestra.core.validators import validate_password
from orchestra.forms import UserCreationForm
from orchestra.forms.widgets import ReadOnlyWidget 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): def clean_username(self):
# Since model.clean() will check this, this is redundant, # 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 # 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(): if systemuser_model.objects.filter(username=username).exists():
raise forms.ValidationError(self.error_messages['duplicate_username']) raise forms.ValidationError(self.error_messages['duplicate_username'])
return 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 <a href=\"password/\">this form</a>."))
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"]

View file

@ -42,11 +42,11 @@ class DomainTestMixin(object):
) )
self.ns1_name = 'ns1.%s' % self.domain_name self.ns1_name = 'ns1.%s' % self.domain_name
self.ns1_records = ( 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_name = 'ns2.%s' % self.domain_name
self.ns2_records = ( 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_name = 'www.%s' % self.domain_name
self.www_records = ( self.www_records = (

View file

@ -6,11 +6,13 @@ from django.core.urlresolvers import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
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, ChangePasswordAdminMixin
from orchestra.admin.utils import admin_link, change_url from orchestra.admin.utils import admin_link, change_url
from orchestra.apps.accounts.admin import SelectAccountAdminMixin, AccountAdminMixin from orchestra.apps.accounts.admin import SelectAccountAdminMixin, AccountAdminMixin
from orchestra.forms import UserCreationForm, UserChangeForm
from .filters import HasMailboxListFilter, HasForwardListFilter, HasAddressListFilter from .filters import HasMailboxListFilter, HasForwardListFilter, HasAddressListFilter
from .forms import MailboxCreationForm, AddressForm
from .models import Mailbox, Address, Autoresponse from .models import Mailbox, Address, Autoresponse
@ -24,14 +26,14 @@ class AutoresponseInline(admin.StackedInline):
return super(AutoresponseInline, self).formfield_for_dbfield(db_field, **kwargs) return super(AutoresponseInline, self).formfield_for_dbfield(db_field, **kwargs)
class MailboxAdmin(AccountAdminMixin, ExtendedModelAdmin): class MailboxAdmin(ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin):
list_display = ( list_display = (
'name', 'account_link', 'uses_custom_filtering', 'display_addresses' 'name', 'account_link', 'uses_custom_filtering', 'display_addresses'
) )
list_filter = (HasAddressListFilter,) list_filter = (HasAddressListFilter,)
add_fieldsets = ( add_fieldsets = (
(None, { (None, {
'fields': ('account', 'name', 'password'), 'fields': ('account', 'name', 'password1', 'password2'),
}), }),
(_("Filtering"), { (_("Filtering"), {
'classes': ('collapse',), 'classes': ('collapse',),
@ -41,7 +43,7 @@ class MailboxAdmin(AccountAdminMixin, ExtendedModelAdmin):
fieldsets = ( fieldsets = (
(None, { (None, {
'classes': ('wide',), 'classes': ('wide',),
'fields': ('account_link', 'name'), 'fields': ('account_link', 'name', 'password'),
}), }),
(_("Filtering"), { (_("Filtering"), {
'classes': ('collapse',), 'classes': ('collapse',),
@ -53,6 +55,9 @@ class MailboxAdmin(AccountAdminMixin, ExtendedModelAdmin):
}), }),
) )
readonly_fields = ('account_link', 'display_addresses', 'addresses_field') readonly_fields = ('account_link', 'display_addresses', 'addresses_field')
change_readonly_fields = ('name',)
add_form = MailboxCreationForm
form = UserChangeForm
def display_addresses(self, mailbox): def display_addresses(self, mailbox):
addresses = [] addresses = []
@ -108,6 +113,7 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
readonly_fields = ('account_link', 'domain_link', 'email_link') readonly_fields = ('account_link', 'domain_link', 'email_link')
filter_by_account_fields = ('domain', 'mailboxes') filter_by_account_fields = ('domain', 'mailboxes')
filter_horizontal = ['mailboxes'] filter_horizontal = ['mailboxes']
form = AddressForm
domain_link = admin_link('domain', order='domain__name') domain_link = admin_link('domain', order='domain__name')

View file

@ -1,6 +1,7 @@
import textwrap import textwrap
import os import os
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -10,24 +11,30 @@ from orchestra.apps.resources import ServiceMonitor
from . import settings from . import settings
from .models import Address 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' model = 'mails.Mailbox'
# TODO related_models = ('resources__content_type') ?? needed for updating disk usage from resource.data # TODO related_models = ('resources__content_type') ?? needed for updating disk usage from resource.data
DEFAULT_GROUP = 'postfix' DEFAULT_GROUP = 'postfix'
def create_user(self, context): def set_user(self, context):
self.append(textwrap(""" self.append(textwrap.dedent("""
if [[ $( id %(username)s ) ]]; then if [[ $( grep "^%(username)s:" %(passwd_path)s ) ]]; then
usermod -p '%(password)s' %(username)s sed -i "s/^%(username)s:.*/%(passwd)s/" %(passwd_path)s
else else
useradd %(username)s --password '%(password)s' --shell /dev/null echo '%(passwd)s' >> %(passwd_path)s
fi""" % context fi""" % context
)) ))
self.append("mkdir -p %(home)s" % 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): def generate_filter(self, mailbox, context):
now = timezone.now().strftime("%B %d, %Y, %H:%M") now = timezone.now().strftime("%B %d, %Y, %H:%M")
@ -38,7 +45,7 @@ class MailSystemUserBackend(ServiceController):
if mailbox.custom_filtering: if mailbox.custom_filtering:
context['filtering'] += mailbox.custom_filtering context['filtering'] += mailbox.custom_filtering
else: else:
context['filtering'] += settings.EMAILS_DEFAUL_FILTERING context['filtering'] += settings.MAILS_DEFAUL_FILTERING
context['filter_path'] = os.path.join(context['home'], '.orchestra.sieve') context['filter_path'] = os.path.join(context['home'], '.orchestra.sieve')
self.append("echo '%(filtering)s' > %(filter_path)s" % context) self.append("echo '%(filtering)s' > %(filter_path)s" % context)
@ -51,7 +58,7 @@ class MailSystemUserBackend(ServiceController):
'quota': mailbox.resources.disk.allocated*1000*1000, 'quota': mailbox.resources.disk.allocated*1000*1000,
}) })
self.append("mkdir -p %(maildir_path)s" % context) self.append("mkdir -p %(maildir_path)s" % context)
self.append(textwrap(""" self.append(textwrap.dedent("""
sed -i '1s/.*/%(quota)s,S/' %(maildirsize_path)s || { sed -i '1s/.*/%(quota)s,S/' %(maildirsize_path)s || {
echo '%(quota)s,S' > %(maildirsize_path)s && echo '%(quota)s,S' > %(maildirsize_path)s &&
chown %(username)s %(maildirsize_path)s; chown %(username)s %(maildirsize_path)s;
@ -60,25 +67,42 @@ class MailSystemUserBackend(ServiceController):
def save(self, mailbox): def save(self, mailbox):
context = self.get_context(mailbox) context = self.get_context(mailbox)
self.create_user(context) self.set_user(context)
self.set_quota(mailbox, context)
self.generate_filter(mailbox, context) self.generate_filter(mailbox, context)
def delete(self, mailbox): def delete(self, mailbox):
context = self.get_context(mailbox) context = self.get_context(mailbox)
self.append("{ sleep 2 && killall -u %(username)s -s KILL; } &" % context) self.append("{ sleep 2 && killall -u %(uid)s -s KILL; } &" % context)
self.append("killall -u %(username)s" % context) self.append("killall -u %(uid)s" % context)
self.append("userdel %(username)s" % context) self.append("sed -i '/^%(username)s:.*/d' %(passwd_path)s" % context)
self.append("rm -fr %(home)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): def get_context(self, mailbox):
context = { context = {
'name': mailbox.name, 'name': mailbox.name,
'username': mailbox.name, 'username': mailbox.name,
'password': mailbox.password if mailbox.is_active else '*%s' % mailbox.password, 'password': mailbox.password if mailbox.active else '*%s' % mailbox.password,
'group': self.DEFAULT_GROUP '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 return context
@ -89,7 +113,7 @@ class PostfixAddressBackend(ServiceController):
def include_virtdomain(self, context): def include_virtdomain(self, context):
self.append( self.append(
'[[ $(grep "^\s*%(domain)s\s*$" %(virtdomains)s) ]]' '[[ $(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): def exclude_virtdomain(self, context):
@ -98,21 +122,21 @@ class PostfixAddressBackend(ServiceController):
self.append('sed -i "s/^%(domain)s//" %(virtdomains)s' % context) self.append('sed -i "s/^%(domain)s//" %(virtdomains)s' % context)
def update_virtusertable(self, context): def update_virtusertable(self, context):
self.append(textwrap(""" self.append(textwrap.dedent("""
LINE="%(email)s\t%(destination)s" LINE="%(email)s\t%(destination)s"
if [[ ! $(grep "^%(email)s\s" %(virtusertable)s) ]]; then if [[ ! $(grep "^%(email)s\s" %(virtusertable)s) ]]; then
echo "$LINE" >> %(virtusertable)s echo "${LINE}" >> %(virtusertable)s
UPDATED=1 UPDATED_VIRTUSERTABLE=1
else else
if [[ ! $(grep "^${LINE}$" %(virtusertable)s) ]]; then if [[ ! $(grep "^${LINE}$" %(virtusertable)s) ]]; then
sed -i "s/^%(email)s\s.*$/${LINE}/" %(virtusertable)s sed -i "s/^%(email)s\s.*$/${LINE}/" %(virtusertable)s
UPDATED=1 UPDATED_VIRTUSERTABLE=1
fi fi
fi""" % context fi""" % context
)) ))
def exclude_virtusertable(self, context): def exclude_virtusertable(self, context):
self.append(textwrap(""" self.append(textwrap.dedent("""
if [[ $(grep "^%(email)s\s") ]]; then if [[ $(grep "^%(email)s\s") ]]; then
sed -i "s/^%(email)s\s.*$//" %(virtusertable)s sed -i "s/^%(email)s\s.*$//" %(virtusertable)s
UPDATED=1 UPDATED=1
@ -131,17 +155,17 @@ class PostfixAddressBackend(ServiceController):
def commit(self): def commit(self):
context = self.get_context_files() context = self.get_context_files()
self.append(textwrap(""" self.append(textwrap.dedent("""
[[ $UPDATED == 1 ]] && { [[ $UPDATED_VIRTUSERTABLE == 1 ]] && { postmap %(virtusertable)s; }
postmap %(virtdomains)s # TODO not sure if always needed
postmap %(virtusertable)s [[ $UPDATED_VIRTDOMAINS == 1 ]] && { /etc/init.d/postfix reload; }
}""" % context """ % context
)) ))
def get_context_files(self): def get_context_files(self):
return { return {
'virtdomains': settings.EMAILS_VIRTDOMAINS_PATH, 'virtdomains': settings.MAILS_VIRTDOMAINS_PATH,
'virtusertable': settings.EMAILS_VIRTUSERTABLE_PATH, 'virtusertable': settings.MAILS_VIRTUSERTABLE_PATH,
} }
def get_context(self, address): def get_context(self, address):
@ -173,7 +197,8 @@ class MaildirDisk(ServiceMonitor):
def get_context(self, mailbox): def get_context(self, mailbox):
context = MailSystemUserBackend().get_context(mailbox) context = MailSystemUserBackend().get_context(mailbox)
context['home'] = settings.EMAILS_HOME % context context.update({
context['rr_path'] = os.path.join(context['home'], 'Maildir/maildirsize') 'rr_path': os.path.join(context['home'], 'Maildir/maildirsize'),
context['object_id'] = mailbox.pk 'object_id': mailbox.pk
})
return context return context

View file

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

View file

@ -1,3 +1,4 @@
from django.contrib.auth.hashers import make_password
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -7,6 +8,7 @@ from orchestra.core import services
from . import validators, settings from . import validators, settings
# TODO rename app to mailboxes
class Mailbox(models.Model): class Mailbox(models.Model):
name = models.CharField(_("name"), max_length=64, unique=True, name = models.CharField(_("name"), max_length=64, unique=True,
@ -36,18 +38,29 @@ class Mailbox(models.Model):
def active(self): def active(self):
return self.is_active and self.account.is_active 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): class Address(models.Model):
name = models.CharField(_("name"), max_length=64, name = models.CharField(_("name"), max_length=64,
validators=[validators.validate_emailname]) validators=[validators.validate_emailname])
domain = models.ForeignKey(settings.EMAILS_DOMAIN_MODEL, domain = models.ForeignKey(settings.MAILS_DOMAIN_MODEL,
verbose_name=_("domain"), verbose_name=_("domain"),
related_name='addresses') related_name='addresses')
mailboxes = models.ManyToManyField(Mailbox, mailboxes = models.ManyToManyField(Mailbox,
verbose_name=_("mailboxes"), verbose_name=_("mailboxes"),
related_name='addresses', blank=True) related_name='addresses', blank=True)
forward = models.CharField(_("forward"), max_length=256, 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"), account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='addresses') related_name='addresses')
@ -62,6 +75,13 @@ class Address(models.Model):
def email(self): def email(self):
return "%s@%s" % (self.name, self.domain) 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): class Autoresponse(models.Model):
address = models.OneToOneField(Address, verbose_name=_("address"), address = models.OneToOneField(Address, verbose_name=_("address"),

View file

@ -8,7 +8,23 @@ from .models import Mailbox, Address
class MailboxSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): class MailboxSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
class Meta: class Meta:
model = Mailbox 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): class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
@ -25,3 +41,9 @@ class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSeri
domain = fields['domain'].queryset domain = fields['domain'].queryset
fields['domain'].queryset = domain.filter(account=account) fields['domain'].queryset = domain.filter(account=account)
return fields 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

View file

@ -1,25 +1,32 @@
from django.conf import settings 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') '%(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') '/etc/postfix/virtusertable')
EMAILS_VIRTDOMAINS_PATH = getattr(settings, 'EMAILS_VIRTDOMAINS_PATH', MAILS_VIRTDOMAINS_PATH = getattr(settings, 'MAILS_VIRTDOMAINS_PATH',
'/etc/postfix/virtdomains') '/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' 'require ["fileinto","regex","envelope","vacation","reject","relational","comparator-i;ascii-numeric"];\n'
'\n' '\n'
'if header :value "ge" :comparator "i;ascii-numeric" "X-Spam-Score" "5" {\n' 'if header :value "ge" :comparator "i;ascii-numeric" "X-Spam-Score" "5" {\n'

View file

View file

@ -1,8 +1,25 @@
#import imaplib import email.utils
#mail = imaplib.IMAP4_SSL('localhost') import imaplib
#mail.login('rata', '3') import os
#('OK', ['Logged in']) 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() #>>> mail.list()
#('OK', ['(\\HasNoChildren) "." INBOX']) #('OK', ['(\\HasNoChildren) "." INBOX'])
#>>> mail.select('INBOX') #>>> mail.select('INBOX')
@ -17,7 +34,7 @@
#('OK', ['Close completed.']) #('OK', ['Close completed.'])
#import poplib
#pop = poplib.POP3('localhost') #pop = poplib.POP3('localhost')
#pop.user('rata') #pop.user('rata')
#pop.pass_('3') #pop.pass_('3')
@ -26,3 +43,287 @@
#>>> pop.quit() #>>> pop.quit()
#'+OK Logging out.' #'+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

View file

@ -140,4 +140,15 @@ def insert_resource_inlines():
for ct, resources in Resource.objects.group_by('content_type').iteritems(): for ct, resources in Resource.objects.group_by('content_type').iteritems():
inline = resource_inline_factory(resources) inline = resource_inline_factory(resources)
model = ct.model_class() 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)

View file

@ -13,3 +13,11 @@ class ResourcesConfig(AppConfig):
from .models import create_resource_relation from .models import create_resource_relation
create_resource_relation() create_resource_relation()
insert_resource_inlines() 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()

View file

@ -1,5 +1,6 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.apps import apps
from django.core import validators from django.core import validators
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@ -101,7 +102,7 @@ class Resource(models.Model):
task.save(update_fields=['crontab']) task.save(update_fields=['crontab'])
if created: if created:
# This only work on tests because of multiprocessing used on real deployments # 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): def delete(self, *args, **kwargs):
super(Resource, self).delete(*args, **kwargs) super(Resource, self).delete(*args, **kwargs)
@ -132,12 +133,15 @@ class ResourceData(models.Model):
def get_or_create(cls, obj, resource): def get_or_create(cls, obj, resource):
ct = ContentType.objects.get_for_model(type(obj)) ct = ContentType.objects.get_for_model(type(obj))
try: try:
return cls.objects.get(content_type=ct, object_id=obj.pk, return cls.objects.get(content_type=ct, object_id=obj.pk, resource=resource)
resource=resource)
except cls.DoesNotExist: except cls.DoesNotExist:
return cls.objects.create(content_object=obj, resource=resource, return cls.objects.create(content_object=obj, resource=resource,
allocated=resource.default_allocation) allocated=resource.default_allocation)
@property
def unit(self):
return self.resource.unit
def get_used(self): def get_used(self):
return helpers.compute_resource_usage(self) return helpers.compute_resource_usage(self)
@ -177,8 +181,10 @@ def create_resource_relation():
data = self.obj.resource_set.get(resource__name=attr) data = self.obj.resource_set.get(resource__name=attr)
except ResourceData.DoesNotExist: except ResourceData.DoesNotExist:
model = self.obj._meta.model_name model = self.obj._meta.model_name
resource = Resource.objects.get(content_type__model=model, name=attr, is_active=True) resource = Resource.objects.get(content_type__model=model, name=attr,
data = ResourceData(content_object=self.obj, resource=resource) is_active=True)
data = ResourceData(content_object=self.obj, resource=resource,
allocated=resource.default_allocation)
return data return data
def __get__(self, obj, cls): def __get__(self, obj, cls):

View file

@ -14,6 +14,12 @@ class ResourceSerializer(serializers.ModelSerializer):
fields = ('name', 'used', 'allocated') fields = ('name', 'used', 'allocated')
read_only_fields = ('used',) 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): def get_name(self, instance):
return instance.resource.name return instance.resource.name
@ -23,8 +29,7 @@ class ResourceSerializer(serializers.ModelSerializer):
# Monkey-patching section # Monkey-patching section
if database_ready(): def insert_resource_serializers():
# TODO why this is even loaded during syncdb?
# Create nested serializers on target models # Create nested serializers on target models
for ct, resources in Resource.objects.group_by('content_type').iteritems(): for ct, resources in Resource.objects.group_by('content_type').iteritems():
model = ct.model_class() model = ct.model_class()
@ -32,7 +37,7 @@ if database_ready():
router.insert(model, 'resources', ResourceSerializer, required=False, many=True, source='resource_set') router.insert(model, 'resources', ResourceSerializer, required=False, many=True, source='resource_set')
except KeyError: except KeyError:
continue continue
# TODO this is a fucking workaround, reimplement this on the proper place
def validate_resources(self, attrs, source, _resources=resources): def validate_resources(self, attrs, source, _resources=resources):
""" Creates missing resources """ """ Creates missing resources """
posted = attrs.get(source, []) posted = attrs.get(source, [])
@ -70,3 +75,6 @@ if database_ready():
] ]
return ret return ret
viewset.metadata = metadata viewset.metadata = metadata
if database_ready():
insert_resource_serializers()

View file

@ -2,17 +2,18 @@ from django.conf.urls import patterns, url
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.util import unquote from django.contrib.admin.util import unquote
from django.contrib.auth.admin import UserAdmin
from django.utils.translation import ugettext, ugettext_lazy as _ 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.admin.utils import wrap_admin_view
from orchestra.apps.accounts.admin import SelectAccountAdminMixin from orchestra.apps.accounts.admin import SelectAccountAdminMixin
from orchestra.forms import UserCreationForm, UserChangeForm
from .forms import UserCreationForm, UserChangeForm
from .models import SystemUser from .models import SystemUser
class SystemUserAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin):
list_display = ('username', 'account_link', 'shell', 'home', 'is_active',) list_display = ('username', 'account_link', 'shell', 'home', 'is_active',)
list_filter = ('is_active', 'shell') list_filter = ('is_active', 'shell')
fieldsets = ( fieldsets = (

View file

@ -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 <a href=\"password/\">this form</a>."))
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"]

View file

@ -46,9 +46,6 @@ class SystemUser(models.Model):
def __unicode__(self): def __unicode__(self):
return self.username return self.username
def set_password(self, raw_password):
self.password = make_password(raw_password)
@cached_property @cached_property
def active(self): def active(self):
a = type(self).account.field.model a = type(self).account.field.model
@ -57,6 +54,9 @@ class SystemUser(models.Model):
except type(self).account.field.rel.to.DoesNotExist: except type(self).account.field.rel.to.DoesNotExist:
return self.is_active return self.is_active
def set_password(self, raw_password):
self.password = make_password(raw_password)
def get_home(self): def get_home(self):
if self.is_main: if self.is_main:
context = { context = {

View file

@ -1,6 +1,7 @@
import ftplib import ftplib
import os import os
import re import re
import time
from functools import partial from functools import partial
import paramiko import paramiko
@ -100,7 +101,7 @@ class SystemUserMixin(object):
self.assertEqual(0, channel.recv_exit_status()) self.assertEqual(0, channel.recv_exit_status())
channel.close() channel.close()
def test_create(self): def test_add(self):
username = '%s_systemuser' % random_ascii(10) username = '%s_systemuser' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
self.add(username, password) self.add(username, password)
@ -166,9 +167,14 @@ class SystemUserMixin(object):
self.assertRaises(ftplib.error_perm, self.validate_ftp, username, password) self.assertRaises(ftplib.error_perm, self.validate_ftp, username, password)
def test_change_password(self): def test_change_password(self):
pass username = '%s_systemuser' % random_ascii(10)
# TODO 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 # TODO test resources
@ -191,7 +197,7 @@ class RESTSystemUserMixin(SystemUserMixin):
def add_group(self, username, groupname): def add_group(self, username, groupname):
user = self.rest.systemusers.retrieve(username=username).get() user = self.rest.systemusers.retrieve(username=username).get()
group = self.rest.systemusers.retrieve(username=groupname).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() user.save()
def disable(self, username): def disable(self, username):
@ -203,6 +209,10 @@ class RESTSystemUserMixin(SystemUserMixin):
user = self.rest.systemusers.retrieve(username=username).get() user = self.rest.systemusers.retrieve(username=username).get()
user.save() user.save()
def change_password(self, username, password):
user = self.rest.systemusers.retrieve(username=username).get()
user.change_password(password)
class AdminSystemUserMixin(SystemUserMixin): class AdminSystemUserMixin(SystemUserMixin):
def setUp(self): def setUp(self):
@ -266,6 +276,7 @@ class AdminSystemUserMixin(SystemUserMixin):
self.selenium.get(url) self.selenium.get(url)
groups = self.selenium.find_element_by_id('id_groups_add_all_link') groups = self.selenium.find_element_by_id('id_groups_add_all_link')
groups.click() groups.click()
time.sleep(0.5)
save = self.selenium.find_element_by_name('_save') save = self.selenium.find_element_by_name('_save')
save.submit() save.submit()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)
@ -280,6 +291,20 @@ class AdminSystemUserMixin(SystemUserMixin):
save.submit() save.submit()
self.assertNotEqual(url, self.selenium.current_url) 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): class RESTSystemUserTest(RESTSystemUserMixin, BaseLiveServerTestCase):
pass pass

View file

@ -1,4 +1,8 @@
from django import forms 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): class PluginDataForm(forms.ModelForm):
@ -25,3 +29,62 @@ class PluginDataForm(forms.ModelForm):
field: self.cleaned_data[field] for field in self.declared_fields field: self.cleaned_data[field] for field in self.declared_fields
} }
return super(PluginDataForm, self).save(commit=commit) 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 <a href=\"password/\">this form</a>."))
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"]

View file

@ -0,0 +1,33 @@
{% extends 'admin/auth/user/change_password.html' %}
{% load i18n %}
{% block content %}<div id="content-main">
<form {% if has_file_field %}enctype="multipart/form-data" {% endif %}action="{{ form_url }}" method="post" id="{{ opts.model_name }}_form" novalidate>{% csrf_token %}{% block form_top %}{% endblock %}
<div>
{% if is_popup %}<input type="hidden" name="{{ is_popup_var }}" value="1" />{% endif %}
{% if to_field %}<input type="hidden" name="{{ to_field_var }}" value="{{ to_field }}" />{% endif %}
<p>{% blocktrans with username=original %}Enter a new password for the user <strong>{{ username }}</strong>.{% endblocktrans %}</p>
{% if errors %}
<p class="errornote">
{% if adminform.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %}
</p>
{% endif %}
{% for fieldset in adminform %}
{% include "admin/includes/fieldset.html" %}
{% endfor %}
<div class="submit-row">
<input type="submit" value="{% trans 'Change password' %}" class="default" />
</div>
<script type="text/javascript">document.getElementById("id_password1").focus();</script>
</div>
</form></div>
{% endblock %}

View file

@ -106,7 +106,8 @@ def run(command, display=True, error_codes=[0], silent=False, stdin=''):
def sshrun(addr, command, *args, **kwargs): def sshrun(addr, command, *args, **kwargs):
cmd = "ssh -o stricthostkeychecking=no root@%s -C '%s'" % (addr, command) command = command.replace("'", """'"'"'""")
cmd = "ssh -o stricthostkeychecking=no -C root@%s '%s'" % (addr, command)
return run(cmd, *args, **kwargs) return run(cmd, *args, **kwargs)

View file

@ -110,6 +110,12 @@ class BaseLiveServerTestCase(AppDependencyMixin, LiveServerTestCase):
def rest_login(self): def rest_login(self):
self.rest.login(username=self.account.username, password=self.account_password) self.rest.login(username=self.account.username, password=self.account_password)
def take_screenshot(self):
timestamp = datetime.datetime.now().isoformat().replace(':', '')
filename = 'screenshot_%s_%s.png' % (self.id(), timestamp)
path = '/home/orchestra/snapshots'
self.selenium.save_screenshot(os.path.join(path, filename))
def snapshot_on_error(test): def snapshot_on_error(test):
@wraps(test) @wraps(test)
@ -118,9 +124,23 @@ def snapshot_on_error(test):
test(*args, **kwargs) test(*args, **kwargs)
except: except:
self = args[0] self = args[0]
timestamp = datetime.datetime.now().isoformat().replace(':', '') self.take_screenshot()
filename = 'screenshot_%s_%s.png' % (self.id(), timestamp)
path = '/home/orchestra/snapshots'
self.selenium.save_screenshot(os.path.join(path, filename))
raise raise
return inner return inner
def save_response_on_error(test):
@wraps(test)
def inner(*args, **kwargs):
try:
test(*args, **kwargs)
except:
self = args[0]
timestamp = datetime.datetime.now().isoformat().replace(':', '')
filename = '%s_%s.html' % (self.id(), timestamp)
path = '/home/orchestra/snapshots'
with open(os.path.join(path, filename), 'w') as dumpfile:
dumpfile.write(self.rest.last_response.content)
raise
return inner