Index: ow/models/user.py
===================================================================
--- ow/models/user.py	(revision 8bae554b97b0673cf21e50e15b41dfc5223a45b1)
+++ ow/models/user.py	(revision fd6da9399d34f5a809b9304b390103dd06b7bd89)
@@ -37,4 +37,5 @@
         self.picture = kw.get('picture', None)  # blob
         self.timezone = kw.get('timezone', 'UTC')
+        self.locale = kw.get('locale', 'en')
         self.__password = None
         self.last_workout_id = 0
Index: ow/schemas/user.py
===================================================================
--- ow/schemas/user.py	(revision 8bae554b97b0673cf21e50e15b41dfc5223a45b1)
+++ ow/schemas/user.py	(revision fd6da9399d34f5a809b9304b390103dd06b7bd89)
@@ -4,4 +4,5 @@
 
 from ow.schemas.blob import FieldStorageBlob
+from ow.utilities import get_available_locale_names
 
 _ = TranslationStringFactory('OpenWorkouts')
@@ -76,4 +77,8 @@
                                whitelist=['jpg', 'jpeg', 'png', 'gif'])
     timezone = validators.OneOf(common_timezones, if_missing='UTC')
+    locale = validators.OneOf(
+        [locale[0] for locale in get_available_locale_names()],
+        if_missing='en'
+    )
 
 
Index: ow/static/css/main.css
===================================================================
--- ow/static/css/main.css	(revision 8bae554b97b0673cf21e50e15b41dfc5223a45b1)
+++ ow/static/css/main.css	(revision fd6da9399d34f5a809b9304b390103dd06b7bd89)
@@ -605,4 +605,7 @@
   border-radius: 4px;
   border: 1px solid #e1e1e1;
+}
+.ow-forms select {
+  width: 100%;
 }
 /* Generic button */
Index: ow/static/less/ui/form.less
===================================================================
--- ow/static/less/ui/form.less	(revision 8bae554b97b0673cf21e50e15b41dfc5223a45b1)
+++ ow/static/less/ui/form.less	(revision fd6da9399d34f5a809b9304b390103dd06b7bd89)
@@ -47,3 +47,6 @@
         border: 1px solid @color-main-light;
     }
+    select {
+        width: 100%;
+    }
 }
Index: ow/templates/edit_profile.pt
===================================================================
--- ow/templates/edit_profile.pt	(revision 8bae554b97b0673cf21e50e15b41dfc5223a45b1)
+++ ow/templates/edit_profile.pt	(revision fd6da9399d34f5a809b9304b390103dd06b7bd89)
@@ -46,5 +46,4 @@
                 <div>
                   <tal:c tal:condition="getattr(context, 'picture', None)">
-
                       <label for="current_picture" i18n:translate="">
                           Current picture:</label>
@@ -101,12 +100,26 @@
                 ${form.textarea('bio', rows=10, cols=50)}
             </p>
-            <div>
-              <label for="timezone" i18n:translate="">Timezone:</label>
-                <small i18n:translate="">
+            <div class="input-container ly-flex ly-2 has-gap">
+              <div>
+                <label for="timezone" i18n:translate="">Timezone:</label>
+                <p>
+                  <small i18n:translate="">
                     All dates and times will be formatted for this timezone
-                </small>
+                  </small>
+                </p>
+                ${form.errorlist('timezone')}
+                ${form.select('timezone', timezones)}
+              </div>
+              <div>
+                <label for="locale" i18n:translate="">Language:</label>
+                <p>
+                  <small i18n:translate="">
+                    All texts in the user interface will appear in this language
+                  </small>
+                </p>
+                ${form.errorlist('locale')}
+                ${form.select('locale', available_locale_names, selected_value=current_locale)}
+              </div>
             </div>
-            ${form.errorlist('timezone')}
-            ${form.select('timezone', timezones)}
         </fieldset>
 
Index: ow/tests/views/test_user.py
===================================================================
--- ow/tests/views/test_user.py	(revision 8bae554b97b0673cf21e50e15b41dfc5223a45b1)
+++ ow/tests/views/test_user.py	(revision fd6da9399d34f5a809b9304b390103dd06b7bd89)
@@ -20,8 +20,11 @@
 from PIL import Image
 
+from pytz import common_timezones
+
 from ow.models.root import OpenWorkouts
 from ow.models.user import User
 from ow.models.workout import Workout
 from ow.views.renderers import OWFormRenderer
+from ow.utilities import get_available_locale_names
 import ow.views.user as user_views
 
@@ -51,4 +54,7 @@
     def dummy_request(self, root):
         request = DummyRequest()
+        request.registry.settings = {
+            'pyramid.default_locale_name': 'en'
+        }
         request.root = root
         return request
@@ -561,4 +567,32 @@
         assert rem.called
         assert response.location == request.resource_url(john)
+        # the response headers contain the proper set_cookie for the default
+        # locale
+        default_locale_name = request.registry.settings[
+            'pyramid.default_locale_name']
+        expected_locale_header = '_LOCALE_=' + default_locale_name + '; Path=/'
+        assert response.headers['Set-Cookie'] == expected_locale_header
+
+    @patch('ow.views.user.remember')
+    def test_login_post_ok_set_locale(self, rem, dummy_request, john):
+        # same as the previous test, but this time the user has set a
+        # locale different than the default one
+        request = dummy_request
+        request.method = 'POST'
+        request.POST['submit'] = True
+        request.POST['email'] = 'john.doe@example.net'
+        request.POST['password'] = 's3cr3t'
+        # verify the user first
+        john.verified = True
+        # set the locale
+        john.locale = 'es'
+        response = user_views.login(request.root, request)
+        assert isinstance(response, HTTPFound)
+        assert rem.called
+        assert response.location == request.resource_url(john)
+        # the response headers contain the proper set_cookie for the user
+        # locale setting
+        expected_locale_header = '_LOCALE_=es; Path=/'
+        assert response.headers['Set-Cookie'] == expected_locale_header
 
     @patch('ow.views.user.forget')
@@ -569,4 +603,10 @@
         assert forg.called
         assert response.location == request.resource_url(request.root)
+        # the response headers contain the needed Set-Cookie header that
+        # invalidates the _LOCALE_ cookie, preventing problems with users
+        # sharing the same web browser (one locale setting being set for
+        # another user)
+        expected_locale_header = '_LOCALE_=; Max-Age=0; Path=/; expires='
+        assert expected_locale_header in response.headers['Set-Cookie']
 
     extensions = ('png', 'jpg', 'jpeg', 'gif')
@@ -639,8 +679,14 @@
         # loaded user profile
         data = ['firstname', 'lastname', 'email', 'nickname', 'bio',
-                'birth_date', 'height', 'weight', 'gender', 'timezone']
+                'birth_date', 'height', 'weight', 'gender', 'timezone',
+                'locale']
         assert list(response['form'].data.keys()) == data
         # and check the email to see data is properly loaded
         assert response['form'].data['email'] == 'john.doe@example.net'
+        assert response['timezones'] == common_timezones
+        assert response[
+            'available_locale_names'] == get_available_locale_names()
+        assert response['current_locale'] == request.registry.settings[
+            'pyramid.default_locale_name']
 
     def test_edit_profile_post_ok(self, profile_post_request, john):
@@ -654,4 +700,28 @@
         assert response.location == request.resource_url(user, 'profile')
         assert user.bio == bio
