diff --git a/modoboa/admin/lib.py b/modoboa/admin/lib.py
index f5da1820e..547d487ca 100644
--- a/modoboa/admin/lib.py
+++ b/modoboa/admin/lib.py
@@ -55,7 +55,7 @@ def get_identities(user, searchquery=None, idtfilter=None, grpfilter=None):
q &= Q(is_superuser=True)
else:
q &= Q(groups__name=grpfilter)
- accounts = User.objects.select_related().filter(q)
+ accounts = User.objects.filter(q).prefetch_related("groups")
aliases = []
if idtfilter is None or not idtfilter \
@@ -63,13 +63,13 @@ def get_identities(user, searchquery=None, idtfilter=None, grpfilter=None):
alct = ContentType.objects.get_for_model(Alias)
ids = user.objectaccess_set.filter(content_type=alct) \
.values_list('object_id', flat=True)
- q = Q(pk__in=ids)
+ q = Q(pk__in=ids, internal=False)
if searchquery is not None:
q &= (
Q(address__icontains=searchquery) |
Q(domain__name__icontains=searchquery)
)
- aliases = Alias.objects.select_related().filter(q)
+ aliases = Alias.objects.select_related("domain").filter(q)
if idtfilter is not None and idtfilter:
aliases = [al for al in aliases if al.type == idtfilter]
return chain(accounts, aliases)
diff --git a/modoboa/admin/migrations/0003_auto_20151118_1215.py b/modoboa/admin/migrations/0003_auto_20151118_1215.py
new file mode 100644
index 000000000..3dbbfe390
--- /dev/null
+++ b/modoboa/admin/migrations/0003_auto_20151118_1215.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('admin', '0002_migrate_from_modoboa_admin'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='aliasrecipient',
+ name='address',
+ field=models.EmailField(max_length=254),
+ ),
+ ]
diff --git a/modoboa/admin/tests/export.py b/modoboa/admin/tests/export.py
index 65801db61..715ce1b23 100644
--- a/modoboa/admin/tests/export.py
+++ b/modoboa/admin/tests/export.py
@@ -37,7 +37,7 @@ class ExportTestCase(ModoTestCase):
def test_export_identities(self):
response = self.__export_identities()
self.assertListEqual(
- "account;admin@test.com;{PLAIN}toto;;;True;DomainAdmins;admin@test.com;10;test.com\r\naccount;admin@test2.com;{PLAIN}toto;;;True;DomainAdmins;admin@test2.com;10;test2.com\r\naccount;user@test.com;{PLAIN}toto;;;True;SimpleUsers;user@test.com;10\r\naccount;user@test2.com;{PLAIN}toto;;;True;SimpleUsers;user@test2.com;10\r\nalias;alias@test.com;True;user@test.com\r\nforward;forward@test.com;True;user@external.com\r\ndlist;postmaster@test.com;True;toto@titi.com;test@truc.fr\r\n",
+ "account;admin@test.com;{PLAIN}toto;;;True;DomainAdmins;admin@test.com;10;test.com\r\naccount;admin@test2.com;{PLAIN}toto;;;True;DomainAdmins;admin@test2.com;10;test2.com\r\naccount;user@test.com;{PLAIN}toto;;;True;SimpleUsers;user@test.com;10\r\naccount;user@test2.com;{PLAIN}toto;;;True;SimpleUsers;user@test2.com;10\r\nalias;alias@test.com;True;user@test.com\r\nforward;forward@test.com;True;user@external.com\r\ndlist;postmaster@test.com;True;test@truc.fr;toto@titi.com\r\n",
response.content.strip()
)
@@ -92,5 +92,5 @@ class ExportTestCase(ModoTestCase):
response = self.__export_identities(idtfilter="dlist")
self.assertEqual(
response.content.strip(),
- "dlist;postmaster@test.com;True;toto@titi.com;test@truc.fr"
+ "dlist;postmaster@test.com;True;test@truc.fr;toto@titi.com"
)
diff --git a/modoboa/core/api.py b/modoboa/core/api.py
new file mode 100644
index 000000000..7f89f380a
--- /dev/null
+++ b/modoboa/core/api.py
@@ -0,0 +1,29 @@
+"""Modoboa core viewsets."""
+
+from django.http import Http404
+
+from rest_framework import status
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from . import models
+from . import serializers
+
+
+class UserPasswordChangeAPIView(APIView):
+
+ """A view to update password for user instances."""
+
+ def put(self, request, pk, format=None):
+ """PUT method hander."""
+ try:
+ user = models.User.objects.get(username=pk)
+ except models.User.DoesNotExist:
+ raise Http404
+ serializer = serializers.UserPasswordSerializer(
+ user, data=request.data)
+ if serializer.is_valid():
+ serializer.save()
+ return Response()
+ return Response(
+ serializer.errors, status=status.HTTP_400_BAD_REQUEST)
diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl
index 8a3e60f91..e53e4c738 100644
--- a/modoboa/core/commands/templates/settings.py.tpl
+++ b/modoboa/core/commands/templates/settings.py.tpl
@@ -48,6 +48,7 @@ INSTALLED_APPS = (
'django.contrib.sites',
'django.contrib.staticfiles',
'reversion',
+ 'rest_framework.authtoken'
{% if devmode %} 'djangobower',{% endif %}
)
@@ -148,6 +149,17 @@ STATICFILES_DIRS = (
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+# Rest framework settings
+
+REST_FRAMEWORK = {
+ 'DEFAULT_AUTHENTICATION_CLASSES': (
+ 'rest_framework.authentication.TokenAuthentication',
+ ),
+ 'DEFAULT_PERMISSION_CLASSES': (
+ 'rest_framework.permissions.IsAuthenticated',
+ )
+}
+
# Modoboa settings
#MODOBOA_CUSTOM_LOGO = os.path.join(MEDIA_URL, "custom_logo.png")
diff --git a/modoboa/core/forms.py b/modoboa/core/forms.py
index 719badc5a..816dc5878 100644
--- a/modoboa/core/forms.py
+++ b/modoboa/core/forms.py
@@ -90,3 +90,17 @@ class ProfileForm(forms.ModelForm):
)
user.save()
return user
+
+
+class APIAccessForm(forms.Form):
+
+ """Form to control API access."""
+
+ enable_api_access = forms.BooleanField(
+ label=_("Enable API access"), required=False)
+
+ def __init__(self, *args, **kwargs):
+ """Initialize form."""
+ user = kwargs.pop("user")
+ super(APIAccessForm, self).__init__(*args, **kwargs)
+ self.fields["enable_api_access"].initial = hasattr(user, "auth_token")
diff --git a/modoboa/core/models.py b/modoboa/core/models.py
index 2a787369e..08c103250 100644
--- a/modoboa/core/models.py
+++ b/modoboa/core/models.py
@@ -166,6 +166,7 @@ class User(PermissionsMixin):
)
def check_password(self, raw_value):
+ """Compare raw_value to current password."""
match = self.password_expr.match(self.password)
if match is None:
return False
diff --git a/modoboa/core/serializers.py b/modoboa/core/serializers.py
new file mode 100644
index 000000000..e81354c30
--- /dev/null
+++ b/modoboa/core/serializers.py
@@ -0,0 +1,41 @@
+"""Modoboa core serializers."""
+
+from django.core.exceptions import ValidationError
+
+from rest_framework import serializers
+from passwords import validators
+
+from . import models
+
+
+class UserPasswordSerializer(serializers.ModelSerializer):
+
+ """A serializer used to change a user password."""
+
+ new_password = serializers.CharField()
+
+ class Meta:
+ model = models.User
+ fields = (
+ "password", "new_password", )
+
+ def validate_password(self, value):
+ """Check password."""
+ if not self.instance.check_password(value):
+ raise serializers.ValidationError("Password not correct")
+ return value
+
+ def validate_new_password(self, value):
+ """Check new password."""
+ try:
+ validators.validate_length(value)
+ validators.complexity(value)
+ except ValidationError as exc:
+ raise serializers.ValidationError(exc.messages[0])
+ return value
+
+ def update(self, instance, validated_data):
+ """Set new password."""
+ instance.set_password(validated_data["new_password"])
+ instance.save()
+ return instance
diff --git a/modoboa/core/templates/core/api_access.html b/modoboa/core/templates/core/api_access.html
new file mode 100644
index 000000000..d0669a7b6
--- /dev/null
+++ b/modoboa/core/templates/core/api_access.html
@@ -0,0 +1,32 @@
+{% load i18n form_tags %}
+
+
{% trans "API access" %} {% trans "Control your access to Modoboa API" %}
+
+{% if user.auth_token %}
+
+
+
{% trans "API access token" %}
+
+
+ {{ user.auth_token }}
+
+
+
+{% endif %}
+
diff --git a/modoboa/core/templates/core/user_index.html b/modoboa/core/templates/core/user_index.html
index 711603221..db4896e44 100644
--- a/modoboa/core/templates/core/user_index.html
+++ b/modoboa/core/templates/core/user_index.html
@@ -18,7 +18,7 @@ $(document).ready(function() {
deflocation: "profile/",
formid: "uprefs_form",
divid: "uprefs_content",
- reload_exceptions: ["profile"]
+ reload_exceptions: ["profile", "api"]
});
});
diff --git a/modoboa/core/templates/core/user_profile.html b/modoboa/core/templates/core/user_profile.html
index 15fb395d4..62617fba7 100644
--- a/modoboa/core/templates/core/user_profile.html
+++ b/modoboa/core/templates/core/user_profile.html
@@ -1,10 +1,13 @@
-{% load i18n form_tags %}{% load url from future %}
+{% load i18n form_tags %}
+{% load url from future %}
+
{% trans "Profile" %} {% trans "Update your personal information" %}
diff --git a/modoboa/core/templatetags/core_tags.py b/modoboa/core/templatetags/core_tags.py
index 805fa289c..eb1f8cd1b 100644
--- a/modoboa/core/templatetags/core_tags.py
+++ b/modoboa/core/templatetags/core_tags.py
@@ -106,6 +106,13 @@ def uprefs_menu(selection, user):
"url": "preferences/",
"label": _("Preferences")},
]
+ if user.is_superuser:
+ entries.append({
+ "name": "api",
+ "class": "ajaxnav",
+ "url": "api/",
+ "label": _("API"),
+ })
entries += events.raiseQueryEvent("UserMenuDisplay", "uprefs_menu", user)
entries = sorted(entries, key=lambda e: e["label"])
return render_to_string('common/menu.html', {
diff --git a/modoboa/core/tests.py b/modoboa/core/tests.py
index 0c27dd593..cb9880917 100644
--- a/modoboa/core/tests.py
+++ b/modoboa/core/tests.py
@@ -2,8 +2,9 @@
from django.core.urlresolvers import reverse
-from modoboa.lib.tests import ModoTestCase
+from modoboa.lib.tests import ModoTestCase, ModoAPITestCase
from . import factories
+from . import models
class AuthenticationTestCase(ModoTestCase):
@@ -80,3 +81,66 @@ class ProfileTestCase(ModoTestCase):
self.assertTrue(
self.clt.login(username="user@test.com", password="Toto1234")
)
+
+
+class APIAccessFormTestCase(ModoTestCase):
+
+ """Check form access."""
+
+ @classmethod
+ def setUpTestData(cls):
+ """Create test data."""
+ super(APIAccessFormTestCase, cls).setUpTestData()
+ cls.account = factories.UserFactory(
+ username="user@test.com", groups=('SimpleUsers',)
+ )
+
+ def test_form_access(self):
+ """Check access restrictions."""
+ url = reverse("core:user_api_access")
+ self.ajax_get(url)
+ self.clt.logout()
+ self.clt.login(username="user@test.com", password="toto")
+ response = self.clt.get(url, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
+ self.assertEqual(response.status_code, 278)
+
+ def test_form(self):
+ """Check that token is created/removed."""
+ url = reverse("core:user_api_access")
+ self.ajax_post(url, {"enable_api_access": True})
+ user = models.User.objects.get(username="admin")
+ self.assertTrue(hasattr(user, "auth_token"))
+ self.ajax_post(url, {"enable_api_access": False})
+ user = models.User.objects.get(username="admin")
+ self.assertFalse(hasattr(user, "auth_token"))
+
+
+class APITestCase(ModoAPITestCase):
+
+ """Check API."""
+
+ @classmethod
+ def setUpTestData(cls):
+ """Create test data."""
+ super(APITestCase, cls).setUpTestData()
+ cls.account = factories.UserFactory(
+ username="user@test.com", groups=('SimpleUsers', )
+ )
+
+ def test_change_password(self):
+ """Check the change password service."""
+ url = reverse(
+ "external_api:user_password_change", args=["user@test.com"])
+ response = self.client.put(
+ url, {"password": "toto", "new_password": "pass"},
+ format="json")
+ # must fail because password is too weak
+ self.assertEqual(response.status_code, 400)
+
+ response = self.client.put(
+ url, {"password": "toto", "new_password": "Toto1234"},
+ format="json")
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(
+ models.User.objects.get(
+ pk=self.account.pk).check_password("Toto1234"))
diff --git a/modoboa/core/urls.py b/modoboa/core/urls.py
index e4fba293d..61c56ceca 100644
--- a/modoboa/core/urls.py
+++ b/modoboa/core/urls.py
@@ -29,4 +29,6 @@ urlpatterns = patterns(
name="user_preferences"),
url(r'^user/profile/$', 'modoboa.core.views.user.profile',
name="user_profile"),
+ url(r'^user/api/$', 'modoboa.core.views.user.api_access',
+ name="user_api_access"),
)
diff --git a/modoboa/core/urls_api.py b/modoboa/core/urls_api.py
new file mode 100644
index 000000000..7e967fef1
--- /dev/null
+++ b/modoboa/core/urls_api.py
@@ -0,0 +1,14 @@
+"""Core API urls."""
+
+from django.conf.urls import patterns, url
+
+from . import api
+
+
+urlpatterns = patterns(
+ "",
+ url("^users/(?P.+)/password/$",
+ api.UserPasswordChangeAPIView.as_view(),
+ name="user_password_change"),
+
+)
diff --git a/modoboa/core/views/user.py b/modoboa/core/views/user.py
index 91583856b..149dc91a3 100644
--- a/modoboa/core/views/user.py
+++ b/modoboa/core/views/user.py
@@ -4,14 +4,16 @@ from django.shortcuts import render
from django.utils import translation
from django.utils.translation import ugettext as _
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
+
+from rest_framework.authtoken.models import Token
from modoboa.lib import events, parameters
from modoboa.lib.cryptutils import encrypt
from modoboa.lib.web_utils import (
_render_to_string, render_to_json_response
)
-from ..forms import ProfileForm
+from ..forms import ProfileForm, APIAccessForm
@login_required
@@ -73,3 +75,25 @@ def preferences(request):
"forms": parameters.get_user_forms(request.user)
})
})
+
+
+@login_required
+@user_passes_test(lambda u: u.is_superuser)
+def api_access(request):
+ """A view to configure API access."""
+ if request.method == "POST":
+ form = APIAccessForm(request.POST, user=request.user)
+ if form.is_valid():
+ if form.cleaned_data.get("enable_api_access"):
+ Token.objects.get_or_create(user=request.user)
+ else:
+ Token.objects.filter(user=request.user).delete()
+ return render_to_json_response(_("Access updated"))
+ return render_to_json_response({
+ "form_errors": form.errors
+ }, status=400)
+ form = APIAccessForm(user=request.user)
+ return render_to_json_response({
+ "content": _render_to_string(
+ request, "core/api_access.html", {"form": form})
+ })
diff --git a/modoboa/lib/parameters.py b/modoboa/lib/parameters.py
index 1ea5eadc2..2cb780b3b 100644
--- a/modoboa/lib/parameters.py
+++ b/modoboa/lib/parameters.py
@@ -207,9 +207,9 @@ def unregister(app=None):
def __is_defined(app, level, name):
not_defined = (
- level not in ['A', 'U']
- or app not in _params[level]
- or name not in _params[level][app]["defaults"]
+ level not in ['A', 'U'] or
+ app not in _params[level] or
+ name not in _params[level][app]["defaults"]
)
if not_defined:
raise NotDefined(app, name)
diff --git a/modoboa/lib/tests.py b/modoboa/lib/tests.py
index 08b2d486e..a83622bcc 100644
--- a/modoboa/lib/tests.py
+++ b/modoboa/lib/tests.py
@@ -8,6 +8,9 @@ from django.core import management
from django.test import TestCase
from django.test.client import Client
+from rest_framework.authtoken.models import Token
+from rest_framework.test import APITestCase
+
from modoboa.lib import parameters
from modoboa.core import models as core_models
@@ -47,6 +50,23 @@ class ModoTestCase(TestCase):
return self.ajax_request('get', *args, **kwargs)
+class ModoAPITestCase(APITestCase):
+
+ """All test cases must inherit from this one."""
+
+ @classmethod
+ def setUpTestData(cls):
+ """Create a default user."""
+ management.call_command("load_initial_data")
+ cls.token = Token.objects.create(
+ user=core_models.User.objects.get(username="admin"))
+
+ def setUp(self):
+ """Setup."""
+ super(ModoAPITestCase, self).setUp()
+ self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key)
+
+
class TestParams(parameters.AdminParametersForm):
app = "test"
diff --git a/modoboa/limits/migrations/0002_auto_20151114_1518.py b/modoboa/limits/migrations/0002_auto_20151114_1518.py
new file mode 100644
index 000000000..a6ed7714f
--- /dev/null
+++ b/modoboa/limits/migrations/0002_auto_20151114_1518.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('limits', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelTable(
+ name='limit',
+ table=None,
+ ),
+ migrations.AlterModelTable(
+ name='limitspool',
+ table=None,
+ ),
+ ]
diff --git a/modoboa/static/js/global.js b/modoboa/static/js/global.js
index 88eeea8df..01ac1f8c0 100644
--- a/modoboa/static/js/global.js
+++ b/modoboa/static/js/global.js
@@ -330,11 +330,13 @@ function activate_widget(e) {
/*
* Default error handler for AJAX requests.
*/
-function default_ajax_error_handler(event, jqxhr, settings) {
+function defaultAjaxErrorHandler(event, jqxhr, settings) {
+ var data;
+
try {
- var data = $.parseJSON(jqxhr.responseText);
+ data = $.parseJSON(jqxhr.responseText);
} catch (x) {
- var data = gettext("Internal error");
+ data = gettext("Internal error");
}
$('body').notify('error', data);
}
@@ -353,7 +355,7 @@ String.prototype.format = function() {
$(document).ready(function() {
$(document).ajaxSuccess(function(e, xhr, settings) { ajax_login_redirect(xhr); });
- $(document).ajaxError(default_ajax_error_handler);
+ $(document).ajaxError(defaultAjaxErrorHandler);
$(document).on('click', 'a[data-toggle="ajaxmodal"]', modalbox);
$(document).on('click', 'a[data-toggle="ajaxmodal-autowidth"]', modalbox_autowidth);
$(document).on('click', '.activator', activate_widget);
diff --git a/modoboa/static/js/twocols_nav.js b/modoboa/static/js/twocols_nav.js
index 9094d549c..404cf6f5c 100644
--- a/modoboa/static/js/twocols_nav.js
+++ b/modoboa/static/js/twocols_nav.js
@@ -25,8 +25,9 @@ TwocolsNav.prototype = {
listen: function() {
$("a.ajaxnav").click($.proxy(this.load_section, this));
$(document).on("click", "#update", $.proxy(function(e) {
+ var $form = $("form").first();
simple_ajax_form_post(e, {
- formid: this.options.formid,
+ formid: $form.attr("id"),
modal: false,
reload_on_success: false,
success_cb: $.proxy(this.save_cb, this)
diff --git a/modoboa/urls.py b/modoboa/urls.py
index eacfe23f8..86fe5b038 100644
--- a/modoboa/urls.py
+++ b/modoboa/urls.py
@@ -36,6 +36,12 @@ urlpatterns += patterns(
parameters.apply_to_django_settings()
+# API urls
+urlpatterns += patterns(
+ "",
+ ("^api/v1/", include("modoboa.urls_api", namespace="external_api")),
+)
+
if 'modoboa.demo' in settings.INSTALLED_APPS:
urlpatterns += patterns(
'',
diff --git a/modoboa/urls_api.py b/modoboa/urls_api.py
new file mode 100644
index 000000000..5baa6af29
--- /dev/null
+++ b/modoboa/urls_api.py
@@ -0,0 +1,10 @@
+"""External API urls."""
+
+from django.conf.urls import patterns, include
+
+
+urlpatterns = patterns(
+ "",
+
+ ("", include("modoboa.core.urls_api")),
+)
diff --git a/requirements.txt b/requirements.txt
index f37d2ca7a..536663d61 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,3 +11,4 @@ django-xforwardedfor-middleware
django-passwords==0.3.4
rfc6266
progressbar==2.3
+djangorestframework==3.3.1
diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py
index 5971f257a..9e938d703 100644
--- a/test_project/test_project/settings.py
+++ b/test_project/test_project/settings.py
@@ -45,7 +45,7 @@ INSTALLED_APPS = (
'django.contrib.sites',
'django.contrib.staticfiles',
'reversion',
-
+ 'rest_framework.authtoken'
)
# A dedicated place to register Modoboa applications
@@ -135,6 +135,17 @@ STATICFILES_DIRS = (
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+# Rest framework settings
+
+REST_FRAMEWORK = {
+ 'DEFAULT_AUTHENTICATION_CLASSES': (
+ 'rest_framework.authentication.TokenAuthentication',
+ ),
+ 'DEFAULT_PERMISSION_CLASSES': (
+ 'rest_framework.permissions.IsAuthenticated',
+ )
+}
+
# Modoboa settings
#MODOBOA_CUSTOM_LOGO = os.path.join(MEDIA_URL, "custom_logo.png")