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

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 лет назад
Родитель
Сommit
ed66fd8f7f
52 измененных файлов с 2553 добавлено и 330 удалено
  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.
 
+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
 -------------
 

+ 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
    models
+   changesets
    registration
+   userprofiles
    authentication
    accountmanagement
    tokens
    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_failure_handler
 
-
 Authentication Events
 ---------------------
 
@@ -32,3 +31,13 @@ Authentication Events
 .. autofunction:: flaskbb_post_reauth
 .. 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_profile_settings_menu
 .. 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_after
 .. 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 .management import views as management_views  # noqa
 from .user import views as user_views  # noqa
+from .display.navigation import NavigationContentType
 
 logger = logging.getLogger(__name__)
 
@@ -320,6 +321,7 @@ def configure_template_filters(app):
     app.jinja_env.filters.update(filters)
 
     app.jinja_env.globals["run_hook"] = template_hook
+    app.jinja_env.globals["NavigationContentType"] = NavigationContentType
 
     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:
             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
     for convenient filtering.
     """
+
     pass
 
 
@@ -37,6 +38,7 @@ class FlaskBBDeprecation(DeprecationWarning, FlaskBBWarning, ABC):
         class RemovedInPluginv3(FlaskBBDeprecation):
             version = (3, 0, 0)
     """
+
     version = abstractproperty(lambda self: None)
 
 
@@ -44,6 +46,7 @@ class RemovedInFlaskBB3(FlaskBBDeprecation):
     """
     warning for features removed in FlaskBB3
     """
+
     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_wtf import FlaskForm
 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.forum.models import Category, Forum
@@ -68,10 +81,7 @@ class UserForm(FlaskForm):
     birthday = BirthdayField(_("Birthday"), format="%d %m %Y", validators=[
         Optional()])
 
-    gender = SelectField(_("Gender"), default="None", choices=[
-        ("None", ""),
-        ("Male", _("Male")),
-        ("Female", _("Female"))])
+    gender = StringField(_("Gender"), validators=[Optional()])
 
     location = StringField(_("Location"), validators=[
         Optional()])

+ 201 - 2
flaskbb/plugins/spec.py

@@ -11,7 +11,7 @@
 
 from pluggy import HookspecMarker
 
-spec = HookspecMarker('flaskbb')
+spec = HookspecMarker("flaskbb")
 
 
 # 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
 @spec
 def flaskbb_tpl_navigation_before():
@@ -624,7 +776,7 @@ def flaskbb_tpl_form_user_details_after(form):
 
 
 @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
     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
@@ -650,6 +802,53 @@ def flaskbb_tpl_profile_settings_menu():
     supplies its own hookwrapper to flatten all the lists into a single list.
 
     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.
 """
 from flask import current_app, flash, redirect, url_for
-from jinja2 import Markup
 from flask_babelplus import gettext as _
+from jinja2 import Markup
 
 from flaskbb.extensions import db
-from flaskbb.utils.datastructures import TemplateEventResult
 from flaskbb.plugins.models import PluginRegistry
+from flaskbb.utils.datastructures import TemplateEventResult
 
 
 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.
     """
     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()]
 
@@ -70,8 +72,8 @@ def remove_zombie_plugins_from_db():
             remove_me.append(p)
 
     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()
     return remove_me

+ 42 - 2
flaskbb/templates/macros.html

@@ -308,12 +308,52 @@
     {%- endif -%}
 {% 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 %}>
-    <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>
 {% 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) %}
 <li {% if endpoint == request.endpoint or active %}class="active"{% endif %} >
     <a href={{ endpoint }} role="tab" data-toggle="tab">{{ name }}</a>

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

@@ -9,34 +9,6 @@
 </ul>
 {% 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 %}
 <!-- middle column -->

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

@@ -9,34 +9,6 @@
 </ul>
 {% 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 %}
 <!-- middle column -->
 <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="col-md-12 col-sm-12 co-xs-12">
                     <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>

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

@@ -1,3 +1,4 @@
+{%- from theme("macros.html") import sidebar with context -%}
 {% extends theme("layout.html") %}
 {% set page_title = _("%(user)s - User", user=user.username) %}
 
@@ -49,33 +50,15 @@
                             {{ run_hook("flaskbb_tpl_profile_sidebar_stats", user=user) }}
                         </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>
                     {% endblock %}
 
@@ -89,10 +72,3 @@
 </div> <!-- end page-view -->
 
 {% 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="sidebar">
             <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 %}
                     <li class="sidenav-header">{{ _(text) }}</li>
                     {% else %}

+ 83 - 78
flaskbb/user/forms.py

@@ -9,111 +9,116 @@
     :license: BSD, see LICENSE for more details.
 """
 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 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.helpers import check_image
+from flaskbb.utils.forms import FlaskBBForm
 
+from ..core.user.update import (
+    EmailUpdate,
+    PasswordUpdate,
+    SettingsUpdate,
+    UserDetailsChange,
+)
 
 logger = logging.getLogger(__name__)
 
 
-class GeneralSettingsForm(FlaskForm):
+class GeneralSettingsForm(FlaskBBForm):
     # The choices for those fields will be generated in the user view
     # because we cannot access the current_app outside of the context
     language = SelectField(_("Language"))
     theme = SelectField(_("Theme"))
-
     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"))
 
     def __init__(self, user, *args, **kwargs):
         self.user = user
-        kwargs['obj'] = self.user
+        kwargs["obj"] = self.user
         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"))
 
-    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"))
 
     def validate_birthday(self, field):
         if field.data is None:
             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 flask_babelplus import gettext as _
 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)
@@ -12,11 +36,52 @@ def flaskbb_tpl_profile_settings_menu():
     the menu
     """
     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.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
 