+
+    def test_edit_profile_post_ok_change_locale(
+            self, profile_post_request, john):
+        request = profile_post_request
+        user = john
+        # Update the locale
+        request.POST['locale'] = 'es'
+        response = user_views.edit_profile(user, request)
+        assert isinstance(response, HTTPFound)
+        assert response.location == request.resource_url(user, 'profile')
+        assert user.locale == 'es'
+
+    def test_edit_profile_post_ok_invalid_locale(
+            self, profile_post_request, john):
+        request = profile_post_request
+        user = john
+        # Update the locale with an invalid option
+        request.POST['locale'] = 'XX'
+        response = user_views.edit_profile(user, request)
+        assert isinstance(response['form'], OWFormRenderer)
+        # as an error happened, the current_locale had not changed
+        assert response['form'].errors == {
+            'locale': "Value must be one of: en; es (not 'XX')"}
+        assert user.locale == 'en'
 
     def test_edit_profile_post_missing_required(
Index: ow/utilities.py
===================================================================
--- ow/utilities.py	(revision 8bae554b97b0673cf21e50e15b41dfc5223a45b1)
+++ ow/utilities.py	(revision fd6da9399d34f5a809b9304b390103dd06b7bd89)
@@ -296,2 +296,16 @@
         if value[0] <= dt.hour <= value[1]:
             return key
+
+
+def get_available_locale_names():
+    """
+    Return a list of tuples with info about available locale/language
+    names.
+
+    The locale codes and names in this list match the available translations
+    under ow/locale for the UI elements
+    """
+    return [
+        ('en', _('English')),
+        ('es', _('Spanish'))
+    ]
Index: ow/views/user.py
===================================================================
--- ow/views/user.py	(revision 8bae554b97b0673cf21e50e15b41dfc5223a45b1)
+++ ow/views/user.py	(revision fd6da9399d34f5a809b9304b390103dd06b7bd89)
@@ -22,5 +22,9 @@
 from ..models.root import OpenWorkouts
 from ..views.renderers import OWFormRenderer
-from ..utilities import timedelta_to_hms, get_verification_token
+from ..utilities import (
+    timedelta_to_hms,
+    get_verification_token,
+    get_available_locale_names
+)
 from ..mail import send_verification_email
 
@@ -76,7 +80,17 @@
                 password = request.POST.get('password', None)
                 if password is not None and user.check_password(password):
+                    # look for the value of locale for this user, to set the
+                    # LOCALE cookie, so the UI appears on the pre-selected lang
+                    default_locale = request.registry.settings.get(
+                        'pyramid.default_locale_name')
+                    locale = getattr(user, 'locale', default_locale)
+                    request.response.set_cookie('_LOCALE_', locale)
+                    # log in the user and send back to the place he wanted to
+                    # visit
                     headers = remember(request, str(user.uid))
+                    request.response.headers.extend(headers)
                     redirect_url = return_to or request.resource_url(user)
-                    return HTTPFound(location=redirect_url, headers=headers)
+                    return HTTPFound(location=redirect_url,
+                                     headers=request.response.headers)
                 else:
                     message = _('Wrong password')
@@ -100,6 +114,9 @@
 @view_config(context=OpenWorkouts, name='logout')
 def logout(context, request):
+    request.response.delete_cookie('_LOCALE_')
     headers = forget(request)
-    return HTTPFound(location=request.resource_url(context), headers=headers)
+    request.response.headers.extend(headers)
+    return HTTPFound(location=request.resource_url(context),
+                     headers=request.response.headers)
 
 
@@ -319,4 +336,8 @@
     renderer='ow:templates/edit_profile.pt')
 def edit_profile(context, request):
+    default_locale = request.registry.settings.get(
+        'pyramid.default_locale_name')
+    available_locale_names = get_available_locale_names()
+    current_locale = request.cookies.get('_LOCALE_', default_locale)
     # if not given a file there is an empty byte in POST, which breaks
     # our blob storage validator.
@@ -340,6 +361,10 @@
         # reindex
         request.root.reindex(context)
+        # set the cookie for the locale/lang
+        request.response.set_cookie('_LOCALE_', form.data['locale'])
+        current_locale = form.data['locale']
         # Saved, send the user to the public view of her profile
-        return HTTPFound(location=request.resource_url(context, 'profile'))
+        return HTTPFound(location=request.resource_url(context, 'profile'),
+                         headers=request.response.headers)
 
     # prevent crashes on the form
@@ -348,5 +373,7 @@
 
     return {'form': OWFormRenderer(form),
-            'timezones': common_timezones}
+            'timezones': common_timezones,
+            'available_locale_names': available_locale_names,
+            'current_locale': current_locale}
 
 
