Просмотр исходного кода

Change username, cleanup in other options

Rafał Pitoń 10 лет назад
Родитель
Сommit
1afa71e951
42 измененных файлов с 1600 добавлено и 74 удалено
  1. 1 0
      docs/developers/acls.rst
  2. 9 0
      misago/acl/algebra.py
  3. 8 0
      misago/acl/tests/test_acl_algebra.py
  4. 173 0
      misago/emberapp/app/components/forms/change-username-form.js
  5. 3 3
      misago/emberapp/app/components/forms/register-form.js
  6. 30 0
      misago/emberapp/app/components/last-username-changes.js
  7. 19 0
      misago/emberapp/app/components/ui-text-preview.js
  8. 11 0
      misago/emberapp/app/helpers/format-date.js
  9. 29 0
      misago/emberapp/app/helpers/rel-date.js
  10. 7 0
      misago/emberapp/app/helpers/x-range.js
  11. 8 1
      misago/emberapp/app/initializers/auth-service.js
  12. 8 0
      misago/emberapp/app/initializers/components-store.js
  13. 7 0
      misago/emberapp/app/models/user.js
  14. 15 0
      misago/emberapp/app/models/username-change.js
  15. 9 0
      misago/emberapp/app/serializers/username-change.js
  16. 2 2
      misago/emberapp/app/services/ajax.js
  17. 12 1
      misago/emberapp/app/services/auth.js
  18. 0 19
      misago/emberapp/app/styles/misago/forms.less
  19. 2 0
      misago/emberapp/app/styles/misago/misago.less
  20. 29 1
      misago/emberapp/app/styles/misago/typo.less
  21. 96 0
      misago/emberapp/app/styles/misago/ui-preview.less
  22. 72 0
      misago/emberapp/app/styles/misago/user-options.less
  23. 7 0
      misago/emberapp/app/styles/misago/variables.less
  24. 62 0
      misago/emberapp/app/templates/components/forms/change-username-form.hbs
  25. 56 0
      misago/emberapp/app/templates/components/last-username-changes.hbs
  26. 3 1
      misago/emberapp/app/templates/options/username.hbs
  27. 4 4
      misago/emberapp/app/transforms/moment-date.js
  28. 19 0
      misago/emberapp/tests/acceptance/auth-service-test.js
  29. 596 0
      misago/emberapp/tests/acceptance/change-username-test.js
  30. 3 0
      misago/emberapp/tests/helpers/create-user.js
  31. 5 0
      misago/users/api/userendpoints/signature.py
  32. 63 0
      misago/users/api/userendpoints/username.py
  33. 41 0
      misago/users/api/usernamechanges.py
  34. 13 6
      misago/users/api/users.py
  35. 7 8
      misago/users/forms/rename.py
  36. 2 2
      misago/users/namechanges.py
  37. 1 1
      misago/users/permissions/account.py
  38. 22 0
      misago/users/serializers/usernamechange.py
  39. 100 0
      misago/users/tests/test_user_username_api.py
  40. 0 25
      misago/users/tests/test_usercp_views.py
  41. 44 0
      misago/users/tests/test_usernamechanges_api.py
  42. 2 0
      misago/users/urls/api.py

+ 1 - 0
docs/developers/acls.rst

@@ -157,6 +157,7 @@ This module provides utilities for summing two acls and supports three most comm
 * **greater**: True beats False, 42 beats 13
 * **lower**: False beats True, 13 beats 42
 * **greater or zero**: 42 beats 13, zero beats everything
+* **lower non zero**, 13 beats 42, everything beats zero
 
 
 .. function:: sum_acls(result_acl, acls=None, roles=None, key=None, **permissions)

+ 9 - 0
misago/acl/algebra.py

@@ -55,3 +55,12 @@ def greater_or_zero(a, b):
 
 def lower(a, b):
     return a if a < b else b
+
+
+def lower_non_zero(a, b):
+    if a == 0:
+        return b
+    elif b == 0:
+        return a
+    else:
+        return lower(a, b)

+ 8 - 0
misago/acl/tests/test_acl_algebra.py

@@ -28,6 +28,14 @@ class ComparisionsTests(TestCase):
         self.assertEqual(algebra.lower(True, False), False)
 
 
+    def test_lower_non_zero(self):
+        """lower non-zero wins test"""
+        self.assertEqual(algebra.lower_non_zero(1, 3), 1)
+        self.assertEqual(algebra.lower_non_zero(0, 2), 2)
+        self.assertEqual(algebra.lower_non_zero(1, 2), 1)
+        self.assertEqual(algebra.lower_non_zero(0, 0), 0)
+
+
 class SumACLTests(TestCase):
     def test_sum_acls(self):
         """acls are summed"""

+ 173 - 0
misago/emberapp/app/components/forms/change-username-form.js

@@ -0,0 +1,173 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  tagName: 'form',
+  classNames: 'form-horizontal',
+
+  isLoaded: false,
+  isErrored: false,
+  isSaving: false,
+
+  options: null,
+  username: '',
+
+  validation: null,
+  setValidation: function() {
+    this.set('validation', Ember.Object.create({}));
+  }.on('init'),
+
+  apiUrl: function() {
+    return 'users/' + this.auth.get('user.id') + '/username';
+  }.property(),
+
+  loadOptions: function() {
+    var self = this;
+    this.ajax.get(this.get('apiUrl')
+    ).then(function(options) {
+      if (self.isDestroyed) { return; }
+      self.setProperties({
+        'options': Ember.Object.create(options),
+        'isLoaded': true
+      });
+    }, function(jqXHR) {
+      if (self.isDestroyed) { return; }
+      if (typeof jqXHR.responseJSON !== 'undefined') {
+        self.set('isErrored', jqXHR.responseJSON);
+      } else if (jqXHR.status === 0) {
+        self.set('isErrored', {'detail': gettext('Lost connection with application.')});
+      } else {
+        self.set('isErrored', {'detail': gettext('Application has errored.')});
+      }
+    });
+  }.on('init'),
+
+  usernameValidation: function() {
+    var state = true;
+
+    var value = Ember.$.trim(this.get('username'));
+    var valueLength = value.length;
+
+    var limit = 0;
+    var message = null;
+    if (valueLength < this.get('options.length_min')) {
+      limit = this.get('options.length_min');
+      message = ngettext('Username must be at least %(limit)s character long.',
+                         'Username must be at least %(limit)s characters long.',
+                         limit);
+      state = [interpolate(message, {limit: limit}, true)];
+    } else if (valueLength > this.get('options.length_max')) {
+      limit = this.get('options.length_max');
+      message = ngettext('Username cannot be longer than %(limit)s characters.',
+                         'Username cannot be longer than %(limit)s characters.',
+                         limit);
+      state = [interpolate(message, {limit: limit}, true)];
+    } else if (!new RegExp('^[0-9a-z]+$', 'i').test(value)) {
+      state = [gettext('Username can only contain latin alphabet letters and digits.')];
+    } else if (value === this.get('auth.user.username')) {
+      state = [gettext('New username is same as current one.')];
+    }
+
+    if (state === true) {
+      this.set('validation.username', 'ok');
+    } else {
+      this.set('validation.username', state);
+    }
+  }.observes('username'),
+
+  submit: function() {
+    if (this.get('isLoading')) {
+      return false;
+    }
+
+    var data = {
+      username: Ember.$.trim(this.get('username')),
+    };
+
+    if (data.username.length === 0) {
+      this.toast.error(gettext('Enter new username.'));
+      return false;
+    }
+
+    if (this.$('.has-error').length) {
+      this.toast.error(gettext('Form contains errors.'));
+      return false;
+    }
+
+    this.set('isSaving', true);
+
+    var self = this;
+    this.ajax.post(this.get('apiUrl'), data
+    ).then(function(response) {
+      if (self.isDestroyed) { return; }
+      self.success(response);
+    }, function(jqXHR) {
+      if (self.isDestroyed) { return; }
+      self.error(jqXHR);
+    }).finally(function() {
+      if (self.isDestroyed) { return; }
+      self.set('isSaving', false);
+    });
+
+    return false;
+  },
+
+  success: function(responseJSON) {
+    this.set('username', '');
+    this.set('validation', Ember.Object.create({}));
+
+    this.toast.success(gettext('Your username has been changed.'));
+    this.get('options').setProperties(responseJSON.options);
+
+    var oldUsername = this.get('auth.user.username');
+
+    this.get('auth.user').setProperties({
+      'username': responseJSON.username,
+      'slug': responseJSON.slug
+    });
+
+    var userPOJO = this.auth.getUserPOJO();
+
+    this.store.pushPayload({
+      'username-changes': [
+        {
+          'id': Math.floor(new Date().getTime() / 1000),
+          'user': userPOJO,
+          'changed_by': userPOJO,
+          'changed_by_username': this.get('auth.user.username'),
+          'changed_by_slug': this.get('auth.user.slug'),
+          'changed_on': moment(),
+          'new_username': this.get('auth.user.username'),
+          'old_username': oldUsername
+        }
+      ]
+    });
+  },
+
+  error: function(jqXHR) {
+    var rejection = jqXHR.responseJSON;
+    if (jqXHR.status === 400) {
+      this.toast.error(rejection.detail);
+      this.get('options').setProperties(rejection.options);
+    } else {
+      this.toast.apiError(jqXHR);
+    }
+  },
+
+  changesLeft: Ember.computed.alias('options.changes_left'),
+
+  changesLeftMessage: function() {
+    var changes = this.get('options.changes_left');
+    var message = ngettext('You can change your username %(changes)s more time.',
+                           'You can change your username %(changes)s more times.',
+                           changes);
+    return interpolate(message, {changes: changes}, true);
+  }.property('options.changes_left'),
+
+  nextChange: function() {
+    if (this.get('options.next_on')) {
+      return moment(this.get('options.next_on'));
+    } else {
+      return null;
+    }
+  }.property('options.next_on', 'clock.tick')
+});

+ 3 - 3
misago/emberapp/app/components/forms/register-form.js

@@ -93,13 +93,13 @@ export default Ember.Component.extend({
     var message = null;
     if (valueLength < this.get('settings.username_length_min')) {
       limit = this.get('settings.username_length_min');
-      message = ngettext('Username must be at least one character long.',
+      message = ngettext('Username must be at least %(limit)s character long.',
                          'Username must be at least %(limit)s characters long.',
                          limit);
       state = [interpolate(message, {limit: limit}, true)];
     } else if (valueLength > this.get('settings.username_length_max')) {
       limit = this.get('settings.username_length_max');
-      message = ngettext('Username cannot be longer than one characters.',
+      message = ngettext('Username cannot be longer than %(limit)s characters.',
                          'Username cannot be longer than %(limit)s characters.',
                          limit);
       state = [interpolate(message, {limit: limit}, true)];
@@ -142,7 +142,7 @@ export default Ember.Component.extend({
 
     var limit = this.get('settings.password_length_min');
     if (valueLength < limit) {
-      var message = ngettext('Valid password must be at least one character long.',
+      var message = ngettext('Valid password must be at least %(limit)s character long.',
                              'Valid password must be at least %(limit)s characters long.',
                              limit);
       state = [interpolate(message, {limit: limit}, true)];

+ 30 - 0
misago/emberapp/app/components/last-username-changes.js

@@ -0,0 +1,30 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  classNames: 'last-username-changes',
+
+  isLoaded: false,
+
+  _modelUnsorted: null,
+  sorting: ['intId:desc'],
+  model: Ember.computed.sort('_modelUnsorted', 'sorting'),
+
+  loadNamechanges: function() {
+    var self = this;
+    this.store.filter('username-change', { user: this.get('auth.user.id') }, function(change) {
+      return change.get('user.id') === self.get('auth.user.id');
+    }).then(function(changes) {
+      if (self.isDestroyed) { return; }
+      self.setProperties({
+        '_modelUnsorted': changes,
+        'isLoaded': true
+      });
+    });
+  }.on('didInsertElement'),
+
+  unloadNamechanges: function() {
+    this.get('_modelUnsorted').forEach(function(item) {
+      item.unloadRecord();
+    });
+  }.on('willDestroyElement')
+});

+ 19 - 0
misago/emberapp/app/components/ui-text-preview.js

@@ -0,0 +1,19 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  tagName: 'span',
+  minLength: 3,
+  maxLength: 10,
+
+  fillWithRandomText: function() {
+    var targetLen = Math.floor(Math.random() * (this.get('maxLength') - this.get('minLength')));
+    targetLen += this.get('minLength');
+
+    var htmlFiller = '';
+    for (var i = 0; i <= targetLen; i ++) {
+      htmlFiller += '&nbsp;&nbsp;';
+    }
+
+    this.$().html(htmlFiller);
+  }.on('didInsertElement')
+});

+ 11 - 0
misago/emberapp/app/helpers/format-date.js

@@ -0,0 +1,11 @@
+import Ember from 'ember';
+
+export function formatDate(moment, options) {
+  if (moment) {
+    return moment.format(options.hash.format || 'LL, LT');
+  } else {
+    return gettext('never');
+  }
+}
+
+export default Ember.Handlebars.makeBoundHelper(formatDate);

+ 29 - 0
misago/emberapp/app/helpers/rel-date.js

@@ -0,0 +1,29 @@
+import Ember from 'ember';
+
+export function relativeDate(date) {
+  if (date) {
+    var days = moment().diff(date, 'days');
+
+    if (days === 0) {
+      var hours = moment().diff(date, 'hours');
+      if (hours < 5){
+        return date.fromNow();
+      } else {
+        return date.format('LT');
+      }
+    } else if (days < 7) {
+      return moment(date).add(7, 'd').calendar();// tiny trick to get rid of "last"
+    } else {
+      var years = moment().diff(date, 'years');
+      if (years) {
+        return date.format('D MMM YYYY');
+      } else {
+        return date.format('D MMM');
+      }
+    }
+  } else {
+    return gettext('never');
+  }
+}
+
+export default Ember.Handlebars.makeBoundHelper(relativeDate);

+ 7 - 0
misago/emberapp/app/helpers/x-range.js

@@ -0,0 +1,7 @@
+import Ember from 'ember';
+
+export function xRange(length) {
+  return new Array(length);
+}
+
+export default Ember.Handlebars.makeBoundHelper(xRange);

+ 8 - 1
misago/emberapp/app/initializers/auth-service.js

@@ -3,7 +3,14 @@ import PreloadStore from 'misago/services/preload-store';
 import Auth from 'misago/services/auth';
 
 export function initialize(container, application) {
-  application.register('misago:user', Ember.Object.create(PreloadStore.get('user')), { instantiate: false });
+  var user = Ember.Object.create(PreloadStore.get('user'));
+
+  // Coerce user ID to string
+  if (user.get('id')) {
+    user.set('id', user.get('id').toString());
+  }
+
+  application.register('misago:user', user, { instantiate: false });
   application.register('misago:isAuthenticated', PreloadStore.get('isAuthenticated'), { instantiate: false });
 
   application.register('service:auth', Auth, { singleton: true });

+ 8 - 0
misago/emberapp/app/initializers/components-store.js

@@ -0,0 +1,8 @@
+export function initialize(container, application) {
+  application.inject('component', 'store', 'store:main');
+}
+
+export default {
+  name: 'components-store',
+  initialize: initialize
+};

+ 7 - 0
misago/emberapp/app/models/user.js

@@ -0,0 +1,7 @@
+import DS from 'ember-data';
+
+export default DS.Model.extend({
+  username: DS.attr('string'),
+  slug: DS.attr('string'),
+  avatar_hash: DS.attr('string')
+});

+ 15 - 0
misago/emberapp/app/models/username-change.js

@@ -0,0 +1,15 @@
+import DS from 'ember-data';
+
+export default DS.Model.extend({
+  user: DS.belongsTo('User'),
+  changed_by: DS.belongsTo('User'),
+  changed_by_username: DS.attr('string'),
+  changed_by_slug: DS.attr('string'),
+  changed_on: DS.attr('moment-date'),
+  new_username: DS.attr('string'),
+  old_username: DS.attr('string'),
+
+  intId: function() {
+    return + this.get('id');
+  }.property('id')
+});

+ 9 - 0
misago/emberapp/app/serializers/username-change.js

@@ -0,0 +1,9 @@
+import DS from 'ember-data';
+import DRFSerializer from './drf';
+
+export default DRFSerializer.extend(DS.EmbeddedRecordsMixin, {
+  attrs: {
+    'user': { embedded: 'always' },
+    'changed_by': { embedded: 'always' }
+  }
+});

+ 2 - 2
misago/emberapp/app/services/ajax.js

@@ -53,11 +53,11 @@ export default Ember.Service.extend({
   },
 
   getAdapter: function(model) {
-    return this.store.adapterFor(model || {typeKey: 'application'});
+    return this.store.adapterFor(model || {modelName: 'application'});
   },
 
   buildRecordProcedureURL: function(adapter, model, record, procedure) {
-    var url = adapter.buildURL(model.typeKey, record.id, record);
+    var url = adapter.buildURL(model.modelName, record.id, record);
     var procedureSegment = Ember.String.decamelize(procedure).replace('_', '-');
 
     return url + procedureSegment + '/';

+ 12 - 1
misago/emberapp/app/services/auth.js

@@ -47,7 +47,18 @@ export default Ember.Service.extend({
 
   userObserver: function() {
     this.session.setItem('auth-user', this.get('user'));
-  }.observes('user.avatar_hash'),
+  }.observes('user.avatar_hash', 'user.username', 'user.slug'),
+
+  // Return user as POJO
+
+  getUserPOJO: function() {
+    return {
+        'id': this.get('user.id'),
+        'username': this.get('user.username'),
+        'slug': this.get('user.slug'),
+        'avatar_hash': this.get('user.avatar_hash')
+      };
+  },
 
   // Anon/auth state
   isAnonymous: Ember.computed.not('isAuthenticated'),

+ 0 - 19
misago/emberapp/app/styles/misago/forms.less

@@ -126,22 +126,3 @@ fieldset {
     padding: 0px;
   }
 }
-
-
-// Edit signature form
-.edit-signature-form {
-  .panel-body {
-    .misago-editor {
-      margin-bottom: @line-height-computed;
-    }
-
-    .well {
-      margin-top: @line-height-computed;
-      margin-bottom: 0px;
-
-      .well-label {
-        font-size: @font-size-small;
-      }
-    }
-  }
-}

+ 2 - 0
misago/emberapp/app/styles/misago/misago.less

@@ -13,9 +13,11 @@
 @import "page-header.less";
 @import "typo.less";
 @import "misc.less";
+@import "ui-preview.less";
 
 // Pages
 @import "errorpages.less";
 @import "loaders.less";
 
 @import "change-avatar.less";
+@import "user-options.less";

+ 29 - 1
misago/emberapp/app/styles/misago/typo.less

@@ -3,7 +3,7 @@
 // --------------------------------------------------
 
 
-// in-site link
+// In-site link
 .site-link {
   &, &:link, &:visited {
     color: @site-link-color;
@@ -17,3 +17,31 @@
     color: @site-link-active-color;
   }
 }
+
+
+// Item name
+.item-name {
+  color: @text-color;
+  font-weight: bold;
+}
+
+a.item-name {
+  &:link, &:hover, &:visited, &:active {
+    color: @text-color;
+    font-weight: bold;
+  }
+
+  &:hover {
+    text-decoration: underline;
+  }
+}
+
+
+// Margins
+.right-margin {
+  margin-right: @line-height-computed;
+}
+
+.left-margin {
+  margin-left: @line-height-computed;
+}

+ 96 - 0
misago/emberapp/app/styles/misago/ui-preview.less

@@ -0,0 +1,96 @@
+//
+// Just utils for doing UI previews
+// --------------------------------------------------
+
+
+// Animatation
+.ui-preview, .ui-preview-text, .ui-preview-img {
+  background: @ui-preview-bg;
+  background-image: linear-gradient(90deg, @ui-preview-bg, @ui-preview-light 50%, @ui-preview-bg);
+  background-size: 1300px 1px;
+
+  .animation(ui-preview-animation 2s linear infinite);
+}
+
+@-webkit-keyframes ui-preview-animation {
+  from  { background-position: 1300px 0; }
+  to    { background-position: 0 0; }
+}
+
+@keyframes ui-preview-animation {
+  from  { background-position: 1300px 0; }
+  to    { background-position: 0 0; }
+}
+
+
+// Fill
+.ui-preview {
+  .fill {
+    background-color: @body-bg;
+
+    // Force icons to use faded preview color
+    .fa, .glyphicon {
+      color: @ui-preview-bg;
+    }
+  }
+}
+
+
+// Icons
+.ui-preview {
+  .fill {
+    .fa, .glyphicon {
+      .opacity(0.6);
+
+      color: @ui-preview-bg;
+
+      .animation(ui-preview-icons 2s linear infinite);
+    }
+  }
+}
+
+@-webkit-keyframes ui-preview-icons {
+  0%  { color: @ui-preview-bg; }
+  50%  { color: @ui-preview-light; }
+  1000%  { color: @ui-preview-bg; }
+}
+
+@keyframes ui-preview-icons {
+  0%  { color: @ui-preview-bg; }
+  50%  { color: @ui-preview-light; }
+  1000%  { color: @ui-preview-bg; }
+}
+
+
+// Few shorthands
+.ui-preview-img {
+  display: inline-block;
+}
+
+.ui-preview-text {
+  border-radius: 20px;
+  .opacity(0.6);
+}
+
+
+// Preset: list-group
+.ui-preview {
+  &.list-group-preview {
+    border-radius: @list-group-border-radius + 1;
+    padding: 1px;
+
+    &>.fill {
+      margin-bottom: 1px;
+      padding: 10px 15px;
+
+      // Round the first and last items
+      &:first-child {
+        .border-top-radius(@list-group-border-radius);
+      }
+      &:last-child {
+        margin-bottom: 0;
+        .border-bottom-radius(@list-group-border-radius);
+      }
+    }
+  }
+}

+ 72 - 0
misago/emberapp/app/styles/misago/user-options.less

@@ -0,0 +1,72 @@
+//
+// User Options Forms
+// --------------------------------------------------
+
+
+// Edit signature form
+.edit-signature-form {
+  .panel-body {
+    .misago-editor {
+      margin-bottom: @line-height-computed;
+    }
+
+    .well {
+      margin-top: @line-height-computed;
+      margin-bottom: 0px;
+
+      .well-label {
+        font-size: @font-size-small;
+      }
+    }
+  }
+}
+
+
+// Change username
+.last-username-changes {
+  .side-avatar {
+    float: left;
+
+    img, .img-preview {
+      border-radius: @avatar-radius;
+    }
+  }
+
+  .first-row {
+    margin-left: 42px + @padding-large-horizontal;
+
+    img, .avatar-preview {
+      border-radius: @avatar-radius-small;
+    }
+  }
+
+  .second-row {
+    margin-top: @line-height-computed / 4;
+    margin-left: 42px + @padding-large-horizontal;
+
+    font-size: @font-size-small;
+  }
+
+  // Larger displays
+  @media (min-width: @screen-md-min) {
+    // don't offset rows
+    .first-row, .second-row {
+      margin: 0px;
+
+      font-size: @font-size-base;
+    }
+
+    // Even rows
+    .list-group-item, .list-group-preview>.fill {
+      overflow: auto;
+
+      .first-row {
+        float: left;
+      }
+
+      .second-row {
+        float: right;
+      }
+    }
+  }
+}

+ 7 - 0
misago/emberapp/app/styles/misago/variables.less

@@ -311,3 +311,10 @@
 @misago-editor-bg:                #fff;
 
 @misago-editor-loader-color:      @gray-light;
+
+
+//== UI Preview
+//
+//** Animation
+@ui-preview-bg:                   @gray-lighter;
+@ui-preview-light:                darken(@ui-preview-bg, 10%);

+ 62 - 0
misago/emberapp/app/templates/components/forms/change-username-form.hbs

@@ -0,0 +1,62 @@
+<div class="panel panel-form">
+  <div class="panel-heading">
+    <h3 class="panel-title">{{gettext "Change username"}}</h3>
+  </div>
+  {{#if isLoaded}}
+    {{#if changesLeft}}
+    <div class="panel-body">
+
+      {{#form-row
+          label=(gettext "New username:")
+          help_text=changesLeftMessage
+          for="id_username"
+          label-class="col-md-4"
+          control-class="col-md-8"
+          validation=validation.username}}
+
+        {{input id="id_username" type="text" class="form-control" autocomplete="off" value=username}}
+      {{/form-row}}
+
+    </div>
+    <div class="panel-footer">
+      <div class="row">
+        <div class="col-md-8 col-md-offset-4">
+
+          {{#if isSaving}}
+          <button type="button" class="btn btn-primary btn-block-sm" disabled="disabled">
+            <span class="fa fa-cog fa-spin"></span>
+            {{gettext "Changing..."}}
+          </button>
+          {{else}}
+          <button type="submit" class="btn btn-primary btn-block-sm">{{gettext "Change username"}}</button>
+          {{/if}}
+
+        </div>
+      </div>
+    </div>
+    {{else}}
+    <div class="panel-body">
+      <p>{{gettext "You can't change your username now."}}</p>
+      {{#if nextChange}}
+      <p>{{gettext "Next change will be possible %(next_change)s." next_change=(from-now nextChange tick=clock.tick)}}</p>
+      {{/if}}
+    </div>
+    {{/if}}
+  {{else if isErrored}}
+  <div class="panel-error">
+
+    <div class="error-icon">
+      <span class="fa fa-warning fa-lg"></span>
+    </div>
+
+    <div class="error-message">
+      <p>{{isErrored.detail}}</p>
+    </div>
+
+  </div>
+  {{else}}
+  <div class="panel-body panel-loader">
+    <div class="loader"></div>
+  </div>
+  {{/if}}
+</div>

+ 56 - 0
misago/emberapp/app/templates/components/last-username-changes.hbs

@@ -0,0 +1,56 @@
+<h4>{{gettext "Last changes history"}}</h4>
+{{#if isLoaded}}
+<ul class="list-group">
+  {{#each model as |change|}}
+  <li class="list-group-item">
+    <div class="side-avatar hidden-md hidden-lg">
+      {{#if change.changed_by}}
+        <a href="#">{{user-avatar user=change.changed_by size=42}}</a>
+      {{else}}
+        <span>{{user-avatar user=change.changed_by size=42}}</span>
+      {{/if}}
+    </div>
+
+    <div class="first-row">
+      {{#if change.changed_by}}
+      <a href="#" class="hidden-xs hidden-sm">{{user-avatar user=change.changed_by size=20}}</a>
+      <a href="#" class="item-name right-margin">{{change.changed_by.username}}</a>
+      {{else}}
+      <span class="hidden-xs hidden-sm">{{user-avatar user=change.changed_by size=20}}</span>
+      <strong class="item-name right-margin"> {{change.changed_by_username}}</strong>
+      {{/if}}
+
+      {{change.old_username}} <span class="fa fa-chevron-right"></span> {{change.new_username}}
+    </div>
+    <div class="second-row text-muted" title="{{format-date change.changed_on tick=clock.tick}}">{{rel-date change.changed_on tick=clock.tick}}</div>
+  </li>
+  {{else}}
+  <li class="list-group-item">
+    <span class="fa fa-info-circle fa-lg"></span>
+    {{gettext "Your username was never changed."}}
+  </li>
+  {{/each}}
+</ul>
+{{else}}
+<div class="ui-preview list-group-preview">
+  {{#each (x-range 5) as |placeholder|}}
+  <div class="fill">
+    <div class="side-avatar hidden-md hidden-lg">
+      <div class="ui-preview-img avatar-preview" style="width: 42px; height: 42px;"></div>
+    </div>
+
+    <div class="first-row">
+      <span class="ui-preview-img avatar-preview hidden-xs hidden-sm" style="width: 20px; height: 20px;">&nbsp;</span>
+      {{ui-text-preview class="ui-preview-text item-name right-margin"}}
+
+      {{ui-text-preview class="ui-preview-text"}}
+      <span class="fa fa-chevron-right"></span>
+      {{ui-text-preview class="ui-preview-text"}}
+    </div>
+    <div class="second-row text-muted">
+      {{ui-text-preview minLength=10 maxLength=18 class="ui-preview-text"}}
+    </div>
+  </div>
+  {{/each}}
+</div>
+{{/if}}

+ 3 - 1
misago/emberapp/app/templates/options/username.hbs

@@ -1 +1,3 @@
-whehuehue
+{{change-username-form}}
+
+{{last-username-changes user=auth.user}}

+ 4 - 4
misago/emberapp/app/transforms/moment-date.js

@@ -1,11 +1,11 @@
 import DS from 'ember-data';
 
 export default DS.Transform.extend({
-  deserialize: function(serialized) {
-    return serialized ? serialized.format() : null;
+  deserialize: function(deserialized) {
+    return deserialized ? moment(deserialized) : null;
   },
 
-  serialize: function(deserialized) {
-    return deserialized ? moment.utc(deserialized) : null;
+  serialize: function(serialized) {
+    return serialized ? serialized.format() : null;
   }
 });

+ 19 - 0
misago/emberapp/tests/acceptance/auth-service-test.js

@@ -82,3 +82,22 @@ test('authenticated user was updated', function(assert) {
   assert.ok(!service.get('needsSync'));
   assert.equal(service.get('user.username'), newUser.get('username'));
 });
+
+test('POJO with user is obtainable', function(assert) {
+  assert.expect(3);
+
+  var user = createUser();
+  service.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  var pojo = service.getUserPOJO();
+
+  assert.ok(pojo);
+  assert.equal(pojo.username, service.get('user.username'));
+
+  service.set('user.username', 'Baww');
+  pojo = service.getUserPOJO();
+  assert.equal(pojo.username, service.get('user.username'));
+});

+ 596 - 0
misago/emberapp/tests/acceptance/change-username-test.js

@@ -0,0 +1,596 @@
+import Ember from 'ember';
+import { module, test } from 'qunit';
+import startApp from '../helpers/start-app';
+import getToastMessage from '../helpers/toast-message';
+import createUser from '../helpers/create-user';
+
+var application, container, auth;
+
+module('Acceptance: Change Username', {
+  beforeEach: function() {
+    application = startApp();
+    container = application.__container__;
+    auth = container.lookup('service:auth');
+  },
+  afterEach: function() {
+    Ember.run(application, 'destroy');
+    Ember.$.mockjax.clear();
+  }
+});
+
+test('/options/change-username form can be accessed', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    responseText: {
+      'length_min': 2,
+      'length_max': 20,
+      'changes_left': 2,
+      'next_on': null
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': []
+    }
+  });
+
+  assert.expect(3);
+
+  visit('/options/change-username/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+    assert.ok(find('#id_username'));
+
+    var listMessage = Ember.$.trim(find('.last-username-changes .list-group-item').text());
+    assert.equal(listMessage, 'Your username was never changed.');
+  });
+});
+
+test('/options/change-username form handles backend error', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 403,
+    responseText: {
+      'detail': 'Nope!'
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': []
+    }
+  });
+
+  assert.expect(3);
+
+  visit('/options/change-username/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+
+    var errorMessage = Ember.$.trim(find('.error-message p').text());
+    assert.equal(errorMessage, 'Nope!');
+
+    var listMessage = Ember.$.trim(find('.last-username-changes .list-group-item').text());
+    assert.equal(listMessage, 'Your username was never changed.');
+  });
+});
+
+test('/options/change-username disallows username change', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  var nextOn = moment();
+  nextOn.add(7, 'days');
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    responseText: {
+      'length_min': 2,
+      'length_max': 20,
+      'changes_left': 0,
+      'next_on': nextOn.format()
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': []
+    }
+  });
+
+  assert.expect(4);
+
+  visit('/options/change-username/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+
+    var errorMessage = Ember.$.trim(find('.panel-body p').first().text());
+    assert.equal(errorMessage, "You can't change your username now.");
+
+    var expiresMessage = Ember.$.trim(find('.panel-body p').last().text());
+    assert.equal(expiresMessage, 'Next change will be possible in 7 days.');
+
+    var listMessage = Ember.$.trim(find('.last-username-changes .list-group-item').text());
+    assert.equal(listMessage, 'Your username was never changed.');
+  });
+});
+
+test('/options/change-username changes username', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  var nextOn = moment();
+  nextOn.add(7, 'days');
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    type: 'GET',
+    responseText: {
+      'length_min': 2,
+      'length_max': 20,
+      'changes_left': 3,
+      'next_on': null
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    type: 'POST',
+    responseText: {
+      'username': 'NewName',
+      'slug': 'newname',
+      'options': {
+        'length_min': 2,
+        'length_max': 20,
+        'changes_left': 3,
+        'next_on': null
+      }
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': []
+    }
+  });
+
+  assert.expect(4);
+
+  visit('/options/change-username/');
+  fillIn('#id_username', 'NewName');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+    assert.equal(getToastMessage(), 'Your username has been changed.');
+
+    var listedUsername = Ember.$.trim(find('.last-username-changes .item-name').text());
+    assert.equal(listedUsername, 'NewName');
+
+    assert.ok(find('.last-username-changes').text().indexOf('BobBoberson') !== -1);
+  });
+});
+
+test('/options/change-username handles API error', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  var nextOn = moment();
+  nextOn.add(7, 'days');
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    type: 'GET',
+    responseText: {
+      'length_min': 2,
+      'length_max': 20,
+      'changes_left': 3,
+      'next_on': null
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 400,
+    type: 'POST',
+    responseText: {
+      'detail': 'Not good new name.'
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': []
+    }
+  });
+
+  assert.expect(3);
+
+  visit('/options/change-username/');
+  fillIn('#id_username', 'NewName');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+    assert.equal(getToastMessage(), 'Not good new name.');
+    assert.equal(find('.last-username-changes').text().indexOf('NewName'), -1);
+  });
+});
+
+test('/options/change-username handles empty form submit', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  var nextOn = moment();
+  nextOn.add(7, 'days');
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    type: 'GET',
+    responseText: {
+      'length_min': 2,
+      'length_max': 20,
+      'changes_left': 3,
+      'next_on': null
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': []
+    }
+  });
+
+  assert.expect(2);
+
+  visit('/options/change-username/');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+    assert.equal(getToastMessage(), 'Enter new username.');
+  });
+});
+
+test('/options/change-username handles too long username', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  var nextOn = moment();
+  nextOn.add(7, 'days');
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    type: 'GET',
+    responseText: {
+      'length_min': 2,
+      'length_max': 5,
+      'changes_left': 3,
+      'next_on': null
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': []
+    }
+  });
+
+  assert.expect(3);
+
+  visit('/options/change-username/');
+  fillIn('#id_username', 'NewNameTooLong');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+    assert.equal(getToastMessage(), 'Form contains errors.');
+
+    var validationMessage = Ember.$.trim(find('.panel-form .form-group .help-block.errors').text());
+    assert.equal(validationMessage, 'Username cannot be longer than 5 characters.');
+  });
+});
+
+test('/options/change-username handles too short username', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  var nextOn = moment();
+  nextOn.add(7, 'days');
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    type: 'GET',
+    responseText: {
+      'length_min': 12,
+      'length_max': 25,
+      'changes_left': 3,
+      'next_on': null
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': []
+    }
+  });
+
+  assert.expect(3);
+
+  visit('/options/change-username/');
+  fillIn('#id_username', 'TooShort');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+    assert.equal(getToastMessage(), 'Form contains errors.');
+
+    var validationMessage = Ember.$.trim(find('.panel-form .form-group .help-block.errors').text());
+    assert.equal(validationMessage, 'Username must be at least 12 characters long.');
+  });
+});
+
+test('/options/change-username handles invalid username', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  var nextOn = moment();
+  nextOn.add(7, 'days');
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    type: 'GET',
+    responseText: {
+      'length_min': 2,
+      'length_max': 25,
+      'changes_left': 3,
+      'next_on': null
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': []
+    }
+  });
+
+  assert.expect(3);
+
+  visit('/options/change-username/');
+  fillIn('#id_username', 'us3rn#me');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+    assert.equal(getToastMessage(), 'Form contains errors.');
+
+    var validationMessage = Ember.$.trim(find('.panel-form .form-group .help-block.errors').text());
+    assert.equal(validationMessage, 'Username can only contain latin alphabet letters and digits.');
+  });
+});
+
+test('/options/change-username handles same username', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  var nextOn = moment();
+  nextOn.add(7, 'days');
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    type: 'GET',
+    responseText: {
+      'length_min': 2,
+      'length_max': 25,
+      'changes_left': 3,
+      'next_on': null
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': []
+    }
+  });
+
+  assert.expect(3);
+
+  visit('/options/change-username/');
+  fillIn('#id_username', user.get('username'));
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+    assert.equal(getToastMessage(), 'Form contains errors.');
+
+    var validationMessage = Ember.$.trim(find('.panel-form .form-group .help-block.errors').text());
+    assert.equal(validationMessage, 'New username is same as current one.');
+  });
+});
+
+test('/options/change-username displays filtered history', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  var nextOn = moment();
+  nextOn.add(7, 'days');
+
+  Ember.$.mockjax({
+    url: '/api/users/' + user.id + '/username/',
+    status: 200,
+    type: 'GET',
+    responseText: {
+      'length_min': 2,
+      'length_max': 25,
+      'changes_left': 3,
+      'next_on': null
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/username-changes/',
+    status: 200,
+    responseText: {
+      'count': 0,
+      'next': null,
+      'previous': null,
+      'results': [
+        {
+            "id": 26,
+            "user": {
+                "id": 42,
+                "username": "LoremIpsum",
+                "slug": "loremipsum",
+                "avatar_hash": "b03dc23d"
+            },
+            "changed_by": {
+                "id": 42,
+                "username": "LoremIpsum",
+                "slug": "loremipsum",
+                "avatar_hash": "b03dc23d"
+            },
+            "changed_by_username": "LoremIpsum",
+            "changed_by_slug": "loremipsum",
+            "changed_on": moment().format(),
+            "new_username": "GoodName",
+            "old_username": "BobBoberson"
+        },
+        {
+            "id": 27,
+            "user": {
+                "id": 40,
+                "username": "LoremIpsum",
+                "slug": "loremipsum",
+                "avatar_hash": "b03dc23d"
+            },
+            "changed_by": {
+                "id": 42,
+                "username": "LoremIpsum",
+                "slug": "loremipsum",
+                "avatar_hash": "b03dc23d"
+            },
+            "changed_by_username": "LoremIpsum",
+            "changed_by_slug": "loremipsum",
+            "changed_on": moment().format(),
+            "new_username": "WrongName",
+            "old_username": "BobBoberson"
+        }
+      ]
+    }
+  });
+
+  assert.expect(4);
+
+  visit('/options/change-username/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.username');
+    assert.equal(find('.last-username-changes .list-group-item').length, 1);
+
+    assert.ok(find('.last-username-changes').text().indexOf('GoodName') !== -1);
+    assert.equal(find('.last-username-changes').text().indexOf('WrongName'), -1);
+  });
+});

