Browse Source

#706: moved forum options, password and email change from forms to serializers

Rafał Pitoń 8 years ago
parent
commit
c24cd46d66

+ 56 - 36
frontend/src/components/options/forum-options.js

@@ -108,52 +108,72 @@ export default class extends Form {
           <fieldset>
           <fieldset>
             <legend>{gettext("Privacy settings")}</legend>
             <legend>{gettext("Privacy settings")}</legend>
 
 
-            <FormGroup label={gettext("Hide my presence")}
-                       helpText={gettext("If you hide your presence, only members with permission to see hidden users will see when you are online.")}
-                       for="id_is_hiding_presence"
-                       labelClass="col-sm-4" controlClass="col-sm-8">
-              <YesNoSwitch id="id_is_hiding_presence"
-                           disabled={this.state.isLoading}
-                           iconOn="visibility_off"
-                           iconOff="visibility"
-                           labelOn={gettext("Hide my presence from other users")}
-                           labelOff={gettext("Show my presence to other users")}
-                           onChange={this.bindInput('is_hiding_presence')}
-                           value={this.state.is_hiding_presence} />
+            <FormGroup
+              label={gettext("Hide my presence")}
+              helpText={gettext("If you hide your presence, only members with permission to see hidden users will see when you are online.")}
+              for="id_is_hiding_presence"
+              labelClass="col-sm-4"
+              controlClass="col-sm-8"
+            >
+              <YesNoSwitch
+                id="id_is_hiding_presence"
+                disabled={this.state.isLoading}
+                iconOn="visibility_off"
+                iconOff="visibility"
+                labelOn={gettext("Hide my presence from other users")}
+                labelOff={gettext("Show my presence to other users")}
+                onChange={this.bindInput('is_hiding_presence')}
+                value={this.state.is_hiding_presence}
+              />
             </FormGroup>
             </FormGroup>
 
 
-            <FormGroup label={gettext("Private thread invitations")}
-                       for="id_limits_private_thread_invites_to"
-                       labelClass="col-sm-4" controlClass="col-sm-8">
-              <Select id="id_limits_private_thread_invites_to"
-                      disabled={this.state.isLoading}
-                      onChange={this.bindInput('limits_private_thread_invites_to')}
-                      value={this.state.limits_private_thread_invites_to}
-                      choices={this.privateThreadInvitesChoices} />
+            <FormGroup
+              label={gettext("Private thread invitations")}
+              for="id_limits_private_thread_invites_to"
+              labelClass="col-sm-4"
+              controlClass="col-sm-8"
+            >
+              <Select
+                id="id_limits_private_thread_invites_to"
+                disabled={this.state.isLoading}
+                onChange={this.bindInput('limits_private_thread_invites_to')}
+                value={this.state.limits_private_thread_invites_to}
+                choices={this.privateThreadInvitesChoices}
+              />
             </FormGroup>
             </FormGroup>
           </fieldset>
           </fieldset>
 
 
           <fieldset>
           <fieldset>
             <legend>{gettext("Automatic subscriptions")}</legend>
             <legend>{gettext("Automatic subscriptions")}</legend>
 
 
-            <FormGroup label={gettext("Threads I start")}
-                       for="id_subscribe_to_started_threads"
-                       labelClass="col-sm-4" controlClass="col-sm-8">
-              <Select id="id_subscribe_to_started_threads"
-                      disabled={this.state.isLoading}
-                      onChange={this.bindInput('subscribe_to_started_threads')}
-                      value={this.state.subscribe_to_started_threads}
-                      choices={this.subscribeToChoices} />
+            <FormGroup
+              label={gettext("Threads I start")}
+              for="id_subscribe_to_started_threads"
+              labelClass="col-sm-4"
+              controlClass="col-sm-8"
+            >
+              <Select
+                id="id_subscribe_to_started_threads"
+                disabled={this.state.isLoading}
+                onChange={this.bindInput('subscribe_to_started_threads')}
+                value={this.state.subscribe_to_started_threads}
+                choices={this.subscribeToChoices}
+              />
             </FormGroup>
             </FormGroup>
 
 
-            <FormGroup label={gettext("Threads I reply to")}
-                       for="id_subscribe_to_replied_threads"
-                       labelClass="col-sm-4" controlClass="col-sm-8">
-              <Select id="id_subscribe_to_replied_threads"
-                      disabled={this.state.isLoading}
-                      onChange={this.bindInput('subscribe_to_replied_threads')}
-                      value={this.state.subscribe_to_replied_threads}
-                      choices={this.subscribeToChoices} />
+            <FormGroup
+              label={gettext("Threads I reply to")}
+              for="id_subscribe_to_replied_threads"
+              labelClass="col-sm-4"
+              controlClass="col-sm-8"
+            >
+              <Select
+                id="id_subscribe_to_replied_threads"
+                disabled={this.state.isLoading}
+                onChange={this.bindInput('subscribe_to_replied_threads')}
+                value={this.state.subscribe_to_replied_threads}
+                choices={this.subscribeToChoices}
+              />
             </FormGroup>
             </FormGroup>
           </fieldset>
           </fieldset>
 
 

File diff suppressed because it is too large
+ 0 - 0
misago/static/misago/js/misago.js.map


+ 6 - 6
misago/users/api/userendpoints/changeemail.py

@@ -6,20 +6,20 @@ from django.utils.translation import ugettext as _
 from misago.conf import settings
 from misago.conf import settings
 from misago.core.mail import mail_user
 from misago.core.mail import mail_user
 from misago.users.credentialchange import store_new_credential
 from misago.users.credentialchange import store_new_credential
-from misago.users.forms.options import ChangeEmailForm
+from misago.users.serializers import ChangeEmailSerializer
 
 
 
 
 def change_email_endpoint(request, pk=None):
 def change_email_endpoint(request, pk=None):
-    form = ChangeEmailForm(request.data, user=request.user)
-    if form.is_valid():
+    serializer = ChangeEmailSerializer(data=request.data, user=request.user)
+    if serializer.is_valid():
         token = store_new_credential(
         token = store_new_credential(
-            request, 'email', form.cleaned_data['new_email'])
+            request, 'email', serializer.validated_data['new_email'])
 
 
         mail_subject = _("Confirm e-mail change on %(forum_name)s forums")
         mail_subject = _("Confirm e-mail change on %(forum_name)s forums")
         mail_subject = mail_subject % {'forum_name': settings.forum_name}
         mail_subject = mail_subject % {'forum_name': settings.forum_name}
 
 
         # swap address with new one so email is sent to new address
         # swap address with new one so email is sent to new address
-        request.user.email = form.cleaned_data['new_email']
+        request.user.email = serializer.validated_data['new_email']
 
 
         mail_user(request, request.user, mail_subject,
         mail_user(request, request.user, mail_subject,
                   'misago/emails/change_email',
                   'misago/emails/change_email',
@@ -28,4 +28,4 @@ def change_email_endpoint(request, pk=None):
         message = _("E-mail change confirmation link was sent to new address.")
         message = _("E-mail change confirmation link was sent to new address.")
         return Response({'detail': message})
         return Response({'detail': message})
     else:
     else:
-        return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

+ 5 - 5
misago/users/api/userendpoints/changepassword.py

@@ -6,14 +6,14 @@ from django.utils.translation import ugettext as _
 from misago.conf import settings
 from misago.conf import settings
 from misago.core.mail import mail_user
 from misago.core.mail import mail_user
 from misago.users.credentialchange import store_new_credential
 from misago.users.credentialchange import store_new_credential
-from misago.users.forms.options import ChangePasswordForm
+from misago.users.serializers import ChangePasswordSerializer
 
 
 
 
 def change_password_endpoint(request, pk=None):
 def change_password_endpoint(request, pk=None):
-    form = ChangePasswordForm(request.data, user=request.user)
-    if form.is_valid():
+    serializer = ChangePasswordSerializer(data=request.data, user=request.user)
+    if serializer.is_valid():
         token = store_new_credential(
         token = store_new_credential(
-            request, 'password', form.cleaned_data['new_password'])
+            request, 'password', serializer.validated_data['new_password'])
 
 
         mail_subject = _("Confirm password change on %(forum_name)s forums")
         mail_subject = _("Confirm password change on %(forum_name)s forums")
         mail_subject = mail_subject % {'forum_name': settings.forum_name}
         mail_subject = mail_subject % {'forum_name': settings.forum_name}
@@ -25,4 +25,4 @@ def change_password_endpoint(request, pk=None):
         return Response({'detail': _("Password change confirmation link "
         return Response({'detail': _("Password change confirmation link "
                                      "was sent to your address.")})
                                      "was sent to your address.")})
     else:
     else:
-        return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

+ 5 - 6
misago/users/api/users.py

@@ -19,12 +19,11 @@ from misago.core.rest_permissions import IsAuthenticatedOrReadOnly
 from misago.core.shortcuts import get_int_or_404
 from misago.core.shortcuts import get_int_or_404
 from misago.threads.moderation import hide_post, hide_thread
 from misago.threads.moderation import hide_post, hide_thread
 from misago.users.bans import get_user_ban
 from misago.users.bans import get_user_ban
-from misago.users.forms.options import ForumOptionsForm
 from misago.users.online.utils import get_user_status
 from misago.users.online.utils import get_user_status
 from misago.users.permissions import (
 from misago.users.permissions import (
     allow_browse_users_list, allow_delete_user, allow_follow_user, allow_moderate_avatar,
     allow_browse_users_list, allow_delete_user, allow_follow_user, allow_moderate_avatar,
     allow_rename_user, allow_see_ban_details)
     allow_rename_user, allow_see_ban_details)
-from misago.users.serializers import BanDetailsSerializer, UserSerializer
+from misago.users.serializers import BanDetailsSerializer, ForumOptionsSerializer, UserSerializer
 from misago.users.viewmodels import Followers, Follows, UserPosts, UserThreads
 from misago.users.viewmodels import Followers, Follows, UserPosts, UserThreads
 
 
 from .rest_permissions import BasePermission, UnbannedAnonOnly
 from .rest_permissions import BasePermission, UnbannedAnonOnly
@@ -104,14 +103,14 @@ class UserViewSet(viewsets.GenericViewSet):
         get_int_or_404(pk)
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users options."))
         allow_self_only(request.user, pk, _("You can't change other users options."))
 
 
-        form = ForumOptionsForm(request.data, instance=request.user)
-        if form.is_valid():
-            form.save()
+        serializer = ForumOptionsSerializer(request.user, data=request.data)
+        if serializer.is_valid():
+            serializer.save()
             return Response({
             return Response({
                 'detail': _("Your forum options have been changed.")
                 'detail': _("Your forum options have been changed.")
             })
             })
         else:
         else:
-            return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
+            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
 
     @detail_route(methods=['get', 'post'])
     @detail_route(methods=['get', 'post'])
     def username(self, request, pk=None):
     def username(self, request, pk=None):

+ 30 - 0
misago/users/migrations/0007_auto_20170219_1639.py

@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.5 on 2017-02-19 16:39
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('misago_users', '0006_update_settings'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='user',
+            name='limits_private_thread_invites_to',
+            field=models.PositiveIntegerField(choices=[(0, 'Everybody'), (1, 'Users I follow'), (2, 'Nobody')], default=0),
+        ),
+        migrations.AlterField(
+            model_name='user',
+            name='subscribe_to_replied_threads',
+            field=models.PositiveIntegerField(choices=[(0, 'No'), (1, 'Notify'), (2, 'Notify with e-mail')], default=0),
+        ),
+        migrations.AlterField(
+            model_name='user',
+            name='subscribe_to_started_threads',
+            field=models.PositiveIntegerField(choices=[(0, 'No'), (1, 'Notify'), (2, 'Notify with e-mail')], default=0),
+        ),
+    ]

+ 6 - 3
misago/users/models/user.py

@@ -242,16 +242,19 @@ class User(AbstractBaseUser, PermissionsMixin):
     )
     )
 
 
     limits_private_thread_invites_to = models.PositiveIntegerField(
     limits_private_thread_invites_to = models.PositiveIntegerField(
-        default=LIMIT_INVITES_TO_NONE
+        default=LIMIT_INVITES_TO_NONE,
+        choices=LIMIT_INVITES_TO_CHOICES,
     )
     )
     unread_private_threads = models.PositiveIntegerField(default=0)
     unread_private_threads = models.PositiveIntegerField(default=0)
     sync_unread_private_threads = models.BooleanField(default=False)
     sync_unread_private_threads = models.BooleanField(default=False)
 
 
     subscribe_to_started_threads = models.PositiveIntegerField(
     subscribe_to_started_threads = models.PositiveIntegerField(
-        default=SUBSCRIBE_NONE
+        default=SUBSCRIBE_NONE,
+        choices=SUBSCRIBE_CHOICES
     )
     )
     subscribe_to_replied_threads = models.PositiveIntegerField(
     subscribe_to_replied_threads = models.PositiveIntegerField(
-        default=SUBSCRIBE_NONE
+        default=SUBSCRIBE_NONE,
+        choices=SUBSCRIBE_CHOICES
     )
     )
 
 
     threads = models.PositiveIntegerField(default=0)
     threads = models.PositiveIntegerField(default=0)

