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.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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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):
|
class AccountCreationForm(UserCreationForm):
|
||||||
super(AccountCreationForm, self).__init__(*args, **kwargs)
|
|
||||||
self.fields['password1'].validators.append(validate_password)
|
|
||||||
|
|
||||||
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"]
|
|
||||||
|
|
|
@ -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 = (
|
||||||
|
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
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.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,
|
||||||
|
@ -35,19 +37,30 @@ class Mailbox(models.Model):
|
||||||
@cached_property
|
@cached_property
|
||||||
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')
|
||||||
|
|
||||||
|
@ -61,6 +74,13 @@ class Address(models.Model):
|
||||||
@property
|
@property
|
||||||
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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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'
|
||||||
|
|
0
orchestra/apps/mails/tests/__init__.py
Normal file
0
orchestra/apps/mails/tests/__init__.py
Normal 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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 = (
|
||||||
|
|
|
@ -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):
|
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 = {
|
||||||
|
|
|
@ -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):
|
||||||
|
@ -202,6 +208,10 @@ class RESTSystemUserMixin(SystemUserMixin):
|
||||||
def save(self, username):
|
def save(self, username):
|
||||||
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):
|
||||||
|
@ -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)
|
||||||
|
@ -279,7 +290,21 @@ class AdminSystemUserMixin(SystemUserMixin):
|
||||||
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)
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
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):
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -109,6 +109,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):
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue