Browse Source

Reimplement user module using services

* Forms have no logic other than transforming data into a change object
* Services for changing email, password, settings and details
* Validation logic is extracted into helper classes
* Controllers simplified
* User controllers tested
* Image based tests are now handled in memory rather than using the network
* Line length is now enforced at 88 characters to prevent churn from black
* Helpers for building dynamic navbars in plugin hooks
  - flaskbb.display.navigation module
  - flaskbb_tpl_profile_sidebar_links hook
* New hooks:
  - flaskbb_gather_password_validators
  - flaskbb_gather_email_validators
  - flaskbb_gather_details_update_validators
  - flaskbb_password_updated
  - flaskbb_email_updated
  - flaskbb_details_updated
  - flaskbb_settings_updated
  - flaskbb_tpl_profile_sidebar_links
* Gender is now a text field rather than a dropdown
Alec Nikolas Reiter 6 years ago
parent
commit
ed66fd8f7f
52 changed files with 2553 additions and 330 deletions
  1. 10 0
      CHANGES
  2. 28 0
      docs/development/api/changesets.rst
  3. 22 0
      docs/development/api/display.rst
  4. 3 0
      docs/development/api/index.rst
  5. 47 0
      docs/development/api/userprofiles.rst
  6. 10 1
      docs/development/hooks/event.rst
  7. 1 0
      docs/development/hooks/template.rst
  8. 2 0
      flaskbb/app.py
  9. 98 0
      flaskbb/core/changesets.py
  10. 15 0
      flaskbb/core/exceptions.py
  11. 0 0
      flaskbb/core/user/__init__.py
  12. 94 0
      flaskbb/core/user/update.py
  13. 3 0
      flaskbb/deprecation.py
  14. 0 0
      flaskbb/display/__init__.py
  15. 110 0
      flaskbb/display/navigation.py
  16. 20 10
      flaskbb/management/forms.py
  17. 201 2
      flaskbb/plugins/spec.py
  18. 8 6
      flaskbb/plugins/utils.py
  19. 42 2
      flaskbb/templates/macros.html
  20. 0 28
      flaskbb/templates/user/all_posts.html
  21. 0 28
      flaskbb/templates/user/all_topics.html
  22. 1 1
      flaskbb/templates/user/profile.html
  23. 10 34
      flaskbb/templates/user/profile_layout.html
  24. 1 1
      flaskbb/templates/user/settings_layout.html
  25. 83 78
      flaskbb/user/forms.py
  26. 71 6
      flaskbb/user/plugins.py
  27. 0 0
      flaskbb/user/services/__init__.py
  28. 91 0
      flaskbb/user/services/factories.py
  29. 92 0
      flaskbb/user/services/update.py
  30. 102 0
      flaskbb/user/services/validators.py
  31. 124 76
      flaskbb/user/views.py
  32. 8 0
      flaskbb/utils/database.py
  33. 4 2
      requirements-test.txt
  34. 1 0
      tests/conftest.py
  35. 8 3
      tests/fixtures/app.py
  36. 76 0
      tests/fixtures/helpers.py
  37. BIN
      tests/fixtures/images/good_image.png
  38. BIN
      tests/fixtures/images/image.gif
  39. BIN
      tests/fixtures/images/image.jpg
  40. BIN
      tests/fixtures/images/image.png
  41. BIN
      tests/fixtures/images/too_big.gif
  42. BIN
      tests/fixtures/images/too_tall.png
  43. BIN
      tests/fixtures/images/too_wide.png
  44. 166 0
      tests/fixtures/images/wrong_mime.svg
  45. 338 0
      tests/unit/user/test_controllers.py
  46. 211 0
      tests/unit/user/test_forms.py
  47. 78 0
      tests/unit/user/test_update_details_handler.py
  48. 79 0
      tests/unit/user/test_update_email_handler.py
  49. 73 0
      tests/unit/user/test_update_password_handler.py
  50. 51 0
      tests/unit/user/test_update_settings.py
  51. 115 0
      tests/unit/user/test_update_validator.py
  52. 56 52
      tests/unit/utils/test_helpers.py

+ 10 - 0
CHANGES

@@ -3,6 +3,16 @@ Changelog
 
 
 Here you can see the full list of changes between each release.
 Here you can see the full list of changes between each release.
 
 
+Version 2.1.0
+-------------
+* Reimplemented User views using services
+* Services for changing email, password, settings and details
+* Hooks for email, password, settings and details updates
+* Hook for user profile sidebar links
+* Added helper for generating dynamic navbar content
+* Gender is now a text field rather than a dropdown
+
+
 Version 2.0.2
 Version 2.0.2
 -------------
 -------------
 
 

+ 28 - 0
docs/development/api/changesets.rst

@@ -0,0 +1,28 @@
+.. _changesets:
+
+Change Sets
+===========
+
+Change sets represent a transition from one state of a model to another. There
+is no change set base class, rather change sets are a collection of attributes
+representing the state change.
+
+However, there are several assisting classes around them.
+
+Interfaces
+----------
+
+.. autoclass:: flaskbb.core.changesets.ChangeSetHandler
+    :members:
+.. autoclass:: flaskbb.core.changesets.ChangeSetValidator
+    :members:
+.. autoclass:: flaskbb.core.changesets.ChangeSetPostProcessor
+    :members:
+
+Helpers
+-------
+
+.. autoclass:: flaskbb.core.changesets.EmptyValue
+    :members:
+
+.. autofunction:: flaskbb.core.changesets.is_empty

+ 22 - 0
docs/development/api/display.rst

@@ -0,0 +1,22 @@
+.. _displayapi:
+
+Display
+=======
+
+FlaskBB exposes a handful of helpers for building dynamic content to be rendered
+into templates.
+
+Navigation
+----------
+
+.. module:: flaskbb.display.navigation
+
+.. autoclass:: NavigationContentType
+    :members:
+    :undoc-members:
+
+.. autoclass:: NavigationItem
+.. autoclass:: NavigationLink
+.. autoclass:: NavigationExternalLink
+.. autoclass:: NavigationHeader
+.. autoclass:: NavigationDivider

+ 3 - 0
docs/development/api/index.rst

@@ -11,8 +11,11 @@ and provided implementations where appropriate.
 
 
    coreexceptions
    coreexceptions
    models
    models
+   changesets
    registration
    registration
+   userprofiles
    authentication
    authentication
    accountmanagement
    accountmanagement
    tokens
    tokens
    deprecations
    deprecations
+   display

+ 47 - 0
docs/development/api/userprofiles.rst

@@ -0,0 +1,47 @@
+.. _userprofiles:
+
+User Profiles
+=============
+
+FlaskBB exposes several interfaces, hooks and validators to customize
+user profile updates, as well as several implementations for these. For
+details on the hooks see :ref:`hooks`
+
+
+
+Change Sets
+-----------
+
+
+.. autoclass:: flaskbb.core.user.update.UserDetailsChange
+
+.. autoclass:: flaskbb.core.user.update.PasswordUpdate
+
+.. autoclass:: flaskbb.core.user.update.EmailUpdate
+
+.. autoclass:: flaskbb.core.user.update.SettingsUpdate
+
+Implementations
+---------------
+
+Services
+~~~~~~~~
+
+.. autoclass:: flaskbb.user.services.update.DefaultDetailsUpdateHandler
+
+.. autoclass:: flaskbb.user.services.update.DefaultPasswordUpdateHandler
+
+.. autoclass:: flaskbb.user.services.update.DefaultEmailUpdateHandler
+
+.. autoclass:: flaskbb.user.services.update.DefaultSettingsUpdateHandler
+
+
+Validators
+~~~~~~~~~~
+
+.. autoclass:: flaskbb.user.services.validators.CantShareEmailValidator
+.. autoclass:: flaskbb.user.services.validators.OldEmailMustMatch
+.. autoclass:: flaskbb.user.services.validators.EmailsMustBeDifferent
+.. autoclass:: flaskbb.user.services.validators.PasswordsMustBeDifferent
+.. autoclass:: flaskbb.user.services.validators.OldPasswordMustMatch
+.. autoclass:: flaskbb.user.services.validators.ValidateAvatarURL

+ 10 - 1
docs/development/hooks/event.rst

@@ -21,7 +21,6 @@ Registration Events
 .. autofunction:: flaskbb_registration_post_processor
 .. autofunction:: flaskbb_registration_post_processor
 .. autofunction:: flaskbb_registration_failure_handler
 .. autofunction:: flaskbb_registration_failure_handler
 
 
-
 Authentication Events
 Authentication Events
 ---------------------
 ---------------------
 
 
@@ -32,3 +31,13 @@ Authentication Events
 .. autofunction:: flaskbb_post_reauth
 .. autofunction:: flaskbb_post_reauth
 .. autofunction:: flaskbb_reauth_failed
 .. autofunction:: flaskbb_reauth_failed
 
 
+Profile Edit Events
+-------------------
+
+.. autofunction:: flaskbb_gather_password_validators
+.. autofunction:: flaskbb_password_updated
+.. autofunction:: flaskbb_gather_email_validators
+.. autofunction:: flaskbb_email_updated
+.. autofunction:: flaskbb_gather_details_update_validators
+.. autofunction:: flaskbb_details_updated
+.. autofunction:: flaskbb_settings_updated

+ 1 - 0
docs/development/hooks/template.rst

@@ -25,6 +25,7 @@ Template Hooks
 .. autofunction:: flaskbb_tpl_form_new_topic_after
 .. autofunction:: flaskbb_tpl_form_new_topic_after
 .. autofunction:: flaskbb_tpl_profile_settings_menu
 .. autofunction:: flaskbb_tpl_profile_settings_menu
 .. autofunction:: flaskbb_tpl_profile_sidebar_stats
 .. autofunction:: flaskbb_tpl_profile_sidebar_stats
+.. autofunction:: flaskbb_tpl_profile_sidebar_links
 .. autofunction:: flaskbb_tpl_post_author_info_before
 .. autofunction:: flaskbb_tpl_post_author_info_before
 .. autofunction:: flaskbb_tpl_post_author_info_after
 .. autofunction:: flaskbb_tpl_post_author_info_after
 .. autofunction:: flaskbb_tpl_post_content_before
 .. autofunction:: flaskbb_tpl_post_content_before

+ 2 - 0
flaskbb/app.py

@@ -99,6 +99,7 @@ from .deprecation import FlaskBBDeprecation
 from .forum import views as forum_views  # noqa
 from .forum import views as forum_views  # noqa
 from .management import views as management_views  # noqa
 from .management import views as management_views  # noqa
 from .user import views as user_views  # noqa
 from .user import views as user_views  # noqa
+from .display.navigation import NavigationContentType
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -320,6 +321,7 @@ def configure_template_filters(app):
     app.jinja_env.filters.update(filters)
     app.jinja_env.filters.update(filters)
 
 
     app.jinja_env.globals["run_hook"] = template_hook
     app.jinja_env.globals["run_hook"] = template_hook
+    app.jinja_env.globals["NavigationContentType"] = NavigationContentType
 
 
     app.pluggy.hook.flaskbb_jinja_directives(app=app)
     app.pluggy.hook.flaskbb_jinja_directives(app=app)
 
 

+ 98 - 0
flaskbb/core/changesets.py

@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.core.changesets
+    ~~~~~~~~~~~~~~~~~~~~~
+
+    Core interfaces for handlers, services, etc.
+
+    :copyright: (c) 2018 the FlaskBB Team
+    :license: BSD, see LICENSE for more details
+"""
+
+from abc import abstractmethod
+from inspect import isclass
+
+from .._compat import ABC
+
+empty = None
+
+
+class EmptyValue(object):
+    """
+    Represents an empty change set value when None is a valid value
+    to apply to the model.
+
+    This class is a singleton.
+    """
+
+    __slots__ = ()
+
+    def __new__(cls):
+        # hack to cut down on instances
+        global empty
+        if empty is None:
+            empty = super(EmptyValue, EmptyValue).__new__(cls)
+        return empty
+
+    def __eq__(self, other):
+        return isinstance(other, EmptyValue) or (
+            isclass(other) and issubclass(other, EmptyValue)
+        )
+
+    def __bool__(self):
+        return False
+
+    __nonzero__ = __bool__
+
+
+empty = EmptyValue()
+
+
+def is_empty(value, consider_none=False):
+    """
+    Helper to check if an arbitrary value is an EmptyValue
+    """
+    return empty == value or (consider_none and value is None)
+
+
+class ChangeSetValidator(ABC):
+    """
+    Used to validate a change set is valid to apply against a model
+    """
+
+    @abstractmethod
+    def validate(self, model, changeset):
+        """
+        May raise a :class:`~flaskbb.core.exceptions.ValidationError`
+        to signify that the changeset cannot be applied to the model.
+        Or a :class:`~flaskbb.core.exceptions.StopValidation` to immediately
+        halt all validation.
+        """
+        pass
+
+
+class ChangeSetHandler(ABC):
+    """
+    Used to apply a changeset to a model.
+    """
+
+    @abstractmethod
+    def apply_changeset(self, model, changeset):
+        """
+        Receives the current model and the changeset object, apply the
+        changeset to the model and persist the model. May raise a
+        :class:`~flaskbb.core.exceptions.StopValidation` if the changeset
+        could not be applied.
+        """
+
+
+class ChangeSetPostProcessor(ABC):
+    """
+    Used to handle actions after a change set has been persisted.
+    """
+
+    @abstractmethod
+    def post_process_changeset(self, model, changeset):
+        """
+        Used to react to a changeset's application to a model.
+        """

+ 15 - 0
flaskbb/core/exceptions.py

@@ -66,3 +66,18 @@ class PersistenceError(BaseFlaskBBError):
         except Exception:
         except Exception:
             raise PersistenceError("Couldn't save user account")
             raise PersistenceError("Couldn't save user account")
     """
     """
+
+
+def accumulate_errors(caller, validators, throw=True):
+    errors = []
+
+    for validator in validators:
+        try:
+            caller(validator)
+        except ValidationError as e:
+            errors.append((e.attribute, e.reason))
+
+    if len(errors) and throw:
+        raise StopValidation(errors)
+
+    return errors

+ 0 - 0
flaskbb/core/user/__init__.py


+ 94 - 0
flaskbb/core/user/update.py

@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.core.user.update
+    ~~~~~~~~~~~~~~~~~~~~~~~~
+
+    This modules provides services used in updating user details
+    across FlaskBB.
+
+    :copyright: (c) 2014-2018 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+
+from abc import abstractmethod
+
+import attr
+
+from ..._compat import ABC
+from ..changesets import empty, is_empty
+
+
+def _should_assign(current, new):
+    return not is_empty(new) and current != new
+
+
+@attr.s(hash=True, cmp=True, repr=True, frozen=True)
+class UserDetailsChange(object):
+    """
+    Object representing a change user details.
+    """
+
+    birthday = attr.ib(default=empty)
+    gender = attr.ib(default=empty)
+    location = attr.ib(default=empty)
+    website = attr.ib(default=empty)
+    avatar = attr.ib(default=empty)
+    signature = attr.ib(default=empty)
+    notes = attr.ib(default=empty)
+
+    def assign_to_user(self, user):
+        for (name, value) in attr.asdict(self).items():
+            if _should_assign(getattr(user, name), value):
+                setattr(user, name, value)
+
+
+@attr.s(hash=True, cmp=True, repr=False, frozen=True)
+class PasswordUpdate(object):
+    """
+    Object representing an update to a user's password.
+    """
+
+    old_password = attr.ib()
+    new_password = attr.ib()
+
+
+@attr.s(hash=True, cmp=True, repr=True, frozen=True)
+class EmailUpdate(object):
+    """
+    Object representing a change to a user's email address.
+    """
+
+    # TODO(anr): Change to str.lower once Python2 is dropped
+    old_email = attr.ib(converter=lambda x: x.lower())
+    new_email = attr.ib(converter=lambda x: x.lower())
+
+
+@attr.s(hash=True, cmp=True, repr=True, frozen=True)
+class SettingsUpdate(object):
+    """
+    Object representing an update to a user's settings.
+    """
+
+    language = attr.ib()
+    theme = attr.ib()
+
+    def assign_to_user(self, user):
+        for (name, value) in attr.asdict(self).items():
+            if _should_assign(getattr(user, name), value):
+                setattr(user, name, value)
+
+
+class UserSettingsUpdatePostProcessor(ABC):
+    """
+    Used to react to a user updating their settings. This post processor
+    recieves the user that updated their settings and the change set that was
+    applied to the user. This post processor is called after the update has
+    been persisted so further changes must be persisted separately.
+    """
+
+    @abstractmethod
+    def post_process_settings_update(self, user, settings_update):
+        """
+        This method is abstract
+        """
+        pass

+ 3 - 0
flaskbb/deprecation.py

@@ -24,6 +24,7 @@ class FlaskBBWarning(Warning):
     Base class for any warnings that FlaskBB itself needs to issue, provided
     Base class for any warnings that FlaskBB itself needs to issue, provided
     for convenient filtering.
     for convenient filtering.
     """
     """
+
     pass
     pass
 
 
 
 
@@ -37,6 +38,7 @@ class FlaskBBDeprecation(DeprecationWarning, FlaskBBWarning, ABC):
         class RemovedInPluginv3(FlaskBBDeprecation):
         class RemovedInPluginv3(FlaskBBDeprecation):
             version = (3, 0, 0)
             version = (3, 0, 0)
     """
     """
+
     version = abstractproperty(lambda self: None)
     version = abstractproperty(lambda self: None)
 
 
 
 
@@ -44,6 +46,7 @@ class RemovedInFlaskBB3(FlaskBBDeprecation):
     """
     """
     warning for features removed in FlaskBB3
     warning for features removed in FlaskBB3
     """
     """
+
     version = (3, 0, 0)
     version = (3, 0, 0)
 
 
 
 

+ 0 - 0
flaskbb/display/__init__.py


+ 110 - 0
flaskbb/display/navigation.py

@@ -0,0 +1,110 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.display.navigation
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    Helpers to create navigation elements in FlaskBB Templates
+
+    :copyright: (c) 2018 the FlaskBB Team
+    :license: BSD, see LICENSE for more details
+"""
+
+from abc import abstractproperty
+from enum import Enum
+
+import attr
+
+from .._compat import ABC
+
+__all__ = (
+    "NavigationContentType",
+    "NavigationLink",
+    "NavigationExternalLink",
+    "NavigationHeader",
+    "NavigationDivider",
+)
+
+
+class NavigationContentType(Enum):
+    """
+    Content type enum for navigation items.
+    """
+
+    link = 0
+    external_link = 1
+    header = 2
+    divider = 3
+
+
+class NavigationItem(ABC):
+    """
+    Abstract NavigationItem class. Not meant for use but provides the common
+    interface for navigation items.
+    """
+    content_type = abstractproperty(lambda: None)
+
+
+@attr.s(cmp=True, hash=True, repr=True, frozen=True, slots=True)
+class NavigationLink(NavigationItem):
+    """
+    Representation of an internal FlaskBB navigation link::
+
+        NavigationLink(
+            endpoint="user.profile",
+            name="{}'s Profile".format(user.username),
+            icon="fa fa-home",
+            active=False,  # default
+            urlforkwargs={"username": user.username}
+        )
+    """
+
+    endpoint = attr.ib()
+    name = attr.ib()
+    icon = attr.ib(default="")
+    active = attr.ib(default=False)
+    urlforkwargs = attr.ib(factory=dict)
+    content_type = NavigationContentType.link
+
+
+@attr.s(cmp=True, hash=True, repr=True, frozen=True, slots=True)
+class NavigationExternalLink(NavigationItem):
+    """
+    Representation of an external navigation link::
+
+        NavigationExternalLink(
+            uri="mailto:{}".format(user.email),
+            name="Email {}".format(user.username),
+            icon="fa fa-at"
+        )
+    """
+    uri = attr.ib()
+    name = attr.ib()
+    icon = attr.ib(default="")
+    content_type = NavigationContentType.external_link
+
+
+@attr.s(cmp=True, hash=True, repr=True, frozen=True, slots=True)
+class NavigationHeader(NavigationItem):
+    """
+    Representation of header text shown in a navigation bar::
+
+        NavigationHeader(
+            text="A header",
+            icon="fa fa-exclamation"
+        )
+    """
+
+    text = attr.ib()
+    icon = attr.ib(default="")
+    content_type = NavigationContentType.header
+
+
+@attr.s(cmp=False, hash=True, repr=True, frozen=True, slots=True)
+class NavigationDivider(NavigationItem):
+    """
+    Representation of a divider in a navigation bar::
+
+        NavigationDivider()
+    """
+
+    content_type = NavigationContentType.divider

+ 20 - 10
flaskbb/management/forms.py

@@ -14,12 +14,25 @@ from flask_allows import Permission
 from flask_babelplus import lazy_gettext as _
 from flask_babelplus import lazy_gettext as _
 from flask_wtf import FlaskForm
 from flask_wtf import FlaskForm
 from sqlalchemy.orm.session import make_transient, make_transient_to_detached
 from sqlalchemy.orm.session import make_transient, make_transient_to_detached
-from wtforms import (BooleanField, HiddenField, IntegerField, PasswordField,
-                     SelectField, StringField, SubmitField, TextAreaField)
-from wtforms.ext.sqlalchemy.fields import (QuerySelectField,
-                                           QuerySelectMultipleField)
-from wtforms.validators import (URL, DataRequired, Email, Length, Optional,
-                                ValidationError, regexp)
+from wtforms import (
+    BooleanField,
+    HiddenField,
+    IntegerField,
+    PasswordField,
+    StringField,
+    SubmitField,
+    TextAreaField,
+)
+from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
+from wtforms.validators import (
+    URL,
+    DataRequired,
+    Email,
+    Length,
+    Optional,
+    ValidationError,
+    regexp,
+)
 
 
 from flaskbb.extensions import db
 from flaskbb.extensions import db
 from flaskbb.forum.models import Category, Forum
 from flaskbb.forum.models import Category, Forum
@@ -68,10 +81,7 @@ class UserForm(FlaskForm):
     birthday = BirthdayField(_("Birthday"), format="%d %m %Y", validators=[
     birthday = BirthdayField(_("Birthday"), format="%d %m %Y", validators=[
         Optional()])
         Optional()])
 
 
-    gender = SelectField(_("Gender"), default="None", choices=[
-        ("None", ""),
-        ("Male", _("Male")),
-        ("Female", _("Female"))])
+    gender = StringField(_("Gender"), validators=[Optional()])
 
 
     location = StringField(_("Location"), validators=[
     location = StringField(_("Location"), validators=[
         Optional()])
         Optional()])

+ 201 - 2
flaskbb/plugins/spec.py

@@ -11,7 +11,7 @@
 
 
 from pluggy import HookspecMarker
 from pluggy import HookspecMarker
 
 
-spec = HookspecMarker('flaskbb')
+spec = HookspecMarker("flaskbb")
 
 
 
 
 # Setup Hooks
 # Setup Hooks
@@ -544,6 +544,158 @@ def flaskbb_form_registration(form):
     """
     """
 
 
 
 
+@spec
+def flaskbb_gather_password_validators(app):
+    """
+    Hook for gathering :class:`~flaskbb.core.changesets.ChangeSetValidator`
+    instances specialized for handling :class:`~flaskbb.core.user.update.PasswordUpdate`
+    This hook should return an iterable::
+
+        class NotLongEnough(ChangeSetValidator):
+            def __init__(self, min_length):
+                self._min_length = min_length
+
+            def validate(self, model, changeset):
+                if len(changeset.new_password) < self._min_length:
+                    raise ValidationError(
+                        "new_password",
+                        "Password must be at least {} characters ".format(
+                            self._min_length
+                        )
+                    )
+
+        @impl
+        def flaskbb_gather_password_validators(app):
+            return [NotLongEnough(app.config['MIN_PASSWORD_LENGTH'])]
+
+    :param app: The current application
+    """
+
+
+@spec
+def flaskbb_gather_email_validators(app):
+    """
+    Hook for gathering :class:`~flaskbb.core.changesets.ChangeSetValidator`
+    instances specialized for :class:`~flaskbb.core.user.update.EmailUpdate`.
+    This hook should return an iterable::
+
+        class BlackListedEmailProviders(ChangeSetValidator):
+            def __init__(self, black_list):
+                self._black_list = black_list
+
+            def validate(self, model, changeset):
+                provider = changeset.new_email.split('@')[1]
+                if provider in self._black_list:
+                    raise ValidationError(
+                        "new_email",
+                        "{} is a black listed email provider".format(provider)
+                    )
+
+        @impl
+        def flaskbb_gather_email_validators(app):
+            return [BlackListedEmailProviders(app.config["EMAIL_PROVIDER_BLACK_LIST"])]
+
+    :param app: The current application
+    """
+
+
+@spec
+def flaskbb_gather_details_update_validators(app):
+    """
+    Hook for gathering :class:`~flaskbb.core.changesets.ChangeSetValidator`
+    instances specialized for :class:`~flaskbb.core.user.update.UserDetailsChange`.
+    This hook should return an iterable::
+
+        class DontAllowImageSignatures(ChangeSetValidator):
+            def __init__(self, renderer):
+                self._renderer = renderer
+
+            def validate(self, model, changeset):
+                rendered = self._renderer.render(changeset.signature)
+                if '<img' in rendered:
+                    raise ValidationError("signature", "No images allowed in signature")
+
+        @impl
+        def flaskbb_gather_details_update_validators(app):
+            renderer = app.pluggy.hook.flaskbb_load_nonpost_markdown_class()
+            return [DontAllowImageSignatures(renderer())]
+
+    :param app: The current application
+    """
+
+
+@spec
+def flaskbb_details_updated(user, details_update):
+    """
+    Hook for responding to a user updating their details. This hook is called
+    after the details update has been persisted.
+
+    See also :class:`~flaskbb.core.changesets.ChangeSetPostProcessor`
+
+    :param user: The user whose details have been updated.
+    :param details_update: The details change set applied to the user.
+    """
+
+
+@spec
+def flaskbb_password_updated(user):
+    """
+    Hook for responding to a user updating their password. This hook is called
+    after the password change has been persisted::
+
+
+        @impl
+        def flaskbb_password_updated(app, user):
+            send_email(
+                "Password changed",
+                [user.email],
+                text_body=...,
+                html_body=...
+            )
+
+
+    See also :class:`~flaskbb.core.changesets.ChangeSetPostProcessor`
+
+    :param user: The user that updated their password.
+    """
+
+
+@spec
+def flaskbb_email_updated(user, email_update):
+    """
+    Hook for responding to a user updating their email. This hook is called after
+    the email change has been persisted::
+
+
+        @impl
+        def flaskbb_email_updated(app):
+            send_email(
+                "Email changed",
+                [email_change.old_email],
+                text_body=...,
+                html_body=...
+            )
+
+    See also :class:`~flaskbb.core.changesets.ChangeSetPostProcessor`.
+
+    :param user: The user whose email was updated.
+    :param email_update: The change set applied to the user.
+    """
+
+
+@spec
+def flaskbb_settings_updated(user, settings_update):
+    """
+    Hook for responding to a user updating their settings. This hook is called after
+    the settings change has been persisted.
+
+    See also :class:`~flaskbb.core.changesets.ChangeSetPostProcessor`
+
+    :param user: The user whose settings have been updated.
+    :param settings: The settings change set applied to the user.
+    """
+
+
 # Template Hooks
 # Template Hooks
 @spec
 @spec
 def flaskbb_tpl_navigation_before():
 def flaskbb_tpl_navigation_before():
@@ -624,7 +776,7 @@ def flaskbb_tpl_form_user_details_after(form):
 
 
 
 
 @spec
 @spec
-def flaskbb_tpl_profile_settings_menu():
+def flaskbb_tpl_profile_settings_menu(user):
     """This hook is emitted on the user settings page in order to populate the
     """This hook is emitted on the user settings page in order to populate the
     side bar menu. Implementations of this hook should return a list of tuples
     side bar menu. Implementations of this hook should return a list of tuples
     that are view name and display text. The display text will be provided to
     that are view name and display text. The display text will be provided to
@@ -650,6 +802,53 @@ def flaskbb_tpl_profile_settings_menu():
     supplies its own hookwrapper to flatten all the lists into a single list.
     supplies its own hookwrapper to flatten all the lists into a single list.
 
 
     in :file:`templates/user/settings_layout.html`
     in :file:`templates/user/settings_layout.html`
+
+    .. versionchanged:: 2.1.0
+        The user param. Typically this will be the current user but might not
+        always be the current user.
+
+    :param user: The user the settings menu is being rendered for.
+    """
+
+
+@spec
+def flaskbb_tpl_profile_sidebar_links(user):
+    """
+    This hook is emitted on the user profile page in order to populate the
+    sidebar menu. Implementations of this hook should return an iterable of
+    :class:`~flaskbb.display.navigation.NavigationItem` instances::
+
+        @impl
+        def flaskbb_tpl_profile_sidebar_links(user):
+            return [
+                NavigationLink(
+                    endpoint="user.profile",
+                    name=_("Overview"),
+                    icon="fa fa-home",
+                    urlforkwargs={"username": user.username},
+                ),
+                NavigationLink(
+                    endpoint="user.view_all_topics",
+                    name=_("Topics"),
+                    icon="fa fa-comments",
+                    urlforkwargs={"username": user.username},
+                ),
+                NavigationLink(
+                    endpoint="user.view_all_posts",
+                    name=_("Posts"),
+                    icon="fa fa-comment",
+                    urlforkwargs={"username": user.username},
+                ),
+            ]
+
+
+    .. warning::
+        Hookwrappers for this spec should not be registered as FlaskBB registers
+        its own hook wrapper to flatten all the results into a single list.
+
+    .. versionadded:: 2.1
+
+    :param user: The user the profile page belongs to.
     """
     """
 
 
 
 

+ 8 - 6
flaskbb/plugins/utils.py

@@ -10,12 +10,12 @@
     :license: BSD, see LICENSE for more details.
     :license: BSD, see LICENSE for more details.
 """
 """
 from flask import current_app, flash, redirect, url_for
 from flask import current_app, flash, redirect, url_for
-from jinja2 import Markup
 from flask_babelplus import gettext as _
 from flask_babelplus import gettext as _
+from jinja2 import Markup
 
 
 from flaskbb.extensions import db
 from flaskbb.extensions import db
-from flaskbb.utils.datastructures import TemplateEventResult
 from flaskbb.plugins.models import PluginRegistry
 from flaskbb.plugins.models import PluginRegistry
+from flaskbb.utils.datastructures import TemplateEventResult
 
 
 
 
 def template_hook(name, silent=True, is_markup=True, **kwargs):
 def template_hook(name, silent=True, is_markup=True, **kwargs):
@@ -60,7 +60,9 @@ def remove_zombie_plugins_from_db():
     Returns the names of the deleted plugins.
     Returns the names of the deleted plugins.
     """
     """
     d_fs_plugins = [p[0] for p in current_app.pluggy.list_disabled_plugins()]
     d_fs_plugins = [p[0] for p in current_app.pluggy.list_disabled_plugins()]
-    d_db_plugins = [p.name for p in PluginRegistry.query.filter_by(enabled=False).all()]  # noqa
+    d_db_plugins = [
+        p.name for p in PluginRegistry.query.filter_by(enabled=False).all()
+    ]  # noqa
 
 
     plugin_names = [p.name for p in PluginRegistry.query.all()]
     plugin_names = [p.name for p in PluginRegistry.query.all()]
 
 
@@ -70,8 +72,8 @@ def remove_zombie_plugins_from_db():
             remove_me.append(p)
             remove_me.append(p)
 
 
     if len(remove_me) > 0:
     if len(remove_me) > 0:
-        PluginRegistry.query.filter(
-            PluginRegistry.name.in_(remove_me)
-        ).delete(synchronize_session='fetch')
+        PluginRegistry.query.filter(PluginRegistry.name.in_(remove_me)).delete(
+            synchronize_session="fetch"
+        )
         db.session.commit()
         db.session.commit()
     return remove_me
     return remove_me

+ 42 - 2
flaskbb/templates/macros.html

@@ -308,12 +308,52 @@
     {%- endif -%}
     {%- endif -%}
 {% endmacro %}
 {% endmacro %}
 
 
-{% macro navlink(endpoint, name, icon='', active='') %}
+{% macro navlink(endpoint, name, icon='', active='', urlforkwargs=None) %}
 <li {% if endpoint == request.endpoint or endpoint == active or active == True %}class="active"{% endif %}>
 <li {% if endpoint == request.endpoint or endpoint == active or active == True %}class="active"{% endif %}>
-    <a href="{{ url_for(endpoint) }}">{% if icon %}<i class="{{ icon }}"></i> {% endif %} {{ name }}</a>
+    <a href="{% if urlforkwargs %}{{ url_for(endpoint, **urlforkwargs) }}{% else %}{{ url_for(endpoint) }}{% endif %}">
+        {% if icon %}<i class="{{ icon }}"></i> {% endif %} {{ name }}
+    </a>
+</li>
+{% endmacro %}
+
+{% macro externalnavlink(uri, name, icon='') %}
+<li>
+    <a href="{{uri}}">{% if icon %}<i class="{{ icon }}"></i> {% endif %} {{ name }}</a>
 </li>
 </li>
 {% endmacro %}
 {% endmacro %}
 
 
+{% macro navtext(text, icon="", cls="") %}
+<li{% if cls %} class="{{ cls }}"{% endif %}><a href="#">{% if icon %}<i class="{{ icon }}"></i> {% endif %}{{ text }}</a></li>
+{% endmacro %}
+
+{% macro navheader(text, icon="", cls="nav-header") %}
+{{ navtext(text, icon, cls) }}
+{% endmacro %}
+
+{% macro navdivider() %}
+<li class="nav-divider"></li>
+{% endmacro %}
+
+{% macro sidebar(items, extra_class="") %}
+{% if items %}
+<ul class="nav {% if extra_class %}{{ extra_class }}{% endif %}">
+    {% for item in items %}
+    {% if item.content_type == NavigationContentType.link %}
+    {{ navlink(item.endpoint, item.name, item.icon, item.active, item.urlforkwargs) }}
+    {% elif item.content_type == NavigationContentType.external_link %}
+    {{ externalnavlink(item.uri, item.name, item.icon) }}
+    {% elif item.content_type == NavigationContentType.header %}
+    {{ navheader(item.text, cls="sidenav-header") }}
+    {% elif item.content_type == NavigationContentType.divider %}
+    {{ navdivider() }}
+    {% else %}
+    {# skip unknown #}
+    {% endif %}
+    {% endfor %}
+</ul>
+{% endif %}
+{% endmacro %}
+
 {% macro tablink_href(endpoint, name, active=False) %}
 {% macro tablink_href(endpoint, name, active=False) %}
 <li {% if endpoint == request.endpoint or active %}class="active"{% endif %} >
 <li {% if endpoint == request.endpoint or active %}class="active"{% endif %} >
     <a href={{ endpoint }} role="tab" data-toggle="tab">{{ name }}</a>
     <a href={{ endpoint }} role="tab" data-toggle="tab">{{ name }}</a>

+ 0 - 28
flaskbb/templates/user/all_posts.html

@@ -9,34 +9,6 @@
 </ul>
 </ul>
 {% endblock %}
 {% endblock %}
 
 
-{% block profile_navigation %}
-<ul class="nav profile-sidenav" id="profile-tabs" role="tablist">
-    <li>
-        <a href="{{ user.url }}">
-            <span class="fa fa-home"></span> {% trans %}Overview{% endtrans %}
-        </a>
-    </li>
-    {#
-    <li>
-        <a href="{{ user.url }}">
-            <span class="fa fa-line-chart"></span> {% trans %}Statistics{% endtrans %}
-        </a>
-    </li>
-    #}
-    <li>
-        <a href="{{ url_for('user.view_all_topics', username=user.username) }}">
-            <span class="fa fa-comments"></span> {% trans %}Topics{% endtrans %}
-        </a>
-    </li>
-
-    <li class="active">
-        <a href="{{ url_for('user.view_all_posts', username=user.username) }}">
-            <span class="fa fa-comment"></span> {% trans %}Posts{% endtrans %}
-        </a>
-    </li>
-</ul>
-{% endblock %}
-
 
 
 {% block profile_content %}
 {% block profile_content %}
 <!-- middle column -->
 <!-- middle column -->

+ 0 - 28
flaskbb/templates/user/all_topics.html

@@ -9,34 +9,6 @@
 </ul>
 </ul>
 {% endblock %}
 {% endblock %}
 
 
-{% block profile_navigation %}
-<ul class="nav profile-sidenav" id="profile-tabs" role="tablist">
-    <li>
-        <a href="{{ user.url }}">
-            <span class="fa fa-home"></span> {% trans %}Overview{% endtrans %}
-        </a>
-    </li>
-    {#
-    <li>
-        <a href="{{ user.url }}">
-            <span class="fa fa-line-chart"></span> {% trans %}Statistics{% endtrans %}
-        </a>
-    </li>
-    #}
-    <li class="active">
-        <a href="{{ url_for('user.view_all_topics', username=user.username) }}">
-            <span class="fa fa-comments"></span> {% trans %}Topics{% endtrans %}
-        </a>
-    </li>
-
-    <li>
-        <a href="{{ url_for('user.view_all_posts', username=user.username) }}">
-            <span class="fa fa-comment"></span> {% trans %}Posts{% endtrans %}
-        </a>
-    </li>
-</ul>
-{% endblock %}
-
 {% block profile_content %}
 {% block profile_content %}
 <!-- middle column -->
 <!-- middle column -->
 <div class="col-md-9 col-sm-9 col-xs-12 profile-content">
 <div class="col-md-9 col-sm-9 col-xs-12 profile-content">

+ 1 - 1
flaskbb/templates/user/profile.html

@@ -27,7 +27,7 @@
             <div class="row">
             <div class="row">
                 <div class="col-md-12 col-sm-12 co-xs-12">
                 <div class="col-md-12 col-sm-12 co-xs-12">
                     <div class="alert-message alert-message-info" role="alert">
                     <div class="alert-message alert-message-info" role="alert">
-                        {% trans %}User has not added any notes about him.{% endtrans %}
+                        {% trans %}User has not added any notes.{% endtrans %}
                     </div>
                     </div>
                 </div>
                 </div>
             </div>
             </div>

+ 10 - 34
flaskbb/templates/user/profile_layout.html

@@ -1,3 +1,4 @@
+{%- from theme("macros.html") import sidebar with context -%}
 {% extends theme("layout.html") %}
 {% extends theme("layout.html") %}
 {% set page_title = _("%(user)s - User", user=user.username) %}
 {% set page_title = _("%(user)s - User", user=user.username) %}
 
 
@@ -49,33 +50,15 @@
                             {{ run_hook("flaskbb_tpl_profile_sidebar_stats", user=user) }}
                             {{ run_hook("flaskbb_tpl_profile_sidebar_stats", user=user) }}
                         </div>
                         </div>
 
 
-                        {% block profile_navigation %}
-                        <ul class="nav profile-sidenav" id="profile-tabs" role="tablist">
-                            <li class="active">
-                                <a href="{{ user.url }}#overview" role="tab" data-toggle="tab">
-                                    <span class="fa fa-home"></span> {% trans %}Overview{% endtrans %}
-                                </a>
-                            </li>
-                            {#
-                            <li>
-                                <a href="{{ user.url }}#info" role="tab" data-toggle="tab">
-                                    <span class="fa fa-line-chart"></span> {% trans %}Statistics{% endtrans %}
-                                </a>
-                            </li>
-                            #}
-                            <li>
-                                <a href="{{ url_for('user.view_all_topics', username=user.username) }}">
-                                    <span class="fa fa-comments"></span> {% trans %}Topics{% endtrans %}
-                                </a>
-                            </li>
-
-                            <li>
-                                <a href="{{ url_for('user.view_all_posts', username=user.username) }}">
-                                    <span class="fa fa-comment"></span> {% trans %}Posts{% endtrans %}
-                                </a>
-                            </li>
-                        </ul>
-                        {% endblock %}
+                    {{ sidebar(
+                        run_hook(
+                            "flaskbb_tpl_profile_sidebar_links",
+                            user=user,
+                            is_markup=False
+                            ),
+                            extra_class="profile-sidebar"
+                        )
+                    }}
                     </div>
                     </div>
                     {% endblock %}
                     {% endblock %}
 
 
@@ -89,10 +72,3 @@
 </div> <!-- end page-view -->
 </div> <!-- end page-view -->
 
 
 {% endblock %} {# content #}
 {% endblock %} {# content #}
-
-{% block scripts %}
-    <script>
-    $('#profile-tabs a[href="#overview"]').tab('show') // Select tab by name
-    //$('#profile-tabs a[href="#info"]').tab('show') // Select tab by name
-    </script>
-{% endblock %}

+ 1 - 1
flaskbb/templates/user/settings_layout.html

@@ -14,7 +14,7 @@
     <div class="col-sm-3">
     <div class="col-sm-3">
         <div class="sidebar">
         <div class="sidebar">
             <ul class="nav sidenav">
             <ul class="nav sidenav">
-                {% for view, text in run_hook('flaskbb_tpl_profile_settings_menu', is_markup=False) %}
+                {% for view, text in run_hook('flaskbb_tpl_profile_settings_menu', is_markup=False, user=current_user) %}
                     {% if view == None %}
                     {% if view == None %}
                     <li class="sidenav-header">{{ _(text) }}</li>
                     <li class="sidenav-header">{{ _(text) }}</li>
                     {% else %}
                     {% else %}

+ 83 - 78
flaskbb/user/forms.py

@@ -9,111 +9,116 @@
     :license: BSD, see LICENSE for more details.
     :license: BSD, see LICENSE for more details.
 """
 """
 import logging
 import logging
-from flask_login import current_user
-from flask_wtf import FlaskForm
-from wtforms import (StringField, PasswordField, TextAreaField, SelectField,
-                     ValidationError, SubmitField)
-from wtforms.validators import (Length, DataRequired, InputRequired, Email,
-                                EqualTo, Optional, URL)
+
 from flask_babelplus import lazy_gettext as _
 from flask_babelplus import lazy_gettext as _
+from wtforms import PasswordField, SelectField, StringField, SubmitField, TextAreaField
+from wtforms.validators import (
+    URL,
+    DataRequired,
+    Email,
+    EqualTo,
+    InputRequired,
+    Length,
+    Optional,
+)
 
 
-from flaskbb.user.models import User
-from flaskbb.extensions import db
 from flaskbb.utils.fields import BirthdayField
 from flaskbb.utils.fields import BirthdayField
-from flaskbb.utils.helpers import check_image
+from flaskbb.utils.forms import FlaskBBForm
 
 
+from ..core.user.update import (
+    EmailUpdate,
+    PasswordUpdate,
+    SettingsUpdate,
+    UserDetailsChange,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
-class GeneralSettingsForm(FlaskForm):
+class GeneralSettingsForm(FlaskBBForm):
     # The choices for those fields will be generated in the user view
     # The choices for those fields will be generated in the user view
     # because we cannot access the current_app outside of the context
     # because we cannot access the current_app outside of the context
     language = SelectField(_("Language"))
     language = SelectField(_("Language"))
     theme = SelectField(_("Theme"))
     theme = SelectField(_("Theme"))
-
     submit = SubmitField(_("Save"))
     submit = SubmitField(_("Save"))
 
 
-
-class ChangeEmailForm(FlaskForm):
-    old_email = StringField(_("Old email address"), validators=[
-        DataRequired(message=_("A valid email address is required.")),
-        Email(message=_("Invalid email address."))])
-
-    new_email = StringField(_("New email address"), validators=[
-        InputRequired(),
-        EqualTo('confirm_new_email', message=_("Email addresses must match.")),
-        Email(message=_("Invalid email address."))])
-
-    confirm_new_email = StringField(_("Confirm email address"), validators=[
-        Email(message=_("Invalid email address."))])
-
+    def as_change(self):
+        return SettingsUpdate(language=self.language.data, theme=self.theme.data)
+
+
+class ChangeEmailForm(FlaskBBForm):
+    old_email = StringField(
+        _("Old email address"),
+        validators=[
+            DataRequired(message=_("A valid email address is required.")),
+            Email(message=_("Invalid email address.")),
+        ],
+    )
+    new_email = StringField(
+        _("New email address"),
+        validators=[
+            InputRequired(),
+            EqualTo("confirm_new_email", message=_("Email addresses must match.")),
+            Email(message=_("Invalid email address.")),
+        ],
+    )
+    confirm_new_email = StringField(
+        _("Confirm email address"),
+        validators=[Email(message=_("Invalid email address."))],
+    )
     submit = SubmitField(_("Save"))
     submit = SubmitField(_("Save"))
 
 
     def __init__(self, user, *args, **kwargs):
     def __init__(self, user, *args, **kwargs):
         self.user = user
         self.user = user
-        kwargs['obj'] = self.user
+        kwargs["obj"] = self.user
         super(ChangeEmailForm, self).__init__(*args, **kwargs)
         super(ChangeEmailForm, self).__init__(*args, **kwargs)
 
 
-    def validate_email(self, field):
-        user = User.query.filter(db.and_(
-                                 User.email.like(field.data),
-                                 db.not_(User.id == self.user.id))).first()
-        if user:
-            raise ValidationError(_("This email address is already taken."))
-
-
-class ChangePasswordForm(FlaskForm):
-    old_password = PasswordField(_("Password"), validators=[
-        DataRequired(message=_("Please enter your password."))])
-
-    new_password = PasswordField(_('New password'), validators=[
-        InputRequired(),
-        EqualTo('confirm_new_password', message=_('New passwords must match.'))
-    ])
-
-    confirm_new_password = PasswordField(_('Confirm new password'))
-
+    def as_change(self):
+        return EmailUpdate(old_email=self.old_email.data, new_email=self.new_email.data)
+
+
+class ChangePasswordForm(FlaskBBForm):
+    old_password = PasswordField(
+        _("Password"),
+        validators=[DataRequired(message=_("Please enter your password."))],
+    )
+    new_password = PasswordField(
+        _("New password"),
+        validators=[
+            InputRequired(),
+            EqualTo("confirm_new_password", message=_("New passwords must match.")),
+        ],
+    )
+    confirm_new_password = PasswordField(_("Confirm new password"))
     submit = SubmitField(_("Save"))
     submit = SubmitField(_("Save"))
 
 
-    def validate_old_password(self, field):
-        if not current_user.check_password(field.data):
-            raise ValidationError(_("Old password is wrong."))
-
-
-class ChangeUserDetailsForm(FlaskForm):
-    birthday = BirthdayField(_("Birthday"), format="%d %m %Y", validators=[
-        Optional()])
-
-    gender = SelectField(_("Gender"), default="None", choices=[
-        ("None", ""),
-        ("Male", _("Male")),
-        ("Female", _("Female"))])
-
-    location = StringField(_("Location"), validators=[
-        Optional()])
-
-    website = StringField(_("Website"), validators=[
-        Optional(), URL()])
-
-    avatar = StringField(_("Avatar"), validators=[
-        Optional(), URL()])
-
-    signature = TextAreaField(_("Forum Signature"), validators=[
-        Optional()])
+    def as_change(self):
+        return PasswordUpdate(
+            new_password=self.new_password.data, old_password=self.old_password.data
+        )
 
 
-    notes = TextAreaField(_("Notes"), validators=[
-        Optional(), Length(min=0, max=5000)])
 
 
+class ChangeUserDetailsForm(FlaskBBForm):
+    birthday = BirthdayField(_("Birthday"), format="%d %m %Y", validators=[Optional()])
+    gender = StringField(_("Gender"), validators=[Optional()])
+    location = StringField(_("Location"), validators=[Optional()])
+    website = StringField(_("Website"), validators=[Optional(), URL()])
+    avatar = StringField(_("Avatar"), validators=[Optional(), URL()])
+    signature = TextAreaField(_("Forum Signature"), validators=[Optional()])
+    notes = TextAreaField(_("Notes"), validators=[Optional(), Length(min=0, max=5000)])
     submit = SubmitField(_("Save"))
     submit = SubmitField(_("Save"))
 
 
     def validate_birthday(self, field):
     def validate_birthday(self, field):
         if field.data is None:
         if field.data is None:
             return True
             return True
 
 
-    def validate_avatar(self, field):
-        if field.data is not None:
-            error, status = check_image(field.data)
-            if error is not None:
-                raise ValidationError(error)
-            return status
+    def as_change(self):
+        return UserDetailsChange(
+            birthday=self.birthday.data,
+            gender=self.gender.data,
+            location=self.location.data,
+            website=self.website.data,
+            avatar=self.avatar.data,
+            signature=self.signature.data,
+            notes=self.notes.data,
+        )

+ 71 - 6
flaskbb/user/plugins.py

@@ -1,7 +1,31 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.user.plugins
+    ~~~~~~~~~~~~~~~~~~~~
+
+    Plugin implementations for the FlaskBB user module.
+
+    :copyright: (c) 2018 the FlaskBB Team
+    :license: BSD, see LICENSE for details
+"""
+
 from itertools import chain
 from itertools import chain
+
+from flask_babelplus import gettext as _
 from pluggy import HookimplMarker
 from pluggy import HookimplMarker
 
 
-impl = HookimplMarker('flaskbb')
+from ..display.navigation import NavigationLink
+from .models import User
+from .services.validators import (
+    CantShareEmailValidator,
+    EmailsMustBeDifferent,
+    OldEmailMustMatch,
+    OldPasswordMustMatch,
+    PasswordsMustBeDifferent,
+    ValidateAvatarURL,
+)
+
+impl = HookimplMarker("flaskbb")
 
 
 
 
 @impl(hookwrapper=True, tryfirst=True)
 @impl(hookwrapper=True, tryfirst=True)
@@ -12,11 +36,52 @@ def flaskbb_tpl_profile_settings_menu():
     the menu
     the menu
     """
     """
     results = [
     results = [
-        (None, 'Account Settings'),
-        ('user.settings', 'General Settings'),
-        ('user.change_user_details', 'Change User Details'),
-        ('user.change_email', 'Change E-Mail Address'),
-        ('user.change_password', 'Change Password')
+        (None, "Account Settings"),
+        ("user.settings", "General Settings"),
+        ("user.change_user_details", "Change User Details"),
+        ("user.change_email", "Change E-Mail Address"),
+        ("user.change_password", "Change Password"),
+    ]
+    outcome = yield
+    outcome.force_result(chain(results, *outcome.get_result()))
+
+
+@impl(hookwrapper=True, tryfirst=True)
+def flaskbb_tpl_profile_sidebar_links(user):
+    results = [
+        NavigationLink(
+            endpoint="user.profile",
+            name=_("Overview"),
+            icon="fa fa-home",
+            urlforkwargs={"username": user.username},
+        ),
+        NavigationLink(
+            endpoint="user.view_all_topics",
+            name=_("Topics"),
+            icon="fa fa-comments",
+            urlforkwargs={"username": user.username},
+        ),
+        NavigationLink(
+            endpoint="user.view_all_posts",
+            name=_("Posts"),
+            icon="fa fa-comment",
+            urlforkwargs={"username": user.username},
+        ),
     ]
     ]
     outcome = yield
     outcome = yield
     outcome.force_result(chain(results, *outcome.get_result()))
     outcome.force_result(chain(results, *outcome.get_result()))
+
+
+@impl
+def flaskbb_gather_password_validators():
+    return [OldPasswordMustMatch(), PasswordsMustBeDifferent()]
+
+
+@impl
+def flaskbb_gather_email_validators():
+    return [OldEmailMustMatch(), EmailsMustBeDifferent(), CantShareEmailValidator(User)]
+
+
+@impl
+def flaskbb_gather_details_update_validators():
+    return [ValidateAvatarURL()]

+ 0 - 0
flaskbb/user/services/__init__.py


+ 91 - 0
flaskbb/user/services/factories.py

@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.user.services.factories
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+    Factory functions for the various FlaskBB user services.
+
+    These factories are provisional and considered private APIs.
+
+    :copyright: 2018, the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+
+from itertools import chain
+
+from flask import current_app
+from flask_login import current_user
+
+from ...extensions import db
+from ...utils.helpers import get_available_languages, get_available_themes
+from ..forms import (
+    ChangeEmailForm,
+    ChangePasswordForm,
+    ChangeUserDetailsForm,
+    GeneralSettingsForm,
+)
+from .update import (
+    DefaultDetailsUpdateHandler,
+    DefaultEmailUpdateHandler,
+    DefaultPasswordUpdateHandler,
+    DefaultSettingsUpdateHandler,
+)
+
+
+def details_update_factory():
+    validators = list(
+        chain.from_iterable(
+            current_app.pluggy.hook.flaskbb_gather_details_update_validators(
+                app=current_app
+            )
+        )
+    )
+    return DefaultDetailsUpdateHandler(db, current_app.pluggy, validators)
+
+
+def password_update_handler():
+    validators = list(
+        chain.from_iterable(
+            current_app.pluggy.hook.flaskbb_gather_password_validators(app=current_app)
+        )
+    )
+
+    return DefaultPasswordUpdateHandler(db, current_app.pluggy, validators)
+
+
+def email_update_handler():
+    validators = list(
+        chain.from_iterable(
+            current_app.pluggy.hook.flaskbb_gather_email_validators(app=current_app)
+        )
+    )
+
+    return DefaultEmailUpdateHandler(db, current_app.pluggy, validators)
+
+
+def settings_update_handler():
+    return DefaultSettingsUpdateHandler(db, current_app.pluggy)
+
+
+def settings_form_factory():
+    form = GeneralSettingsForm()
+    form.theme.choices = get_available_themes()
+    form.theme.choices.insert(0, ("", "Default"))
+    form.language.choices = get_available_languages()
+
+    if not form.is_submitted() or not form.validate_on_submit():
+        form.theme.data = current_user.theme
+        form.language.data = current_user.language
+
+    return form
+
+
+def change_password_form_factory():
+    return ChangePasswordForm(user=current_user)
+
+
+def change_email_form_factory():
+    return ChangeEmailForm(user=current_user)
+
+
+def change_details_form_factory():
+    return ChangeUserDetailsForm(obj=current_user)

+ 92 - 0
flaskbb/user/services/update.py

@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.user.services.update
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    User update services.
+
+    :copyright: (c) 2018 the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+
+import attr
+
+from ...core.exceptions import accumulate_errors
+from ...core.changesets import ChangeSetHandler
+from ...utils.database import try_commit
+
+
+@attr.s(cmp=False, frozen=True, repr=True, hash=False)
+class DefaultDetailsUpdateHandler(ChangeSetHandler):
+    """
+    Validates and updates a user's details and persists the changes to the database.
+    """
+
+    db = attr.ib()
+    plugin_manager = attr.ib()
+    validators = attr.ib(factory=list)
+
+    def apply_changeset(self, model, changeset):
+        accumulate_errors(
+            lambda v: v.validate(model, changeset), self.validators
+        )
+        changeset.assign_to_user(model)
+        try_commit(self.db.session, "Could not update details")
+        self.plugin_manager.hook.flaskbb_details_updated(
+            user=model, details_update=changeset
+        )
+
+
+@attr.s(cmp=False, frozen=True, repr=True, hash=False)
+class DefaultPasswordUpdateHandler(ChangeSetHandler):
+    """
+    Validates and updates a user's password and persists the changes to the database.
+    """
+
+    db = attr.ib()
+    plugin_manager = attr.ib()
+    validators = attr.ib(factory=list)
+
+    def apply_changeset(self, model, changeset):
+        accumulate_errors(
+            lambda v: v.validate(model, changeset), self.validators
+        )
+        model.password = changeset.new_password
+        try_commit(self.db.session, "Could not update password")
+        self.plugin_manager.hook.flaskbb_password_updated(user=model)
+
+
+@attr.s(cmp=False, frozen=True, repr=True, hash=False)
+class DefaultEmailUpdateHandler(ChangeSetHandler):
+    """
+    Validates and updates a user's email and persists the changes to the database.
+    """
+
+    db = attr.ib()
+    plugin_manager = attr.ib()
+    validators = attr.ib(factory=list)
+
+    def apply_changeset(self, model, changeset):
+        accumulate_errors(lambda v: v.validate(model, changeset), self.validators)
+        model.email = changeset.new_email
+        try_commit(self.db.session, "Could not update email")
+        self.plugin_manager.hook.flaskbb_email_updated(
+            user=model, email_update=changeset
+        )
+
+
+@attr.s(cmp=False, frozen=True, repr=True, hash=False)
+class DefaultSettingsUpdateHandler(ChangeSetHandler):
+    """
+    Updates a user's settings and persists the changes to the database.
+    """
+
+    db = attr.ib()
+    plugin_manager = attr.ib()
+
+    def apply_changeset(self, model, changeset):
+        changeset.assign_to_user(model)
+        try_commit(self.db.session, "Could not update settings")
+        self.plugin_manager.hook.flaskbb_settings_updated(
+            user=model, settings_update=changeset
+        )

+ 102 - 0
flaskbb/user/services/validators.py

@@ -0,0 +1,102 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.user.services.validators
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+    Validators for use with user services.
+
+    :copyright: (c) 2018 the Flaskbb Team.
+    :license: BSD, see LICENSE for more details
+"""
+
+
+import attr
+from flask_babelplus import gettext as _
+from requests.exceptions import RequestException
+from ...core.changesets import ChangeSetValidator
+from sqlalchemy import func
+
+from ...core.exceptions import StopValidation, ValidationError
+from ...utils.helpers import check_image
+
+
+@attr.s(cmp=False, hash=False, frozen=True, repr=True)
+class CantShareEmailValidator(ChangeSetValidator):
+    """
+    Validates that the new email for the user isn't currently registered by
+    another user.
+    """
+    users = attr.ib()
+
+    def validate(self, model, changeset):
+        others = self.users.query.filter(
+            self.users.id != model.id,
+            func.lower(self.users.email) == changeset.new_email,
+        ).count()
+
+        if others != 0:
+            raise ValidationError(
+                "new_email",
+                _("%(email)s is already registered", email=changeset.new_email),
+            )
+
+
+class OldEmailMustMatch(ChangeSetValidator):
+    """
+    Validates that the email entered by the user is the current email of the user.
+    """
+    def validate(self, model, changeset):
+        if model.email != changeset.old_email:
+            raise StopValidation([("old_email", _("Old email does not match"))])
+
+
+class EmailsMustBeDifferent(ChangeSetValidator):
+    """
+    Validates that the new email entered by the user isn't the same as the
+    current email for the user.
+    """
+    def validate(self, model, changeset):
+        if model.email == changeset.new_email:
+            raise ValidationError("new_email", _("New email address must be different"))
+
+
+class PasswordsMustBeDifferent(ChangeSetValidator):
+    """
+    Validates that the new password entered by the user isn't the same as the
+    current email for the user.
+    """
+    def validate(self, model, changeset):
+        if model.check_password(changeset.new_password):
+            raise ValidationError("new_password", _("New password must be different"))
+
+
+class OldPasswordMustMatch(ChangeSetValidator):
+    """
+    Validates that the old password entered by the user is the current password
+    for the user.
+    """
+    def validate(self, model, changeset):
+        if not model.check_password(changeset.old_password):
+            raise StopValidation([("old_password", _("Old password is wrong"))])
+
+
+class ValidateAvatarURL(ChangeSetValidator):
+    """
+    Validates that the target avatar url currently meets constraints like
+    height and width.
+
+    .. warning::
+
+        This validator only checks the **current** state of the image however
+        if the image at the URL changes then this isn't re-run and the new
+        image could break these contraints.
+    """
+    def validate(self, user, details_change):
+        if not details_change.avatar:
+            return
+
+        try:
+            error, ignored = check_image(details_change.avatar)
+            if error:
+                raise ValidationError("avatar", error)
+        except RequestException:
+            raise ValidationError("avatar", _("Could not retrieve avatar"))

+ 124 - 76
flaskbb/user/views.py

@@ -11,118 +11,172 @@
 """
 """
 import logging
 import logging
 
 
-from flask import Blueprint, flash, request
+import attr
+from flask import Blueprint, flash, redirect, request, url_for
 from flask.views import MethodView
 from flask.views import MethodView
 from flask_babelplus import gettext as _
 from flask_babelplus import gettext as _
 from flask_login import current_user, login_required
 from flask_login import current_user, login_required
 from pluggy import HookimplMarker
 from pluggy import HookimplMarker
 
 
-from flaskbb.user.forms import (ChangeEmailForm, ChangePasswordForm,
-                                ChangeUserDetailsForm, GeneralSettingsForm)
 from flaskbb.user.models import User
 from flaskbb.user.models import User
-from flaskbb.utils.helpers import (get_available_languages,
-                                   get_available_themes, register_view,
-                                   render_template)
-
-impl = HookimplMarker('flaskbb')
+from flaskbb.utils.helpers import register_view, render_template
+
+from ..core.exceptions import PersistenceError, StopValidation
+from .services.factories import (
+    change_details_form_factory,
+    change_email_form_factory,
+    change_password_form_factory,
+    details_update_factory,
+    email_update_handler,
+    password_update_handler,
+    settings_form_factory,
+    settings_update_handler,
+)
+
+impl = HookimplMarker("flaskbb")
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+@attr.s(frozen=True, cmp=False, hash=False, repr=True)
 class UserSettings(MethodView):
 class UserSettings(MethodView):
+    form = attr.ib(factory=settings_form_factory)
+    settings_update_handler = attr.ib(factory=settings_update_handler)
+
     decorators = [login_required]
     decorators = [login_required]
-    form = GeneralSettingsForm
 
 
     def get(self):
     def get(self):
-        form = self.form()
-
-        form.theme.choices = get_available_themes()
-        form.theme.choices.insert(0, ('', 'Default'))
-        form.language.choices = get_available_languages()
-        form.theme.data = current_user.theme
-        form.language.data = current_user.language
-
-        return render_template("user/general_settings.html", form=form)
+        return self.render()
 
 
     def post(self):
     def post(self):
-        form = self.form()
-
-        form.theme.choices = get_available_themes()
-        form.theme.choices.insert(0, ('', 'Default'))
-        form.language.choices = get_available_languages()
-
-        if form.validate_on_submit():
-            current_user.theme = form.theme.data
-            current_user.language = form.language.data
-            current_user.save()
+        if self.form.validate_on_submit():
+            try:
+                self.settings_update_handler.apply_changeset(
+                    current_user, self.form.as_change()
+                )
+            except StopValidation as e:
+                self.form.populate_errors(e.reasons)
+                return self.render()
+            except PersistenceError:
+                logger.exception("Error while updating user settings")
+                flash(_("Error while updating user settings"), "danger")
+                return self.redirect()
 
 
             flash(_("Settings updated."), "success")
             flash(_("Settings updated."), "success")
-        else:
-            form.theme.data = current_user.theme
-            form.language.data = current_user.language
+            return self.redirect()
+        return self.render()
 
 
-        return render_template("user/general_settings.html", form=form)
+    def render(self):
+        return render_template("user/general_settings.html", form=self.form)
 
 
+    def redirect(self):
+        return redirect(url_for("user.settings"))
 
 
+
+@attr.s(frozen=True, hash=False, cmp=False, repr=True)
 class ChangePassword(MethodView):
 class ChangePassword(MethodView):
+    form = attr.ib(factory=change_password_form_factory)
+    password_update_handler = attr.ib(factory=password_update_handler)
     decorators = [login_required]
     decorators = [login_required]
-    form = ChangePasswordForm
 
 
     def get(self):
     def get(self):
-        return render_template("user/change_password.html", form=self.form())
+        return self.render()
 
 
     def post(self):
     def post(self):
-        form = self.form()
-        if form.validate_on_submit():
-            current_user.password = form.new_password.data
-            current_user.save()
+        if self.form.validate_on_submit():
+            try:
+                self.password_update_handler.apply_changeset(
+                    current_user, self.form.as_change()
+                )
+            except StopValidation as e:
+                self.form.populate_errors(e.reasons)
+                return self.render()
+            except PersistenceError:
+                logger.exception("Error while changing password")
+                flash(_("Error while changing password"), "danger")
+                return self.redirect()
 
 
             flash(_("Password updated."), "success")
             flash(_("Password updated."), "success")
-        return render_template("user/change_password.html", form=form)
+            return self.redirect()
+        return self.render()
+
+    def render(self):
+        return render_template("user/change_password.html", form=self.form)
 
 
+    def redirect(self):
+        return redirect(url_for("user.change_password"))
 
 
+
+@attr.s(frozen=True, cmp=False, hash=False, repr=True)
 class ChangeEmail(MethodView):
 class ChangeEmail(MethodView):
+    form = attr.ib(factory=change_email_form_factory)
+    update_email_handler = attr.ib(factory=email_update_handler)
     decorators = [login_required]
     decorators = [login_required]
-    form = ChangeEmailForm
 
 
     def get(self):
     def get(self):
-        return render_template(
-            "user/change_email.html", form=self.form(current_user)
-        )
+        return self.render()
 
 
     def post(self):
     def post(self):
-        form = self.form(current_user)
-        if form.validate_on_submit():
-            current_user.email = form.new_email.data
-            current_user.save()
+        if self.form.validate_on_submit():
+            try:
+                self.update_email_handler.apply_changeset(
+                    current_user, self.form.as_change()
+                )
+            except StopValidation as e:
+                self.form.populate_errors(e.reasons)
+                return self.render()
+            except PersistenceError:
+                logger.exception("Error while updating email")
+                flash(_("Error while updating email"), "danger")
+                return self.redirect()
 
 
             flash(_("Email address updated."), "success")
             flash(_("Email address updated."), "success")
-        return render_template("user/change_email.html", form=form)
+            return self.redirect()
+        return self.render()
+
+    def render(self):
+        return render_template("user/change_email.html", form=self.form)
 
 
+    def redirect(self):
+        return redirect(url_for("user.change_email"))
 
 
+
+@attr.s(frozen=True, repr=True, cmp=False, hash=False)
 class ChangeUserDetails(MethodView):
 class ChangeUserDetails(MethodView):
+    form = attr.ib(factory=change_details_form_factory)
+    details_update_handler = attr.ib(factory=details_update_factory)
     decorators = [login_required]
     decorators = [login_required]
-    form = ChangeUserDetailsForm
 
 
     def get(self):
     def get(self):
-        return render_template(
-            "user/change_user_details.html", form=self.form(obj=current_user)
-        )
+        return self.render()
 
 
     def post(self):
     def post(self):
-        form = self.form(obj=current_user)
 
 
-        if form.validate_on_submit():
-            form.populate_obj(current_user)
-            current_user.save()
+        if self.form.validate_on_submit():
+            try:
+                self.details_update_handler.apply_changeset(
+                    current_user, self.form.as_change()
+                )
+            except StopValidation as e:
+                self.form.populate_errors(e.reasons)
+                return self.render()
+            except PersistenceError:
+                logger.exception("Error while updating user details")
+                flash(_("Error while updating user details"), "danger")
+                return self.redirect()
 
 
             flash(_("User details updated."), "success")
             flash(_("User details updated."), "success")
+            return self.redirect()
+        return self.render()
 
 
-        return render_template("user/change_user_details.html", form=form)
+    def render(self):
+        return render_template("user/change_user_details.html", form=self.form)
 
 
+    def redirect(self):
+        return redirect(url_for("user.change_user_details"))
 
 
-class AllUserTopics(MethodView):
 
 
+class AllUserTopics(MethodView):  # pragma: no cover
     def get(self, username):
     def get(self, username):
         page = request.args.get("page", 1, type=int)
         page = request.args.get("page", 1, type=int)
         user = User.query.filter_by(username=username).first_or_404()
         user = User.query.filter_by(username=username).first_or_404()
@@ -130,8 +184,7 @@ class AllUserTopics(MethodView):
         return render_template("user/all_topics.html", user=user, topics=topics)
         return render_template("user/all_topics.html", user=user, topics=topics)
 
 
 
 
-class AllUserPosts(MethodView):
-
+class AllUserPosts(MethodView):  # pragma: no cover
     def get(self, username):
     def get(self, username):
         page = request.args.get("page", 1, type=int)
         page = request.args.get("page", 1, type=int)
         user = User.query.filter_by(username=username).first_or_404()
         user = User.query.filter_by(username=username).first_or_404()
@@ -139,8 +192,7 @@ class AllUserPosts(MethodView):
         return render_template("user/all_posts.html", user=user, posts=posts)
         return render_template("user/all_posts.html", user=user, posts=posts)
 
 
 
 
-class UserProfile(MethodView):
-
+class UserProfile(MethodView):  # pragma: no cover
     def get(self, username):
     def get(self, username):
         user = User.query.filter_by(username=username).first_or_404()
         user = User.query.filter_by(username=username).first_or_404()
         return render_template("user/profile.html", user=user)
         return render_template("user/profile.html", user=user)
@@ -150,38 +202,34 @@ class UserProfile(MethodView):
 def flaskbb_load_blueprints(app):
 def flaskbb_load_blueprints(app):
     user = Blueprint("user", __name__)
     user = Blueprint("user", __name__)
     register_view(
     register_view(
-        user,
-        routes=['/settings/email'],
-        view_func=ChangeEmail.as_view('change_email')
+        user, routes=["/settings/email"], view_func=ChangeEmail.as_view("change_email")
     )
     )
     register_view(
     register_view(
-        user,
-        routes=['/settings/general'],
-        view_func=UserSettings.as_view('settings')
+        user, routes=["/settings/general"], view_func=UserSettings.as_view("settings")
     )
     )
     register_view(
     register_view(
         user,
         user,
-        routes=['/settings/password'],
-        view_func=ChangePassword.as_view('change_password')
+        routes=["/settings/password"],
+        view_func=ChangePassword.as_view("change_password"),
     )
     )
     register_view(
     register_view(
         user,
         user,
         routes=["/settings/user-details"],
         routes=["/settings/user-details"],
-        view_func=ChangeUserDetails.as_view('change_user_details')
+        view_func=ChangeUserDetails.as_view("change_user_details"),
     )
     )
     register_view(
     register_view(
         user,
         user,
-        routes=['/<username>/posts'],
-        view_func=AllUserPosts.as_view('view_all_posts')
+        routes=["/<username>/posts"],
+        view_func=AllUserPosts.as_view("view_all_posts"),
     )
     )
     register_view(
     register_view(
         user,
         user,
-        routes=['/<username>/topics'],
-        view_func=AllUserTopics.as_view('view_all_topics')
+        routes=["/<username>/topics"],
+        view_func=AllUserTopics.as_view("view_all_topics"),
     )
     )
 
 
     register_view(
     register_view(
-        user, routes=['/<username>'], view_func=UserProfile.as_view('profile')
+        user, routes=["/<username>"], view_func=UserProfile.as_view("profile")
     )
     )
 
 
     app.register_blueprint(user, url_prefix=app.config["USER_URL_PREFIX"])
     app.register_blueprint(user, url_prefix=app.config["USER_URL_PREFIX"])

+ 8 - 0
flaskbb/utils/database.py

@@ -14,6 +14,7 @@ from flask_login import current_user
 from flask_sqlalchemy import BaseQuery
 from flask_sqlalchemy import BaseQuery
 from sqlalchemy.ext.declarative import declared_attr
 from sqlalchemy.ext.declarative import declared_attr
 from flaskbb.extensions import db
 from flaskbb.extensions import db
+from ..core.exceptions import PersistenceError
 
 
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -158,3 +159,10 @@ class HideableMixin(object):
 
 
 class HideableCRUDMixin(HideableMixin, CRUDMixin):
 class HideableCRUDMixin(HideableMixin, CRUDMixin):
     pass
     pass
+
+
+def try_commit(session, message="Error while saving"):
+    try:
+        session.commit()
+    except Exception:
+        raise PersistenceError(message)

+ 4 - 2
requirements-test.txt

@@ -1,5 +1,7 @@
 -rrequirements-cov.txt
 -rrequirements-cov.txt
-pytest==3.6.4
-pytest-mock==1.10.0
+flake8==3.5.0
 freezegun==0.3.10
 freezegun==0.3.10
 mock==2.0.0 ; python_version<'3.3'
 mock==2.0.0 ; python_version<'3.3'
+pytest==3.6.4
+pytest-mock==1.10.0
+responses==0.9.0

+ 1 - 0
tests/conftest.py

@@ -4,3 +4,4 @@ from tests.fixtures.forum import *  # noqa
 from tests.fixtures.plugin import *  # noqa
 from tests.fixtures.plugin import *  # noqa
 from tests.fixtures.settings_fixture import *  # noqa
 from tests.fixtures.settings_fixture import *  # noqa
 from tests.fixtures.user import *  # noqa
 from tests.fixtures.user import *  # noqa
+from tests.fixtures.helpers import *  # noqa

+ 8 - 3
tests/fixtures/app.py

@@ -1,10 +1,9 @@
 import pytest
 import pytest
 
 
 from flaskbb import create_app
 from flaskbb import create_app
-from flaskbb.extensions import db
 from flaskbb.configs.testing import TestingConfig as Config
 from flaskbb.configs.testing import TestingConfig as Config
-from flaskbb.utils.populate import create_default_groups, \
-    create_default_settings
+from flaskbb.extensions import db
+from flaskbb.utils.populate import create_default_groups, create_default_settings
 
 
 
 
 @pytest.yield_fixture(autouse=True)
 @pytest.yield_fixture(autouse=True)
@@ -27,6 +26,12 @@ def request_context(application):
 
 
 
 
 @pytest.fixture()
 @pytest.fixture()
+def post_request_context(application):
+    with application.test_request_context(method="POST"):
+        yield
+
+
+@pytest.fixture()
 def default_groups(database):
 def default_groups(database):
     """Creates the default groups"""
     """Creates the default groups"""
     return create_default_groups()
     return create_default_groups()

+ 76 - 0
tests/fixtures/helpers.py

@@ -0,0 +1,76 @@
+from collections import namedtuple
+from os import path
+
+import pytest
+
+from responses import RequestsMock, Response
+
+
+@pytest.fixture(scope="function")
+def responses():
+    mock = RequestsMock(assert_all_requests_are_fired=True)
+    with mock:
+        yield mock
+
+
+_here = __file__
+
+
+ImageResponse = namedtuple("ImageResponse", ["raw", "url", "headers"])
+
+
+def _get_image_bytes(which):
+    img_path = path.join(path.realpath(path.dirname(_here)), "images", which)
+    with open(img_path, "rb") as fh:
+        return fh.read()
+
+
+def _get_image_resp(which, mime):
+    raw = _get_image_bytes(which)
+    return Response(
+        method="GET",
+        body=raw,
+        url="http://example/{}".format(which),
+        headers={"Content-Type": mime, "Content-Length": str(len(raw))},
+        stream=True,
+    )
+
+
+@pytest.fixture(scope="function")
+def image_just_right():
+    return _get_image_resp("good_image.png", "image/png")
+
+
+@pytest.fixture(scope="function")
+def image_too_big():
+    return _get_image_resp("too_big.gif", "image/gif")
+
+
+@pytest.fixture(scope="function")
+def image_too_tall():
+    return _get_image_resp("too_tall.png", "image/png")
+
+
+@pytest.fixture(scope="function")
+def image_too_wide():
+    return _get_image_resp("too_wide.png", "image/png")
+
+
+@pytest.fixture(scope="function")
+def image_wrong_mime():
+    return _get_image_resp("wrong_mime.svg", "image/svg+xml")
+
+
+@pytest.fixture(scope="function")
+def image_jpg():
+    return _get_image_resp("image.jpg", "image/jpeg")
+
+
+@pytest.fixture(scope="function")
+def image_gif():
+    return _get_image_resp("image.gif", "image/gif")
+
+
+@pytest.fixture(scope="function")
+def image_png():
+    return _get_image_resp("image.png", "image/png")

BIN
tests/fixtures/images/good_image.png


BIN
tests/fixtures/images/image.gif


BIN
tests/fixtures/images/image.jpg


BIN
tests/fixtures/images/image.png


BIN
tests/fixtures/images/too_big.gif


BIN
tests/fixtures/images/too_tall.png


BIN
tests/fixtures/images/too_wide.png


+ 166 - 0
tests/fixtures/images/wrong_mime.svg

@@ -0,0 +1,166 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   version="1.1"
+   width="460"
+   height="180"
+   id="svg2">
+  <defs
+     id="defs4" />
+  <g
+     transform="translate(-27.820801,-24.714976)"
+     id="layer1">
+    <path
+       d="M 96.944917,182.03377 C 89.662681,176.30608 81.894549,170.81448 76.586317,163.08166 65.416842,149.44499 56.816875,133.6567 50.937585,117.06515 47.383955,106.27654 46.166898,94.709824 41.585799,84.338096 c -4.792287,-7.533044 0.821224,-15.767897 9.072722,-18.16242 3.673742,-0.705104 10.133327,-4.170258 2.335951,-1.693539 -6.990592,5.128871 -7.667129,-4.655603 -0.498823,-5.27517 4.892026,-0.650249 6.692895,-4.655044 5.019966,-8.260251 -5.251326,-3.424464 12.733737,-7.18801 3.684373,-12.297799 -9.426987,-10.170666 13.186339,-12.128546 7.607283,-0.577786 -1.335447,8.882061 15.801226,-1.627907 11.825117,8.628945 4.041283,4.925694 15.133562,1.1211 14.85838,8.031392 5.887092,0.404678 7.907562,5.358061 13.433992,5.738347 5.72759,2.586557 16.1108,4.624792 18.0598,11.079149 -5.68242,4.498756 -18.84089,-9.292674 -19.47305,3.160397 1.71659,18.396078 1.27926,37.346439 8.00986,54.864989 3.18353,10.60759 10.9012,18.95779 17.87109,27.21946 6.66875,8.09126 15.70186,13.78715 24.90885,18.58338 8.07647,3.80901 16.78383,6.33528 25.58583,7.92044 3.5701,-2.7307 9.87303,-12.8828 15.44238,-8.60188 0.26423,4.81007 -11.0541,10.05512 -0.53248,9.5235 6.17819,-1.86378 10.46336,4.77803 15.55099,-1.21289 4.68719,5.55206 19.48197,-3.54734 16.14693,7.80115 -4.50972,2.90955 -11.08689,1.15142 -15.60404,5.15397 -7.44757,-3.71979 -13.37691,3.32843 -21.6219,2.43707 -9.15641,1.64002 -18.4716,2.30204 -27.75473,2.31642 -15.22952,-1.20328 -30.78158,-1.71049 -45.26969,-7.01291 -8.16166,-2.37161 -16.12649,-7.01887 -23.299683,-11.66829 z m 12.862043,5.5729 c 7.9696,3.44651 15.76243,7.07889 24.49656,8.17457 13.85682,1.92727 28.16653,4.89163 42.07301,2.18757 -6.2939,-2.84199 -12.80077,1.10719 -19.07096,-2.0322 -7.52033,1.61821 -15.59049,-0.41223 -23.23574,-1.41189 -8.69395,-3.87259 -18.0762,-6.53549 -26.21772,-11.56219 -10.173155,-3.71578 5.26142,4.76524 8.00873,5.45214 6.35952,3.60969 -6.99343,-1.85044 -8.87589,-3.35101 -5.32648,-2.9879 -6.00529,-2.36357 -0.52745,0.67085 1.10332,0.64577 2.19359,1.32226 3.34946,1.87216 z M 94.642259,176.88976 c 7.722781,2.86052 -0.03406,-5.43082 -3.572941,-4.94904 -1.567906,-2.72015 -5.9903,-4.43854 -2.870721,-5.89973 -5.611524,1.9481 -5.878319,-7.40814 -8.516004,-6.07139 -5.936516,-1.87454 -2.310496,-8.51501 -9.381929,-12.59292 -0.645488,-4.29697 -7.02577,-8.02393 -9.060801,-14.50525 -0.898786,-3.31843 -7.208336,-12.84783 -3.332369,-3.97927 3.300194,8.53747 9.106618,15.84879 13.93868,23.15175 3.752083,6.95328 8.182497,14.22026 15.015767,18.55788 2.303436,2.20963 4.527452,5.59533 7.780318,6.28797 z M 72.39456,152.46355 c 0.26956,-1.16626 1.412424,2.52422 0,0 z m 31.49641,27.85526 c 1.71013,-0.76577 -2.45912,-0.96476 0,0 z m 4.19228,1.52924 c -0.43419,-2.1116 -1.91376,1.18074 0,0 z m 5.24749,2.18891 c 2.49828,-2.37871 -3.85009,-1.49983 0,0 z m 8.99389,5.01274 c 1.51811,-2.2439 -4.85872,-0.84682 0,0 z m -17.2707,-12.03933 c 3.88031,-2.51023 -5.01186,-0.0347 0,0 z m 3.9366,1.96293 c -0.11004,-1.32709 -1.40297,0.59432 0,0 z m 19.67473,12.28006 c 3.16281,1.99601 18.46961,4.3749 8.88477,0.81847 -1.60377,0.33811 -17.77263,-4.57336 -8.88477,-0.81847 z M 97.430958,166.92721 c -0.307503,-1.33094 -4.909341,-1.4694 0,0 z m 9.159302,5.33813 c 2.38371,-1.66255 -4.94757,-1.28235 0,0 z m 7.70426,4.72382 c 3.42065,-1.28963 -5.54907,-1.29571 0,0 z M 93.703927,162.86805 c 3.711374,2.84621 14.967683,0.36473 5.683776,-1.69906 -4.225516,-2.2524 -13.74889,-3.79415 -7.25757,1.35821 l 1.573785,0.34088 9e-6,-3e-5 z m 25.808723,15.75216 c 1.54595,-2.63388 -6.48298,-1.50411 0,0 z m -7.84249,-6.23284 c 9.0752,2.56719 -7.63142,-5.739 -2.23911,-0.94466 l 1.19513,0.54082 1.04399,0.4039 -1e-5,-6e-5 z m 15.72354,9.0878 c 8.59474,0.082 -7.76304,-1.18486 0,1e-5 l 0,-1e-5 z M 90.396984,157.89545 c -0.335695,-1.60094 -2.120962,0.13419 0,0 z m 51.535396,31.73502 c 0.2292,-2.89141 -2.80486,2.15157 0,0 z m -36.86817,-22.75299 c -0.51986,-1.52251 -2.68548,-0.0622 0,0 z m -13.852128,-9.98649 c 4.934237,-0.29629 -6.755322,-2.17418 0,0 z M 74.802387,146.28394 c -0.614146,-2.36536 -5.369213,-4.2519 0,0 z m 43.079323,27.33941 c -0.90373,-1.0307 -0.4251,0.22546 0,0 z m 26.81408,16.45475 c -0.086,-1.57503 -1.46039,0.59616 0,0 z m -29.18712,-18.90528 c 0.48266,-2.02932 -4.20741,-0.61442 0,0 z M 95.532612,158.51286 c 3.670785,-0.39305 -5.880434,-2.48161 0,0 z M 129.32396,179.51 c 5.72042,-2.26627 -5.57541,-1.10635 0,0 z m -17.57682,-11.93145 c 6.59278,0.85002 -7.84442,-4.48425 -1.44651,-0.4773 l 1.4465,0.47734 1e-5,-4e-5 z m 22.91296,14.0886 c 6.15514,-3.67975 4.12588,8.61677 10.44254,1.0388 6.23086,-4.54942 -5.38086,5.62451 2.29838,0.81116 5.55359,-3.71438 13.75643,1.76075 18.93848,3.5472 3.72659,-0.18307 7.34938,3.22236 11.16973,1.15059 7.3542,-1.98082 -14.38097,-2.93789 -8.68344,-6.4523 -6.72914,1.95848 -11.70093,-2.33483 -15.01213,-6.64508 -7.54812,-1.74298 -16.27548,-5.602 -20.04257,-12.28184 -1.5359,-2.50802 2.21884,0.35333 -1.32586,-3.74638 -4.54834,-4.04546 -6.81948,-8.63766 -9.87278,-13.5552 -3.64755,-1.94587 -4.07249,-7.67345 -4.44123,-0.19201 0.0289,-4.72164 -4.40393,-7.89964 -5.48589,-6.57859 -0.0194,-4.54721 4.74396,-2.26787 1.40945,-5.63228 -0.71771,-4.71302 -3.08085,-9.6241 -3.79115,-14.9453 -1.1036,-2.56502 -0.15541,-8.05863 -3.76662,-2.25204 -1.31566,6.13669 -0.43668,-7.54129 1.6093,-3.03083 2.68543,-4.60251 -0.9641,-4.0612 -1.11361,-3.42211 1.74931,-3.88333 1.10719,-9.39159 -0.45644,-7.29023 0.93213,-4.11586 1.47259,-15.147529 -1.3951,-13.192579 1.73833,-4.303958 3.29668,-19.694077 -4.24961,-13.826325 -3.058358,0.04294 -8.354541,1.110195 -10.858032,2.355243 7.849502,4.326857 -0.789543,1.562577 -3.984808,0.874879 -0.416343,4.003642 -3.58119,2.272086 -7.535123,2.311339 6.315273,0.781339 -3.075253,6.458962 -6.698132,4.253506 -4.705102,2.248756 4.060621,7.862038 0.0944,9.597586 0.487433,2.616581 -7.208227,-0.944906 -6.603832,5.097711 -4.56774,-1.92155 -0.628961,7.16796 1.656273,4.09382 7.768882,2.10261 5.469108,6.89631 5.666947,11.44992 -1.265833,2.6534 -6.249495,-6.23691 -1.109939,-5.82517 -4.054715,-6.58674 -4.485232,-2.38081 -7.854566,0.67911 -0.783857,0.22222 8.5944,4.35376 2.709059,6.3967 5.177884,0.79894 5.325199,5.33008 6.379284,8.19735 3.11219,3.24152 2.475226,-3.57931 6.199071,0.31623 -2.356488,-3.4705 -12.48183,-9.77839 -4.329567,-7.7553 -0.04358,-3.49291 -1.474412,-6.30951 1.02322,-6.24118 2.473367,-4.47926 -2.590385,11.044 2.984725,5.35124 1.543285,-0.67388 1.92554,-4.48494 4.699544,0.35989 4.029096,3.96363 1.45533,6.83577 -4.228162,3.20648 1.016828,3.44946 7.603062,4.68217 6.365348,10.07646 1.3121,4.7444 3.147844,2.99695 4.747999,2.72266 1.25523,4.60973 1.968016,1.2201 2.027559,-0.97355 5.747357,1.23033 4.401142,4.62773 6.199456,7.00134 3.960416,1.78761 -5.668696,-12.11713 1.130659,-4.18106 7.153577,6.4586 2.682797,9.15464 -3.736856,8.11995 4.063129,-0.32824 5.373423,5.49305 10.455693,5.28853 4.63456,2.20477 7.77237,10.67291 -0.21613,7.1478 -2.77074,-2.49821 -12.575734,-5.5801 -4.56731,-0.82823 7.39657,3.42523 13.27117,5.47432 20.40487,9.77384 5.10535,3.64464 7.31104,7.81908 9.24607,8.64541 -4.29084,2.04946 -12.93089,-1.63655 -6.51514,-2.76618 -4.00168,-0.72894 -8.50258,-2.75259 -4.66961,2.2333 3.25926,2.72127 11.54708,2.43298 13.0328,2.74132 -1.25934,2.77488 -3.4207,2.99556 0.0516,3.21078 -3.87375,2.06438 1.24216,2.38403 1.60114,3.56362 z m -7.9215,-22.36993 c -2.35682,-2.46475 -2.9662,-7.08134 -0.41852,-3.06426 1.30648,0.52466 4.18523,7.54428 0.41857,3.06426 l -5e-5,0 z m 25.79733,16.38693 c 1.47004,-0.0952 0.0427,1.11681 0,0 z m -29.51867,-22.43039 c -0.0904,-3.72637 0.8525,2.87419 0,0 z m -2.56392,-3.44965 c -2.96446,-5.72787 3.73721,1.62212 0,0 z M 89.382646,128.35916 c 1.7416,-0.46446 0.856841,2.97864 0,0 z m 24.728294,13.40357 c 1.06957,-4.01654 1.25692,3.37014 0,0 z M 96.64115,129.61525 c -1.231543,-2.21638 2.576009,2.07865 0,0 z m 14.99279,4.80618 c -2.80851,-6.29223 1.98836,-3.43699 0.62135,1.03124 l -0.62135,-1.03124 0,0 z M 85.778757,117.17864 c -1.255624,-2.06432 -3.332663,-8.12135 -2.663982,-9.97042 0.604935,3.0114 6.403914,12.95956 2.844571,4.12096 -3.933386,-7.40908 4.701805,2.40491 5.590052,4.2529 0.413624,1.83837 -2.426789,-0.50225 -0.502192,3.80828 -3.509809,-4.90766 -2.071967,2.71088 -5.268449,-2.21172 z m -7.990701,-5.50612 c 0.328938,-4.79981 1.829262,3.29132 0,0 z m 3.594293,1.23728 c 1.715175,-3.62282 2.908243,5.05052 0,0 z m -8.64616,-6.68847 c -2.974956,-2.95622 -5.127809,-5.68132 0.139193,-1.83474 2.029482,0.0792 -4.509002,-6.19705 0.488751,-1.99305 5.25531,0.95822 2.5951,8.61674 -0.627944,3.82779 z m 4.541717,-0.11873 c 1.727646,-1.71203 0.917172,1.6853 0,0 z m 2.794587,0.8959 c -2.619181,-4.9094 3.178801,2.05822 0,0 z m -5.55546,-5.30909 c -8.64844,-7.696511 10.867309,4.02451 1.4129,1.4269 l -1.412955,-1.42683 5.5e-5,-7e-5 z m 24.77908,14.39717 c -3.742506,-2.24398 -0.991777,-15.79747 0.284503,-6.52785 3.638294,-1.17695 -0.200879,4.78728 2.512784,4.73208 -0.42767,3.76305 -1.64169,5.11594 -2.797287,1.79577 z m 9.165207,5.41684 c 0.36705,-4.08462 0.77249,2.79262 0,0 z m -1.59198,-1.57295 c 0.41206,-1.74497 0.0426,2.05487 0,0 z M 76.213566,99.16032 c -5.556046,-7.665657 16.147323,7.75413 3.558556,1.9443 -1.315432,-0.34404 -2.898208,-0.46688 -3.558556,-1.9443 z m 17.649112,9.35749 c -0.525779,-6.45461 1.174169,1.06991 -1.92e-4,-2e-5 l 1.92e-4,2e-5 z m 13.399762,8.59585 c 1.03698,-3.67668 0.0773,2.43221 0,0 z M 77.064685,96.23472 c 3.302172,-0.706291 13.684695,5.79939 4.150224,1.85832 -1.059396,-1.17279 -3.317802,-0.63994 -4.150224,-1.85832 z m 28.356745,14.13312 c 0.35296,-6.60002 1.97138,-3.94233 0.0122,0.94474 l -0.0121,-0.94473 -5e-5,-1e-5 z M 79.52277,93.938099 c 1.345456,-1.97361 -3.571631,-8.923063 0.708795,-2.492797 1.849543,1.469605 5.355103,2.461959 2.260017,3.080216 4.867744,4.294162 -1.187244,1.163612 -2.968812,-0.587419 z m 24.49612,14.368161 c 0.92952,-7.51843 0.81971,4.40485 0,0 z M 76.712755,86.993902 c 1.027706,-0.439207 0.542746,1.369335 0,0 z m 6.389622,3.803092 c 1.644416,-3.450522 3.03351,3.848297 0,0 z m 18.023553,10.026276 c -0.0174,-1.3252 0.34003,1.92765 0,0 z m -1.04404,-2.31139 c -2.501612,-6.171646 2.32693,3.26759 0,0 z m -1.536003,-4.046372 c -0.419906,-2.550188 1.427129,3.203862 -7.3e-5,-9e-6 l 7.3e-5,9e-6 z m 2.499773,-4.063514 c -1.71663,-3.025123 2.16777,-13.331073 2.60122,-6.939418 -1.81185,4.980256 -0.52268,7.766309 0.74129,1.086388 2.33417,-5.257159 -0.50421,10.374054 -3.34255,5.853057 l 4e-5,-2.7e-5 z m 2.56889,-15.326649 c 0.74833,-0.918921 0.16609,1.107082 0,0 z m -4.290016,84.534235 c -1.017552,-0.88802 0.127775,0.56506 0,0 z m 8.837726,4.47065 c 4.91599,1.26135 4.89086,-0.76487 0.44782,-1.36683 -2.3898,-2.22316 -9.930475,-4.58124 -3.18119,-0.27586 0.44699,1.13227 1.85944,1.10589 2.73337,1.64269 z M 90.708067,152.48725 c 2.708244,2.01956 10.201213,5.72375 3.858186,0.76868 2.138588,-2.48467 -4.093336,-3.80722 -2.026067,-5.46927 -5.258175,-3.21755 -4.147962,-2.93133 -0.464111,-2.8301 -6.319385,-2.82462 0.912163,-2.61333 0.571661,-4.06067 -2.436706,-0.48126 -12.103074,-4.29664 -6.416395,0.31341 -5.780887,-2.94751 -1.377603,1.09799 -3.12488,0.67029 -5.911336,-1.61178 5.264392,4.50224 -0.938845,2.98448 3.391327,2.6875 9.128301,6.88393 1.433786,2.84407 -1.013816,1.45934 5.506273,3.67136 7.106665,4.77911 z m 9.243194,5.31013 c 11.238769,3.62163 -5.510018,-4.4246 0,0 z m 47.316399,28.66432 c 0.14496,-2.22965 -1.53604,1.90201 0,0 z m 4.86324,2.04679 c 2.59297,-2.51255 0.106,4.00222 4.29655,-0.61509 0.0453,-3.30544 -0.12904,-5.25783 -4.81563,-1.24252 -1.29194,0.71648 -1.86871,3.76288 0.51908,1.85761 z M 74.932378,140.02637 c -0.796355,-3.1304 -5.581949,-3.11418 0,0 z m 5.193029,3.40294 c -1.928397,-3.19739 -6.880525,-2.89469 0,0 z m 29.543373,17.81697 c 2.8844,2.56199 13.24761,1.87984 3.50331,0.31527 -1.44321,-2.13386 -9.16415,-1.6203 -3.50331,-0.31527 z m 40.61236,25.08153 c 4.43933,-3.72512 -4.30122,1.66183 0,0 z m 9.2328,6.34473 c 0.0277,-1.19543 -1.91352,0.52338 0,0 z m 0.0142,-1.6736 c 4.91602,-5.20866 -4.76346,0.30807 -4e-5,0 l 4e-5,0 z M 62.15981,129.33339 c -4.189944,-5.97826 -2.604586,-8.66544 -6.645136,-13.54677 -0.764913,-3.73279 -6.931672,-12.20326 -3.189579,-3.22947 3.42754,5.24836 4.446054,13.37434 9.834715,16.77624 z m 95.82635,60.00977 c 9.04429,-5.84575 -3.7125,-2.54641 0,0 z m 6.9041,2.70461 c 4.52911,-3.88867 -2.86491,-0.81334 0,0 z M 73.393094,133.41838 c 1.296204,-1.92838 -3.347642,-0.24666 0,0 z m 90.055596,56.78275 c 4.38526,-2.82746 -1.01036,-2.39335 -0.79483,0.26003 l 0.79484,-0.26003 -1e-5,0 z m -59.51386,-37.51178 c -0.15075,-1.90924 -2.31574,0.16206 0,0 z m 3.67794,2.11629 c -1.16888,-2.36318 -1.79716,0.37121 0,0 z m 62.8725,37.30625 c 5.61806,-4.05283 -3.4056,-0.77594 -1.17927,0.76785 l 1.17927,-0.76785 0,0 z m -2.15131,-1.03979 c 4.57663,-3.83506 -4.83183,1.69954 0,0 z m 10.99163,7.31983 c 3.0728,-2.05816 -3.73316,-0.66575 0,0 z M 76.211249,132.02781 c 4.118965,0.92286 16.460394,10.1439 9.179466,0.63772 -3.728991,-1.10384 -1.492605,-10.21906 -5.29621,-8.60579 2.552972,4.2649 2.100461,6.08018 -3.259642,3.3914 -6.736808,-3.28853 -3.785888,1.6297 -2.469293,2.98518 -1.794185,0.40772 2.373226,1.5572 1.845679,1.59149 z m -18.76588,-14.82026 c 0.737407,-3.04991 -6.789814,-16.77881 -3.554464,-6.87916 1.167861,2.07373 1.049123,6.00387 3.554464,6.87916 z m 34.443451,21.23513 c -2.120989,-1.77378 -0.100792,-0.25103 0,0 z m 5.222997,1.21548 c -0.0027,-3.23079 -5.77326,-1.31196 0,0 z m 45.261473,28.53321 c -0.86326,-2.20739 -3.41229,-0.0512 8e-5,4e-5 l -8e-5,-4e-5 z m 2.17351,1.58769 c -0.32087,-1.23546 -1.25399,0.23848 0,0 z m 17.94015,11.3001 c 1.72546,-1.27472 -2.15318,-0.1628 0,0 z M 66.819057,119.6006 c 4.935243,-1.91072 -5.28775,-1.36248 0,0 z m 71.569733,45.08937 c -0.0549,-3.19499 -3.14622,0.79264 0,0 z M 64.869152,115.05675 c 3.170167,-1.07084 -2.932663,-0.70531 0,0 z m 9.201532,4.45726 c -0.0575,-1.05014 -0.973336,0.39747 0,0 z m 112.231406,68.82181 c 4.0765,-0.8265 13.36606,2.07489 14.86752,-1.08086 -4.95044,-0.12019 -17.12734,-3.49263 -17.70346,0.80479 l 1.08368,0.17072 1.75226,0.10534 0,1e-5 z M 76.995161,120.25099 c 0.07087,-3.23755 -2.524669,-0.12092 0,0 z M 52.801998,103.4687 c -1.098703,-6.16843 -4.178791,-0.93357 0,0 z m 5.769195,1.45013 c 0.07087,-1.9807 -5.280562,-1.78224 0,0 z m 3.296917,1.61923 c -0.953019,-0.77196 -0.745959,0.97521 0,0 z m 20.744719,13.30775 c 0.976615,-0.89718 -2.312116,-0.66455 0,0 z M 59.672204,102.88617 c -0.557624,-4.65897 -6.681999,-0.69805 0,0 z M 47.844441,95.21166 c -0.168219,-2.150189 -1.152625,0.81111 0,0 z m 1.759336,-1.328672 c -0.28703,-2.549584 -1.510515,0.324387 0,0 z m 9.720792,5.802442 c 4.110486,-1.61465 -7.487254,-3.33984 -0.839893,-0.30506 l 0.839893,0.30506 z m 130.097601,80.35913 c 2.63092,-2.4121 -3.34373,-0.74577 0,0 z m 15.71669,8.14691 c 1.05433,-3.1186 -2.65452,0.41058 0,0 z M 60.318012,94.590436 c 0.433018,-3.016773 -3.258762,0.59902 0,0 z M 46.487687,85.324242 c -0.742965,-4.25911 -0.64134,-11.735065 6.465133,-9.208583 -9.485962,1.883339 6.56534,11.790095 4.538357,3.968363 3.988626,0.195294 7.802669,-2.357284 5.709487,1.516403 7.85876,-0.867958 13.307129,-7.682612 20.898169,-6.72768 5.913058,-0.782493 12.378182,-1.375955 18.750257,-3.756157 5.23905,-0.37743 10.28235,-6.018062 7.41068,-9.361383 -7.14456,-0.604513 -14.62339,0.289393 -22.520112,1.858993 -8.750559,1.819117 -16.699014,5.275307 -25.528125,6.758866 -8.605891,1.15604 1.730998,3.185165 -0.734074,3.637227 -4.490681,1.558136 5.355488,2.608852 -0.582182,4.251428 C 57.228283,77.56448 53.411411,76.304535 54.977788,72.440196 46.7341,73.50992 39.490264,76.931325 46.003276,85.320342 l 0.484402,0.0037 9e-6,-2.56e-4 z m 19.864291,-10.1168 c 1.932856,-7.120464 10.355229,5.859274 3.168052,0.945776 -0.858453,-0.642457 -2.2703,-1.166588 -3.168052,-0.945776 z m 0.376038,-3.452197 c 2.789661,-2.078257 1.482964,1.16516 0,0 z m 3.542213,0.05622 c 0.251833,-3.27648 8.108752,1.73455 1.295517,1.179499 l -1.295517,-1.179499 0,0 z m 4.84543,-1.948193 c 1.769481,-2.067535 0.50862,1.83906 0,0 z m 1.239563,-0.83005 c 2.946379,-3.540216 16.68561,-2.259413 6.628966,-0.34519 -2.695543,-2.029363 -4.761797,1.196575 -6.628966,0.34519 z m 17.930017,-2.763886 c -0.448199,-9.670222 8.907771,3.436477 0,0 z m 5.087539,-0.02784 c 1.860022,-4.873906 7.218072,-1.955774 0.860732,-0.979407 0.13805,0.518656 -0.18642,2.516173 -0.860732,0.979407 z M 58.311842,92.088739 c 5.55753,-3.403212 -5.899945,-2.952541 0,0 l 0,0 z m 4.109214,1.141866 c 1.948513,-2.071884 -4.233857,-0.840369 0,0 z M 50.313395,84.63767 c 3.175569,-2.439416 -3.757842,-0.927473 0,0 z M 214.41578,187.30012 c 0.0918,-2.83019 -2.42718,1.27537 0,0 z m -16.67487,-11.37935 c 0.47417,-3.25845 -2.14286,0.28408 0,0 z m 21.26022,12.47672 c 4.43994,0.015 13.45265,-1.37884 3.79217,-1.37442 -1.51594,0.23641 -8.83311,0.18571 -3.79216,1.37439 l -1e-5,3e-5 z M 66.035603,91.23339 c 3.593258,-0.246807 5.621861,-3.963629 -0.694932,-3.749977 -9.789949,-1.013541 8.637508,3.352129 -1.255898,2.10383 -1.329368,0.880346 1.873606,1.889721 1.95083,1.646147 z m 3.164618,1.601748 c -0.375177,-2.307063 -1.111156,1.225591 0,0 z m 3.753896,-10.009901 c 1.559281,-1.934055 -2.157697,-0.517053 0,0 z M 61.003998,62.84999 c 6.412879,-2.181631 15.182392,-4.633087 18.210335,1.074184 -3.081589,-3.70893 -1.24361,-7.360157 1.666959,-1.937407 4.115576,5.486669 6.175915,-2.495489 3.499086,-4.335821 3.050468,3.790246 6.520044,5.581281 2.042429,0.239564 4.865693,-5.852929 -9.742712,0.766433 -13.063105,0.699775 -1.597564,0.717062 -16.493576,3.79889 -12.355704,4.259705 z m 3.75831,-7.197834 c 3.657324,-2.760416 12.648968,1.641989 6.879078,-2.743367 -0.564117,-0.498292 -12.636077,3.325475 -6.879078,2.743367 z m 13.333489,0.550473 c 4.280389,0.109225 -1.84632,-5.750287 3.254304,-3.095159 -0.837696,-2.736627 -5.938558,-3.248956 -8.432316,-4.342312 -1.410474,2.502054 2.870977,7.471102 5.178012,7.437471 z M 67.100291,44.099162 c 1.480803,-2.007406 -2.59521,1.017699 0,0 z m 5.449586,1.304353 c 6.897867,-0.914901 -1.758292,-2.970542 -1.389954,-0.07352 l 1.389954,0.07352 0,-9e-6 z M 62.374386,37.441437 c -4.856866,-6.340205 9.133987,1.065769 4.199411,-5.572646 -4.153254,-3.307245 -8.144297,3.721775 -4.199411,5.572646 z m 62.330124,33.572802 c 2.22762,-3.948988 -9.19697,-5.323011 -1.5009,-1.399578 0.70858,0.236781 0.54821,1.6727 1.5009,1.399578 z"
+       id="path2900"
+       style="fill:#000000" />
+    <g
+       id="text3850"
+       style="font-size:40px;font-style:normal;font-weight:normal;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans">
+      <path
+         d="m 229.85182,43.77803 c -0.79695,3.140714 -1.28913,8.414146 -1.47656,15.820313 -7e-5,1.453199 -0.65632,2.179761 -1.96875,2.179687 -1.31257,7.4e-5 -2.22663,-0.632738 -2.74219,-1.898437 -1.40631,-3.421796 -2.74225,-5.812419 -4.00781,-7.171875 -1.50006,-1.593666 -3.49225,-2.554603 -5.97656,-2.882813 -2.67193,-0.421789 -9.32818,-0.632726 -19.96875,-0.632812 -2.43754,8.6e-5 -4.03129,0.257898 -4.78125,0.773437 -0.46879,0.32821 -0.70316,1.031335 -0.70313,2.109375 l 0,31.851563 c -3e-5,1.078175 0.67966,1.5938 2.03906,1.546875 4.17184,-0.04682 10.21871,-0.328075 18.14063,-0.84375 1.54682,-0.187449 2.58979,-0.691355 3.12891,-1.511719 0.539,-0.820259 1.06634,-2.941351 1.58203,-6.363281 0.32806,-1.87494 1.42963,-2.601502 3.30468,-2.179688 1.59369,0.328186 2.27338,1.054747 2.03907,2.179688 -1.31256,6.375052 -1.73444,14.671919 -1.26563,24.890627 0.0468,1.21878 -0.72662,1.87503 -2.32031,1.96875 -1.31256,0.14065 -2.13287,-0.56247 -2.46094,-2.10938 -1.2188,-5.859333 -3.48052,-8.988236 -6.78515,-9.386716 -3.30474,-0.398394 -8.68364,-0.597612 -16.13672,-0.597656 -0.84379,4.4e-5 -1.26566,0.304731 -1.26563,0.914062 l 0,31.64063 c -3e-5,2.34375 0.86716,3.9375 2.60156,4.78125 1.35934,0.70313 4.28903,1.33594 8.78907,1.89843 2.29683,0.23438 3.30464,1.24219 3.02343,3.02344 -0.28129,1.54688 -2.34379,2.15625 -6.1875,1.82813 -11.1094,-0.89063 -20.27345,-0.84375 -27.49218,0.14062 -2.01564,0.28125 -3.02345,-0.53906 -3.02344,-2.46094 -1e-5,-1.21874 1.0078,-1.92187 3.02344,-2.10937 4.59373,-0.51562 6.8906,-4.54687 6.89062,-12.09375 l 0,-60.187502 c -2e-5,-3.093671 -0.5508,-5.472575 -1.65234,-7.136719 -1.10158,-1.663977 -3.15236,-3.175695 -6.15235,-4.535156 -1.87501,-0.843661 -2.57813,-1.992098 -2.10937,-3.445313 0.23436,-0.890532 0.60936,-1.382719 1.125,-1.476562 0.46874,-0.140532 1.71092,-0.04678 3.72656,0.28125 2.95311,0.468842 9.91404,0.703217 20.88281,0.703125 12.93746,9.2e-5 24.11713,-0.281158 33.53907,-0.84375 3.14055,-0.187407 4.71086,0.07041 4.71093,0.773437 -7e-5,0.187592 -0.0235,0.375092 -0.0703,0.5625 z"
+         id="path2830"
+         style="font-size:144px;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 275.55495,133.14522 c -4e-5,1.875 -1.05473,2.69531 -3.16407,2.46094 -6.46877,-0.60938 -14.48439,-0.51563 -24.04687,0.28125 -1.92189,0.18749 -3.10548,0.14062 -3.55078,-0.14063 -0.44532,-0.28125 -0.66798,-1.05469 -0.66797,-2.32031 -1e-5,-1.125 1.27733,-2.07422 3.83203,-2.84766 2.55467,-0.77343 3.83202,-3.08202 3.83203,-6.92578 l 0,-63.632812 c -1e-5,-3.796796 -0.55079,-6.585856 -1.65234,-8.367188 -1.10158,-1.781164 -3.03517,-3.163975 -5.80078,-4.148437 -1.45313,-0.515537 -2.1797,-1.242099 -2.17969,-2.179688 -1e-5,-1.406158 1.05468,-2.460845 3.16406,-3.164062 3.18749,-1.031156 6.49217,-2.624904 9.91406,-4.78125 2.81248,-1.687401 4.59373,-2.53115 5.34375,-2.53125 1.73435,1e-4 2.60154,1.195412 2.60157,3.585937 -3e-5,-0.187403 -0.0938,2.156345 -0.28125,7.03125 -0.14065,4.64071 -0.18753,9.211018 -0.14063,13.710938 l 0.28125,62.789062 c -2e-5,2.85939 0.7031,4.9336 2.10938,6.22266 1.40622,1.28906 3.82028,2.14453 7.24218,2.5664 2.10934,0.23438 3.16403,1.03126 3.16407,2.39063 z"
+         id="path2832"
+         style="font-size:144px;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 339.67995,128.43428 c -7e-5,0.98438 -1.79303,2.47266 -5.37891,4.46484 -3.58599,1.99219 -6.45708,2.98828 -8.61328,2.98829 -1.82817,-10e-6 -3.44536,-0.89063 -4.85156,-2.67188 -1.40629,-1.78125 -2.39067,-2.67187 -2.95313,-2.67187 -0.42191,0 -2.64847,0.96094 -6.67969,2.88281 -4.03128,1.92187 -8.08596,2.88281 -12.16406,2.88281 -3.84377,0 -7.0547,-1.125 -9.63281,-3.375 -2.81251,-2.48437 -4.21876,-5.85937 -4.21875,-10.125 -1e-5,-8.10935 9.28123,-13.92185 27.84375,-17.4375 3.18746,-0.60934 4.80465,-1.89841 4.85156,-3.86719 l 0.14063,-4.499997 c 0.28121,-7.687454 -3.11723,-11.5312 -10.19532,-11.53125 -2.01565,5e-5 -3.9258,1.804735 -5.73046,5.414062 -1.80471,3.609416 -4.39456,5.554727 -7.76954,5.835938 -3.84376,0.375038 -5.76563,-1.242148 -5.76562,-4.851563 -1e-5,-2.249954 2.85936,-4.874951 8.57812,-7.875 5.99998,-3.14057 11.7656,-4.710881 17.29688,-4.710937 9.51558,5.6e-5 14.22651,4.523489 14.13281,13.570312 l -0.28125,28.968755 c -0.0469,3.04688 1.24214,4.57032 3.86719,4.57031 0.51557,1e-5 1.49994,-0.11718 2.95312,-0.35156 1.45307,-0.23437 2.29682,-0.35156 2.53125,-0.35157 1.35932,1e-5 2.039,0.91407 2.03907,2.74219 z M 318.0237,112.40303 c 0.0468,-1.17185 -0.2227,-1.94529 -0.8086,-2.32031 -0.58597,-0.37498 -1.51175,-0.44529 -2.77734,-0.21094 -11.2969,2.01565 -16.94533,5.69533 -16.94531,11.03906 -2e-5,5.39064 2.92966,8.08595 8.78906,8.08594 2.34372,1e-5 4.75778,-0.44531 7.24219,-1.33594 2.90621,-1.03124 4.35933,-2.27342 4.35937,-3.72656 z"
+         id="path2834"
+         style="font-size:144px;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 392.13307,120.06709 c -5e-5,4.96876 -1.9102,8.91798 -5.73047,11.84766 -3.82035,2.92969 -9.03519,4.39453 -15.64453,4.39453 -4.40627,0 -8.81252,-0.46875 -13.21875,-1.40625 -3.79688,-0.84375 -6.00001,-1.61719 -6.60937,-2.32031 -0.37501,-0.65625 -0.56251,-3.86718 -0.5625,-9.63281 -1e-5,-2.48436 0.56249,-3.77343 1.6875,-3.86719 1.12499,-0.14061 2.08592,0.46876 2.88281,1.82812 3.51561,6.14064 9.18748,9.21095 17.01562,9.21094 6.60934,1e-5 9.91403,-2.29687 9.91407,-6.89062 -4e-5,-2.01562 -0.75004,-3.70311 -2.25,-5.0625 -1.64066,-1.54686 -4.82816,-3.35155 -9.5625,-5.41407 -6.84377,-3.04685 -11.41408,-5.71872 -13.71094,-8.01562 -2.48439,-2.43747 -3.72657,-5.718716 -3.72656,-9.843752 -1e-5,-5.062455 1.9453,-8.999951 5.83593,-11.8125 3.60936,-2.718695 8.43748,-4.078069 14.48438,-4.078125 3.79684,5.6e-5 7.26559,0.304743 10.40625,0.914062 3.37496,0.60943 5.13277,1.359429 5.27344,2.25 0.37495,2.625051 1.14839,6.421922 2.32031,11.390625 0.14058,0.609416 -0.51567,1.101603 -1.96875,1.476563 -1.54692,0.328165 -2.57817,0.07035 -3.09375,-0.773438 -3.70317,-6.046828 -8.39066,-9.070262 -14.0625,-9.070312 -6.4219,5e-5 -9.63283,2.062548 -9.63281,6.1875 -2e-5,2.296916 0.86716,4.12504 2.60156,5.484375 1.54685,1.171912 5.17966,3.000035 10.89844,5.484372 5.99996,2.57816 10.07808,4.89847 12.23437,6.96094 2.81245,2.6719 4.2187,6.25783 4.21875,10.75781 z"
+         id="path2836"
+         style="font-size:144px;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 473.69557,132.72334 c -7e-5,1.64063 -1.10163,2.50782 -3.30469,2.60157 -3.28131,0.0469 -7.57037,0.28124 -12.86718,0.70312 -2.62506,0.51562 -4.50006,0.1875 -5.625,-0.98437 -7.4063,-7.96875 -13.68754,-16.31249 -18.84375,-25.03125 -0.42191,-0.74998 -0.96097,-1.12498 -1.61719,-1.125 -0.79691,2e-5 -2.17972,0.70315 -4.14844,2.10937 -2.20315,1.21877 -3.30471,2.95315 -3.30469,5.20313 -2e-5,1.59376 0.0469,3.89064 0.14063,6.89062 0.0937,3.00001 0.84372,4.96876 2.25,5.90625 0.98435,0.65626 3.25778,1.17188 6.82031,1.54688 2.20309,0.28125 3.30465,1.10156 3.30469,2.46093 -4e-5,1.07813 -0.17582,1.7461 -0.52734,2.00391 -0.3516,0.25781 -1.27738,0.31641 -2.77735,0.17578 -4.68753,-0.42187 -12.60939,-0.1875 -23.76562,0.70313 -2.81251,0.23437 -4.33595,-0.11719 -4.57032,-1.05469 -0.0937,-0.32813 -0.14063,-0.79688 -0.14062,-1.40625 -1e-5,-1.45312 1.42968,-2.55469 4.28906,-3.30469 2.57811,-0.65624 3.86718,-3.67968 3.86719,-9.07031 l 0,-61.453127 c -1e-5,-3.843671 -0.37501,-6.515543 -1.125,-8.015625 -1.03126,-1.92179 -3.18751,-3.421788 -6.46875,-4.5 -1.54688,-0.515536 -2.32032,-1.242098 -2.32031,-2.179688 -1e-5,-1.359283 1.10155,-2.413969 3.30468,-3.164062 3.51562,-1.17178 6.86718,-2.788966 10.05469,-4.851563 2.57811,-1.6874 4.17186,-2.531149 4.78125,-2.53125 1.92185,1.01e-4 2.88279,1.21885 2.88281,3.65625 -2e-5,-0.328027 -0.0235,1.992283 -0.0703,6.960938 -0.0469,3.421962 -0.0703,8.015707 -0.0703,13.78125 l 0.14062,44.015627 c -2e-5,1.21878 0.3281,1.82815 0.98438,1.82812 0.7031,3e-5 1.78122,-0.60934 3.23437,-1.82812 3.8906,-3.046842 8.67184,-7.031213 14.34375,-11.953127 1.12496,-1.17183 1.68746,-2.109329 1.6875,-2.8125 -4e-5,-1.265577 -1.89848,-2.156201 -5.69531,-2.671875 -1.64066,-0.18745 -2.4141,-1.101512 -2.32031,-2.742188 0.14059,-1.64057 0.9609,-2.343695 2.46094,-2.109375 3.37495,0.468805 8.29682,0.726617 14.76562,0.773438 4.49994,0.04693 8.9765,0.07037 13.42969,0.07031 1.45306,0.04693 2.17962,0.914116 2.17969,2.601563 -7e-5,1.5938 -1.14851,2.460986 -3.44532,2.601562 -3.60943,0.140674 -7.00787,0.960986 -10.19531,2.460938 -4.45317,2.015669 -9.21098,5.554728 -14.27344,10.617187 -0.37504,0.281286 -0.56254,0.632847 -0.5625,1.054687 -4e-5,0.65629 0.79684,2.2266 2.39063,4.71094 5.85933,8.90627 11.39057,15.63283 16.59375,20.17969 3.32806,2.85938 6.44525,4.28907 9.35156,4.28906 2.15618,1e-5 3.49212,0.15235 4.00781,0.45703 0.51556,0.30469 0.77337,1.11329 0.77344,2.42578 z"
+         id="path2838"
+         style="font-size:144px;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+    </g>
+    <g
+       transform="translate(0,4)"
+       id="text2870"
+       style="font-size:40px;font-style:normal;font-weight:normal;line-height:89.99999762%;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans">
+      <path
+         d="m 285.18048,153.2296 c -2e-5,0.16407 -0.31447,0.33269 -0.94336,0.50586 -0.41017,0.10938 -0.75653,0.48308 -1.03906,1.12109 -1.64064,3.64584 -3.10809,6.58529 -4.40234,8.81836 -0.0274,0.0547 -0.0957,0.0775 -0.20508,0.0684 -0.10028,-0.009 -0.16864,-0.041 -0.20508,-0.0957 -0.21876,-0.32813 -0.77931,-1.47201 -1.68164,-3.43164 -0.82944,-1.80469 -1.27605,-2.70703 -1.33985,-2.70703 -0.10027,0 -0.5879,0.87044 -1.46289,2.61132 -0.99349,1.94141 -1.58138,3.04883 -1.76367,3.32227 -0.0274,0.0638 -0.0912,0.0911 -0.1914,0.082 -0.10027,0 -0.17775,-0.0365 -0.23243,-0.10937 -0.66537,-1.03907 -2.19662,-3.88281 -4.59375,-8.53125 -0.27344,-0.54687 -0.57877,-0.89322 -0.91601,-1.03907 -0.58334,-0.2552 -0.875,-0.44204 -0.875,-0.56054 0,-0.35546 0.19596,-0.51497 0.58789,-0.47852 1.75911,0.1823 3.29947,0.14584 4.62109,-0.10937 0.30078,-0.0547 0.45117,0.0456 0.45117,0.30078 0,0.35548 -0.20508,0.58334 -0.61523,0.68359 -0.53777,0.12761 -0.80665,0.32814 -0.80664,0.60156 -1e-5,0.17319 0.0638,0.4284 0.19141,0.76563 0.32812,0.84766 0.8203,1.92318 1.47656,3.22656 0.65624,1.30339 1.03905,1.95508 1.14844,1.95508 0.0729,0 0.46027,-0.67903 1.16211,-2.03711 0.70181,-1.35807 1.05272,-2.11002 1.05273,-2.25586 -1e-5,-0.35546 -0.1185,-0.73827 -0.35547,-1.14844 -0.28256,-0.60155 -0.61069,-0.96158 -0.98437,-1.08008 -0.60157,-0.20962 -0.90236,-0.4147 -0.90235,-0.61523 -1e-5,-0.36457 0.20507,-0.51496 0.61524,-0.45117 1.40363,0.19142 2.88931,0.15496 4.45703,-0.10938 0.30077,-0.0547 0.45116,0.0365 0.45117,0.27344 -1e-5,0.34637 -0.22332,0.56056 -0.66992,0.64258 -0.52866,0.10027 -0.79298,0.36915 -0.79297,0.80664 -10e-6,0.20964 0.0592,0.45574 0.17773,0.73828 1.40364,3.4362 2.21483,5.1543 2.4336,5.1543 0.082,0 0.4466,-0.66992 1.09375,-2.00977 0.69269,-1.43098 1.17576,-2.58397 1.44922,-3.45898 0.0182,-0.0456 0.0273,-0.0957 0.0273,-0.15039 -2e-5,-0.39192 -0.30536,-0.69726 -0.91602,-0.91602 -0.47397,-0.15494 -0.71095,-0.32811 -0.71093,-0.51953 -2e-5,-0.319 0.15037,-0.44205 0.45117,-0.36914 0.41014,0.0912 1.14842,0.15952 2.21484,0.20508 1.00259,0.0365 1.66339,0.0319 1.98242,-0.0137 0.37368,-0.0456 0.56053,0.0592 0.56055,0.31445 z"
+         id="path2877"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 297.29376,160.98155 c -1e-5,0.39193 -0.52865,0.87956 -1.58594,1.46289 -1.20313,0.64714 -2.43815,0.97071 -3.70507,0.97071 -1.37631,0 -2.53386,-0.45118 -3.47266,-1.35352 -1.00261,-0.95703 -1.50391,-2.23307 -1.50391,-3.82812 0,-1.80469 0.56055,-3.2539 1.68164,-4.34766 1.04818,-1.02082 2.33333,-1.53124 3.85547,-1.53125 0.90234,1e-5 1.74544,0.28712 2.5293,0.86133 0.70181,0.51042 1.16666,1.09376 1.39453,1.75 0.082,0.22787 0.20507,0.35547 0.36914,0.38281 0.23697,0.0456 0.35546,0.20509 0.35547,0.47852 -10e-6,0.3737 -0.44662,0.69271 -1.33984,0.95703 l -6.30274,1.87304 c 0.41016,2.16928 1.52213,3.25391 3.33594,3.25391 1.04817,0 2.24218,-0.38281 3.58203,-1.14844 0.20051,-0.11848 0.39648,-0.17773 0.58789,-0.17773 0.14582,0 0.21874,0.13216 0.21875,0.39648 z m -3.24023,-5.48242 c -1e-5,-0.50129 -0.20281,-0.94107 -0.6084,-1.31934 -0.40561,-0.37824 -0.90463,-0.56737 -1.49707,-0.56738 -1.67709,10e-6 -2.51563,1.20769 -2.51563,3.62305 l 0,0.32812 3.71875,-1.12109 c 0.60156,-0.17317 0.90234,-0.48762 0.90235,-0.94336 z"
+         id="path2879"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 311.11603,157.13976 c -2e-5,1.80469 -0.62892,3.31771 -1.88672,4.53906 -1.20314,1.17578 -2.63412,1.76367 -4.29297,1.76367 -0.93881,0 -1.91862,-0.15495 -2.93945,-0.46484 -0.98438,-0.30078 -1.48112,-0.57422 -1.49024,-0.82032 -0.009,-0.20963 -0.005,-0.94335 0.0137,-2.20117 0.0273,-1.54036 0.041,-2.66145 0.041,-3.36328 0,-1.05728 -0.009,-2.59081 -0.0273,-4.60059 -0.0182,-2.00975 -0.0274,-3.34275 -0.0274,-3.99902 0,-0.65623 -0.10026,-1.1074 -0.30078,-1.35351 -0.18229,-0.21874 -0.57878,-0.39191 -1.18945,-0.51954 -0.23698,-0.082 -0.35547,-0.2324 -0.35547,-0.45117 0,-0.19139 0.17773,-0.35089 0.5332,-0.47851 0.51042,-0.18228 1.14388,-0.48762 1.90039,-0.91602 0.60156,-0.33722 0.97526,-0.50584 1.1211,-0.50586 0.28254,2e-5 0.42382,0.23244 0.42383,0.69727 -1e-5,0.0365 -0.0137,0.51043 -0.041,1.42187 -0.0182,0.85679 -0.0228,1.72723 -0.0137,2.61133 l 0.0273,4.73047 c 0,0.44662 0.15494,0.57423 0.46485,0.38281 1.09374,-0.62889 2.23306,-0.94335 3.41796,-0.94336 1.36718,1e-5 2.47916,0.41245 3.33594,1.23731 0.85676,0.82488 1.28514,1.90267 1.28516,3.2334 z m -2.1875,1.21679 c -1e-5,-1.24869 -0.34637,-2.27408 -1.03906,-3.07617 -0.65626,-0.7565 -1.45379,-1.13476 -2.39258,-1.13477 -0.64714,1e-5 -1.28517,0.16863 -1.91407,0.50586 -0.62891,0.33725 -0.94336,0.69272 -0.94335,1.06641 l 0,3.66406 c -1e-5,1.75912 0.97069,2.63868 2.9121,2.63867 1.01172,1e-5 1.82747,-0.32584 2.44727,-0.97753 0.61978,-0.65169 0.92968,-1.5472 0.92969,-2.68653 z"
+         id="path2881"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 333.23712,161.93858 c 0.0365,0.082 0.0547,0.14128 0.0547,0.17774 -2e-5,0.1914 -0.46486,0.50586 -1.39453,0.94336 -1.07553,0.5013 -1.709,0.75195 -1.90039,0.75195 -0.16408,0 -0.3532,-0.27572 -0.56739,-0.82715 -0.2142,-0.55143 -0.36231,-0.82715 -0.44433,-0.82715 0.009,0 -0.50815,0.23926 -1.55176,0.71778 -1.04363,0.47851 -1.83432,0.71777 -2.37207,0.71777 -1.20313,0 -2.24675,-0.43294 -3.13086,-1.29883 -1.06641,-1.02994 -1.59961,-2.4746 -1.59961,-4.33398 0,-1.55859 0.62891,-2.90299 1.88672,-4.03321 1.11198,-0.99347 2.24674,-1.49022 3.4043,-1.49023 0.8203,1e-5 1.80012,0.10483 2.93945,0.31445 0.35546,0.0638 0.53319,-0.041 0.5332,-0.31445 l 0,-4.67578 c -1e-5,-0.52863 -0.12761,-0.90233 -0.38281,-1.12109 -0.16407,-0.14582 -0.55144,-0.319 -1.16211,-0.51954 -0.29167,-0.10935 -0.43751,-0.28709 -0.4375,-0.5332 -10e-6,-0.22785 0.19596,-0.39647 0.58789,-0.50586 0.53775,-0.16404 1.18033,-0.43748 1.92774,-0.82031 0.61978,-0.32811 1.00259,-0.49217 1.14843,-0.49219 0.33723,2e-5 0.50585,0.23244 0.50586,0.69727 -1e-5,-0.009 -0.0137,0.44435 -0.041,1.36035 -0.0274,0.91603 -0.041,1.80698 -0.041,2.67285 l 0,12.42773 c -10e-6,0.51954 0.23241,0.7793 0.69727,0.7793 0.17316,0 0.40558,-0.0273 0.69726,-0.082 0.319,-0.0547 0.53319,0.0501 0.64258,0.31445 z m -4.22461,-1.35351 0,-4.89453 c -1e-5,-0.40104 -0.35548,-0.81575 -1.0664,-1.24414 -0.75652,-0.45572 -1.57683,-0.68359 -2.46094,-0.6836 -1.90495,1e-5 -2.85743,1.17579 -2.85742,3.52735 -10e-6,1.25781 0.37825,2.33333 1.13476,3.22656 0.75651,0.89323 1.64518,1.33984 2.66602,1.33984 0.46483,0 1.01171,-0.15494 1.64062,-0.46484 0.6289,-0.3099 0.94335,-0.57878 0.94336,-0.80664 z"
+         id="path2883"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 345.09064,160.98155 c -1e-5,0.39193 -0.52866,0.87956 -1.58594,1.46289 -1.20313,0.64714 -2.43816,0.97071 -3.70508,0.97071 -1.37631,0 -2.53386,-0.45118 -3.47265,-1.35352 -1.00261,-0.95703 -1.50391,-2.23307 -1.50391,-3.82812 0,-1.80469 0.56054,-3.2539 1.68164,-4.34766 1.04817,-1.02082 2.33333,-1.53124 3.85547,-1.53125 0.90233,1e-5 1.74543,0.28712 2.5293,0.86133 0.70181,0.51042 1.16665,1.09376 1.39453,1.75 0.082,0.22787 0.20506,0.35547 0.36914,0.38281 0.23697,0.0456 0.35545,0.20509 0.35547,0.47852 -2e-5,0.3737 -0.44663,0.69271 -1.33985,0.95703 l -6.30273,1.87304 c 0.41015,2.16928 1.52213,3.25391 3.33594,3.25391 1.04816,0 2.24217,-0.38281 3.58203,-1.14844 0.20051,-0.11848 0.39647,-0.17773 0.58789,-0.17773 0.14582,0 0.21874,0.13216 0.21875,0.39648 z m -3.24024,-5.48242 c -10e-6,-0.50129 -0.2028,-0.94107 -0.6084,-1.31934 -0.4056,-0.37824 -0.90462,-0.56737 -1.49707,-0.56738 -1.67708,10e-6 -2.51562,1.20769 -2.51562,3.62305 l 0,0.32812 3.71875,-1.12109 c 0.60155,-0.17317 0.90233,-0.48762 0.90234,-0.94336 z"
+         id="path2885"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 360.08868,153.20226 c -1e-5,0.17318 -0.28712,0.35092 -0.86132,0.5332 -0.37371,0.10938 -0.69272,0.48308 -0.95704,1.12109 -0.44662,1.07553 -1.22592,2.65235 -2.33789,4.73047 -0.93881,1.75912 -1.66342,3.04427 -2.17382,3.85547 -0.0274,0.0638 -0.0866,0.0911 -0.17774,0.082 -0.0912,-0.009 -0.15951,-0.0456 -0.20508,-0.10937 -1.93229,-2.77995 -3.58659,-5.6237 -4.96289,-8.53125 -0.29167,-0.61979 -0.57878,-0.96614 -0.86133,-1.03907 -0.52864,-0.20962 -0.79296,-0.39647 -0.79296,-0.56054 0,-0.36458 0.17773,-0.52408 0.5332,-0.47852 0.55599,0.0729 1.38086,0.0912 2.47461,0.0547 1.05729,-0.0273 1.80012,-0.082 2.22851,-0.16406 0.26432,-0.0547 0.39648,0.0456 0.39649,0.30078 -10e-6,0.31902 -0.18686,0.5241 -0.56055,0.61523 -0.71094,0.19142 -1.06641,0.43751 -1.0664,0.73828 -1e-5,0.13673 0.0456,0.33269 0.13671,0.58789 0.30989,0.76564 0.86588,1.87078 1.66797,3.31543 0.80208,1.44467 1.25781,2.167 1.36719,2.167 0.082,0 0.46939,-0.69271 1.16211,-2.07813 0.72916,-1.46744 1.25324,-2.65234 1.57227,-3.55469 0.0456,-0.10025 0.0683,-0.20051 0.0684,-0.30078 -1e-5,-0.35546 -0.21876,-0.64712 -0.65625,-0.875 -0.42839,-0.17317 -0.64258,-0.33723 -0.64257,-0.49219 -10e-6,-0.319 0.14126,-0.45116 0.42382,-0.39648 0.36458,0.0638 1.00716,0.1185 1.92774,0.16406 0.92056,0.0456 1.51756,0.0547 1.79101,0.0274 0.33723,-0.0638 0.50585,0.0319 0.50586,0.28711 z"
+         id="path2887"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 372.21564,160.98155 c -1e-5,0.39193 -0.52866,0.87956 -1.58594,1.46289 -1.20313,0.64714 -2.43816,0.97071 -3.70508,0.97071 -1.37631,0 -2.53386,-0.45118 -3.47265,-1.35352 -1.00261,-0.95703 -1.50391,-2.23307 -1.50391,-3.82812 0,-1.80469 0.56054,-3.2539 1.68164,-4.34766 1.04817,-1.02082 2.33333,-1.53124 3.85547,-1.53125 0.90233,1e-5 1.74543,0.28712 2.5293,0.86133 0.70181,0.51042 1.16665,1.09376 1.39453,1.75 0.082,0.22787 0.20506,0.35547 0.36914,0.38281 0.23697,0.0456 0.35545,0.20509 0.35547,0.47852 -2e-5,0.3737 -0.44663,0.69271 -1.33985,0.95703 l -6.30273,1.87304 c 0.41015,2.16928 1.52213,3.25391 3.33594,3.25391 1.04816,0 2.24217,-0.38281 3.58203,-1.14844 0.20051,-0.11848 0.39647,-0.17773 0.58789,-0.17773 0.14582,0 0.21874,0.13216 0.21875,0.39648 z m -3.24024,-5.48242 c -10e-6,-0.50129 -0.2028,-0.94107 -0.6084,-1.31934 -0.4056,-0.37824 -0.90462,-0.56737 -1.49707,-0.56738 -1.67708,10e-6 -2.51562,1.20769 -2.51562,3.62305 l 0,0.32812 3.71875,-1.12109 c 0.60155,-0.17317 0.90233,-0.48762 0.90234,-0.94336 z"
+         id="path2889"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 380.66486,162.88194 c -10e-6,0.36459 -0.20509,0.52409 -0.61524,0.47852 -1.25782,-0.11849 -2.81641,-0.10026 -4.67578,0.0547 -0.3737,0.0365 -0.60384,0.0273 -0.69043,-0.0274 -0.0866,-0.0547 -0.12988,-0.20508 -0.12988,-0.45117 0,-0.21875 0.24837,-0.40332 0.74512,-0.55371 0.49674,-0.15039 0.74511,-0.59928 0.74511,-1.34668 l 0,-12.37305 c 0,-0.73826 -0.1071,-1.28058 -0.32129,-1.62695 -0.21419,-0.34634 -0.59017,-0.61522 -1.12793,-0.80664 -0.28255,-0.10024 -0.42383,-0.24152 -0.42382,-0.42383 -1e-5,-0.27342 0.20507,-0.4785 0.61523,-0.61523 0.61979,-0.20051 1.26237,-0.5104 1.92773,-0.92969 0.54687,-0.32811 0.89323,-0.49217 1.03907,-0.49219 0.33723,2e-5 0.50585,0.23244 0.50586,0.69727 -10e-6,-0.0364 -0.0182,0.41929 -0.0547,1.36718 -0.0273,0.90236 -0.0365,1.79104 -0.0273,2.66602 l 0.0547,12.20898 c 0,0.556 0.13672,0.95932 0.41016,1.20997 0.27343,0.25065 0.74283,0.41699 1.4082,0.49902 0.41015,0.0456 0.61523,0.20052 0.61524,0.46484 z"
+         id="path2891"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 393.9129,157.76866 c -10e-6,1.58594 -0.5879,2.92806 -1.76367,4.02637 -1.17579,1.09831 -2.63412,1.64746 -4.375,1.64746 -1.74089,0 -3.11719,-0.48307 -4.12891,-1.44922 -0.97526,-0.94791 -1.46289,-2.20117 -1.46289,-3.75976 0,-1.61328 0.61524,-2.97135 1.84571,-4.07422 1.194,-1.0664 2.62043,-1.5996 4.27929,-1.59961 1.77734,1e-5 3.16275,0.47852 4.15625,1.43554 0.96614,0.9297 1.44921,2.18751 1.44922,3.77344 z m -2.40625,0.83399 c -10e-6,-1.43099 -0.34636,-2.5931 -1.03906,-3.48633 -0.67449,-0.86588 -1.53126,-1.29882 -2.57031,-1.29883 -0.96615,1e-5 -1.75456,0.33953 -2.36524,1.01855 -0.61068,0.67905 -0.91602,1.5062 -0.91601,2.48145 -1e-5,1.56771 0.35546,2.78907 1.0664,3.66406 0.65625,0.80209 1.51302,1.20313 2.57032,1.20313 1.00259,0 1.79556,-0.33268 2.3789,-0.99805 0.58333,-0.66536 0.87499,-1.52669 0.875,-2.58398 z"
+         id="path2893"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 408.20001,157.39952 c -1e-5,1.70443 -0.57195,3.15137 -1.71582,4.34082 -1.14389,1.18945 -2.4769,1.78418 -3.99902,1.78418 -0.92058,0 -1.80925,-0.16406 -2.66602,-0.49219 -0.0911,-0.0365 -0.13672,0.18685 -0.13672,0.66993 l 0,4.11523 c 0,0.66536 0.54232,1.09374 1.62696,1.28516 0.50129,0.0911 0.81802,0.17545 0.95019,0.25293 0.13216,0.0775 0.19824,0.20735 0.19824,0.38964 0,0.30989 -0.20508,0.45117 -0.61523,0.42383 -1.91407,-0.12761 -3.59571,-0.082 -5.04492,0.13672 -0.30078,0.0456 -0.48763,0.0456 -0.56055,0 -0.0729,-0.0456 -0.10937,-0.16863 -0.10937,-0.36914 0,-0.15495 0.25976,-0.3418 0.77929,-0.56055 0.44662,-0.18229 0.66992,-0.61979 0.66993,-1.3125 l 0,-12.05859 c -1e-5,-0.97525 -0.26889,-1.58593 -0.80665,-1.83203 -0.63802,-0.28254 -0.95703,-0.51497 -0.95703,-0.69727 0,-0.20051 0.18685,-0.3509 0.56055,-0.45117 0.48307,-0.11848 0.97982,-0.32811 1.49023,-0.62891 0.42839,-0.24608 0.68815,-0.36912 0.7793,-0.36914 0.27344,2e-5 0.49674,0.25067 0.66992,0.75196 0.25521,0.72917 0.42383,1.09376 0.50586,1.09375 0.0182,1e-5 0.41015,-0.21419 1.17578,-0.64258 0.83854,-0.46483 1.65885,-0.69726 2.46094,-0.69727 1.30338,1e-5 2.3789,0.36915 3.22656,1.10743 1.01171,0.86589 1.51757,2.11914 1.51758,3.75976 z m -2.32422,1.09375 c -10e-6,-1.48567 -0.36915,-2.64322 -1.10742,-3.47266 -0.60157,-0.69269 -1.30795,-1.03905 -2.11914,-1.03906 -0.62891,1e-5 -1.23047,0.21876 -1.80469,0.65625 -0.75651,0.56511 -1.13477,1.35353 -1.13476,2.36524 l 0,3.86914 c -1e-5,0.24609 0.34635,0.50586 1.03906,0.77929 0.74739,0.30079 1.55859,0.45118 2.43359,0.45118 1.79557,0 2.69335,-1.20313 2.69336,-3.60938 z"
+         id="path2895"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 432.90509,162.90929 c -2e-5,0.30078 -0.1413,0.44205 -0.42383,0.42382 -1.85939,-0.13671 -3.59116,-0.13671 -5.19531,0 -0.35549,0.0273 -0.53322,-0.13216 -0.5332,-0.47851 -2e-5,-0.27344 0.24152,-0.41927 0.72461,-0.4375 0.61065,-0.0365 0.91599,-0.5332 0.91601,-1.49024 l 0,-3.63671 c -2e-5,-2.04166 -0.82944,-3.0625 -2.48828,-3.0625 -0.87502,0 -1.6771,0.15951 -2.40625,0.47851 -0.66538,0.28256 -1.00262,0.56511 -1.01172,0.84766 l -0.0547,5.42773 c -10e-6,0.56511 0.0684,0.93881 0.20508,1.1211 0.10025,0.1276 0.32356,0.21875 0.66992,0.27343 0.80207,0.13672 1.20311,0.30534 1.20313,0.50586 -2e-5,0.17318 -0.0228,0.29167 -0.0684,0.35547 -0.0547,0.0911 -0.21877,0.13216 -0.49219,0.12305 -1.03907,-0.0365 -2.55209,-0.0182 -4.53906,0.0547 -0.28256,0.009 -0.46485,-0.0137 -0.54688,-0.0684 -0.082,-0.0547 -0.12305,-0.1823 -0.12304,-0.38282 -10e-6,-0.23697 0.25975,-0.38281 0.77929,-0.4375 0.5651,-0.0638 0.84765,-0.57877 0.84766,-1.54492 l 0,-3.71875 c -10e-6,-1.0026 -0.22787,-1.77733 -0.68359,-2.32422 -0.40105,-0.49218 -0.91147,-0.73827 -1.53125,-0.73828 -0.91147,1e-5 -1.73178,0.18458 -2.46094,0.55371 -0.72917,0.36915 -1.09376,0.76336 -1.09375,1.18262 l 0,4.99023 c -1e-5,0.56511 0.14583,0.95704 0.4375,1.17579 0.26432,0.20052 0.74283,0.32356 1.43555,0.36914 0.37369,0.0182 0.56054,0.14583 0.56054,0.38281 0,0.26432 -0.15039,0.39648 -0.45117,0.39648 -2.27865,0 -3.92839,0.0638 -4.94922,0.19141 -0.34635,0.0456 -0.56966,0.0456 -0.66992,0 -0.082,-0.0456 -0.12305,-0.15495 -0.12305,-0.32813 0,-0.22786 0.33724,-0.42382 1.01172,-0.58789 0.41016,-0.10937 0.61523,-0.64257 0.61524,-1.59961 l 0,-4.19726 c -1e-5,-1.0664 -0.28711,-1.68163 -0.86133,-1.8457 -0.48308,-0.13671 -0.77702,-0.23697 -0.88184,-0.30079 -0.10482,-0.0638 -0.15722,-0.16861 -0.15722,-0.31445 0,-0.16405 0.47395,-0.51041 1.42187,-1.03906 1.0026,-0.56509 1.61784,-0.84765 1.8457,-0.84766 0.19141,1e-5 0.35319,0.27573 0.48536,0.82715 0.13215,0.55144 0.23469,0.82716 0.30761,0.82715 0.10026,10e-6 0.37825,-0.14127 0.83399,-0.42383 0.5651,-0.35546 1.07551,-0.61978 1.53125,-0.79297 0.74739,-0.29165 1.51757,-0.43749 2.31054,-0.4375 0.63802,1e-5 1.194,0.13673 1.66797,0.41016 0.32812,0.1823 0.62434,0.43295 0.88868,0.75195 0.21873,0.27345 0.32811,0.41017 0.32812,0.41016 -10e-6,1e-5 0.23697,-0.13671 0.71094,-0.41016 0.55597,-0.319 1.09829,-0.56965 1.62695,-0.75195 0.77472,-0.27343 1.52668,-0.41015 2.25586,-0.41016 2.49738,1e-5 3.74607,1.37176 3.74609,4.11524 l 0,4.42968 c -2e-5,0.51954 0.12303,0.88868 0.36914,1.10743 0.21873,0.18229 0.64712,0.33724 1.28516,0.46484 0.48305,0.0911 0.72459,0.22787 0.72461,0.41016 z"
+         id="path2897"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 444.45782,160.98155 c -1e-5,0.39193 -0.52865,0.87956 -1.58593,1.46289 -1.20314,0.64714 -2.43816,0.97071 -3.70508,0.97071 -1.37631,0 -2.53386,-0.45118 -3.47266,-1.35352 -1.0026,-0.95703 -1.5039,-2.23307 -1.5039,-3.82812 0,-1.80469 0.56054,-3.2539 1.68164,-4.34766 1.04817,-1.02082 2.33333,-1.53124 3.85547,-1.53125 0.90233,1e-5 1.74543,0.28712 2.52929,0.86133 0.70182,0.51042 1.16666,1.09376 1.39453,1.75 0.082,0.22787 0.20507,0.35547 0.36914,0.38281 0.23697,0.0456 0.35546,0.20509 0.35547,0.47852 -10e-6,0.3737 -0.44662,0.69271 -1.33984,0.95703 l -6.30273,1.87304 c 0.41015,2.16928 1.52213,3.25391 3.33593,3.25391 1.04817,0 2.24218,-0.38281 3.58203,-1.14844 0.20051,-0.11848 0.39648,-0.17773 0.58789,-0.17773 0.14583,0 0.21874,0.13216 0.21875,0.39648 z m -3.24023,-5.48242 c -10e-6,-0.50129 -0.20281,-0.94107 -0.6084,-1.31934 -0.4056,-0.37824 -0.90463,-0.56737 -1.49707,-0.56738 -1.67709,10e-6 -2.51563,1.20769 -2.51562,3.62305 l 0,0.32812 3.71875,-1.12109 c 0.60155,-0.17317 0.90233,-0.48762 0.90234,-0.94336 z"
+         id="path2899"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 460.86407,163.03233 c -1e-5,0.28256 -0.20509,0.41016 -0.61523,0.38282 -1.95965,-0.10026 -3.44532,-0.11849 -4.45703,-0.0547 -0.51954,0.0365 -0.80209,-0.0273 -0.84766,-0.19141 -0.0182,-0.0547 -0.0273,-0.13216 -0.0273,-0.23242 -1e-5,-0.18229 0.26431,-0.34635 0.79297,-0.49219 0.48306,-0.13672 0.7246,-0.60612 0.72461,-1.4082 l 0,-3.63672 c -1e-5,-2.08723 -0.90691,-3.13085 -2.72071,-3.13086 -0.8112,1e-5 -1.56315,0.19142 -2.25586,0.57422 -0.65625,0.37371 -0.98438,0.76108 -0.98437,1.16211 l 0,5.00391 c -1e-5,0.85677 0.56054,1.33528 1.68164,1.43554 0.42838,0.0365 0.64257,0.17318 0.64258,0.41016 -10e-6,0.22786 -0.0592,0.36458 -0.17774,0.41016 -0.0547,0.0182 -0.20964,0.0228 -0.46484,0.0137 -1.43099,-0.0547 -2.90756,0.0182 -4.42969,0.21875 -0.32812,0.0456 -0.5332,0.0592 -0.61523,0.041 -0.18229,-0.0365 -0.27344,-0.17773 -0.27344,-0.42383 0,-0.21874 0.25976,-0.40559 0.7793,-0.56054 0.48307,-0.14583 0.7246,-0.75195 0.72461,-1.81836 l 0,-4.14258 c -1e-5,-0.70182 -0.0912,-1.14843 -0.27344,-1.33984 -0.12761,-0.13671 -0.55143,-0.32812 -1.27148,-0.57422 -0.1823,-0.0638 -0.27344,-0.1914 -0.27344,-0.38281 0,-0.18229 0.18685,-0.34179 0.56054,-0.47852 0.51042,-0.1914 1.07097,-0.49674 1.68165,-0.91602 0.51041,-0.34634 0.83853,-0.51952 0.98437,-0.51953 0.24609,1e-5 0.41015,0.28029 0.49219,0.84082 0.082,0.56056 0.19596,0.84083 0.34179,0.84082 -0.0729,1e-5 0.39193,-0.26659 1.39454,-0.7998 1.00259,-0.53319 1.99152,-0.7998 2.96679,-0.79981 2.36067,1e-5 3.55012,1.34441 3.56836,4.03321 l 0.0273,4.40234 c -2e-5,0.56511 0.14582,0.97071 0.4375,1.2168 0.21873,0.18229 0.64256,0.33724 1.27148,0.46484 0.41014,0.082 0.61522,0.23242 0.61523,0.45117 z"
+         id="path2901"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 469.64142,161.99327 c -10e-6,0.4375 -0.40105,0.8112 -1.20313,1.12109 -0.71094,0.27344 -1.43099,0.41016 -2.16015,0.41016 -1.97787,0 -2.9668,-1.05273 -2.9668,-3.1582 l 0,-4.94922 c 0,-0.6289 -0.0501,-1.01399 -0.15039,-1.15527 -0.10026,-0.14127 -0.43294,-0.28027 -0.99805,-0.417 -0.14583,-0.0365 -0.21875,-0.17772 -0.21875,-0.42382 0,-0.26432 0.0547,-0.42382 0.16407,-0.47852 0.98437,-0.48306 1.84114,-1.34895 2.57031,-2.59766 0.10025,-0.17316 0.29622,-0.22785 0.58789,-0.16406 0.20051,0.0638 0.30533,0.18231 0.31445,0.35547 l 0.0547,1.70898 c -10e-6,0.12762 0.0228,0.21876 0.0684,0.27344 0.0638,0.082 0.20963,0.12306 0.4375,0.12305 l 3.04883,0 c 0.17317,1e-5 0.17317,0.2142 0,0.64258 -0.20965,0.51954 -0.51954,0.7793 -0.92969,0.77929 l -2.06445,0 c -0.35548,1e-5 -0.57423,0.0593 -0.65625,0.17774 -0.0638,0.082 -0.0957,0.30535 -0.0957,0.66992 l 0,4.4707 c 0,1.13021 0.10026,1.84571 0.30078,2.14649 0.26432,0.38281 0.87956,0.57422 1.84571,0.57422 0.319,0 0.70637,-0.0524 1.16211,-0.15723 0.45572,-0.10482 0.69725,-0.15723 0.72461,-0.15723 0.10936,0 0.16405,0.0684 0.16406,0.20508 z"
+         id="path2903"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 475.84845,162.60851 c -1e-5,2.36979 -0.99805,4.15168 -2.99414,5.3457 -0.18229,0.10937 -0.31446,0.16406 -0.39649,0.16406 -0.1276,0 -0.24609,-0.14584 -0.35546,-0.4375 -0.0365,-0.10026 -0.0547,-0.1823 -0.0547,-0.24609 0,-0.15495 0.25976,-0.41016 0.7793,-0.76563 0.89322,-0.61979 1.33984,-1.32161 1.33984,-2.10547 0,-0.40104 -0.25977,-0.76562 -0.7793,-1.09375 -0.72917,-0.45573 -1.09375,-1.01627 -1.09375,-1.68164 0,-0.44661 0.15039,-0.81803 0.45117,-1.11426 0.30078,-0.29622 0.70182,-0.44433 1.20313,-0.44433 0.60156,0 1.06868,0.21875 1.40137,0.65625 0.33267,0.4375 0.49901,1.01172 0.49902,1.72266 z"
+         id="path2905"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 278.95978,182.96866 c -1e-5,1.58594 -0.58791,2.92806 -1.76367,4.02637 -1.1758,1.09831 -2.63413,1.64746 -4.375,1.64746 -1.74089,0 -3.1172,-0.48307 -4.12891,-1.44922 -0.97526,-0.94791 -1.46289,-2.20117 -1.46289,-3.75977 0,-1.61327 0.61523,-2.97134 1.8457,-4.07421 1.19401,-1.0664 2.62044,-1.5996 4.2793,-1.59961 1.77733,1e-5 3.16275,0.47852 4.15625,1.43554 0.96613,0.9297 1.44921,2.18751 1.44922,3.77344 z m -2.40625,0.83399 c -1e-5,-1.43099 -0.34637,-2.5931 -1.03906,-3.48633 -0.67449,-0.86588 -1.53126,-1.29882 -2.57032,-1.29883 -0.96615,1e-5 -1.75456,0.33953 -2.36523,1.01855 -0.61068,0.67905 -0.91602,1.5062 -0.91602,2.48145 0,1.56771 0.35547,2.78906 1.06641,3.66406 0.65624,0.80209 1.51301,1.20313 2.57031,1.20313 1.0026,0 1.79557,-0.33268 2.37891,-0.99805 0.58332,-0.66536 0.87499,-1.52669 0.875,-2.58398 z"
+         id="path2907"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 295.59845,188.23233 c -2e-5,0.28255 -0.20509,0.41016 -0.61523,0.38282 -1.95965,-0.10027 -3.44533,-0.11849 -4.45704,-0.0547 -0.51954,0.0365 -0.80209,-0.0274 -0.84765,-0.19141 -0.0182,-0.0547 -0.0274,-0.13216 -0.0274,-0.23242 -1e-5,-0.18229 0.26432,-0.34635 0.79297,-0.49219 0.48306,-0.13672 0.7246,-0.60612 0.72461,-1.4082 l 0,-3.63672 c -10e-6,-2.08723 -0.90691,-3.13085 -2.7207,-3.13086 -0.81121,1e-5 -1.56316,0.19142 -2.25586,0.57422 -0.65626,0.37371 -0.98438,0.76107 -0.98438,1.16211 l 0,5.00391 c 0,0.85677 0.56055,1.33528 1.68165,1.43554 0.42837,0.0365 0.64257,0.17318 0.64257,0.41016 0,0.22786 -0.0593,0.36458 -0.17773,0.41015 -0.0547,0.0182 -0.20964,0.0228 -0.46484,0.0137 -1.431,-0.0547 -2.90756,0.0182 -4.42969,0.21875 -0.32813,0.0456 -0.53321,0.0592 -0.61524,0.041 -0.18229,-0.0365 -0.27344,-0.17773 -0.27343,-0.42383 -10e-6,-0.21875 0.25976,-0.40559 0.77929,-0.56054 0.48307,-0.14584 0.72461,-0.75195 0.72461,-1.81836 l 0,-4.14258 c 0,-0.70182 -0.0912,-1.14843 -0.27344,-1.33984 -0.1276,-0.13671 -0.55143,-0.32812 -1.27148,-0.57422 -0.18229,-0.0638 -0.27344,-0.1914 -0.27344,-0.38282 0,-0.18228 0.18685,-0.34178 0.56055,-0.47851 0.51041,-0.1914 1.07096,-0.49674 1.68164,-0.91602 0.51041,-0.34634 0.83854,-0.51952 0.98438,-0.51953 0.24608,1e-5 0.41015,0.28029 0.49218,0.84082 0.082,0.56056 0.19596,0.84083 0.3418,0.84082 -0.0729,1e-5 0.39192,-0.26659 1.39453,-0.7998 1.0026,-0.53319 1.99153,-0.7998 2.9668,-0.79981 2.36066,1e-5 3.55011,1.34441 3.56836,4.03321 l 0.0273,4.40234 c -10e-6,0.56511 0.14582,0.9707 0.4375,1.2168 0.21874,0.18229 0.64256,0.33724 1.27149,0.46484 0.41014,0.082 0.61521,0.23242 0.61523,0.45117 z"
+         id="path2909"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 307.02814,186.18155 c -1e-5,0.39193 -0.52866,0.87956 -1.58594,1.46289 -1.20313,0.64714 -2.43816,0.97071 -3.70508,0.97071 -1.37631,0 -2.53386,-0.45118 -3.47265,-1.35352 -1.00261,-0.95703 -1.50391,-2.23307 -1.50391,-3.82813 0,-1.80468 0.56054,-3.25389 1.68164,-4.34765 1.04817,-1.02082 2.33333,-1.53124 3.85547,-1.53125 0.90233,1e-5 1.74543,0.28712 2.5293,0.86133 0.70181,0.51042 1.16665,1.09376 1.39453,1.75 0.082,0.22787 0.20506,0.35547 0.36914,0.38281 0.23697,0.0456 0.35545,0.20508 0.35547,0.47851 -2e-5,0.37371 -0.44663,0.69272 -1.33985,0.95704 l -6.30273,1.87304 c 0.41015,2.16928 1.52213,3.25391 3.33594,3.25391 1.04816,0 2.24217,-0.38281 3.58203,-1.14844 0.20051,-0.11849 0.39647,-0.17773 0.58789,-0.17773 0.14582,0 0.21874,0.13216 0.21875,0.39648 z m -3.24024,-5.48242 c -10e-6,-0.50129 -0.2028,-0.94107 -0.6084,-1.31934 -0.4056,-0.37824 -0.90462,-0.56737 -1.49707,-0.56738 -1.67708,1e-5 -2.51562,1.20769 -2.51562,3.62305 l 0,0.32812 3.71875,-1.12109 c 0.60155,-0.17317 0.90233,-0.48762 0.90234,-0.94336 z"
+         id="path2911"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 328.91681,187.13858 c 0.0364,0.082 0.0547,0.14128 0.0547,0.17774 -2e-5,0.1914 -0.46486,0.50586 -1.39453,0.94336 -1.07554,0.5013 -1.709,0.75195 -1.9004,0.75195 -0.16407,0 -0.3532,-0.27572 -0.56738,-0.82715 -0.2142,-0.55143 -0.36231,-0.82715 -0.44433,-0.82715 0.009,0 -0.50815,0.23926 -1.55176,0.71778 -1.04363,0.47851 -1.83432,0.71777 -2.37207,0.71777 -1.20313,0 -2.24675,-0.43294 -3.13086,-1.29883 -1.06641,-1.02994 -1.59961,-2.4746 -1.59961,-4.33398 0,-1.55859 0.6289,-2.90299 1.88672,-4.03321 1.11197,-0.99348 2.24674,-1.49022 3.40429,-1.49023 0.82031,1e-5 1.80013,0.10483 2.93946,0.31445 0.35546,0.0638 0.53319,-0.041 0.5332,-0.31445 l 0,-4.67578 c -1e-5,-0.52863 -0.12761,-0.90233 -0.38281,-1.1211 -0.16407,-0.14581 -0.55144,-0.31899 -1.16211,-0.51953 -0.29168,-0.10935 -0.43751,-0.28709 -0.4375,-0.5332 -10e-6,-0.22785 0.19595,-0.39647 0.58789,-0.50586 0.53775,-0.16404 1.18033,-0.43748 1.92773,-0.82031 0.61978,-0.32811 1.0026,-0.49217 1.14844,-0.49219 0.33723,2e-5 0.50585,0.23244 0.50586,0.69727 -1e-5,-0.009 -0.0137,0.44435 -0.041,1.36035 -0.0274,0.91603 -0.041,1.80698 -0.041,2.67285 l 0,12.42773 c -1e-5,0.51954 0.23241,0.7793 0.69727,0.7793 0.17316,0 0.40558,-0.0273 0.69726,-0.082 0.319,-0.0547 0.53319,0.0501 0.64258,0.31445 z m -4.22461,-1.35351 0,-4.89453 c -1e-5,-0.40104 -0.35548,-0.81575 -1.06641,-1.24414 -0.75651,-0.45572 -1.57683,-0.68359 -2.46093,-0.6836 -1.90496,1e-5 -2.85743,1.17579 -2.85743,3.52735 0,1.25781 0.37826,2.33333 1.13477,3.22656 0.7565,0.89323 1.64518,1.33984 2.66602,1.33984 0.46483,0 1.01171,-0.15494 1.64062,-0.46484 0.6289,-0.3099 0.94335,-0.57878 0.94336,-0.80664 z"
+         id="path2913"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 339.23907,178.78507 c -1e-5,1.10287 -0.31902,1.6543 -0.95703,1.65429 -0.10027,1e-5 -0.43295,-0.0638 -0.99804,-0.1914 -0.56511,-0.1276 -0.97983,-0.1914 -1.24414,-0.19141 -1.02084,1e-5 -1.53126,0.68816 -1.53125,2.06445 l 0,4.03321 c -10e-6,0.47396 0.16405,0.8112 0.49218,1.01172 0.1914,0.11849 0.66992,0.27799 1.43555,0.47851 0.41015,0.10026 0.61523,0.25521 0.61523,0.46485 0,0.24609 -0.0342,0.3942 -0.10253,0.44433 -0.0684,0.0501 -0.23927,0.0615 -0.5127,0.0342 -2.00521,-0.20963 -3.59115,-0.21875 -4.75781,-0.0273 -0.36459,0.0638 -0.59701,0.0638 -0.69727,0 -0.082,-0.0547 -0.12305,-0.21419 -0.12304,-0.47852 -1e-5,-0.24609 0.25292,-0.44205 0.75878,-0.58789 0.50586,-0.14583 0.75879,-0.58333 0.75879,-1.3125 l 0,-4.38867 c 0,-0.57421 -0.0752,-0.96158 -0.22558,-1.16211 -0.1504,-0.20051 -0.56283,-0.46939 -1.23731,-0.80664 l -0.082,-0.041 c -0.18229,-0.0911 -0.27344,-0.21874 -0.27344,-0.38281 0,-0.18228 0.18685,-0.33723 0.56055,-0.46484 0.6289,-0.21874 1.20312,-0.52864 1.72266,-0.92969 0.42838,-0.33723 0.70182,-0.50585 0.82031,-0.50586 0.32812,1e-5 0.52408,0.28256 0.58789,0.84766 0.082,0.72006 0.17773,1.08008 0.28711,1.08008 0.009,0 0.51953,-0.34179 1.53125,-1.0254 0.66536,-0.4466 1.39452,-0.66991 2.1875,-0.66992 0.65624,1e-5 0.98436,0.35092 0.98437,1.05274 z"
+         id="path2915"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 352.7879,182.96866 c -10e-6,1.58594 -0.5879,2.92806 -1.76367,4.02637 -1.17579,1.09831 -2.63412,1.64746 -4.375,1.64746 -1.74089,0 -3.11719,-0.48307 -4.12891,-1.44922 -0.97526,-0.94791 -1.46289,-2.20117 -1.46289,-3.75977 0,-1.61327 0.61524,-2.97134 1.84571,-4.07421 1.194,-1.0664 2.62043,-1.5996 4.27929,-1.59961 1.77734,1e-5 3.16275,0.47852 4.15625,1.43554 0.96614,0.9297 1.44921,2.18751 1.44922,3.77344 z m -2.40625,0.83399 c -10e-6,-1.43099 -0.34636,-2.5931 -1.03906,-3.48633 -0.67449,-0.86588 -1.53126,-1.29882 -2.57031,-1.29883 -0.96615,1e-5 -1.75456,0.33953 -2.36524,1.01855 -0.61068,0.67905 -0.91602,1.5062 -0.91601,2.48145 -1e-5,1.56771 0.35546,2.78906 1.0664,3.66406 0.65625,0.80209 1.51302,1.20313 2.57032,1.20313 1.00259,0 1.79556,-0.33268 2.3789,-0.99805 0.58333,-0.66536 0.87499,-1.52669 0.875,-2.58398 z"
+         id="path2917"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 367.07501,182.59952 c -1e-5,1.70443 -0.57195,3.15137 -1.71582,4.34082 -1.14389,1.18945 -2.4769,1.78418 -3.99902,1.78418 -0.92058,0 -1.80925,-0.16406 -2.66602,-0.49219 -0.0911,-0.0365 -0.13672,0.18685 -0.13672,0.66992 l 0,4.11524 c 0,0.66536 0.54232,1.09374 1.62696,1.28516 0.50129,0.0911 0.81802,0.17544 0.95019,0.25292 0.13216,0.0775 0.19824,0.20736 0.19824,0.38965 0,0.30989 -0.20508,0.45117 -0.61523,0.42383 -1.91407,-0.12761 -3.59571,-0.082 -5.04492,0.13672 -0.30078,0.0456 -0.48763,0.0456 -0.56055,0 -0.0729,-0.0456 -0.10937,-0.16863 -0.10937,-0.36914 0,-0.15495 0.25976,-0.3418 0.77929,-0.56055 0.44662,-0.18229 0.66992,-0.61979 0.66993,-1.3125 l 0,-12.05859 c -1e-5,-0.97525 -0.26889,-1.58593 -0.80665,-1.83203 -0.63802,-0.28254 -0.95703,-0.51497 -0.95703,-0.69727 0,-0.20051 0.18685,-0.3509 0.56055,-0.45117 0.48307,-0.11848 0.97982,-0.32811 1.49023,-0.62891 0.42839,-0.24608 0.68815,-0.36913 0.7793,-0.36914 0.27344,1e-5 0.49674,0.25067 0.66992,0.75196 0.25521,0.72917 0.42383,1.09376 0.50586,1.09375 0.0182,1e-5 0.41015,-0.21419 1.17578,-0.64258 0.83854,-0.46483 1.65885,-0.69726 2.46094,-0.69727 1.30338,1e-5 2.3789,0.36915 3.22656,1.10742 1.01171,0.8659 1.51757,2.11915 1.51758,3.75977 z m -2.32422,1.09375 c -10e-6,-1.48567 -0.36915,-2.64322 -1.10742,-3.47266 -0.60157,-0.6927 -1.30795,-1.03905 -2.11914,-1.03906 -0.62891,1e-5 -1.23047,0.21876 -1.80469,0.65625 -0.75651,0.56511 -1.13477,1.35352 -1.13476,2.36524 l 0,3.86914 c -1e-5,0.24609 0.34635,0.50586 1.03906,0.77929 0.74739,0.30079 1.55859,0.45118 2.43359,0.45118 1.79557,0 2.69335,-1.20313 2.69336,-3.60938 z"
+         id="path2919"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 387.33673,187.16593 c -10e-6,0.1914 -0.34864,0.48079 -1.0459,0.86816 -0.69727,0.38737 -1.25554,0.58106 -1.6748,0.58106 -0.35548,0 -0.66993,-0.17318 -0.94336,-0.51954 -0.27345,-0.34635 -0.46485,-0.51953 -0.57422,-0.51953 -0.082,0 -0.51498,0.18685 -1.29883,0.56055 -0.78386,0.3737 -1.57227,0.56055 -2.36523,0.56055 -0.7474,0 -1.37175,-0.21875 -1.87305,-0.65625 -0.54688,-0.48308 -0.82031,-1.13932 -0.82031,-1.96875 0,-1.57682 1.80468,-2.70703 5.41406,-3.39063 0.61978,-0.11848 0.93424,-0.36913 0.94336,-0.75195 l 0.0273,-0.875 c 0.0547,-1.49478 -0.60612,-2.24218 -1.98242,-2.24219 -0.39193,1e-5 -0.76335,0.35092 -1.11426,1.05274 -0.35091,0.70183 -0.85449,1.08008 -1.51074,1.13476 -0.7474,0.0729 -1.12109,-0.24153 -1.12109,-0.94336 0,-0.43749 0.55598,-0.94791 1.66797,-1.53125 1.16666,-0.61067 2.28775,-0.916 3.36328,-0.91601 1.85025,1e-5 2.76626,0.87956 2.74804,2.63867 l -0.0547,5.63281 c -0.009,0.59245 0.24152,0.88867 0.75195,0.88867 0.10025,0 0.29166,-0.0228 0.57422,-0.0684 0.28254,-0.0456 0.4466,-0.0683 0.49219,-0.0684 0.26431,1e-5 0.39647,0.17774 0.39648,0.53321 z m -4.21094,-3.11719 c 0.009,-0.22786 -0.0433,-0.37825 -0.15722,-0.45117 -0.11394,-0.0729 -0.29396,-0.0866 -0.54004,-0.041 -2.19662,0.39193 -3.29493,1.10743 -3.29492,2.14649 -10e-6,1.04817 0.56965,1.57226 1.70898,1.57226 0.45572,0 0.92512,-0.0866 1.4082,-0.25976 0.5651,-0.20052 0.84765,-0.44206 0.84766,-0.72461 z"
+         id="path2921"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 396.52423,187.19327 c -10e-6,0.4375 -0.40105,0.8112 -1.20312,1.12109 -0.71095,0.27344 -1.431,0.41016 -2.16016,0.41016 -1.97787,0 -2.9668,-1.05273 -2.9668,-3.1582 l 0,-4.94922 c 0,-0.6289 -0.0501,-1.01399 -0.15039,-1.15528 -0.10026,-0.14126 -0.43294,-0.28026 -0.99804,-0.41699 -0.14584,-0.0365 -0.21876,-0.17772 -0.21875,-0.42383 -1e-5,-0.26431 0.0547,-0.42381 0.16406,-0.47851 0.98437,-0.48306 1.84114,-1.34895 2.57031,-2.59766 0.10026,-0.17316 0.29622,-0.22785 0.58789,-0.16406 0.20052,0.0638 0.30533,0.1823 0.31445,0.35547 l 0.0547,1.70898 c 0,0.12762 0.0228,0.21876 0.0684,0.27344 0.0638,0.082 0.20963,0.12306 0.4375,0.12305 l 3.04883,0 c 0.17317,1e-5 0.17317,0.2142 0,0.64258 -0.20964,0.51954 -0.51954,0.7793 -0.92969,0.77929 l -2.06445,0 c -0.35548,1e-5 -0.57422,0.0593 -0.65625,0.17774 -0.0638,0.082 -0.0957,0.30534 -0.0957,0.66992 l 0,4.4707 c -1e-5,1.13021 0.10025,1.84571 0.30078,2.14649 0.26431,0.38281 0.87955,0.57422 1.8457,0.57422 0.319,0 0.70637,-0.0524 1.16211,-0.15723 0.45572,-0.10482 0.69726,-0.15723 0.72461,-0.15723 0.10936,0 0.16405,0.0684 0.16406,0.20508 z"
+         id="path2923"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 416.32111,187.16593 c -2e-5,0.1914 -0.34865,0.48079 -1.0459,0.86816 -0.69728,0.38737 -1.25555,0.58106 -1.67481,0.58106 -0.35547,0 -0.66993,-0.17318 -0.94336,-0.51954 -0.27344,-0.34635 -0.46485,-0.51953 -0.57422,-0.51953 -0.082,0 -0.51498,0.18685 -1.29882,0.56055 -0.78386,0.3737 -1.57227,0.56055 -2.36524,0.56055 -0.7474,0 -1.37175,-0.21875 -1.87304,-0.65625 -0.54688,-0.48308 -0.82032,-1.13932 -0.82032,-1.96875 0,-1.57682 1.80469,-2.70703 5.41407,-3.39063 0.61978,-0.11848 0.93423,-0.36913 0.94335,-0.75195 l 0.0273,-0.875 c 0.0547,-1.49478 -0.60613,-2.24218 -1.98242,-2.24219 -0.39194,1e-5 -0.76335,0.35092 -1.11426,1.05274 -0.35092,0.70183 -0.8545,1.08008 -1.51074,1.13476 -0.7474,0.0729 -1.1211,-0.24153 -1.1211,-0.94336 0,-0.43749 0.55599,-0.94791 1.66797,-1.53125 1.16666,-0.61067 2.28776,-0.916 3.36328,-0.91601 1.85025,1e-5 2.76627,0.87956 2.74805,2.63867 l -0.0547,5.63281 c -0.009,0.59245 0.24153,0.88867 0.75196,0.88867 0.10025,0 0.29165,-0.0228 0.57421,-0.0684 0.28254,-0.0456 0.44661,-0.0683 0.49219,-0.0684 0.26431,1e-5 0.39647,0.17774 0.39649,0.53321 z m -4.21094,-3.11719 c 0.009,-0.22786 -0.0433,-0.37825 -0.15723,-0.45117 -0.11394,-0.0729 -0.29395,-0.0866 -0.54004,-0.041 -2.19662,0.39193 -3.29492,1.10743 -3.29492,2.14649 0,1.04817 0.56966,1.57226 1.70899,1.57226 0.45572,0 0.92512,-0.0866 1.4082,-0.25976 0.5651,-0.20052 0.84765,-0.44206 0.84765,-0.72461 z"
+         id="path2925"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 432.45392,187.19327 c -10e-6,0.4375 -0.40105,0.8112 -1.20313,1.12109 -0.71094,0.27344 -1.43099,0.41016 -2.16015,0.41016 -1.97787,0 -2.9668,-1.05273 -2.9668,-3.1582 l 0,-4.94922 c 0,-0.6289 -0.0501,-1.01399 -0.15039,-1.15528 -0.10026,-0.14126 -0.43294,-0.28026 -0.99805,-0.41699 -0.14583,-0.0365 -0.21875,-0.17772 -0.21875,-0.42383 0,-0.26431 0.0547,-0.42381 0.16407,-0.47851 0.98437,-0.48306 1.84114,-1.34895 2.57031,-2.59766 0.10025,-0.17316 0.29622,-0.22785 0.58789,-0.16406 0.20051,0.0638 0.30533,0.1823 0.31445,0.35547 l 0.0547,1.70898 c -10e-6,0.12762 0.0228,0.21876 0.0684,0.27344 0.0638,0.082 0.20963,0.12306 0.4375,0.12305 l 3.04883,0 c 0.17317,1e-5 0.17317,0.2142 0,0.64258 -0.20965,0.51954 -0.51954,0.7793 -0.92969,0.77929 l -2.06445,0 c -0.35548,1e-5 -0.57423,0.0593 -0.65625,0.17774 -0.0638,0.082 -0.0957,0.30534 -0.0957,0.66992 l 0,4.4707 c 0,1.13021 0.10026,1.84571 0.30078,2.14649 0.26432,0.38281 0.87956,0.57422 1.84571,0.57422 0.319,0 0.70637,-0.0524 1.16211,-0.15723 0.45572,-0.10482 0.69725,-0.15723 0.72461,-0.15723 0.10936,0 0.16405,0.0684 0.16406,0.20508 z"
+         id="path2927"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 439.30353,172.66007 c -1e-5,0.41928 -0.15723,0.80893 -0.47168,1.16894 -0.31446,0.36004 -0.65854,0.54006 -1.03223,0.54004 -0.42839,2e-5 -0.7793,-0.12759 -1.05273,-0.38281 -0.27344,-0.25519 -0.41016,-0.58788 -0.41016,-0.99805 0,-0.40102 0.16634,-0.77472 0.49902,-1.12109 0.33268,-0.34634 0.69043,-0.51952 1.07325,-0.51953 0.92968,1e-5 1.39452,0.43751 1.39453,1.3125 z m 1.51758,15.47656 c -0.0456,0.26432 -0.15496,0.41471 -0.32813,0.45117 -0.0456,0.009 -0.26433,0 -0.65625,-0.0273 -1.35808,-0.0911 -2.69336,-0.0729 -4.00586,0.0547 -0.35547,0.0365 -0.57878,0.0228 -0.66992,-0.041 -0.0912,-0.0638 -0.13672,-0.20964 -0.13672,-0.4375 0,-0.20964 0.24154,-0.38281 0.72461,-0.51953 0.52864,-0.15495 0.79297,-0.66081 0.79297,-1.51758 l 0,-3.58203 c 0,-0.72005 -0.0729,-1.22591 -0.21875,-1.51758 -0.20052,-0.40103 -0.61524,-0.70637 -1.24414,-0.91601 -0.28256,-0.10026 -0.42383,-0.25065 -0.42383,-0.45118 0,-0.26431 0.20508,-0.46027 0.61523,-0.58789 0.76563,-0.23697 1.44466,-0.55142 2.03711,-0.94336 0.47396,-0.32811 0.76562,-0.49217 0.875,-0.49218 0.36458,1e-5 0.54232,0.23699 0.53321,0.71093 -0.0365,2.38803 -0.0547,4.82162 -0.0547,7.30078 -10e-6,0.59245 0.0866,1.01628 0.25977,1.27149 0.1914,0.28255 0.55598,0.48307 1.09375,0.60156 0.59244,0.13672 0.86132,0.35091 0.80664,0.64258 z"
+         id="path2929"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 464.89728,188.10929 c -3e-5,0.30078 -0.1413,0.44205 -0.42383,0.42382 -1.8594,-0.13671 -3.59117,-0.13671 -5.19531,0 -0.35549,0.0274 -0.53322,-0.13216 -0.53321,-0.47851 -1e-5,-0.27344 0.24152,-0.41927 0.72461,-0.4375 0.61066,-0.0365 0.916,-0.5332 0.91602,-1.49024 l 0,-3.63671 c -2e-5,-2.04166 -0.82945,-3.0625 -2.48828,-3.0625 -0.87502,0 -1.6771,0.15951 -2.40625,0.47851 -0.66538,0.28256 -1.00262,0.56511 -1.01172,0.84766 l -0.0547,5.42773 c -10e-6,0.56511 0.0684,0.9388 0.20508,1.1211 0.10025,0.1276 0.32355,0.21875 0.66992,0.27343 0.80207,0.13672 1.20311,0.30534 1.20313,0.50586 -2e-5,0.17318 -0.0228,0.29167 -0.0684,0.35547 -0.0547,0.0911 -0.21877,0.13216 -0.49219,0.12305 -1.03908,-0.0365 -2.5521,-0.0182 -4.53906,0.0547 -0.28256,0.009 -0.46486,-0.0137 -0.54688,-0.0684 -0.082,-0.0547 -0.12305,-0.1823 -0.12304,-0.38282 -10e-6,-0.23698 0.25975,-0.38281 0.77929,-0.4375 0.5651,-0.0638 0.84765,-0.57877 0.84766,-1.54492 l 0,-3.71875 c -10e-6,-1.0026 -0.22788,-1.77733 -0.6836,-2.32422 -0.40105,-0.49218 -0.91146,-0.73827 -1.53125,-0.73828 -0.91146,1e-5 -1.73177,0.18458 -2.46093,0.55371 -0.72917,0.36915 -1.09376,0.76336 -1.09375,1.18262 l 0,4.99023 c -1e-5,0.56511 0.14583,0.95704 0.4375,1.17579 0.26431,0.20052 0.74283,0.32356 1.43554,0.36914 0.37369,0.0182 0.56054,0.14583 0.56055,0.38281 -10e-6,0.26432 -0.1504,0.39648 -0.45117,0.39648 -2.27865,0 -3.92839,0.0638 -4.94922,0.19141 -0.34636,0.0456 -0.56966,0.0456 -0.66992,0 -0.082,-0.0456 -0.12305,-0.15495 -0.12305,-0.32813 0,-0.22786 0.33724,-0.42382 1.01172,-0.58789 0.41015,-0.10937 0.61523,-0.64257 0.61523,-1.59961 l 0,-4.19726 c 0,-1.0664 -0.28711,-1.68163 -0.86132,-1.8457 -0.48308,-0.13672 -0.77702,-0.23698 -0.88184,-0.30079 -0.10482,-0.0638 -0.15723,-0.16861 -0.15723,-0.31445 0,-0.16405 0.47396,-0.51041 1.42188,-1.03906 1.0026,-0.5651 1.61783,-0.84765 1.8457,-0.84766 0.1914,1e-5 0.35319,0.27573 0.48535,0.82715 0.13216,0.55144 0.2347,0.82716 0.30762,0.82715 0.10026,1e-5 0.37825,-0.14127 0.83399,-0.42383 0.56509,-0.35546 1.07551,-0.61978 1.53125,-0.79297 0.74738,-0.29165 1.51756,-0.43749 2.31054,-0.4375 0.63801,1e-5 1.194,0.13673 1.66797,0.41016 0.32811,0.1823 0.62434,0.43295 0.88867,0.75195 0.21874,0.27345 0.32812,0.41017 0.32813,0.41016 -10e-6,1e-5 0.23696,-0.13671 0.71094,-0.41016 0.55597,-0.319 1.09829,-0.56965 1.62695,-0.75195 0.77472,-0.27343 1.52667,-0.41015 2.25586,-0.41016 2.49737,1e-5 3.74607,1.37176 3.74609,4.11524 l 0,4.42968 c -2e-5,0.51954 0.12303,0.88868 0.36914,1.10743 0.21873,0.18229 0.64712,0.33724 1.28516,0.46484 0.48305,0.0911 0.72458,0.22786 0.72461,0.41016 z"
+         id="path2931"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+      <path
+         d="m 476.45001,186.18155 c -1e-5,0.39193 -0.52865,0.87956 -1.58594,1.46289 -1.20313,0.64714 -2.43815,0.97071 -3.70507,0.97071 -1.37631,0 -2.53386,-0.45118 -3.47266,-1.35352 -1.00261,-0.95703 -1.50391,-2.23307 -1.50391,-3.82813 0,-1.80468 0.56055,-3.25389 1.68164,-4.34765 1.04818,-1.02082 2.33333,-1.53124 3.85547,-1.53125 0.90234,1e-5 1.74544,0.28712 2.5293,0.86133 0.70181,0.51042 1.16666,1.09376 1.39453,1.75 0.082,0.22787 0.20507,0.35547 0.36914,0.38281 0.23697,0.0456 0.35546,0.20508 0.35547,0.47851 -10e-6,0.37371 -0.44662,0.69272 -1.33984,0.95704 l -6.30274,1.87304 c 0.41016,2.16928 1.52213,3.25391 3.33594,3.25391 1.04817,0 2.24218,-0.38281 3.58203,-1.14844 0.20051,-0.11849 0.39648,-0.17773 0.58789,-0.17773 0.14582,0 0.21874,0.13216 0.21875,0.39648 z m -3.24023,-5.48242 c -1e-5,-0.50129 -0.20281,-0.94107 -0.6084,-1.31934 -0.40561,-0.37824 -0.90463,-0.56737 -1.49707,-0.56738 -1.67709,1e-5 -2.51563,1.20769 -2.51563,3.62305 l 0,0.32812 3.71875,-1.12109 c 0.60156,-0.17317 0.90234,-0.48762 0.90235,-0.94336 z"
+         id="path2933"
+         style="font-size:28px;font-variant:normal;font-stretch:normal;text-align:end;line-height:89.99999762%;writing-mode:lr-tb;text-anchor:end;font-family:High Tower Text;-inkscape-font-specification:High Tower Text" />
+    </g>
+  </g>
+</svg>

+ 338 - 0
tests/unit/user/test_controllers.py

@@ -0,0 +1,338 @@
+from datetime import date
+
+import pytest
+from flask import get_flashed_messages, url_for
+from flask_login import current_user, login_user
+from werkzeug.datastructures import MultiDict
+
+from flaskbb.core.exceptions import PersistenceError, StopValidation
+from flaskbb.core.changesets import ChangeSetHandler
+from flaskbb.core.user.update import (
+    EmailUpdate,
+    PasswordUpdate,
+    SettingsUpdate,
+    UserDetailsChange,
+)
+from flaskbb.user.forms import (
+    ChangeEmailForm,
+    ChangePasswordForm,
+    ChangeUserDetailsForm,
+    GeneralSettingsForm,
+)
+from flaskbb.user.views import (
+    ChangeEmail,
+    ChangePassword,
+    ChangeUserDetails,
+    UserSettings,
+)
+
+
+@pytest.fixture(scope="function", autouse=True)
+def setup_request(user, default_settings, post_request_context):
+    login_user(user)
+
+
+class TestUserSettingsView(object):
+    def test_renders_get_okay(self, mock):
+        form = self.produce_form({})
+        handler = mock.Mock(spec=ChangeSetHandler)
+        handler = UserSettings(form=form, settings_update_handler=handler)
+
+        handler.get()
+
+    def test_update_user_settings_successfully(self, user, mock):
+        form = self.produce_form(data={"language": "python", "theme": "solarized"})
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = UserSettings(form=form, settings_update_handler=handler)
+
+        result = view.post()
+        flashed = get_flashed_messages(with_categories=True)
+
+        assert len(flashed) == 1
+        assert flashed[0] == ("success", "Settings updated.")
+        assert result.status_code == 302
+        assert result.headers["Location"] == url_for("user.settings")
+        handler.apply_changeset.assert_called_once_with(
+            user, SettingsUpdate(language="python", theme="solarized")
+        )
+
+    def test_update_user_settings_fail_with_not_valid(self, mock):
+        form = self.produce_form(data={"language": "ruby", "theme": "solarized"})
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = UserSettings(form=form, settings_update_handler=handler)
+
+        view.post()
+        flashed = get_flashed_messages()
+
+        assert not len(flashed)
+        handler.apply_changeset.assert_not_called()
+        assert form.errors
+
+    def test_update_user_settings_fail_with_stopvalidation_error(self, mock):
+        form = self.produce_form(data={"language": "python", "theme": "molokai"})
+        handler = mock.Mock(spec=ChangeSetHandler)
+        handler.apply_changeset.side_effect = StopValidation(
+            [("theme", "Solarized is better")]
+        )
+        view = UserSettings(form=form, settings_update_handler=handler)
+
+        view.post()
+        flashed = get_flashed_messages()
+
+        assert not (len(flashed))
+        assert form.errors["theme"] == ["Solarized is better"]
+
+    def test_update_user_settings_fails_with_persistence_error(self, mock):
+        form = self.produce_form(data={"language": "python", "theme": "molokai"})
+        handler = mock.Mock(spec=ChangeSetHandler)
+        handler.apply_changeset.side_effect = PersistenceError("Nope")
+        view = UserSettings(form=form, settings_update_handler=handler)
+
+        result = view.post()
+        flashed = get_flashed_messages(with_categories=True)
+
+        assert len(flashed) == 1
+        assert flashed[0] == ("danger", "Error while updating user settings")
+        assert result.status_code == 302
+        assert result.headers["Location"] == url_for("user.settings")
+
+    def produce_form(self, data):
+        form = GeneralSettingsForm(formdata=MultiDict(data), meta={"csrf": False})
+        form.language.choices = [("python", "python"), ("ecmascript", "ecmascript")]
+        form.theme.choices = [("molokai", "molokai"), ("solarized", "solarized")]
+        return form
+
+
+class TestChangePasswordView(object):
+    def test_renders_get_okay(self, mock):
+        form = self.produce_form()
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = ChangePassword(form=form, password_update_handler=handler)
+
+        view.get()
+
+    def test_updates_user_password_okay(self, user, mock):
+        form = self.produce_form(
+            old_password="password",
+            new_password="newpassword",
+            confirm_new_password="newpassword",
+        )
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = ChangePassword(form=form, password_update_handler=handler)
+
+        result = view.post()
+        flashed = get_flashed_messages(with_categories=True)
+
+        assert len(flashed) == 1
+        assert flashed[0] == ("success", "Password updated.")
+        assert result.status_code == 302
+        assert result.headers["Location"] == url_for("user.change_password")
+        handler.apply_changeset.assert_called_once_with(
+            user, PasswordUpdate(old_password="password", new_password="newpassword")
+        )
+
+    def test_updates_user_password_fails_with_invalid_inpit(self, mock, user):
+        form = self.produce_form(
+            old_password="password",
+            new_password="newpassword",
+            confirm_new_password="whoops",
+        )
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = ChangePassword(form=form, password_update_handler=handler)
+
+        view.post()
+
+        handler.apply_changeset.assert_not_called()
+        assert "new_password" in form.errors
+
+    def test_update_user_password_fails_with_stopvalidation_error(self, mock):
+        form = self.produce_form(
+            old_password="password",
+            new_password="newpassword",
+            confirm_new_password="newpassword",
+        )
+        handler = mock.Mock(spec=ChangeSetHandler)
+        handler.apply_changeset.side_effect = StopValidation(
+            [("new_password", "That's not a very strong password")]
+        )
+        view = ChangePassword(form=form, password_update_handler=handler)
+
+        view.post()
+
+        assert form.errors["new_password"] == ["That's not a very strong password"]
+
+    def test_update_user_password_fails_with_persistence_error(self, mock):
+        form = self.produce_form(
+            old_password="password",
+            new_password="newpassword",
+            confirm_new_password="newpassword",
+        )
+        handler = mock.Mock(spec=ChangeSetHandler)
+        handler.apply_changeset.side_effect = PersistenceError("no")
+        view = ChangePassword(form=form, password_update_handler=handler)
+
+        result = view.post()
+        flashed = get_flashed_messages(with_categories=True)
+
+        assert flashed == [("danger", "Error while changing password")]
+        assert result.status_code == 302
+        assert result.headers["Location"] == url_for("user.change_password")
+
+    def produce_form(self, **kwargs):
+        return ChangePasswordForm(
+            formdata=MultiDict(kwargs), meta={"csrf": False}, obj=current_user
+        )
+
+
+class TestChangeEmailView(object):
+    def test_renders_get_okay(self, mock):
+        form = self.produce_form()
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = ChangeEmail(form=form, update_email_handler=handler)
+
+        view.get()
+
+    def test_update_user_email_successfully(self, user, mock):
+        form = self.produce_form(
+            old_email=user.email,
+            new_email="new@email.mail",
+            confirm_new_email="new@email.mail",
+        )
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = ChangeEmail(form=form, update_email_handler=handler)
+
+        result = view.post()
+        flashed = get_flashed_messages(with_categories=True)
+
+        assert flashed == [("success", "Email address updated.")]
+        handler.apply_changeset.assert_called_once_with(
+            user, EmailUpdate(old_email=user.email, new_email="new@email.mail")
+        )
+        assert result.status_code == 302
+        assert result.headers["Location"] == url_for("user.change_email")
+
+    def test_update_user_email_fails_with_invalid_input(self, user, mock):
+        form = self.produce_form(old_email=user.email, new_email="new@e.mail")
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = ChangeEmail(form=form, update_email_handler=handler)
+
+        view.post()
+
+        assert form.errors
+        handler.apply_changeset.assert_not_called()
+
+    def test_update_user_email_fails_with_stopvalidation(self, user, mock):
+        form = self.produce_form(
+            old_email=user.email, new_email="new@e.mail", confirm_new_email="new@e.mail"
+        )
+        handler = mock.Mock(spec=ChangeSetHandler)
+        handler.apply_changeset.side_effect = StopValidation(
+            [("new_email", "bad email")]
+        )
+        view = ChangeEmail(form=form, update_email_handler=handler)
+
+        view.post()
+
+        assert form.errors == {"new_email": ["bad email"]}
+
+    def test_update_email_fails_with_persistence_error(self, user, mock):
+        form = self.produce_form(
+            old_email=user.email, new_email="new@e.mail", confirm_new_email="new@e.mail"
+        )
+        handler = mock.Mock(spec=ChangeSetHandler)
+        handler.apply_changeset.side_effect = PersistenceError("nope")
+        view = ChangeEmail(form=form, update_email_handler=handler)
+
+        result = view.post()
+        flashed = get_flashed_messages(with_categories=True)
+
+        assert flashed == [("danger", "Error while updating email")]
+        assert result.status_code == 302
+        assert result.headers["Location"] == url_for("user.change_email")
+
+    def produce_form(self, **data):
+        return ChangeEmailForm(
+            formdata=MultiDict(data), user=current_user, meta={"csrf": False}
+        )
+
+
+class TestChangeUserDetailsView(object):
+    def test_renders_get_okay(self, mock):
+        form = self.produce_form()
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = ChangeUserDetails(form=form, details_update_handler=handler)
+
+        view.get()
+
+    def test_update_user_details_successfully_updates(self, user, mock):
+        form = self.produce_form(
+            birthday="25 04 2000",
+            gender="awesome",
+            location="here",
+            website="http://web.site",
+            avatar="http://web.site/avatar.png",
+            signature="use a cursive font",
+            notes="got 'em",
+        )
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = ChangeUserDetails(form=form, details_update_handler=handler)
+
+        result = view.post()
+        flashed = get_flashed_messages(with_categories=True)
+
+        assert flashed == [("success", "User details updated.")]
+        assert result.status_code == 302
+        assert result.headers["Location"] == url_for("user.change_user_details")
+        handler.apply_changeset.assert_called_once_with(
+            user,
+            UserDetailsChange(
+                birthday=date(2000, 4, 25),
+                gender="awesome",
+                location="here",
+                website="http://web.site",
+                avatar="http://web.site/avatar.png",
+                signature="use a cursive font",
+                notes="got 'em",
+            ),
+        )
+
+    def test_update_user_fails_with_invalid_input(self, mock):
+        form = self.produce_form(birthday="99 99 999999")
+        handler = mock.Mock(spec=ChangeSetHandler)
+        view = ChangeUserDetails(form=form, details_update_handler=handler)
+
+        view.post()
+
+        assert form.errors == {"birthday": ["Not a valid date value"]}
+
+    def test_update_user_fails_with_stopvalidation(self, mock):
+        form = self.produce_form(birthday="25 04 2000")
+        handler = mock.Mock(spec=ChangeSetHandler)
+        handler.apply_changeset.side_effect = StopValidation(
+            [("birthday", "I just want you to know that's a great birthday")]
+        )
+        view = ChangeUserDetails(form=form, details_update_handler=handler)
+
+        view.post()
+
+        assert form.errors == {
+            "birthday": ["I just want you to know that's a great birthday"]
+        }
+
+    def test_update_user_fails_with_persistence_error(self, mock):
+        form = self.produce_form(birthday="25 04 2000")
+        handler = mock.Mock(spec=ChangeSetHandler)
+        handler.apply_changeset.side_effect = PersistenceError("no")
+        view = ChangeUserDetails(form=form, details_update_handler=handler)
+
+        result = view.post()
+        flashed = get_flashed_messages(with_categories=True)
+
+        assert flashed == [("danger", "Error while updating user details")]
+        assert result.status_code == 302
+        assert result.headers["Location"] == url_for("user.change_user_details")
+
+    def produce_form(self, **kwargs):
+        return ChangeUserDetailsForm(
+            obj=current_user, formdata=MultiDict(kwargs), meta={"csrf": False}
+        )

+ 211 - 0
tests/unit/user/test_forms.py

@@ -0,0 +1,211 @@
+from datetime import date
+
+import pytest
+from werkzeug.datastructures import MultiDict
+
+from flaskbb.core.user.update import (
+    EmailUpdate,
+    PasswordUpdate,
+    SettingsUpdate,
+    UserDetailsChange,
+)
+from flaskbb.user import forms
+
+pytestmark = pytest.mark.usefixtures("post_request_context", "default_settings")
+
+
+class TestGeneralSettingsForm(object):
+    def test_transforms_to_expected_change_object(self):
+        data = MultiDict({"language": "python", "theme": "molokai", "submit": True})
+
+        form = forms.GeneralSettingsForm(formdata=data)
+
+        expected = SettingsUpdate(language="python", theme="molokai")
+        assert form.as_change() == expected
+
+
+class TestChangeEmailForm(object):
+    def test_transforms_to_expected_change_object(self, Fred):
+        data = MultiDict(
+            {
+                "old_email": Fred.email,
+                "new_email": "totally@real.email",
+                "confirm_new_email": "totally@real.email",
+                "submit": True,
+            }
+        )
+
+        form = forms.ChangeEmailForm(Fred, formdata=data)
+        expected = EmailUpdate(old_email=Fred.email, new_email="totally@real.email")
+
+        assert form.as_change() == expected
+
+    def test_valid_inputs(self, Fred):
+        data = MultiDict(
+            {
+                "old_email": Fred.email,
+                "new_email": "totally@real.email",
+                "confirm_new_email": "totally@real.email",
+                "submit": True,
+            }
+        )
+
+        form = forms.ChangeEmailForm(Fred, formdata=data, meta={"csrf": False})
+
+        assert form.validate_on_submit()
+
+    @pytest.mark.parametrize(
+        "formdata",
+        [
+            {"old_email": "notanemail"},
+            {"old_email": ""},
+            {"new_email": "notanemail", "confirm_new_email": "notanemail"},
+            {"new_email": ""},
+            {"new_email": "not@the.same"},
+            {"confirm_new_email": ""},
+        ],
+    )
+    def test_invalid_inputs(self, Fred, formdata):
+        data = {
+            "submit": True,
+            "old_email": Fred.email,
+            "new_email": "totally@real.email",
+            "confirm_new_email": "totally@real.email",
+        }
+        data.update(formdata)
+        form = forms.ChangeEmailForm(
+            Fred, formdata=MultiDict(data), meta={"csrf": False}
+        )
+
+        assert not form.validate_on_submit()
+
+
+class TestChangePasswordForm(object):
+    def test_transforms_to_expected_change_object(self):
+        data = MultiDict(
+            {
+                "old_password": "old_password",
+                "new_password": "password",
+                "confirm_new_password": "password",
+                "submit": True,
+            }
+        )
+        form = forms.ChangePasswordForm(formdata=data)
+        expected = PasswordUpdate(old_password="old_password", new_password="password")
+        assert form.as_change() == expected
+
+    def test_valid_inputs(self):
+        data = MultiDict(
+            {
+                "submit": True,
+                "old_password": "old_password",
+                "new_password": "password",
+                "confirm_new_password": "password",
+            }
+        )
+        form = forms.ChangePasswordForm(formdata=data, meta={"csrf": False})
+        assert form.validate_on_submit()
+
+    @pytest.mark.parametrize(
+        "formdata",
+        [
+            {"old_password": ""},
+            {"new_password": ""},
+            {"confirm_new_password": ""},
+            {"new_password": "doesntmatch"},
+        ],
+    )
+    def test_invalid_inputs(self, formdata):
+        data = {
+            "old_password": "old_password",
+            "new_password": "password",
+            "confirm_new_password": "password",
+            "submit": True,
+        }
+        data.update(formdata)
+        form = forms.ChangePasswordForm(formdata=MultiDict(data))
+        assert not form.validate_on_submit()
+
+
+class TestChangeUserDetailsForm(object):
+    def test_transforms_to_expected_change_object(self):
+        data = MultiDict(
+            dict(
+                submit=True,
+                birthday="25 06 2000",
+                gender="awesome",
+                location="here",
+                website="http://flaskbb.org",
+                avatar="https://totally.real/image.img",
+                signature="test often",
+                notes="testy mctest face",
+            )
+        )
+        form = forms.ChangeUserDetailsForm(formdata=data)
+        expected = UserDetailsChange(
+            birthday=date(2000, 6, 25),
+            gender="awesome",
+            location="here",
+            website="http://flaskbb.org",
+            avatar="https://totally.real/image.img",
+            signature="test often",
+            notes="testy mctest face",
+        )
+
+        assert form.as_change() == expected
+
+    @pytest.mark.parametrize(
+        "formdata",
+        [
+            {},
+            dict(
+                birthday="",
+                gender="",
+                location="",
+                website="",
+                avatar="",
+                signature="",
+                notes="",
+            ),
+        ],
+    )
+    def test_valid_inputs(self, formdata):
+        data = dict(
+            submit=True,
+            birthday="25 06 2000",
+            gender="awesome",
+            location="here",
+            website="http://flaskbb.org",
+            avatar="https://totally.real/image.img",
+            signature="test often",
+            notes="testy mctest face",
+        )
+        data.update(formdata)
+
+        form = forms.ChangeUserDetailsForm(
+            formdata=MultiDict(data), meta={"csrf": False}
+        )
+
+        assert form.validate_on_submit()
+
+    @pytest.mark.parametrize(
+        "formdata",
+        [{"avatar": "notaurl"}, {"website": "notanemail"}, {"notes": "a" * 5001}],
+    )
+    def test_invalid_inputs(self, formdata):
+        data = dict(
+            submit=True,
+            birthday="25 06 2000",
+            gender="awesome",
+            location="here",
+            website="http://flaskbb.org",
+            avatar="https://totally.real/image.img",
+            signature="test often",
+            notes="testy mctest face",
+        )
+        data.update(formdata)
+        form = forms.ChangeUserDetailsForm(
+            formdata=MultiDict(data), meta={"csrf": False}
+        )
+
+        assert not form.validate_on_submit()

+ 78 - 0
tests/unit/user/test_update_details_handler.py

@@ -0,0 +1,78 @@
+from uuid import uuid4
+
+import pytest
+from pluggy import HookimplMarker
+
+from flaskbb.core.exceptions import PersistenceError, StopValidation, ValidationError
+from flaskbb.core.changesets import ChangeSetValidator, ChangeSetPostProcessor
+from flaskbb.core.user.update import UserDetailsChange
+from flaskbb.user.models import User
+from flaskbb.user.services.update import DefaultDetailsUpdateHandler
+
+
+class TestDefaultDetailsUpdateHandler(object):
+    def test_raises_stop_validation_if_errors_occur(
+        self, mock, user, database, plugin_manager
+    ):
+        validator = mock.Mock(spec=ChangeSetValidator)
+        validator.validate.side_effect = ValidationError(
+            "location", "Dont be from there"
+        )
+
+        details = UserDetailsChange()
+        hook_impl = mock.Mock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        handler = DefaultDetailsUpdateHandler(
+            validators=[validator], db=database, plugin_manager=plugin_manager
+        )
+
+        with pytest.raises(StopValidation) as excinfo:
+            handler.apply_changeset(user, details)
+
+        assert excinfo.value.reasons == [("location", "Dont be from there")]
+        hook_impl.post_process_changeset.assert_not_called()
+
+    def test_raises_persistence_error_if_save_fails(self, mock, user, plugin_manager):
+        details = UserDetailsChange()
+        db = mock.Mock()
+        db.session.commit.side_effect = Exception("no")
+
+        hook_impl = mock.Mock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        handler = DefaultDetailsUpdateHandler(
+            validators=[], db=db, plugin_manager=plugin_manager
+        )
+
+        with pytest.raises(PersistenceError) as excinfo:
+            handler.apply_changeset(user, details)
+
+        assert "Could not update details" in str(excinfo.value)
+        hook_impl.post_process_changeset.assert_not_called()
+
+    def test_actually_updates_users_details(self, user, database, plugin_manager, mock):
+        location = str(uuid4())
+        details = UserDetailsChange(location=location)
+        hook_impl = mock.Mock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        handler = DefaultDetailsUpdateHandler(
+            db=database, plugin_manager=plugin_manager
+        )
+
+        handler.apply_changeset(user, details)
+        same_user = User.query.get(user.id)
+
+        assert same_user.location == location
+        hook_impl.post_process_changeset.assert_called_once_with(
+            user=user, details_update=details
+        )
+
+    @staticmethod
+    def impl(post_processor):
+        class Impl:
+            @HookimplMarker("flaskbb")
+            def flaskbb_details_updated(self, user, details_update):
+                post_processor.post_process_changeset(
+                    user=user, details_update=details_update
+                )
+
+        return Impl()

+ 79 - 0
tests/unit/user/test_update_email_handler.py

@@ -0,0 +1,79 @@
+from uuid import uuid4
+
+import pytest
+from pluggy import HookimplMarker
+
+from flaskbb.core.exceptions import PersistenceError, StopValidation, ValidationError
+from flaskbb.core.changesets import ChangeSetPostProcessor, ChangeSetValidator
+from flaskbb.core.user.update import EmailUpdate
+from flaskbb.user.models import User
+from flaskbb.user.services.update import DefaultEmailUpdateHandler
+
+
+def random_email():
+    return "{}@not.real.at.all".format(str(uuid4()))
+
+
+class TestDefaultEmailUpdateHandler(object):
+    def test_raises_stop_validation_if_errors_occur(
+        self, mock, user, database, plugin_manager
+    ):
+        validator = mock.Mock(spec=ChangeSetValidator)
+        validator.validate.side_effect = ValidationError(
+            "new_email", "That's not even valid"
+        )
+        hook_impl = mock.Mock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        email_change = EmailUpdate(user.email, random_email())
+        handler = DefaultEmailUpdateHandler(
+            db=database, validators=[validator], plugin_manager=plugin_manager
+        )
+
+        with pytest.raises(StopValidation) as excinfo:
+            handler.apply_changeset(user, email_change)
+
+        assert excinfo.value.reasons == [("new_email", "That's not even valid")]
+        hook_impl.post_process_changeset.assert_not_called()
+
+    def test_raises_persistence_error_if_save_fails(self, mock, user, plugin_manager):
+        email_change = EmailUpdate(user.email, random_email())
+        db = mock.Mock()
+        db.session.commit.side_effect = Exception("no")
+        hook_impl = mock.Mock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        handler = DefaultEmailUpdateHandler(
+            db=db, validators=[], plugin_manager=plugin_manager
+        )
+
+        with pytest.raises(PersistenceError) as excinfo:
+            handler.apply_changeset(user, email_change)
+
+        assert "Could not update email" in str(excinfo.value)
+        hook_impl.post_process_changeset.assert_not_called()
+
+    def test_actually_updates_email(self, user, database, mock, plugin_manager):
+        new_email = random_email()
+        email_change = EmailUpdate("test", new_email)
+        hook_impl = mock.Mock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        handler = DefaultEmailUpdateHandler(
+            db=database, validators=[], plugin_manager=plugin_manager
+        )
+
+        handler.apply_changeset(user, email_change)
+        same_user = User.query.get(user.id)
+        assert same_user.email == new_email
+        hook_impl.post_process_changeset.assert_called_once_with(
+            user=user, email_update=email_change
+        )
+
+    @staticmethod
+    def impl(post_processor):
+        class Impl:
+            @HookimplMarker("flaskbb")
+            def flaskbb_email_updated(self, user, email_update):
+                post_processor.post_process_changeset(
+                    user=user, email_update=email_update
+                )
+
+        return Impl()

+ 73 - 0
tests/unit/user/test_update_password_handler.py

@@ -0,0 +1,73 @@
+from uuid import uuid4
+
+import pytest
+from pluggy import HookimplMarker
+
+from flaskbb.core.changesets import ChangeSetValidator, ChangeSetPostProcessor
+from flaskbb.core.exceptions import PersistenceError, StopValidation, ValidationError
+from flaskbb.core.user.update import (
+    PasswordUpdate,
+)
+from flaskbb.user.models import User
+from flaskbb.user.services.update import DefaultPasswordUpdateHandler
+
+
+class TestDefaultPasswordUpdateHandler(object):
+    def test_raises_stop_validation_if_errors_occur(
+        self, mock, user, database, plugin_manager
+    ):
+        validator = mock.Mock(spec=ChangeSetValidator)
+        validator.validate.side_effect = ValidationError(
+            "new_password", "Don't use that password"
+        )
+        password_change = PasswordUpdate(str(uuid4()), str(uuid4()))
+        hook_impl = mock.MagicMock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        handler = DefaultPasswordUpdateHandler(
+            db=database, plugin_manager=plugin_manager, validators=[validator]
+        )
+
+        with pytest.raises(StopValidation) as excinfo:
+            handler.apply_changeset(user, password_change)
+        assert excinfo.value.reasons == [("new_password", "Don't use that password")]
+        hook_impl.post_process_changeset.assert_not_called()
+
+    def test_raises_persistence_error_if_save_fails(self, mock, user, plugin_manager):
+        password_change = PasswordUpdate(str(uuid4()), str(uuid4()))
+        db = mock.Mock()
+        db.session.commit.side_effect = Exception("no")
+        hook_impl = mock.MagicMock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        handler = DefaultPasswordUpdateHandler(
+            db=db, plugin_manager=plugin_manager, validators=[]
+        )
+
+        with pytest.raises(PersistenceError) as excinfo:
+            handler.apply_changeset(user, password_change)
+
+        assert "Could not update password" in str(excinfo.value)
+        hook_impl.post_process_changeset.assert_not_called()
+
+    def test_actually_updates_password(self, user, database, plugin_manager, mock):
+        new_password = str(uuid4())
+        password_change = PasswordUpdate("test", new_password)
+        hook_impl = mock.MagicMock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        handler = DefaultPasswordUpdateHandler(
+            db=database, plugin_manager=plugin_manager, validators=[]
+        )
+
+        handler.apply_changeset(user, password_change)
+        same_user = User.query.get(user.id)
+
+        assert same_user.check_password(new_password)
+        hook_impl.post_process_changeset.assert_called_once_with(user=user)
+
+    @staticmethod
+    def impl(post_processor):
+        class Impl:
+            @HookimplMarker("flaskbb")
+            def flaskbb_password_updated(self, user):
+                post_processor.post_process_changeset(user=user)
+
+        return Impl()

+ 51 - 0
tests/unit/user/test_update_settings.py

@@ -0,0 +1,51 @@
+import pytest
+from pluggy import HookimplMarker
+
+from flaskbb.core.changesets import ChangeSetPostProcessor
+from flaskbb.core.exceptions import PersistenceError
+from flaskbb.core.user.update import SettingsUpdate
+from flaskbb.user.models import User
+from flaskbb.user.services.update import DefaultSettingsUpdateHandler
+
+
+class TestDefaultSettingsUpdateHandler(object):
+    def test_raises_persistence_error_if_save_fails(self, mock, user, plugin_manager):
+        settings_update = SettingsUpdate(language="python", theme="molokai")
+        db = mock.Mock()
+        db.session.commit.side_effect = Exception("no")
+        hook_impl = mock.Mock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        handler = DefaultSettingsUpdateHandler(db=db, plugin_manager=plugin_manager)
+
+        with pytest.raises(PersistenceError) as excinfo:
+            handler.apply_changeset(user, settings_update)
+
+        assert "Could not update settings" in str(excinfo.value)
+        hook_impl.post_process_changeset.assert_not_called()
+
+    def test_actually_updates_password(self, user, database, mock, plugin_manager):
+        settings_update = SettingsUpdate(language="python", theme="molokai")
+        hook_impl = mock.Mock(spec=ChangeSetPostProcessor)
+        plugin_manager.register(self.impl(hook_impl))
+        handler = DefaultSettingsUpdateHandler(
+            db=database, plugin_manager=plugin_manager
+        )
+
+        handler.apply_changeset(user, settings_update)
+        same_user = User.query.get(user.id)
+
+        assert same_user.theme == "molokai"
+        assert same_user.language == "python"
+        hook_impl.post_process_changeset.assert_called_once_with(
+            user=user, settings_update=settings_update
+        )
+
+    @staticmethod
+    def impl(post_processor):
+        class Impl:
+            @HookimplMarker("flaskbb")
+            def flaskbb_settings_updated(self, user, settings_update):
+                post_processor.post_process_changeset(
+                    user=user, settings_update=settings_update
+                )
+        return Impl()

+ 115 - 0
tests/unit/user/test_update_validator.py

@@ -0,0 +1,115 @@
+from uuid import uuid4
+
+import pytest
+from requests.exceptions import RequestException
+
+from flaskbb.core.exceptions import StopValidation, ValidationError
+from flaskbb.core.user.update import EmailUpdate, PasswordUpdate, UserDetailsChange
+from flaskbb.user.models import User
+from flaskbb.user.services import validators
+
+pytestmark = pytest.mark.usefixtures("default_settings")
+
+
+class TestEmailsMustBeDifferent(object):
+    def test_raises_if_emails_match(self, Fred):
+        matching_emails = EmailUpdate("same@email.example", Fred.email)
+
+        with pytest.raises(ValidationError) as excinfo:
+            validators.EmailsMustBeDifferent().validate(Fred, matching_emails)
+        assert "New email address must be different" in str(excinfo.value)
+
+    def test_doesnt_raise_if_emails_are_different(self, Fred):
+        different_emails = EmailUpdate("old@email.example", "new@email.example")
+
+        validators.EmailsMustBeDifferent().validate(Fred, different_emails)
+
+
+class TestPasswordsMustBeDifferent(object):
+    def test_raises_if_passwords_are_the_same(self, Fred):
+        change = PasswordUpdate("fred", "fred")
+
+        with pytest.raises(ValidationError) as excinfo:
+            validators.PasswordsMustBeDifferent().validate(Fred, change)
+
+        assert "New password must be different" in str(excinfo.value)
+
+    def test_doesnt_raise_if_passwords_dont_match(self, Fred):
+        change = PasswordUpdate("fred", "actuallycompletelydifferent")
+
+        validators.PasswordsMustBeDifferent().validate(Fred, change)
+
+
+class TestCantShareEmailValidator(object):
+    def test_raises_if_email_is_already_registered(self, Fred, user):
+        change = EmailUpdate("old@email.example", user.email)
+
+        with pytest.raises(ValidationError) as excinfo:
+            validators.CantShareEmailValidator(User).validate(Fred, change)
+
+        assert "is already registered" in str(excinfo.value)
+
+    def test_doesnt_raise_if_email_isnt_registered(self, Fred):
+        change = EmailUpdate("old@email.example", "new@email.example")
+
+        validators.CantShareEmailValidator(User).validate(Fred, change)
+
+
+class TestOldEmailMustMatchValidator(object):
+    def test_raises_if_old_email_doesnt_match(self, Fred):
+        change = EmailUpdate("not@the.same.one.bit", "probably@real.email.provider")
+
+        with pytest.raises(StopValidation) as excinfo:
+            validators.OldEmailMustMatch().validate(Fred, change)
+
+        assert [("old_email", "Old email does not match")] == excinfo.value.reasons
+
+    def test_doesnt_raise_if_old_email_matches(self, Fred):
+        change = EmailUpdate(Fred.email, "probably@real.email.provider")
+
+        validators.OldEmailMustMatch().validate(Fred, change)
+
+
+class TestOldPasswordMustMatchValidator(object):
+    def test_raises_if_old_password_doesnt_match(self, Fred):
+        change = PasswordUpdate(str(uuid4()), str(uuid4()))
+
+        with pytest.raises(StopValidation) as excinfo:
+            validators.OldPasswordMustMatch().validate(Fred, change)
+
+        assert [("old_password", "Old password is wrong")] == excinfo.value.reasons
+
+    def test_doesnt_raise_if_old_passwords_match(self, Fred):
+        change = PasswordUpdate("fred", str(uuid4()))
+        validators.OldPasswordMustMatch().validate(Fred, change)
+
+
+class TestValidateAvatarURL(object):
+    def test_passes_if_avatar_url_is_none(self, Fred):
+        change = UserDetailsChange()
+        validators.ValidateAvatarURL().validate(Fred, change)
+
+    def test_raises_if_check_raises_requests_error(self, Fred, responses):
+        url = "http://notfake.example/image.png"
+        change = UserDetailsChange(avatar=url)
+        responses.add(responses.GET, url=url, body=RequestException())
+
+        with pytest.raises(ValidationError) as excinfo:
+            validators.ValidateAvatarURL().validate(Fred, change)
+
+        assert excinfo.value.attribute == "avatar"
+        assert excinfo.value.reason == "Could not retrieve avatar"
+
+    def test_raises_if_image_doesnt_pass_checks(self, image_too_tall, Fred, responses):
+        change = UserDetailsChange(avatar=image_too_tall.url)
+        responses.add(image_too_tall)
+
+        with pytest.raises(ValidationError) as excinfo:
+            validators.ValidateAvatarURL().validate(Fred, change)
+
+        assert "too high" in excinfo.value.reason
+
+    def tests_passes_if_image_is_just_right(self, image_just_right, Fred, responses):
+        change = UserDetailsChange(avatar=image_just_right.url)
+        responses.add(image_just_right)
+        validators.ValidateAvatarURL().validate(Fred, change)

+ 56 - 52
tests/unit/utils/test_helpers.py

@@ -3,16 +3,24 @@ import datetime as dt
 
 
 from flaskbb.forum.models import Forum
 from flaskbb.forum.models import Forum
 from flaskbb.utils.helpers import (
 from flaskbb.utils.helpers import (
-    check_image, crop_title, format_quote, forum_is_unread, get_image_info,
-    is_online, slugify, time_utcnow, topic_is_unread)
+    check_image,
+    crop_title,
+    format_quote,
+    forum_is_unread,
+    get_image_info,
+    is_online,
+    slugify,
+    time_utcnow,
+    topic_is_unread,
+)
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.settings import flaskbb_config
 
 
 
 
 def test_slugify():
 def test_slugify():
     """Test the slugify helper method."""
     """Test the slugify helper method."""
-    assert slugify(u'Hello world') == u'hello-world'
+    assert slugify(u"Hello world") == u"hello-world"
 
 
-    assert slugify(u'¿Cómo está?') == u'como-esta'
+    assert slugify(u"¿Cómo está?") == u"como-esta"
 
 
 
 
 def test_forum_is_unread(guest, user, forum, topic, forumsread):
 def test_forum_is_unread(guest, user, forum, topic, forumsread):
@@ -64,17 +72,13 @@ def test_topic_is_unread(guest, user, forum, topic, topicsread, forumsread):
     assert topic_is_unread(topic, topicsread, user, forumsread)
     assert topic_is_unread(topic, topicsread, user, forumsread)
 
 
     # TopicsRead is none and the forum has never been marked as read
     # TopicsRead is none and the forum has never been marked as read
-    assert topic_is_unread(
-        topic, topicsread=None, user=user, forumsread=forumsread
-    )
+    assert topic_is_unread(topic, topicsread=None, user=user, forumsread=forumsread)
 
 
     # lets mark the forum as read
     # lets mark the forum as read
     forumsread.cleared = time_utcnow()
     forumsread.cleared = time_utcnow()
     forumsread.last_read = time_utcnow()
     forumsread.last_read = time_utcnow()
     forumsread.save()
     forumsread.save()
-    assert not topic_is_unread(
-        topic, topicsread=None, user=user, forumsread=forumsread
-    )
+    assert not topic_is_unread(topic, topicsread=None, user=user, forumsread=forumsread)
 
 
     # disabled tracker
     # disabled tracker
     flaskbb_config["TRACKER_LENGTH"] = 0
     flaskbb_config["TRACKER_LENGTH"] = 0
@@ -107,66 +111,66 @@ def test_format_quote(topic):
     assert actual == expected_markdown
     assert actual == expected_markdown
 
 
 
 
-def test_get_image_info():
-    # some random jpg/gif/png images from my imgur account
-    jpg = "http://i.imgur.com/NgVIeRG.jpg"
-    gif = "http://i.imgur.com/l3Vmp4m.gif"
-    png = "http://i.imgur.com/JXzKxNs.png"
-
-    # Issue #207 Image - This works now
-    # issue_img = "http://b.reich.io/gtlbjc.jpg"
-    # issue_img = get_image_info(issue_img)
-    # assert issue_img["content_type"] == "JPEG"
-
-    jpg_img = get_image_info(jpg)
+def test_get_image_info_jpg(responses, image_jpg):
+    responses.add(image_jpg)
+    jpg_img = get_image_info(image_jpg.url)
     assert jpg_img["content_type"] == "JPEG"
     assert jpg_img["content_type"] == "JPEG"
     assert jpg_img["height"] == 1024
     assert jpg_img["height"] == 1024
     assert jpg_img["width"] == 1280
     assert jpg_img["width"] == 1280
     assert jpg_img["size"] == 209.06
     assert jpg_img["size"] == 209.06
 
 
-    gif_img = get_image_info(gif)
+
+def test_get_image_info_gif(responses, image_gif):
+    responses.add(image_gif)
+    gif_img = get_image_info(image_gif.url)
     assert gif_img["content_type"] == "GIF"
     assert gif_img["content_type"] == "GIF"
     assert gif_img["height"] == 168
     assert gif_img["height"] == 168
     assert gif_img["width"] == 400
     assert gif_img["width"] == 400
     assert gif_img["size"] == 576.138
     assert gif_img["size"] == 576.138
 
 
-    png_img = get_image_info(png)
+
+def test_get_image_info_png(responses, image_png):
+    responses.add(image_png)
+    png_img = get_image_info(image_png.url)
     assert png_img["content_type"] == "PNG"
     assert png_img["content_type"] == "PNG"
     assert png_img["height"] == 1080
     assert png_img["height"] == 1080
     assert png_img["width"] == 1920
     assert png_img["width"] == 1920
     assert png_img["size"] == 269.409
     assert png_img["size"] == 269.409
 
 
 
 
-def test_check_image(default_settings):
-    # test200_100.png
-    img_width = "http://i.imgur.com/4dAWAZI.png"
-    # test100_200.png
-    img_height = "http://i.imgur.com/I7GwF3D.png"
-    # test100_100.png
-    img_ok = "http://i.imgur.com/CYV6NzT.png"
-    # random too big image
-    img_size = "http://i.imgur.com/l3Vmp4m.gif"
-    # random image wrong type
-    img_type = "https://flaskbb.org/static/imgs/flask.svg"
-
-    data = check_image(img_width)
-    assert "wide" in data[0]
-    assert not data[1]
+def assert_bad_image_check(result, mesg):
+    assert not result[1]
+    assert mesg in result[0]
 
 
-    data = check_image(img_height)
-    assert "high" in data[0]
-    assert not data[1]
 
 
-    data = check_image(img_type)
-    assert "type" in data[0]
-    assert not data[1]
-
-    data = check_image(img_ok)
-    assert data[0] is None
-    assert data[1]
+def test_check_image_too_big(image_too_big, default_settings, responses):
+    responses.add(image_too_big)
 
 
     flaskbb_config["AVATAR_WIDTH"] = 1000
     flaskbb_config["AVATAR_WIDTH"] = 1000
     flaskbb_config["AVATAR_HEIGHT"] = 1000
     flaskbb_config["AVATAR_HEIGHT"] = 1000
-    data = check_image(img_size)
-    assert "big" in data[0]
-    assert not data[1]
+    assert_bad_image_check(check_image(image_too_big.url), "big")
+
+
+def test_check_image_too_tall(image_too_tall, default_settings, responses):
+    responses.add(image_too_tall)
+
+    assert_bad_image_check(check_image(image_too_tall.url), "high")
+
+
+def test_check_image_too_wide(image_too_wide, default_settings, responses):
+    responses.add(image_too_wide)
+
+    assert_bad_image_check(check_image(image_too_wide.url), "wide")
+
+
+def test_check_image_wrong_mime(image_wrong_mime, default_settings, responses):
+    responses.add(image_wrong_mime)
+
+    assert_bad_image_check(check_image(image_wrong_mime.url), "type")
+
+
+def test_check_image_just_right(image_just_right, default_settings, responses):
+    responses.add(image_just_right)
+
+    result = check_image(image_just_right.url)
+    assert result[1]