+ 1 - 0
misago/users/serializers/__init__.py

@@ -1,4 +1,5 @@
 from .ban import *
 from .ban import *
+from .options import *
 from .rank import *
 from .rank import *
 from .user import *
 from .user import *
 from .auth import *
 from .auth import *

+ 5 - 0
misago/users/serializers/auth.py

@@ -32,6 +32,11 @@ class AuthenticatedUserSerializer(UserSerializer, AuthFlags):
     class Meta:
     class Meta:
         model = UserModel
         model = UserModel
         fields = UserSerializer.Meta.fields + (
         fields = UserSerializer.Meta.fields + (
+            'is_hiding_presence',
+            'limits_private_thread_invites_to',
+            'subscribe_to_started_threads',
+            'subscribe_to_replied_threads',
+
             'is_authenticated',
             'is_authenticated',
             'is_anonymous',
             'is_anonymous',
         )
         )

+ 96 - 0
misago/users/serializers/options.py

@@ -0,0 +1,96 @@
+from rest_framework import serializers
+
+from django.contrib.auth import get_user_model
+from django.contrib.auth.password_validation import validate_password
+from django.utils.translation import ugettext_lazy as _
+
+from misago.conf import settings
+from misago.core.forms import YesNoSwitch
+from misago.users.validators import validate_email
+
+
+UserModel = get_user_model()
+
+__all__ = [
+    'ForumOptionsSerializer',
+    'EditSignatureSerializer',
+    'ChangePasswordSerializer',
+    'ChangeEmailSerializer',
+]
+
+
+class ForumOptionsSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = UserModel
+        fields = [
+            'is_hiding_presence',
+            'limits_private_thread_invites_to',
+            'subscribe_to_started_threads',
+            'subscribe_to_replied_threads'
+        ]
+        extra_kwargs = {
+            'limits_private_thread_invites_to': {
+                'required': True
+            },
+            'subscribe_to_started_threads': {
+                'required': True
+            },
+            'subscribe_to_replied_threads': {
+                'required': True
+            },
+        }
+
+
+class EditSignatureSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = UserModel
+        fields = ['signature']
+
+    def validate(self, data):
+        if len(data.get('signature', '')) > settings.signature_length_max:
+            raise serializers.ValidationError(_("Signature is too long."))
+
+        return data
+
+
+class ChangePasswordSerializer(serializers.Serializer):
+    password = serializers.CharField(max_length=200)
+    new_password = serializers.CharField(max_length=200)
+
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop('user', None)
+        super(ChangePasswordSerializer, self).__init__(*args, **kwargs)
+
+    def validate_password(self, value):
+        if not self.user.check_password(value):
+            raise serializers.ValidationError(_("Entered password is invalid."))
+        return value
+
+    def validate_new_password(self, value):
+        validate_password(value, user=self.user)
+        return value
+
+
+class ChangeEmailSerializer(serializers.Serializer):
+    password = serializers.CharField(max_length=200)
+    new_email = serializers.CharField(max_length=200)
+
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop('user', None)
+        super(ChangeEmailSerializer, self).__init__(*args, **kwargs)
+
+    def validate_password(self, value):
+        if not self.user.check_password(value):
+            raise serializers.ValidationError(_("Entered password is invalid."))
+        return value
+
+    def validate_new_email(self, value):
+        if not value:
+            raise serializers.ValidationError(_("You have to enter new e-mail address."))
+
+        if value.lower() == self.user.email.lower():
+            raise serializers.ValidationError(_("New e-mail is same as current one."))
+
+        validate_email(value)
+
+        return value

+ 54 - 16
misago/users/tests/test_user_changeemail_api.py

@@ -1,5 +1,6 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core import mail
 from django.core import mail
+from django.urls import reverse
 
 
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -20,21 +21,19 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 405)
         self.assertEqual(response.status_code, 405)
 
 
-    def test_change_email(self):
-        """api allows users to change their e-mail addresses"""
-        response = self.client.post(self.link, data={
-            'new_email': 'new@email.com',
-            'password': self.USER_PASSWORD
-        })
-        self.assertEqual(response.status_code, 200)
+    def test_empty_input(self):
+        """api errors correctly for empty input"""
+        response = self.client.post(self.link, data={})
 
 
-        self.assertIn('Confirm e-mail change', mail.outbox[0].subject)
-        for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
-            if line.startswith('http://'):
-                token = line.rstrip('/').split('/')[-1]
-                break
-        else:
-            self.fail("E-mail sent didn't contain confirmation url")
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'new_email': [
+                "This field is required."
+            ],
+            'password': [
+                "This field is required."
+            ],
+        })
 
 
     def test_invalid_password(self):
     def test_invalid_password(self):
         """api errors correctly for invalid password"""
         """api errors correctly for invalid password"""
@@ -50,13 +49,25 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
             'new_email': '',
             'new_email': '',
             'password': self.USER_PASSWORD
             'password': self.USER_PASSWORD
         })
         })