-from flask import Blueprint, flash, request
+import attr
+from flask import Blueprint, flash, redirect, request, url_for
 from flask.views import MethodView
 from flask_babelplus import gettext as _
 from flask_login import current_user, login_required
 from pluggy import HookimplMarker
 
-from flaskbb.user.forms import (ChangeEmailForm, ChangePasswordForm,
-                                ChangeUserDetailsForm, GeneralSettingsForm)
 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__)
 
 
+@attr.s(frozen=True, cmp=False, hash=False, repr=True)
 class UserSettings(MethodView):
+    form = attr.ib(factory=settings_form_factory)
+    settings_update_handler = attr.ib(factory=settings_update_handler)
+
     decorators = [login_required]
-    form = GeneralSettingsForm
 
     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):
-        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")
-        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):
+    form = attr.ib(factory=change_password_form_factory)
+    password_update_handler = attr.ib(factory=password_update_handler)
     decorators = [login_required]
-    form = ChangePasswordForm
 
     def get(self):
-        return render_template("user/change_password.html", form=self.form())
+        return self.render()
 
     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")
-        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):
+    form = attr.ib(factory=change_email_form_factory)
+    update_email_handler = attr.ib(factory=email_update_handler)
     decorators = [login_required]
-    form = ChangeEmailForm
 
     def get(self):
-        return render_template(
-            "user/change_email.html", form=self.form(current_user)
-        )
+        return self.render()
 
     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")
-        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):
+    form = attr.ib(factory=change_details_form_factory)
+    details_update_handler = attr.ib(factory=details_update_factory)
     decorators = [login_required]
-    form = ChangeUserDetailsForm
 
     def get(self):
-        return render_template(
-            "user/change_user_details.html", form=self.form(obj=current_user)
-        )
+        return self.render()
 
     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")
+            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):
         page = request.args.get("page", 1, type=int)
         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)
 
 
-class AllUserPosts(MethodView):
-
+class AllUserPosts(MethodView):  # pragma: no cover
     def get(self, username):
         page = request.args.get("page", 1, type=int)
         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)
 
 
-class UserProfile(MethodView):
-
+class UserProfile(MethodView):  # pragma: no cover
     def get(self, username):
         user = User.query.filter_by(username=username).first_or_404()
         return render_template("user/profile.html", user=user)
@@ -150,38 +202,34 @@ class UserProfile(MethodView):
 def flaskbb_load_blueprints(app):
     user = Blueprint("user", __name__)
     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(
-        user,
-        routes=['/settings/general'],
-        view_func=UserSettings.as_view('settings')
+        user, routes=["/settings/general"], view_func=UserSettings.as_view("settings")
     )
     register_view(
         user,
-        routes=['/settings/password'],
-        view_func=ChangePassword.as_view('change_password')
+        routes=["/settings/password"],
+        view_func=ChangePassword.as_view("change_password"),
     )
     register_view(
         user,
         routes=["/settings/user-details"],
-        view_func=ChangeUserDetails.as_view('change_user_details')
+        view_func=ChangeUserDetails.as_view("change_user_details"),
     )
     register_view(
         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(
         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(
-        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"])

+ 8 - 0
flaskbb/utils/database.py

@@ -14,6 +14,7 @@ from flask_login import current_user
 from flask_sqlalchemy import BaseQuery
 from sqlalchemy.ext.declarative import declared_attr
 from flaskbb.extensions import db
+from ..core.exceptions import PersistenceError
 
 
 logger = logging.getLogger(__name__)
@@ -158,3 +159,10 @@ class HideableMixin(object):
 
 class HideableCRUDMixin(HideableMixin, CRUDMixin):
     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
-pytest==3.6.4
-pytest-mock==1.10.0
+flake8==3.5.0
 freezegun==0.3.10
 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.settings_fixture 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
 
 from flaskbb import create_app
-from flaskbb.extensions import db
 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)
@@ -27,6 +26,12 @@ def request_context(application):
 
 
 @pytest.fixture()
+def post_request_context(application):
+    with application.test_request_context(method="POST"):
+        yield
+
+
+@pytest.fixture()
 def default_groups(database):
     """Creates the 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.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
 
 
 def test_slugify():
     """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):
@@ -64,17 +72,13 @@ def test_topic_is_unread(guest, user, forum, topic, topicsread, forumsread):
     assert topic_is_unread(topic, topicsread, user, forumsread)
 
     # 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
     forumsread.cleared = time_utcnow()
     forumsread.last_read = time_utcnow()
     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
     flaskbb_config["TRACKER_LENGTH"] = 0
@@ -107,66 +111,66 @@ def test_format_quote(topic):
     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["height"] == 1024
     assert jpg_img["width"] == 1280
     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["height"] == 168
     assert gif_img["width"] == 400
     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["height"] == 1080
     assert png_img["width"] == 1920
     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_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]