mailboxes functional tests passing
This commit is contained in:
parent
5786132ca8
commit
6240fa3139
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 <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"]
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
22
orchestra/apps/mails/forms.py
Normal file
22
orchestra/apps/mails/forms.py
Normal 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"))
|
|
@ -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,
|
||||
|
@ -36,18 +38,29 @@ class Mailbox(models.Model):
|
|||
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')
|
||||
|
||||
|
@ -62,6 +75,13 @@ class Address(models.Model):
|
|||
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):
|
||||
address = models.OneToOneField(Address, verbose_name=_("address"),
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
0
orchestra/apps/mails/tests/__init__.py
Normal file
0
orchestra/apps/mails/tests/__init__.py
Normal file
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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"]
|
|
@ -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 = {
|
||||
|
|
|
@ -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):
|
||||
|
@ -203,6 +209,10 @@ class RESTSystemUserMixin(SystemUserMixin):
|
|||
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):
|
||||
def setUp(self):
|
||||
|
@ -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)
|
||||
|
@ -280,6 +291,20 @@ class AdminSystemUserMixin(SystemUserMixin):
|
|||
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
|
||||
|
|
|
@ -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 <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"]
|
||||
|
|
33
orchestra/templates/admin/orchestra/change_password.html
Normal file
33
orchestra/templates/admin/orchestra/change_password.html
Normal 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 %}
|
||||
|
|
@ -106,7 +106,8 @@ def run(command, display=True, error_codes=[0], silent=False, stdin=''):
|
|||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
@ -110,6 +110,12 @@ class BaseLiveServerTestCase(AppDependencyMixin, LiveServerTestCase):
|
|||
def rest_login(self):
|
||||
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):
|
||||
@wraps(test)
|
||||
|
@ -118,9 +124,23 @@ def snapshot_on_error(test):
|
|||
test(*args, **kwargs)
|
||||
except:
|
||||
self = args[0]
|
||||
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))
|
||||
self.take_screenshot()
|
||||
raise
|
||||
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
|
||||
|
||||
|
|
Loading…
Reference in a new issue