-        self.assertContains(response, 'new_email":["This field is required', status_code=400)
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'new_email': [
+                "This field may not be blank."
+            ],
+        })
 
 
         response = self.client.post(self.link, data={
         response = self.client.post(self.link, data={
             'new_email': 'newmail',
             'new_email': 'newmail',
             'password': self.USER_PASSWORD
             'password': self.USER_PASSWORD
         })
         })
-        self.assertContains(response, 'valid email address', status_code=400)
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'new_email': [
+                "Enter a valid email address."
+            ],
+        })
 
 
     def test_email_taken(self):
     def test_email_taken(self):
         """api validates email usage"""
         """api validates email usage"""
@@ -67,3 +78,30 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
             'password': self.USER_PASSWORD
             'password': self.USER_PASSWORD
         })
         })
         self.assertContains(response, 'not available', status_code=400)
         self.assertContains(response, 'not available', status_code=400)
+
+    def test_change_email(self):
+        """api allows users to change their e-mail addresses"""
+        new_email = 'new@email.com'
+
+        response = self.client.post(self.link, data={
+            'new_email': new_email,
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.assertIn('Confirm e-mail change', mail.outbox[0].subject)
+        for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
+            if line.startswith('http://'):
+                token = line.rstrip('/').split('/')[-1]
+                break
+        else:
+            self.fail("E-mail sent didn't contain confirmation url")
+
+        response = self.client.get(reverse('misago:options-confirm-email-change', kwargs={
+            'token': token
+        }))
+
+        self.assertEqual(response.status_code, 200)
+
+        self.reload_user()
+        self.assertEqual(self.user.email, new_email)

+ 65 - 19
misago/users/tests/test_user_changepassword_api.py

@@ -1,5 +1,6 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core import mail
 from django.core import mail
+from django.urls import reverse
 
 
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -17,21 +18,19 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 405)
         self.assertEqual(response.status_code, 405)
 
 
-    def test_change_password(self):
-        """api allows users to change their passwords"""
-        response = self.client.post(self.link, data={
-            'new_password': 'N3wP@55w0rd',
-            'password': self.USER_PASSWORD
-        })
-        self.assertEqual(response.status_code, 200)
+    def test_empty_input(self):
+        """api errors correctly for empty input"""
+        response = self.client.post(self.link, data={})
 
 
-        self.assertIn('Confirm password change', mail.outbox[0].subject)
-        for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
-            if line.startswith('http://'):
-                token = line.rstrip('/').split('/')[-1]
-                break
-        else:
-            self.fail("E-mail sent didn't contain confirmation url")
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'new_password': [
+                "This field is required."
+            ],
+            'password': [
+                "This field is required."
+            ],
+        })
 
 
     def test_invalid_password(self):
     def test_invalid_password(self):
         """api errors correctly for invalid password"""
         """api errors correctly for invalid password"""
@@ -39,18 +38,65 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
             'new_password': 'N3wP@55w0rd',
             'new_password': 'N3wP@55w0rd',
             'password': 'Lor3mIpsum'
             'password': 'Lor3mIpsum'
         })
         })
-        self.assertContains(response, 'password is invalid', status_code=400)
 
 
-    def test_invalid_input(self):
-        """api errors correctly for invalid input"""
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'password': [
+                "Entered password is invalid."
+            ],
+        })
+
+    def test_blank_input(self):
+        """api errors correctly for blank input"""
         response = self.client.post(self.link, data={
         response = self.client.post(self.link, data={
             'new_password': '',
             'new_password': '',
             'password': self.USER_PASSWORD
             'password': self.USER_PASSWORD
         })
         })
-        self.assertContains(response, 'new_password":["This field is required', status_code=400)
 
 
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'new_password': [
+                "This field may not be blank."
+            ],
+        })
+
+    def test_short_new_pasword(self):
+        """api errors correctly for short new password"""
         response = self.client.post(self.link, data={
         response = self.client.post(self.link, data={
             'new_password': 'n',
             'new_password': 'n',
             'password': self.USER_PASSWORD
             'password': self.USER_PASSWORD
         })
         })
-        self.assertContains(response, 'password is too short', status_code=400)
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'new_password': [
+                "This password is too short. It must contain at least 7 characters."
+            ],
+        })
+
+    def test_change_password(self):
+        """api allows users to change their passwords"""
+        new_password = 'N3wP@55w0rd'
+
+        response = self.client.post(self.link, data={
+            'new_password': new_password,
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.assertIn('Confirm password change', mail.outbox[0].subject)
+        for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
+            if line.startswith('http://'):
+                token = line.rstrip('/').split('/')[-1]
+                break
+        else:
+            self.fail("E-mail sent didn't contain confirmation url")
+
+        response = self.client.get(reverse('misago:options-confirm-password-change', kwargs={
+            'token': token
+        }))
+
+        self.assertEqual(response.status_code, 200)
+
+        self.reload_user()
+        self.assertTrue(self.user.check_password(new_password))

+ 36 - 11
misago/users/tests/test_users_api.py

@@ -273,27 +273,51 @@ class UserRetrieveTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
-class UserCategoriesOptionsTests(AuthenticatedUserTestCase):
+class UserForumOptionsTests(AuthenticatedUserTestCase):
     """
     """
     tests for user forum options RPC (POST to /api/users/1/forum-options/)
     tests for user forum options RPC (POST to /api/users/1/forum-options/)
     """
     """
     def setUp(self):
     def setUp(self):
-        super(UserCategoriesOptionsTests, self).setUp()
+        super(UserForumOptionsTests, self).setUp()
         self.link = '/api/users/%s/forum-options/' % self.user.pk
         self.link = '/api/users/%s/forum-options/' % self.user.pk
 
 
     def test_empty_request(self):
     def test_empty_request(self):
         """empty request is handled"""
         """empty request is handled"""
         response = self.client.post(self.link)
         response = self.client.post(self.link)
+
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'limits_private_thread_invites_to': [
+                'This field is required.',
+            ],
+            'subscribe_to_started_threads': [
+                'This field is required.',
+            ],
+            'subscribe_to_replied_threads': [
+                'This field is required.',
+            ],
+        })
 
 
-        fields = (
-            'limits_private_thread_invites_to',
-            'subscribe_to_started_threads',
-            'subscribe_to_replied_threads'
-        )
+    def test_change_forum_invalid_ranges(self):
+        """api validates ranges for fields"""
+        response = self.client.post(self.link, data={
+            'limits_private_thread_invites_to': 541,
+            'subscribe_to_started_threads': 44,
+            'subscribe_to_replied_threads': 321
+        })
 
 
-        for field in fields:
-            self.assertContains(response, '"%s"' % field, status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'limits_private_thread_invites_to': [
+                '"541" is not a valid choice.',
+            ],
+            'subscribe_to_started_threads': [
+                '"44" is not a valid choice.',
+            ],
+            'subscribe_to_replied_threads': [
+                '"321" is not a valid choice.',
+            ],
+        })
 
 
     def test_change_forum_options(self):
     def test_change_forum_options(self):
         """forum options are changed"""
         """forum options are changed"""