+ 3 - 0
misago/emberapp/tests/helpers/create-user.js

@@ -90,5 +90,8 @@ export default function createUser(newProps) {
     user.setProperties(newProps);
   }
 
+  // make sure ID is string
+  user.set('id', user.get('id').toString());
+
   return user;
 }

+ 5 - 0
misago/users/api/userendpoints/signature.py

@@ -1,3 +1,4 @@
+from django.core.exceptions import PermissionDenied
 from django.utils.translation import ugettext as _
 
 from rest_framework import status
@@ -13,6 +14,10 @@ from misago.users.signatures import is_user_signature_valid, set_user_signature
 def signature_endpoint(request):
     user = request.user
 
+    if not user.acl['can_have_signature']:
+        raise PermissionDenied(
+            _("You don't have permission to change signature."))
+
     if user.is_signature_locked:
         if user.signature_lock_user_message:
             reason = format_plaintext_for_html(

+ 63 - 0
misago/users/api/userendpoints/username.py

@@ -0,0 +1,63 @@
+from django.core.exceptions import PermissionDenied
+from django.db import IntegrityError
+from django.utils.translation import ugettext as _
+
+from rest_framework import status
+from rest_framework.response import Response
+
+from misago.conf import settings
+
+from misago.users.namechanges import UsernameChanges
+from misago.users.forms.rename import ChangeUsernameForm
+
+
+def username_endpoint(request):
+    if request.method == 'POST':
+        return change_username(request)
+    else:
+        return options_response(get_username_options(request.user))
+
+
+def get_username_options(user):
+    options = UsernameChanges(user)
+    return {
+        'changes_left': options.left,
+        'next_on': options.next_on,
+        'length_min': settings.username_length_min,
+        'length_max': settings.username_length_max,
+    }
+
+
+def options_response(options):
+    if options['next_on']:
+        options['next_on'] = options['next_on'].isoformat()
+    return Response(options)
+
+
+def change_username(request):
+    options = get_username_options(request.user)
+    if not options['changes_left']:
+        return Response({
+            'detail': _("You can't change your username now."),
+            'options': options
+        },
+        status=status.HTTP_400_BAD_REQUEST)
+
+    form = ChangeUsernameForm(request.data, user=request.user)
+    if form.is_valid():
+        try:
+            form.change_username(changed_by=request.user)
+            return Response({
+                'username': request.user.username,
+                'slug': request.user.slug,
+                'options': get_username_options(request.user)
+            })
+        except IntegrityError:
+            return Response({
+                'detail': _("Error changing username. Please try again."),
+                'options': options
+            },
+            status=status.HTTP_400_BAD_REQUEST)
+    else:
+        return Response({'detail': form.non_field_errors()[0]},
+                        status=status.HTTP_400_BAD_REQUEST)

+ 41 - 0
misago/users/api/usernamechanges.py

@@ -0,0 +1,41 @@
+from django.core.exceptions import PermissionDenied
+from django.utils.translation import ugettext as _
+
+from rest_framework import status, viewsets, mixins
+from rest_framework.response import Response
+
+from misago.users.models import UsernameChange
+from misago.users.rest_permissions import BasePermission
+from misago.users.serializers.usernamechange import UsernameChangeSerializer
+
+
+class UsernameChangesViewSetPermission(BasePermission):
+    def has_permission(self, request, view):
+        try:
+            user_id = int(request.GET.get('user'))
+        except (ValueError, TypeError):
+            user_id = -1
+
+        if user_id == request.user.pk:
+            return True
+        elif not request.user.acl.get('can_see_users_name_history'):
+            raise PermissionDenied(_("You don't have permission to "
+                                     "see other users name history."))
+        return True
+
+
+class UsernameChangesViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
+    permission_classes = (UsernameChangesViewSetPermission,)
+    serializer_class = UsernameChangeSerializer
+    queryset = UsernameChange.objects
+    paginate_by = 20
+
+    def get_queryset(self):
+        queryset = UsernameChange.objects.select_related('user', 'changed_by')
+        user = self.request.query_params.get('user', None)
+        if user is not None:
+            try:
+                queryset = queryset.filter(user_id=int(user))
+            except (ValueError, TypeError):
+                queryset = queryset.none()
+        return queryset.order_by('-id')

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

@@ -11,9 +11,12 @@ from misago.users.rest_permissions import (BasePermission,
     IsAuthenticatedOrReadOnly, UnbannedAnonOnly)
 from misago.users.forms.options import ForumOptionsForm
 
+from misago.users.serializers import UserSerializer
+
 from misago.users.api.userendpoints.avatar import avatar_endpoint
 from misago.users.api.userendpoints.create import create_endpoint
 from misago.users.api.userendpoints.signature import signature_endpoint
+from misago.users.api.userendpoints.username import username_endpoint
 
 
 class UserViewSetPermission(BasePermission):
@@ -36,6 +39,7 @@ def allow_self_only(user, pk, message):
 class UserViewSet(viewsets.GenericViewSet):
     permission_classes = (UserViewSetPermission,)
     parser_classes=(JSONParser, MultiPartParser)
+    serializer_class = UserSerializer
     queryset = get_user_model().objects.all()
 
     def list(self, request):
@@ -66,12 +70,15 @@ class UserViewSet(viewsets.GenericViewSet):
             return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
 
     @detail_route(methods=['get', 'post'])
-    def signature(self, request, pk=None):
-        message = _("You can't change other users signatures.")
-        allow_self_only(request.user, pk, message)
+    def username(self, request, pk=None):
+        allow_self_only(
+            request.user, pk, _("You can't change other users names."))
+
+        return username_endpoint(request)
 
-        if not request.user.acl['can_have_signature']:
-            raise PermissionDenied(
-                _("You don't have permission to change signature."))
+    @detail_route(methods=['get', 'post'])
+    def signature(self, request, pk=None):
+        allow_self_only(
+            request.user, pk, _("You can't change other users signatures."))
 
         return signature_endpoint(request)

+ 7 - 8
misago/users/forms/rename.py

@@ -4,8 +4,7 @@ from misago.users.validators import validate_username
 
 
 class ChangeUsernameForm(forms.Form):
-    new_username = forms.CharField(label=_("New username"), max_length=200,
-                                   required=False)
+    username = forms.CharField(max_length=200, required=False)
 
     def __init__(self, *args, **kwargs):
         self.user = kwargs.pop('user', None)
@@ -13,20 +12,20 @@ class ChangeUsernameForm(forms.Form):
 
     def clean(self):
         data = super(ChangeUsernameForm, self).clean()
-        new_username = data.get('new_username')
+        username = data.get('username')
 
-        if not new_username:
+        if not username:
             raise forms.ValidationError(_("Enter new username."))
 
-        if new_username == self.user.username:
+        if username == self.user.username:
             message = _("New username is same as current one.")
             raise forms.ValidationError(message)
 
-        validate_username(new_username, exclude=self.user)
+        validate_username(username, exclude=self.user)
 
         return data
 
     def change_username(self, changed_by):
-        self.user.set_username(self.cleaned_data['new_username'],
-                          changed_by=changed_by)
+        self.user.set_username(
+            self.cleaned_data['username'], changed_by=changed_by)
         self.user.save(update_fields=['username', 'slug'])

+ 2 - 2
misago/users/namechanges.py

@@ -30,9 +30,9 @@ class UsernameChanges(object):
         else:
             self.left = name_changes_allowed - used_changes
 
-        if name_changes_expire:
+        if not self.left and name_changes_expire:
             try:
-                self.next_on = valid_changes_qs.latest()
+                self.next_on = valid_changes_qs.latest().changed_on
                 self.next_on += timedelta(days=name_changes_expire)
             except UsernameChange.DoesNotExist:
                 pass

+ 1 - 1
misago/users/permissions/account.py

@@ -57,7 +57,7 @@ def build_acl(acl, roles, key_name):
 
     return algebra.sum_acls(new_acl, roles=roles, key=key_name,
         name_changes_allowed=algebra.greater,
-        name_changes_expire=algebra.lower,
+        name_changes_expire=algebra.lower_non_zero,
         can_have_signature=algebra.greater,
         allow_signature_links=algebra.greater,
         allow_signature_images=algebra.greater,

+ 22 - 0
misago/users/serializers/usernamechange.py

@@ -0,0 +1,22 @@
+from rest_framework import serializers
+
+from misago.users.models import UsernameChange
+from misago.users.serializers.user import BasicUserSerializer
+
+
+class UsernameChangeSerializer(serializers.ModelSerializer):
+    user = BasicUserSerializer(many=False, read_only=True)
+    changed_by = BasicUserSerializer(many=False, read_only=True)
+
+    class Meta:
+        model = UsernameChange
+        fields = (
+            'id',
+            'user',
+            'changed_by',
+            'changed_by_username',
+            'changed_by_slug',
+            'changed_on',
+            'new_username',
+            'old_username'
+        )

+ 100 - 0
misago/users/tests/test_user_username_api.py

@@ -0,0 +1,100 @@
+import json
+
+from django.contrib.auth import get_user_model
+
+from misago.acl.testutils import override_acl
+from misago.conf import settings
+
+from misago.users.testutils import AuthenticatedUserTestCase
+
+
+class UserUsernameTests(AuthenticatedUserTestCase):
+    """
+    tests for user change name RPC (POST to /api/users/1/username/)
+    """
+    def setUp(self):
+        super(UserUsernameTests, self).setUp()
+        self.link = '/api/users/%s/username/' % self.user.pk
+
+    def test_get_change_username_options(self):
+        """get to API returns options"""
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(response.content)
+
+        self.assertIsNotNone(response_json['changes_left'])
+        self.assertEqual(response_json['length_min'],
+                         settings.username_length_min)
+        self.assertEqual(response_json['length_max'],
+                         settings.username_length_max)
+        self.assertIsNone(response_json['next_on'])
+
+        for i in xrange(response_json['changes_left']):
+            self.user.set_username('NewName%s' % i, self.user)
+
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['changes_left'], 0)
+        self.assertIsNotNone(response_json['next_on'])
+
+    def test_change_username_no_changes_left(self):
+        """api returns error 400 if there are no username changes left"""
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(response.content)
+        for i in xrange(response_json['changes_left']):
+            self.user.set_username('NewName%s' % i, self.user)
+
+        response = self.client.get(self.link)
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['changes_left'], 0)
+
+        response = self.client.post(self.link, data={
+            'username': 'Pointless'
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('change your username now', response.content)
+        self.assertTrue(self.user.username != 'Pointless')
+
+    def test_change_username_no_input(self):
+        """api returns error 400 if new username is empty"""
+        response = self.client.post(self.link, data={})
+
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('Enter new username.', response.content)
+
+    def test_change_username_invalid_name(self):
+        """api returns error 400 if new username is wrong"""
+        response = self.client.post(self.link, data={
+            'username': '####'
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('can only contain latin', response.content)
+
+    def test_change_username(self):
+        """api changes username and records change"""
+        response = self.client.get(self.link)
+        changes_left = json.loads(response.content)['changes_left']
+
+        username = self.user.username
+        new_username = 'NewUsernamu'
+
+        response = self.client.post(self.link, data={
+            'username': new_username
+        })
+
+        self.assertEqual(response.status_code, 200)
+        options = json.loads(response.content)['options']
+        self.assertEqual(changes_left, options['changes_left'] + 1)
+
+        self.reload_user()
+        self.assertEqual(self.user.username, new_username)
+
+        self.assertEqual(self.user.namechanges.last().new_username,
+                         new_username)

+ 0 - 25
misago/users/tests/test_usercp_views.py

@@ -12,31 +12,6 @@ from misago.users.avatars import store
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-class ChangeUsernameTests(AuthenticatedUserTestCase):
-    def setUp(self):
-        super(ChangeUsernameTests, self).setUp()
-        self.view_link = reverse('misago:usercp_change_username')
-
-    def test_change_username_get(self):
-        """GET to usercp change username view returns 200"""
-        response = self.client.get(self.view_link)
-        self.assertEqual(response.status_code, 200)
-        self.assertIn('Change username', response.content)
-
-    def test_change_username_post(self):
-        """POST to usercp change username view returns 302"""
-        response = self.client.post(self.view_link,
-                                    data={'new_username': 'Boberson'})
-        self.assertEqual(response.status_code, 302)
-
-        test_user = get_user_model().objects.get(pk=self.user.pk)
-        self.assertEqual(test_user.username, 'Boberson')
-
-        response = self.client.get(self.view_link)
-        self.assertEqual(response.status_code, 200)
-        self.assertIn(test_user.username, response.content)
-
-
 class ChangeEmailPasswordTests(AuthenticatedUserTestCase):
     def setUp(self):
         super(ChangeEmailPasswordTests, self).setUp()

+ 44 - 0
misago/users/tests/test_usernamechanges_api.py

@@ -0,0 +1,44 @@
+from django.contrib.auth import get_user_model
+
+from misago.acl.testutils import override_acl
+
+from misago.users.testutils import AuthenticatedUserTestCase
+
+
+class UsernameChangesApiTests(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(UsernameChangesApiTests, self).setUp()
+        self.link = '/api/username-changes/'
+
+    def test_user_can_always_see_his_name_changes(self):
+        """list returns own username changes"""
+        self.user.set_username('NewUsername', self.user)
+
+        override_acl(self.user, {'can_see_users_name_history': False})
+
+        response = self.client.get('%s?user=%s' % (self.link, self.user.pk))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(self.user.username, response.content)
+
+    def test_list_handles_invalid_filter(self):
+        """list returns no username changes for invalid filter"""
+        self.user.set_username('NewUsername', self.user)
+
+        override_acl(self.user, {'can_see_users_name_history': True})
+
+        response = self.client.get('%s?user=abcd' % self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('[]', response.content)
+
+    def test_list_denies_permission(self):
+        """list denies permission for other user (or all) if no access"""
+        override_acl(self.user, {'can_see_users_name_history': False})
+
+        response = self.client.get(
+            '%s?user=%s' % (self.link, self.user.pk + 1))
+        self.assertEqual(response.status_code, 403)
+        self.assertIn("don't have permission to", response.content)
+
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 403)
+        self.assertIn("don't have permission to", response.content)

+ 2 - 0
misago/users/urls/api.py

@@ -1,6 +1,7 @@
 from django.conf.urls import patterns, url
 from misago.core.apirouter import MisagoApiRouter
 from misago.users.api.users import UserViewSet
+from misago.users.api.usernamechanges import UsernameChangesViewSet
 
 
 urlpatterns = patterns('misago.users.api.auth',
@@ -17,4 +18,5 @@ urlpatterns += patterns('misago.users.api.captcha',
 
 router = MisagoApiRouter()
 router.register(r'users', UserViewSet)
+router.register(r'username-changes', UsernameChangesViewSet)
 urlpatterns += router.urls