@@ -312,7 +336,7 @@ class UserCategoriesOptionsTests(AuthenticatedUserTestCase):
         self.assertEqual(self.user.subscribe_to_replied_threads, 1)
         self.assertEqual(self.user.subscribe_to_replied_threads, 1)
 
 
         response = self.client.post(self.link, data={
         response = self.client.post(self.link, data={
-            'is_hiding_presence': 'true',
+            'is_hiding_presence': True,
             'limits_private_thread_invites_to': 1,
             'limits_private_thread_invites_to': 1,
             'subscribe_to_started_threads': 2,
             'subscribe_to_started_threads': 2,
             'subscribe_to_replied_threads': 1
             'subscribe_to_replied_threads': 1
@@ -327,7 +351,7 @@ class UserCategoriesOptionsTests(AuthenticatedUserTestCase):
         self.assertEqual(self.user.subscribe_to_replied_threads, 1)
         self.assertEqual(self.user.subscribe_to_replied_threads, 1)
 
 
         response = self.client.post(self.link, data={
         response = self.client.post(self.link, data={
-            'is_hiding_presence': 'false',
+            'is_hiding_presence': False,
             'limits_private_thread_invites_to': 1,
             'limits_private_thread_invites_to': 1,
             'subscribe_to_started_threads': 2,
             'subscribe_to_started_threads': 2,
             'subscribe_to_replied_threads': 1
             'subscribe_to_replied_threads': 1
@@ -341,6 +365,7 @@ class UserCategoriesOptionsTests(AuthenticatedUserTestCase):
         self.assertEqual(self.user.subscribe_to_started_threads, 2)
         self.assertEqual(self.user.subscribe_to_started_threads, 2)
         self.assertEqual(self.user.subscribe_to_replied_threads, 1)
         self.assertEqual(self.user.subscribe_to_replied_threads, 1)
 
 
+
 class UserFollowTests(AuthenticatedUserTestCase):
 class UserFollowTests(AuthenticatedUserTestCase):
     """
     """
     tests for user follow RPC (POST to /api/users/1/follow/)
     tests for user follow RPC (POST to /api/users/1/follow/)

+ 2 - 2
misago/users/views/forgottenpassword.py

@@ -51,8 +51,8 @@ def reset_password_form(request, pk, token):
             raise Banned(ban)
             raise Banned(ban)
     except ResetError as e:
     except ResetError as e:
         return render(request, 'misago/forgottenpassword/error.html', {
         return render(request, 'misago/forgottenpassword/error.html', {
-                'message': e.args[0],
-            }, status=400)
+            'message': e.args[0],
+        }, status=400)
 
 
     api_url = reverse('misago:api:change-forgotten-password', kwargs={
     api_url = reverse('misago:api:change-forgotten-password', kwargs={
         'pk': pk,
         'pk': pk,

+ 1 - 1
misago/users/views/options.py

@@ -22,7 +22,7 @@ def index(request, *args, **kwargs):
 
 
     request.frontend_context.update({
     request.frontend_context.update({
         'USER_OPTIONS': user_options
         'USER_OPTIONS': user_options
-    });
+    })
 
 
     return render(request, 'misago/options/noscript.html')
     return render(request, 'misago/options/noscript.html')
 
 

Some files were not shown because too many files changed in this diff