Merge pull request #110 from SamR1/sport-preferences

Add user sport preferences
This commit is contained in:
Sam 2021-11-13 15:48:23 +01:00 committed by GitHub
commit d60bf52505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 2047 additions and 271 deletions

View File

@ -7,6 +7,7 @@
#### New Features
* [#91](https://github.com/SamR1/FitTrackee/issues/91) - Display elevation chart with min and max altitude of workout
* [#90](https://github.com/SamR1/FitTrackee/issues/90) - Add user sports preferences
* [#18](https://github.com/SamR1/FitTrackee/issues/18) - Better UI
#### Bugs Fixed
@ -22,6 +23,8 @@
* [#98/#109](https://github.com/SamR1/FitTrackee/pull/109) - Added stopped_speed_threshold to support slow movement
* [#84/#93](https://github.com/SamR1/FitTrackee/pull/93) - Add elevation data and new sports
In this release 5 issue were closed.
## Version 0.4.9 (2021/07/16)

View File

@ -8,6 +8,8 @@ Authentication
auth.logout_user,
auth.get_authenticated_user_profile,
auth.edit_user,
auth.edit_user_preferences,
auth.edit_user_sport_preferences,
auth.edit_picture,
auth.del_picture,
auth.request_password_reset,

View File

@ -7,6 +7,7 @@
#### New Features
* [#91](https://github.com/SamR1/FitTrackee/issues/91) - Display elevation chart with min and max altitude of workout
* [#90](https://github.com/SamR1/FitTrackee/issues/90) - Add user sports preferences
* [#18](https://github.com/SamR1/FitTrackee/issues/18) - Better UI
#### Bugs Fixed
@ -22,6 +23,8 @@
* [#98/#109](https://github.com/SamR1/FitTrackee/pull/109) - Added stopped_speed_threshold to support slow movement
* [#84/#93](https://github.com/SamR1/FitTrackee/pull/93) - Add elevation data and new sports
In this release 5 issue were closed.
## Version 0.4.9 (2021/07/16)

View File

@ -31,10 +31,21 @@ Administration
- enable or disable a sport (a sport can be disabled even if workout with this sport exists)
Account
^^^^^^^
Account & preferences
^^^^^^^^^^^^^^^^^^^^^
- A user can create, update and deleted his account
- A user can reset his password (*new in 0.3.0*)
- A user can set language, timezone and first day of week.
- A user can set sport preferences (*new in 0.5.0*):
- change sport color (used for sport image and charts)
- can override stopped speed threshold (for next uploaded gpx files)
- disable/enable a sport.
.. note::
| If a sport is disabled by an administrator, it can not be enabled by a user. In this case, it will only appear in preferences if it has user's workouts and the user can only change sport color.
| A disabled sport (by admin or user) will not appear in dropdown when **adding a workout**.
| A workout with a disabled sport will still be displayed in the application.
Workouts
@ -51,29 +62,33 @@ Workouts
- Skiing (Cross Country) (**new in 0.5.0**)
- Trail (**new in 0.5.0**)
- Walking
- (*new in 0.5.0*) Stopped speed threshold used by `gpxpy <https://github.com/tkrajina/gpxpy>`_ is not the default one (0.1 km/h instead of 1 km/h) for the following sports:
- (*new in 0.5.0*) Stopped speed threshold used by `gpxpy <https://github.com/tkrajina/gpxpy>`_ is not the default one for the following sports (0.1 km/h instead of 1 km/h):
- Hiking
- Skiing (Cross Country)
- Trail
- Walking
- Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user settings). The calendar displays up to 100 workouts.
- Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance)
- A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed
- Workout edition and deletion. User can add a note
.. note::
It can be overridden in user preferences.
- Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts.
- Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance).
- A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed.
- Workout edition and deletion. User can add a note.
- User statistics
- User records by sports:
- average speed
- farest distance
- longest duration
- maximum speed
- Workouts list and filter
- Workouts list and filter. Only sports with workouts are displayed in sport dropdown.
.. note::
for now, only the owner of the workout can see it.
For now, only the owner of the workout can see it.
Translations
^^^^^^^^^^^^
FitTrackee is available in English and French (which can be saved in the user settings).
FitTrackee is available in English and French (which can be saved in the user preferences).
Dashboard

View File

@ -516,6 +516,179 @@
</dl>
</dd></dl>
<dl class="http post">
<dt class="sig sig-object http" id="post--api-auth-profile-edit-preferences">
<span class="sig-name descname"><span class="pre">POST</span> </span><span class="sig-name descname"><span class="pre">/api/auth/profile/edit/preferences</span></span><a class="headerlink" href="#post--api-auth-profile-edit-preferences" title="Permalink to this definition"></a></dt>
<dd><p>edit authenticated user preferences</p>
<p><strong>Example request</strong>:</p>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="nf">POST</span> <span class="nn">/api/auth/profile/edit/preferences</span> <span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
</pre></div>
</div>
<p><strong>Example response</strong>:</p>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
<span class="p">{</span>
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;admin&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;bio&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;birth_date&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;created_at&quot;</span><span class="p">:</span> <span class="s2">&quot;Sun, 14 Jul 2019 14:09:58 GMT&quot;</span><span class="p">,</span>
<span class="nt">&quot;email&quot;</span><span class="p">:</span> <span class="s2">&quot;sam@example.com&quot;</span><span class="p">,</span>
<span class="nt">&quot;first_name&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;language&quot;</span><span class="p">:</span> <span class="s2">&quot;en&quot;</span><span class="p">,</span>
<span class="nt">&quot;last_name&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;location&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;nb_sports&quot;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
<span class="nt">&quot;nb_workouts&quot;</span><span class="p">:</span> <span class="mi">6</span><span class="p">,</span>
<span class="nt">&quot;picture&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;records&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">9</span><span class="p">,</span>
<span class="nt">&quot;record_type&quot;</span><span class="p">:</span> <span class="s2">&quot;AS&quot;</span><span class="p">,</span>
<span class="nt">&quot;sport_id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;user&quot;</span><span class="p">:</span> <span class="s2">&quot;sam&quot;</span><span class="p">,</span>
<span class="nt">&quot;value&quot;</span><span class="p">:</span> <span class="mi">18</span><span class="p">,</span>
<span class="nt">&quot;workout_date&quot;</span><span class="p">:</span> <span class="s2">&quot;Sun, 07 Jul 2019 08:00:00 GMT&quot;</span><span class="p">,</span>
<span class="nt">&quot;workout_id&quot;</span><span class="p">:</span> <span class="s2">&quot;hvYBqYBRa7wwXpaStWR4V2&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span>
<span class="nt">&quot;record_type&quot;</span><span class="p">:</span> <span class="s2">&quot;FD&quot;</span><span class="p">,</span>
<span class="nt">&quot;sport_id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;user&quot;</span><span class="p">:</span> <span class="s2">&quot;sam&quot;</span><span class="p">,</span>
<span class="nt">&quot;value&quot;</span><span class="p">:</span> <span class="mi">18</span><span class="p">,</span>
<span class="nt">&quot;workout_date&quot;</span><span class="p">:</span> <span class="s2">&quot;Sun, 07 Jul 2019 08:00:00 GMT&quot;</span><span class="p">,</span>
<span class="nt">&quot;workout_id&quot;</span><span class="p">:</span> <span class="s2">&quot;hvYBqYBRa7wwXpaStWR4V2&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">11</span><span class="p">,</span>
<span class="nt">&quot;record_type&quot;</span><span class="p">:</span> <span class="s2">&quot;LD&quot;</span><span class="p">,</span>
<span class="nt">&quot;sport_id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;user&quot;</span><span class="p">:</span> <span class="s2">&quot;sam&quot;</span><span class="p">,</span>
<span class="nt">&quot;value&quot;</span><span class="p">:</span> <span class="s2">&quot;1:01:00&quot;</span><span class="p">,</span>
<span class="nt">&quot;workout_date&quot;</span><span class="p">:</span> <span class="s2">&quot;Sun, 07 Jul 2019 08:00:00 GMT&quot;</span><span class="p">,</span>
<span class="nt">&quot;workout_id&quot;</span><span class="p">:</span> <span class="s2">&quot;hvYBqYBRa7wwXpaStWR4V2&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">12</span><span class="p">,</span>
<span class="nt">&quot;record_type&quot;</span><span class="p">:</span> <span class="s2">&quot;MS&quot;</span><span class="p">,</span>
<span class="nt">&quot;sport_id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;user&quot;</span><span class="p">:</span> <span class="s2">&quot;sam&quot;</span><span class="p">,</span>
<span class="nt">&quot;value&quot;</span><span class="p">:</span> <span class="mi">18</span><span class="p">,</span>
<span class="nt">&quot;workout_date&quot;</span><span class="p">:</span> <span class="s2">&quot;Sun, 07 Jul 2019 08:00:00 GMT&quot;</span><span class="p">,</span>
<span class="nt">&quot;workout_id&quot;</span><span class="p">:</span> <span class="s2">&quot;hvYBqYBRa7wwXpaStWR4V2&quot;</span>
<span class="p">}</span>
<span class="p">],</span>
<span class="nt">&quot;sports_list&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="mi">1</span><span class="p">,</span>
<span class="mi">4</span><span class="p">,</span>
<span class="mi">6</span>
<span class="p">],</span>
<span class="nt">&quot;timezone&quot;</span><span class="p">:</span> <span class="s2">&quot;Europe/Paris&quot;</span><span class="p">,</span>
<span class="nt">&quot;total_distance&quot;</span><span class="p">:</span> <span class="mf">67.895</span><span class="p">,</span>
<span class="nt">&quot;total_duration&quot;</span><span class="p">:</span> <span class="s2">&quot;6:50:27&quot;</span><span class="p">,</span>
<span class="nt">&quot;username&quot;</span><span class="p">:</span> <span class="nt">&quot;sam&quot;</span>
<span class="nt">&quot;weekm&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="p">},</span>
<span class="nt">&quot;message&quot;</span><span class="p">:</span> <span class="s2">&quot;user preferences updated&quot;</span><span class="p">,</span>
<span class="nt">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;success&quot;</span>
<span class="p">}</span>
</pre></div>
</div>
<dl class="field-list simple">
<dt class="field-odd">Request JSON Object</dt>
<dd class="field-odd"><ul class="simple">
<li><p><strong>timezone</strong> (<em>string</em>) user time zone</p></li>
<li><p><strong>weekm</strong> (<em>string</em>) does week start on Monday?</p></li>
<li><p><strong>language</strong> (<em>string</em>) language preferences</p></li>
</ul>
</dd>
<dt class="field-even">Request Headers</dt>
<dd class="field-even"><ul class="simple">
<li><p><span><a class="reference external" href="https://tools.ietf.org/html/rfc7235#section-4.2">Authorization</a></span> OAuth 2.0 Bearer Token</p></li>
</ul>
</dd>
<dt class="field-odd">Status Codes</dt>
<dd class="field-odd"><ul class="simple">
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.1">200 OK</a></span> user preferences updated</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1">400 Bad Request</a></span> <ul>
<li><p>invalid payload</p></li>
<li><p>password: password and password confirmation dont match</p></li>
</ul>
</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2">401 Unauthorized</a></span> <ul>
<li><p>provide a valid auth token</p></li>
<li><p>signature expired, please log in again</p></li>
<li><p>invalid token, please log in again</p></li>
</ul>
</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.5.1">500 Internal Server Error</a></span> error, please try again or contact the administrator</p></li>
</ul>
</dd>
</dl>
</dd></dl>
<dl class="http post">
<dt class="sig sig-object http" id="post--api-auth-profile-edit-sports">
<span class="sig-name descname"><span class="pre">POST</span> </span><span class="sig-name descname"><span class="pre">/api/auth/profile/edit/sports</span></span><a class="headerlink" href="#post--api-auth-profile-edit-sports" title="Permalink to this definition"></a></dt>
<dd><p>edit authenticated user sport preferences</p>
<p><strong>Example request</strong>:</p>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="nf">POST</span> <span class="nn">/api/auth/profile/edit/sports</span> <span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
</pre></div>
</div>
<p><strong>Example response</strong>:</p>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
<span class="p">{</span>
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="s2">&quot;#000000&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;sport_id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;user_id&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span>
<span class="nt">&quot;message&quot;</span><span class="p">:</span> <span class="s2">&quot;user sport preferences updated&quot;</span><span class="p">,</span>
<span class="nt">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;success&quot;</span>
<span class="p">}</span>
</pre></div>
</div>
<dl class="field-list simple">
<dt class="field-odd">Request JSON Object</dt>
<dd class="field-odd"><ul class="simple">
<li><p><strong>color</strong> (<em>string</em>) valid hexadecimal color</p></li>
<li><p><strong>is_active</strong> (<em>boolean</em>) is sport available when adding a workout</p></li>
<li><p><strong>stopped_speed_threshold</strong> (<em>float</em>) stopped speed threshold used by gpxpy</p></li>
</ul>
</dd>
<dt class="field-even">Request Headers</dt>
<dd class="field-even"><ul class="simple">
<li><p><span><a class="reference external" href="https://tools.ietf.org/html/rfc7235#section-4.2">Authorization</a></span> OAuth 2.0 Bearer Token</p></li>
</ul>
</dd>
<dt class="field-odd">Status Codes</dt>
<dd class="field-odd"><ul class="simple">
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.1">200 OK</a></span> user preferences updated</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1">400 Bad Request</a></span> <ul>
<li><p>invalid payload</p></li>
<li><p>invalid hexadecimal color</p></li>
</ul>
</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2">401 Unauthorized</a></span> <ul>
<li><p>provide a valid auth token</p></li>
<li><p>signature expired, please log in again</p></li>
<li><p>invalid token, please log in again</p></li>
</ul>
</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.5.1">500 Internal Server Error</a></span> error, please try again or contact the administrator</p></li>
</ul>
</dd>
</dl>
</dd></dl>
<dl class="http post">
<dt class="sig sig-object http" id="post--api-auth-picture">
<span class="sig-name descname"><span class="pre">POST</span> </span><span class="sig-name descname"><span class="pre">/api/auth/picture</span></span><a class="headerlink" href="#post--api-auth-picture" title="Permalink to this definition"></a></dt>

View File

@ -147,40 +147,52 @@
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-transport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Transport)&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Transport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/hiking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Hiking&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Hiking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/mountain-biking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Mountain Biking&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Mountain Biking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/running.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Running&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Running&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">6</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/walking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Walking&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Walking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">}</span>
<span class="p">]</span>
<span class="p">},</span>
@ -198,46 +210,58 @@
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-transport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Transport)&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Transport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/hiking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Hiking&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Hiking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/mountain-biking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Mountain Biking&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Mountain Biking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/running.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Running&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Running&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">6</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/walking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Walking&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Walking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">}</span>
<span class="p">]</span>
<span class="p">},</span>
@ -290,10 +314,12 @@
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">}</span>
<span class="p">]</span>
<span class="p">},</span>
@ -311,11 +337,13 @@
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">}</span>
<span class="p">]</span>
<span class="p">},</span>
@ -385,11 +413,13 @@ Authenticated user must be an admin</p>
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span>
<span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">}</span>
<span class="p">]</span>
<span class="p">},</span>

View File

@ -278,6 +278,7 @@
<h4>New Features<a class="headerlink" href="#new-features" title="Permalink to this headline"></a></h4>
<ul class="simple">
<li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/issues/91">#91</a> - Display elevation chart with min and max altitude of workout</p></li>
<li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/issues/90">#90</a> - Add user sports preferences</p></li>
<li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/issues/18">#18</a> - Better UI</p></li>
</ul>
</section>
@ -300,6 +301,7 @@
<li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/pull/109">#98/#109</a> - Added stopped_speed_threshold to support slow movement</p></li>
<li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/pull/93">#84/#93</a> - Add elevation data and new sports</p></li>
</ul>
<p>In this release 5 issue were closed.</p>
</section>
</section>
<section id="version-0-4-9-2021-07-16">

View File

@ -80,7 +80,7 @@
<li><a class="reference internal" href="#">Features</a><ul>
<li><a class="reference internal" href="#list">List</a><ul>
<li><a class="reference internal" href="#administration">Administration</a></li>
<li><a class="reference internal" href="#account">Account</a></li>
<li><a class="reference internal" href="#account-preferences">Account &amp; preferences</a></li>
<li><a class="reference internal" href="#workouts">Workouts</a></li>
<li><a class="reference internal" href="#translations">Translations</a></li>
</ul>
@ -174,12 +174,30 @@
</li>
</ul>
</section>
<section id="account">
<h3>Account<a class="headerlink" href="#account" title="Permalink to this headline"></a></h3>
<section id="account-preferences">
<h3>Account &amp; preferences<a class="headerlink" href="#account-preferences" title="Permalink to this headline"></a></h3>
<ul class="simple">
<li><p>A user can create, update and deleted his account</p></li>
<li><p>A user can reset his password (<em>new in 0.3.0</em>)</p></li>
<li><p>A user can set language, timezone and first day of week.</p></li>
<li><dl class="simple">
<dt>A user can set sport preferences (<em>new in 0.5.0</em>):</dt><dd><ul>
<li><p>change sport color (used for sport image and charts)</p></li>
<li><p>can override stopped speed threshold (for next uploaded gpx files)</p></li>
<li><p>disable/enable a sport.</p></li>
</ul>
</dd>
</dl>
</li>
</ul>
<div class="admonition note">
<p class="admonition-title">Note</p>
<div class="line-block">
<div class="line">If a sport is disabled by an administrator, it can not be enabled by a user. In this case, it will only appear in preferences if it has users workouts and the user can only change sport color.</div>
<div class="line">A disabled sport (by admin or user) will not appear in dropdown when <strong>adding a workout</strong>.</div>
<div class="line">A workout with a disabled sport will still be displayed in the application.</div>
</div>
</div>
</section>
<section id="workouts">
<h3>Workouts<a class="headerlink" href="#workouts" title="Permalink to this headline"></a></h3>
@ -202,7 +220,7 @@
</dl>
</li>
<li><dl class="simple">
<dt>(<em>new in 0.5.0</em>) Stopped speed threshold used by <a class="reference external" href="https://github.com/tkrajina/gpxpy">gpxpy</a> is not the default one (0.1 km/h instead of 1 km/h) for the following sports:</dt><dd><ul>
<dt>(<em>new in 0.5.0</em>) Stopped speed threshold used by <a class="reference external" href="https://github.com/tkrajina/gpxpy">gpxpy</a> is not the default one for the following sports (0.1 km/h instead of 1 km/h):</dt><dd><ul>
<li><p>Hiking</p></li>
<li><p>Skiing (Cross Country)</p></li>
<li><p>Trail</p></li>
@ -211,10 +229,16 @@
</dd>
</dl>
</li>
<li><p>Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user settings). The calendar displays up to 100 workouts.</p></li>
<li><p>Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance)</p></li>
<li><p>A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed</p></li>
<li><p>Workout edition and deletion. User can add a note</p></li>
</ul>
<div class="admonition note">
<p class="admonition-title">Note</p>
<p>It can be overridden in user preferences.</p>
</div>
<ul class="simple">
<li><p>Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts.</p></li>
<li><p>Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance).</p></li>
<li><p>A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed.</p></li>
<li><p>Workout edition and deletion. User can add a note.</p></li>
<li><p>User statistics</p></li>
<li><dl class="simple">
<dt>User records by sports:</dt><dd><ul>
@ -226,16 +250,16 @@
</dd>
</dl>
</li>
<li><p>Workouts list and filter</p></li>
<li><p>Workouts list and filter. Only sports with workouts are displayed in sport dropdown.</p></li>
</ul>
<div class="admonition note">
<p class="admonition-title">Note</p>
<p>for now, only the owner of the workout can see it.</p>
<p>For now, only the owner of the workout can see it.</p>
</div>
</section>
<section id="translations">
<h3>Translations<a class="headerlink" href="#translations" title="Permalink to this headline"></a></h3>
<p>FitTrackee is available in English and French (which can be saved in the user settings).</p>
<p>FitTrackee is available in English and French (which can be saved in the user preferences).</p>
</section>
</section>
<section id="dashboard">

View File

@ -255,6 +255,16 @@
<td>
<a href="api/auth.html#post--api-auth-profile-edit"><code class="xref">POST /api/auth/profile/edit</code></a></td><td>
<em></em></td></tr>
<tr>
<td></td>
<td>
<a href="api/auth.html#post--api-auth-profile-edit-preferences"><code class="xref">POST /api/auth/profile/edit/preferences</code></a></td><td>
<em></em></td></tr>
<tr>
<td></td>
<td>
<a href="api/auth.html#post--api-auth-profile-edit-sports"><code class="xref">POST /api/auth/profile/edit/sports</code></a></td><td>
<em></em></td></tr>
<tr>
<td></td>
<td>

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -8,6 +8,8 @@ Authentication
auth.logout_user,
auth.get_authenticated_user_profile,
auth.edit_user,
auth.edit_user_preferences,
auth.edit_user_sport_preferences,
auth.edit_picture,
auth.del_picture,
auth.request_password_reset,

View File

@ -31,10 +31,21 @@ Administration
- enable or disable a sport (a sport can be disabled even if workout with this sport exists)
Account
^^^^^^^
Account & preferences
^^^^^^^^^^^^^^^^^^^^^
- A user can create, update and deleted his account
- A user can reset his password (*new in 0.3.0*)
- A user can set language, timezone and first day of week.
- A user can set sport preferences (*new in 0.5.0*):
- change sport color (used for sport image and charts)
- can override stopped speed threshold (for next uploaded gpx files)
- disable/enable a sport.
.. note::
| If a sport is disabled by an administrator, it can not be enabled by a user. In this case, it will only appear in preferences if it has user's workouts and the user can only change sport color.
| A disabled sport (by admin or user) will not appear in dropdown when **adding a workout**.
| A workout with a disabled sport will still be displayed in the application.
Workouts
@ -51,29 +62,33 @@ Workouts
- Skiing (Cross Country) (**new in 0.5.0**)
- Trail (**new in 0.5.0**)
- Walking
- (*new in 0.5.0*) Stopped speed threshold used by `gpxpy <https://github.com/tkrajina/gpxpy>`_ is not the default one (0.1 km/h instead of 1 km/h) for the following sports:
- (*new in 0.5.0*) Stopped speed threshold used by `gpxpy <https://github.com/tkrajina/gpxpy>`_ is not the default one for the following sports (0.1 km/h instead of 1 km/h):
- Hiking
- Skiing (Cross Country)
- Trail
- Walking
- Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user settings). The calendar displays up to 100 workouts.
- Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance)
- A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed
- Workout edition and deletion. User can add a note
.. note::
It can be overridden in user preferences.
- Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts.
- Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance).
- A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed.
- Workout edition and deletion. User can add a note.
- User statistics
- User records by sports:
- average speed
- farest distance
- longest duration
- maximum speed
- Workouts list and filter
- Workouts list and filter. Only sports with workouts are displayed in sport dropdown.
.. note::
for now, only the owner of the workout can see it.
For now, only the owner of the workout can see it.
Translations
^^^^^^^^^^^^
FitTrackee is available in English and French (which can be saved in the user settings).
FitTrackee is available in English and French (which can be saved in the user preferences).
Dashboard

View File

@ -1 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/static/css/fork-awesome.min.css"><link rel="stylesheet" href="/static/css/leaflet.css"><title>FitTrackee</title><link href="/static/css/admin.04e24276.css" rel="prefetch"><link href="/static/css/main.c790adb1.css" rel="prefetch"><link href="/static/css/main~workouts.66c5ef05.css" rel="prefetch"><link href="/static/css/profile.b52bc193.css" rel="prefetch"><link href="/static/css/reset.bd9657a8.css" rel="prefetch"><link href="/static/css/workouts.d0767062.css" rel="prefetch"><link href="/static/js/admin.2f1d393d.js" rel="prefetch"><link href="/static/js/chunk-2d0c9189.c81458cc.js" rel="prefetch"><link href="/static/js/chunk-2d0cf391.020c75ea.js" rel="prefetch"><link href="/static/js/chunk-2d0da8f3.c8c3e7e8.js" rel="prefetch"><link href="/static/js/chunk-2d2248b6.d84473c1.js" rel="prefetch"><link href="/static/js/chunk-2d22523a.4b710d99.js" rel="prefetch"><link href="/static/js/main.e5da50b8.js" rel="prefetch"><link href="/static/js/main~workouts.a74990d7.js" rel="prefetch"><link href="/static/js/profile.6a786c1d.js" rel="prefetch"><link href="/static/js/reset.518e646f.js" rel="prefetch"><link href="/static/js/workouts.1c22fd12.js" rel="prefetch"><link href="/static/css/app.534b9c5c.css" rel="preload" as="style"><link href="/static/js/app.9ada5ac5.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.71654064.js" rel="preload" as="script"><link href="/static/css/app.534b9c5c.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="fittrackee_client"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but FitTrackee doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.71654064.js"></script><script src="/static/js/app.9ada5ac5.js"></script></body></html>
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/static/css/fork-awesome.min.css"><link rel="stylesheet" href="/static/css/leaflet.css"><title>FitTrackee</title><link href="/static/css/admin.babfd43e.css" rel="prefetch"><link href="/static/css/main.7229c1ab.css" rel="prefetch"><link href="/static/css/main~workouts.0edb3403.css" rel="prefetch"><link href="/static/css/profile.05400f70.css" rel="prefetch"><link href="/static/css/reset.46776e72.css" rel="prefetch"><link href="/static/css/workouts.1b0a7916.css" rel="prefetch"><link href="/static/js/admin.2f1d393d.js" rel="prefetch"><link href="/static/js/chunk-2d0c9189.c81458cc.js" rel="prefetch"><link href="/static/js/chunk-2d0cf391.020c75ea.js" rel="prefetch"><link href="/static/js/chunk-2d0da8f3.c8c3e7e8.js" rel="prefetch"><link href="/static/js/chunk-2d2248b6.d84473c1.js" rel="prefetch"><link href="/static/js/chunk-2d22523a.4b710d99.js" rel="prefetch"><link href="/static/js/main.db9cee98.js" rel="prefetch"><link href="/static/js/main~workouts.a74990d7.js" rel="prefetch"><link href="/static/js/profile.62578012.js" rel="prefetch"><link href="/static/js/reset.518e646f.js" rel="prefetch"><link href="/static/js/workouts.d69cf48a.js" rel="prefetch"><link href="/static/css/app.e1e7e23c.css" rel="preload" as="style"><link href="/static/js/app.0f3b3ab5.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.71654064.js" rel="preload" as="script"><link href="/static/css/app.e1e7e23c.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="fittrackee_client"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but FitTrackee doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.71654064.js"></script><script src="/static/js/app.0f3b3ab5.js"></script></body></html>

View File

@ -64,7 +64,7 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/img/workouts/mountains.svg"
},
{
"revision": "04e22b3a72bc3a810319eb7a512e4b23",
"revision": "926210f132992651a9543d9c76da25ba",
"url": "/index.html"
},
{
@ -76,12 +76,12 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/robots.txt"
},
{
"revision": "ca5244fc3bcfc65816b9",
"url": "/static/css/admin.04e24276.css"
"revision": "33002d1c4452ecd02e50",
"url": "/static/css/admin.babfd43e.css"
},
{
"revision": "fe2e967e1efad7b13e67",
"url": "/static/css/app.534b9c5c.css"
"revision": "4f95d958d90a2ac1b9a0",
"url": "/static/css/app.e1e7e23c.css"
},
{
"revision": "82c1118c918377daaa71a320ab8eea42",
@ -92,24 +92,24 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/css/leaflet.css"
},
{
"revision": "d3cf46cfc6340753d540",
"url": "/static/css/main.c790adb1.css"
"revision": "00c35b353719122c16cd",
"url": "/static/css/main.7229c1ab.css"
},
{
"revision": "68364924c988a1f11b42",
"url": "/static/css/main~workouts.66c5ef05.css"
"revision": "11b770a11a1cd8dae5f4",
"url": "/static/css/main~workouts.0edb3403.css"
},
{
"revision": "3438ac3f32223591afd9",
"url": "/static/css/profile.b52bc193.css"
"revision": "058a877bc4b9cbf8929f",
"url": "/static/css/profile.05400f70.css"
},
{
"revision": "688d813785d3c55a7d33",
"url": "/static/css/reset.bd9657a8.css"
"revision": "8635e7636aa413afd289",
"url": "/static/css/reset.46776e72.css"
},
{
"revision": "5e13fc66c78986a630a0",
"url": "/static/css/workouts.d0767062.css"
"revision": "c78ff76a4bb0919c4b94",
"url": "/static/css/workouts.1b0a7916.css"
},
{
"revision": "e719f9244c69e28e7d00e725ca1e280e",
@ -192,12 +192,12 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/img/pt-sans-v9-latin-regular.f1f73e45.svg"
},
{
"revision": "ca5244fc3bcfc65816b9",
"revision": "33002d1c4452ecd02e50",
"url": "/static/js/admin.2f1d393d.js"
},
{
"revision": "fe2e967e1efad7b13e67",
"url": "/static/js/app.9ada5ac5.js"
"revision": "4f95d958d90a2ac1b9a0",
"url": "/static/js/app.0f3b3ab5.js"
},
{
"revision": "bd7d183c9f68e5f4027d",
@ -224,23 +224,23 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/js/chunk-vendors.71654064.js"
},
{
"revision": "d3cf46cfc6340753d540",
"url": "/static/js/main.e5da50b8.js"
"revision": "00c35b353719122c16cd",
"url": "/static/js/main.db9cee98.js"
},
{
"revision": "68364924c988a1f11b42",
"revision": "11b770a11a1cd8dae5f4",
"url": "/static/js/main~workouts.a74990d7.js"
},
{
"revision": "3438ac3f32223591afd9",
"url": "/static/js/profile.6a786c1d.js"
"revision": "058a877bc4b9cbf8929f",
"url": "/static/js/profile.62578012.js"
},
{
"revision": "688d813785d3c55a7d33",
"revision": "8635e7636aa413afd289",
"url": "/static/js/reset.518e646f.js"
},
{
"revision": "5e13fc66c78986a630a0",
"url": "/static/js/workouts.1c22fd12.js"
"revision": "c78ff76a4bb0919c4b94",
"url": "/static/js/workouts.d69cf48a.js"
}
]);

View File

@ -14,7 +14,7 @@
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");
importScripts(
"/precache-manifest.200309f56fa439f700e940d169066203.js"
"/precache-manifest.c1f31e9729586ecf3c442890704f31cc.js"
);
workbox.core.setCacheNameDetails({prefix: "fittrackee_client"});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["profile"],{"0bb3":function(e,t,n){},"36e8":function(e,t,n){"use strict";n.r(t);var c=n("7a23"),r=n("dad5"),o=n("2906"),u=function(e){return Object(c["pushScopeId"])("data-v-d342b648"),e=e(),Object(c["popScopeId"])(),e},a={key:0,id:"profile",class:"container view"},s=u((function(){return Object(c["createElementVNode"])("div",{id:"bottom"},null,-1)})),d=Object(c["defineComponent"])({setup:function(e){var t=Object(o["a"])(),n=Object(c["computed"])((function(){return t.getters[r["a"].GETTERS.AUTH_USER_PROFILE]}));return function(e,t){var r=Object(c["resolveComponent"])("router-view");return Object(c["unref"])(n).username?(Object(c["openBlock"])(),Object(c["createElementBlock"])("div",a,[Object(c["createVNode"])(r,{user:Object(c["unref"])(n)},null,8,["user"]),s])):Object(c["createCommentVNode"])("",!0)}}}),b=(n("6171"),n("6b0d")),i=n.n(b);const f=i()(d,[["__scopeId","data-v-d342b648"]]);t["default"]=f},6171:function(e,t,n){"use strict";n("0bb3")},"9b98":function(e,t,n){"use strict";n("d332")},ad3d:function(e,t,n){"use strict";n.r(t);var c=n("7a23"),r=n("6c02"),o=n("3c44"),u=n("71a7"),a=n("dad5"),s=n("2906"),d={key:0,id:"user",class:"view"},b={class:"box"},i=Object(c["defineComponent"])({setup:function(e){var t=Object(r["c"])(),n=Object(s["a"])(),i=Object(c["computed"])((function(){return n.getters[a["e"].GETTERS.USER]}));return Object(c["onBeforeMount"])((function(){t.params.username&&"string"===typeof t.params.username&&n.dispatch(a["e"].ACTIONS.GET_USER,t.params.username)})),Object(c["onBeforeUnmount"])((function(){n.dispatch(a["e"].ACTIONS.EMPTY_USER)})),function(e,t){return Object(c["unref"])(i).username?(Object(c["openBlock"])(),Object(c["createElementBlock"])("div",d,[Object(c["createVNode"])(o["a"],{user:Object(c["unref"])(i)},null,8,["user"]),Object(c["createElementVNode"])("div",b,[Object(c["createVNode"])(u["a"],{user:Object(c["unref"])(i),"from-admin":!0},null,8,["user"])])])):Object(c["createCommentVNode"])("",!0)}}}),f=(n("9b98"),n("6b0d")),O=n.n(f);const p=O()(i,[["__scopeId","data-v-218f8f1e"]]);t["default"]=p},d332:function(e,t,n){}}]);
//# sourceMappingURL=profile.62578012.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["profile"],{"36e8":function(e,t,n){"use strict";n.r(t);var c=n("7a23"),r=n("dad5"),o=n("2906"),u={key:0,id:"profile",class:"container view"},a=Object(c["defineComponent"])({setup:function(e){var t=Object(o["a"])(),n=Object(c["computed"])((function(){return t.getters[r["a"].GETTERS.AUTH_USER_PROFILE]}));return function(e,t){var r=Object(c["resolveComponent"])("router-view");return Object(c["unref"])(n).username?(Object(c["openBlock"])(),Object(c["createElementBlock"])("div",u,[Object(c["createVNode"])(r,{user:Object(c["unref"])(n)},null,8,["user"])])):Object(c["createCommentVNode"])("",!0)}}}),s=(n("44ab"),n("6b0d")),b=n.n(s);const i=b()(a,[["__scopeId","data-v-bb090bfa"]]);t["default"]=i},"44ab":function(e,t,n){"use strict";n("4fe6")},"4fe6":function(e,t,n){},"9b98":function(e,t,n){"use strict";n("d332")},ad3d:function(e,t,n){"use strict";n.r(t);var c=n("7a23"),r=n("6c02"),o=n("3c44"),u=n("71a7"),a=n("dad5"),s=n("2906"),b={key:0,id:"user",class:"view"},i={class:"box"},d=Object(c["defineComponent"])({setup:function(e){var t=Object(r["c"])(),n=Object(s["a"])(),d=Object(c["computed"])((function(){return n.getters[a["e"].GETTERS.USER]}));return Object(c["onBeforeMount"])((function(){t.params.username&&"string"===typeof t.params.username&&n.dispatch(a["e"].ACTIONS.GET_USER,t.params.username)})),Object(c["onBeforeUnmount"])((function(){n.dispatch(a["e"].ACTIONS.EMPTY_USER)})),function(e,t){return Object(c["unref"])(d).username?(Object(c["openBlock"])(),Object(c["createElementBlock"])("div",b,[Object(c["createVNode"])(o["a"],{user:Object(c["unref"])(d)},null,8,["user"]),Object(c["createElementVNode"])("div",i,[Object(c["createVNode"])(u["a"],{user:Object(c["unref"])(d),"from-admin":!0},null,8,["user"])])])):Object(c["createCommentVNode"])("",!0)}}}),f=(n("9b98"),n("6b0d")),O=n.n(f);const j=O()(d,[["__scopeId","data-v-218f8f1e"]]);t["default"]=j},d332:function(e,t,n){}}]);
//# sourceMappingURL=profile.6a786c1d.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,76 @@
"""add sport preferences
Revision ID: 080acc8ee956
Revises: 9842464bb885
Create Date: 2021-11-12 10:20:23.786727
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '080acc8ee956'
down_revision = '9842464bb885'
branch_labels = None
depends_on = None
def upgrade():
op.drop_constraint('sports_img_key', 'sports', type_='unique')
op.drop_column('sports', 'img')
op.create_table(
'users_sports_preferences',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('sport_id', sa.Integer(), nullable=False),
sa.Column('color', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column(
'stopped_speed_threshold',
sa.Float(),
nullable=False,
),
sa.ForeignKeyConstraint(
['sport_id'],
['sports.id'],
),
sa.ForeignKeyConstraint(
['user_id'],
['users.id'],
),
sa.PrimaryKeyConstraint('user_id', 'sport_id'),
)
def downgrade():
op.drop_table('users_sports_preferences')
op.add_column(
'sports',
sa.Column(
'img', sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
)
op.create_unique_constraint('sports_img_key', 'sports', ['img'])
op.execute(
"""
UPDATE sports AS s
SET img = si.img
FROM (VALUES
('Cycling (Sport)','/img/sports/cycling-sport.png'),
('Cycling (Transport)','/img/sports/cycling-transport.png'),
('Hiking','/img/sports/hiking.png'),
('Mountain Biking','/img/sports/mountain-biking.png'),
('Running','/img/sports/running.png'),
('Walking','/img/sports/walking.png'),
('Mountain Biking (Electric)','/img/sports/electric-mountain-biking.png'),
('Trail','/img/sports/trail.png'),
('Skiing (Alpine)','/img/sports/alpine-skiing.png'),
('Skiing (Cross Country)','/img/sports/cross-country-skiing.png'),
('Rowing','/img/sports/rowing.png')
) AS si(label, img)
WHERE si.label = s.label;
"""
)

View File

@ -3,7 +3,8 @@ import datetime
import pytest
from fittrackee import db
from fittrackee.users.models import User
from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport
@pytest.fixture()
@ -81,3 +82,31 @@ def user_3() -> User:
db.session.add(user)
db.session.commit()
return user
@pytest.fixture()
def user_sport_1_preference(
user_1: User, sport_1_cycling: Sport
) -> UserSportPreference:
user_sport = UserSportPreference(
user_id=user_1.id,
sport_id=sport_1_cycling.id,
stopped_speed_threshold=sport_1_cycling.stopped_speed_threshold,
)
db.session.add(user_sport)
db.session.commit()
return user_sport
@pytest.fixture()
def user_admin_sport_1_preference(
user_1_admin: User, sport_1_cycling: Sport
) -> UserSportPreference:
user_sport = UserSportPreference(
user_id=user_1_admin.id,
sport_id=sport_1_cycling.id,
stopped_speed_threshold=sport_1_cycling.stopped_speed_threshold,
)
db.session.add(user_sport)
db.session.commit()
return user_sport

View File

@ -830,6 +830,186 @@ class TestUserPreferencesUpdate(ApiTestCaseMixin):
assert 'error' in data['status']
class TestUserSportPreferencesUpdate(ApiTestCaseMixin):
def test_it_returns_error_if_payload_is_empty(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(dict()),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 400
assert 'invalid payload' in data['message']
assert 'error' in data['status']
def test_it_returns_error_if_sport_id_is_missing(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(dict(is_active=True)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'error'
assert data['message'] == 'invalid payload'
assert response.status_code == 400
def test_it_returns_error_if_sport_not_found(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(dict(sport_id=1, is_active=True)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'not found' in data['status']
def test_it_returns_error_if_payload_contains_only_sport_id(
self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(dict(sport_id=1)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'error'
assert data['message'] == 'invalid payload'
assert response.status_code == 400
def test_it_returns_error_if_color_is_invalid(
self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(
dict(
sport_id=sport_1_cycling.id,
color='invalid',
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'error'
assert data['message'] == 'invalid hexadecimal color'
assert response.status_code == 400
@pytest.mark.parametrize(
'input_color',
['#000000', '#FFF'],
)
def test_it_updates_sport_color_for_auth_user(
self,
app: Flask,
user_1: User,
sport_2_running: Sport,
input_color: str,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(
dict(
sport_id=sport_2_running.id,
color=input_color,
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'user sport preferences updated'
assert response.status_code == 200
assert data['data']['user_id'] == user_1.id
assert data['data']['sport_id'] == sport_2_running.id
assert data['data']['color'] == input_color
assert data['data']['is_active'] is True
assert data['data']['stopped_speed_threshold'] == 0.1
def test_it_disables_sport_for_auth_user(
self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(
dict(
sport_id=sport_1_cycling.id,
is_active=False,
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'user sport preferences updated'
assert response.status_code == 200
assert data['data']['user_id'] == user_1.id
assert data['data']['sport_id'] == sport_1_cycling.id
assert data['data']['color'] is None
assert data['data']['is_active'] is False
assert data['data']['stopped_speed_threshold'] == 1
def test_it_updates_stopped_speed_threshold_for_auth_user(
self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(
dict(
sport_id=sport_1_cycling.id,
stopped_speed_threshold=0.5,
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'user sport preferences updated'
assert response.status_code == 200
assert data['data']['user_id'] == user_1.id
assert data['data']['sport_id'] == sport_1_cycling.id
assert data['data']['color'] is None
assert data['data']['is_active']
assert data['data']['stopped_speed_threshold'] == 0.5
class TestUserPicture(ApiTestCaseMixin):
def test_it_updates_user_picture(self, app: Flask, user_1: User) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)

View File

@ -5,7 +5,7 @@ from unittest.mock import patch
from flask import Flask
from fittrackee.users.models import User
from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport, Workout
from ..api_test_case import ApiTestCaseMixin
@ -944,6 +944,22 @@ class TestDeleteUser(ApiTestCaseMixin):
assert response.status_code == 204
def test_user_with_preferences_can_delete_its_own_account(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.delete(
'/api/users/test',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 204
def test_user_with_picture_can_delete_its_own_account(
self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str
) -> None:

View File

@ -1,6 +1,6 @@
from flask import Flask
from fittrackee.users.models import User
from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport, Workout
@ -59,3 +59,19 @@ class TestUserModel:
== workout_cycling_user_1.short_id
)
assert serialized_user['records'][0]['workout_date']
class TestUserSportModel:
def test_user_model(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
serialized_user_sport = user_sport_1_preference.serialize()
assert serialized_user_sport['user_id'] == user_1.id
assert serialized_user_sport['sport_id'] == sport_1_cycling.id
assert serialized_user_sport['color'] is None
assert serialized_user_sport['is_active']
assert serialized_user_sport['stopped_speed_threshold'] == 1

View File

@ -5,7 +5,7 @@ from flask import Flask
from gpxpy.gpx import MovingData
from werkzeug.datastructures import FileStorage
from fittrackee.users.models import User
from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport
from fittrackee.workouts.utils import process_files
@ -56,3 +56,33 @@ class TestStoppedSpeedThreshold:
stopped_speed_threshold=expected_threshold
)
gpx_track_segment_mock.assert_called_with(expected_threshold)
def test_it_calls_get_moving_data_with_threshold_depending_from_user_preference( # noqa
self,
app: Flask,
user_1: User,
gpx_file_storage: FileStorage,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
expected_threshold = 0.7
user_sport_1_preference.stopped_speed_threshold = expected_threshold
with patch(
'fittrackee.workouts.utils.get_new_file_path',
return_value='/tmp/fitTrackee/uploads/test.png',
), patch(
'gpxpy.gpx.GPXTrackSegment.get_moving_data',
return_value=moving_data,
) as gpx_track_segment_mock:
process_files(
auth_user_id=user_1.id,
folders=folders,
workout_data={'sport_id': sport_1_cycling.id},
workout_file=gpx_file_storage,
)
assert gpx_track_segment_mock.call_args_list[0] == call(
stopped_speed_threshold=expected_threshold
)
gpx_track_segment_mock.assert_called_with(expected_threshold)

View File

@ -2,7 +2,8 @@ import json
from flask import Flask
from fittrackee.users.models import User
from fittrackee import db
from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport, Workout
from ..api_test_case import ApiTestCaseMixin
@ -10,8 +11,10 @@ from ..api_test_case import ApiTestCaseMixin
expected_sport_1_cycling_result = {
'id': 1,
'label': 'Cycling',
'img': None,
'is_active': True,
'is_active_for_user': True,
'color': None,
'stopped_speed_threshold': 1,
}
expected_sport_1_cycling_admin_result = expected_sport_1_cycling_result.copy()
expected_sport_1_cycling_admin_result['has_workouts'] = False
@ -19,8 +22,10 @@ expected_sport_1_cycling_admin_result['has_workouts'] = False
expected_sport_2_running_result = {
'id': 2,
'label': 'Running',
'img': None,
'is_active': True,
'is_active_for_user': True,
'color': None,
'stopped_speed_threshold': 0.1,
}
expected_sport_2_running_admin_result = expected_sport_2_running_result.copy()
expected_sport_2_running_admin_result['has_workouts'] = False
@ -28,8 +33,10 @@ expected_sport_2_running_admin_result['has_workouts'] = False
expected_sport_1_cycling_inactive_result = {
'id': 1,
'label': 'Cycling',
'img': None,
'is_active': False,
'is_active_for_user': False,
'color': None,
'stopped_speed_threshold': 1,
}
expected_sport_1_cycling_inactive_admin_result = (
expected_sport_1_cycling_inactive_result.copy()
@ -111,6 +118,39 @@ class TestGetSports(ApiTestCaseMixin):
data['data']['sports'][1] == expected_sport_2_running_admin_result
)
def test_it_gets_sports_with_auth_user_preferences(
self,
app: Flask,
user_1_admin: User,
sport_1_cycling: Sport,
sport_2_running: Sport,
user_admin_sport_1_preference: UserSportPreference,
) -> None:
user_admin_sport_1_preference.color = '#000000'
user_admin_sport_1_preference.stopped_speed_threshold = 0.5
user_admin_sport_1_preference.is_active = False
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, as_admin=True
)
response = client.get(
'/api/sports',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['sports']) == 2
assert data['data']['sports'][0]['color'] == '#000000'
assert data['data']['sports'][0]['stopped_speed_threshold'] == 0.5
assert data['data']['sports'][0]['is_active_for_user'] is False
assert (
data['data']['sports'][1] == expected_sport_2_running_admin_result
)
class TestGetSport(ApiTestCaseMixin):
def test_it_gets_a_sport(
@ -129,6 +169,26 @@ class TestGetSport(ApiTestCaseMixin):
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0] == expected_sport_1_cycling_result
def test_it_gets_a_sport_with_preferences(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.get(
'/api/sports/1',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0] == expected_sport_1_cycling_result
def test_it_returns_404_if_sport_does_not_exist(
self, app: Flask, user_1: User
) -> None:
@ -205,6 +265,7 @@ class TestUpdateSport(ApiTestCaseMixin):
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is False
assert data['data']['sports'][0]['is_active_for_user'] is False
assert data['data']['sports'][0]['has_workouts'] is False
def test_it_enables_a_sport(
@ -227,6 +288,7 @@ class TestUpdateSport(ApiTestCaseMixin):
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is True
assert data['data']['sports'][0]['is_active_for_user'] is True
assert data['data']['sports'][0]['has_workouts'] is False
def test_it_disables_a_sport_with_workouts(
@ -252,6 +314,7 @@ class TestUpdateSport(ApiTestCaseMixin):
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is False
assert data['data']['sports'][0]['is_active_for_user'] is False
assert data['data']['sports'][0]['has_workouts'] is True
def test_it_enables_a_sport_with_workouts(
@ -278,8 +341,63 @@ class TestUpdateSport(ApiTestCaseMixin):
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is True
assert data['data']['sports'][0]['is_active_for_user'] is True
assert data['data']['sports'][0]['has_workouts'] is True
def test_it_disables_a_sport_with_preferences(
self,
app: Flask,
user_1_admin: User,
sport_1_cycling: Sport,
user_admin_sport_1_preference: UserSportPreference,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, as_admin=True
)
response = client.patch(
'/api/sports/1',
content_type='application/json',
data=json.dumps(dict(is_active=False)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is False
assert data['data']['sports'][0]['is_active_for_user'] is False
assert data['data']['sports'][0]['is_active_for_user'] is False
assert data['data']['sports'][0]['has_workouts'] is False
def test_it_enables_a_sport_with_preferences(
self,
app: Flask,
user_1_admin: User,
sport_1_cycling: Sport,
user_admin_sport_1_preference: UserSportPreference,
) -> None:
sport_1_cycling.is_active = False
client, auth_token = self.get_test_client_and_auth_token(
app, as_admin=True
)
response = client.patch(
'/api/sports/1',
content_type='application/json',
data=json.dumps(dict(is_active=True)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is True
assert data['data']['sports'][0]['is_active_for_user'] is True
assert data['data']['sports'][0]['has_workouts'] is False
def test_returns_error_if_user_has_no_admin_rights(
self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None:

View File

@ -2,7 +2,8 @@ from typing import Dict, Optional
from flask import Flask
from fittrackee.users.models import User
from fittrackee import db
from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport, Workout
@ -15,10 +16,13 @@ class TestSportModel:
assert 'Cycling' == sport.label
assert '<Sport \'Cycling\'>' == str(sport)
serialized_sport = sport.serialize(is_admin)
serialized_sport = sport.serialize(is_admin=is_admin)
assert 1 == serialized_sport['id']
assert 'Cycling' == serialized_sport['label']
assert serialized_sport['is_active'] is True
assert serialized_sport['is_active_for_user'] is True
assert serialized_sport['color'] is None
assert serialized_sport['stopped_speed_threshold'] == 1
return serialized_sport
def test_sport_model(self, app: Flask, sport_1_cycling: Sport) -> None:
@ -44,3 +48,87 @@ class TestSportModel:
) -> None:
serialized_sport = self.assert_sport_model(sport_1_cycling, True)
assert serialized_sport['has_workouts'] is True
class TestSportModelWithPreferences:
def test_sport_model_with_color_preference(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
user_sport_1_preference.color = '#00000'
serialized_sport = sport_1_cycling.serialize(
sport_preferences=user_sport_1_preference.serialize()
)
assert serialized_sport['id'] == 1
assert serialized_sport['label'] == 'Cycling'
assert serialized_sport['is_active'] is True
assert serialized_sport['is_active_for_user'] is True
assert serialized_sport['color'] == '#00000'
assert serialized_sport['stopped_speed_threshold'] == 1
assert 'has_workouts' not in serialized_sport
def test_sport_model_with_is_active_preference(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
user_sport_1_preference.is_active = False
serialized_sport = sport_1_cycling.serialize(
sport_preferences=user_sport_1_preference.serialize()
)
assert serialized_sport['id'] == 1
assert serialized_sport['label'] == 'Cycling'
assert serialized_sport['is_active'] is True
assert serialized_sport['is_active_for_user'] is False
assert serialized_sport['color'] is None
assert serialized_sport['stopped_speed_threshold'] == 1
assert 'has_workouts' not in serialized_sport
def test_inactive_sport_model_with_is_active_preference(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
sport_1_cycling.is_active = False
user_sport_1_preference.is_active = True
serialized_sport = sport_1_cycling.serialize(
sport_preferences=user_sport_1_preference.serialize()
)
assert serialized_sport['id'] == 1
assert serialized_sport['label'] == 'Cycling'
assert serialized_sport['is_active'] is False
assert serialized_sport['is_active_for_user'] is False
assert serialized_sport['color'] is None
assert serialized_sport['stopped_speed_threshold'] == 1
assert 'has_workouts' not in serialized_sport
def test_sport_model_with_threshold_preference(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
user_sport_1_preference.stopped_speed_threshold = 0.5
db.session.commit()
serialized_sport = sport_1_cycling.serialize(
sport_preferences=user_sport_1_preference.serialize()
)
assert serialized_sport['id'] == 1
assert serialized_sport['label'] == 'Cycling'
assert serialized_sport['is_active'] is True
assert serialized_sport['is_active_for_user'] is True
assert serialized_sport['color'] is None
assert serialized_sport['stopped_speed_threshold'] == 0.5
assert 'has_workouts' not in serialized_sport

View File

@ -1,5 +1,6 @@
import datetime
import os
import re
from typing import Dict, Tuple, Union
import jwt
@ -10,6 +11,7 @@ from werkzeug.utils import secure_filename
from fittrackee import appLog, bcrypt, db
from fittrackee.responses import (
DataNotFoundErrorResponse,
ForbiddenErrorResponse,
HttpResponse,
InvalidPayloadErrorResponse,
@ -19,15 +21,18 @@ from fittrackee.responses import (
)
from fittrackee.tasks import reset_password_email
from fittrackee.utils import get_readable_duration, verify_extension_and_size
from fittrackee.workouts.models import Sport
from fittrackee.workouts.utils_files import get_absolute_file_path
from .decorators import authenticate
from .models import User
from .models import User, UserSportPreference
from .utils import check_passwords, register_controls
from .utils_token import decode_user_token
auth_blueprint = Blueprint('auth', __name__)
HEX_COLOR_REGEX = regex = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
@auth_blueprint.route('/auth/register', methods=['POST'])
def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
@ -677,6 +682,108 @@ def edit_user_preferences(auth_user_id: int) -> Union[Dict, HttpResponse]:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/profile/edit/sports', methods=['POST'])
@authenticate
def edit_user_sport_preferences(
auth_user_id: int,
) -> Union[Dict, HttpResponse]:
"""
edit authenticated user sport preferences
**Example request**:
.. sourcecode:: http
POST /api/auth/profile/edit/sports HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"color": "#000000",
"is_active": true,
"sport_id": 1,
"stopped_speed_threshold": 1,
"user_id": 1
},
"message": "user sport preferences updated",
"status": "success"
}
:<json string color: valid hexadecimal color
:<json boolean is_active: is sport available when adding a workout
:<json float stopped_speed_threshold: stopped speed threshold used by gpxpy
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: user preferences updated
:statuscode 400:
- invalid payload
- invalid hexadecimal color
:statuscode 401:
- provide a valid auth token
- signature expired, please log in again
- invalid token, please log in again
:statuscode 500: error, please try again or contact the administrator
"""
post_data = request.get_json()
if (
not post_data
or 'sport_id' not in post_data
or len(post_data.keys()) == 1
):
return InvalidPayloadErrorResponse()
sport_id = post_data.get('sport_id')
sport = Sport.query.filter_by(id=sport_id).first()
if not sport:
return DataNotFoundErrorResponse('sports')
color = post_data.get('color')
is_active = post_data.get('is_active')
stopped_speed_threshold = post_data.get('stopped_speed_threshold')
try:
user_sport = UserSportPreference.query.filter_by(
user_id=auth_user_id,
sport_id=sport_id,
).first()
if not user_sport:
user_sport = UserSportPreference(
user_id=auth_user_id,
sport_id=sport_id,
stopped_speed_threshold=sport.stopped_speed_threshold,
)
db.session.add(user_sport)
db.session.flush()
if color:
if re.match(HEX_COLOR_REGEX, color) is None:
return InvalidPayloadErrorResponse('invalid hexadecimal color')
user_sport.color = color
if is_active is not None:
user_sport.is_active = is_active
if stopped_speed_threshold:
user_sport.stopped_speed_threshold = stopped_speed_threshold
db.session.commit()
return {
'status': 'success',
'message': 'user sport preferences updated',
'data': user_sport.serialize(),
}
# handler errors
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/picture', methods=['POST'])
@authenticate
def edit_picture(auth_user_id: int) -> Union[Dict, HttpResponse]:

View File

@ -143,3 +143,41 @@ class User(BaseModel):
'total_distance': float(total[0]),
'total_duration': str(total[1]),
}
class UserSportPreference(BaseModel):
__tablename__ = 'users_sports_preferences'
user_id = db.Column(
db.Integer,
db.ForeignKey('users.id'),
primary_key=True,
)
sport_id = db.Column(
db.Integer,
db.ForeignKey('sports.id'),
primary_key=True,
)
color = db.Column(db.String(50), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
stopped_speed_threshold = db.Column(db.Float, default=1.0, nullable=False)
def __init__(
self,
user_id: int,
sport_id: int,
stopped_speed_threshold: float,
) -> None:
self.user_id = user_id
self.sport_id = sport_id
self.is_active = True
self.stopped_speed_threshold = stopped_speed_threshold
def serialize(self) -> Dict:
return {
'user_id': self.user_id,
'sport_id': self.sport_id,
'color': self.color,
'is_active': self.is_active,
'stopped_speed_threshold': self.stopped_speed_threshold,
}

View File

@ -14,10 +14,11 @@ from fittrackee.responses import (
UserNotFoundErrorResponse,
handle_error_and_return_response,
)
from fittrackee.workouts.models import Record, Workout, WorkoutSegment
from fittrackee.workouts.utils_files import get_absolute_file_path
from .decorators import authenticate, authenticate_as_admin
from .models import User, Workout
from .models import User, UserSportPreference
users_blueprint = Blueprint('users', __name__)
@ -555,9 +556,15 @@ def delete_user(
'no other user has admin rights'
)
for workout in Workout.query.filter_by(user_id=user.id).all():
db.session.delete(workout)
db.session.flush()
db.session.query(UserSportPreference).filter(
UserSportPreference.user_id == user.id
).delete()
db.session.query(Record).filter(Record.user_id == user.id).delete()
db.session.query(WorkoutSegment).filter(
WorkoutSegment.workout_id == Workout.id, Workout.user_id == user.id
).delete(synchronize_session=False)
db.session.query(Workout).filter(Workout.user_id == user.id).delete()
db.session.flush()
user_picture = user.picture
db.session.delete(user)
db.session.commit()

View File

@ -70,7 +70,6 @@ class Sport(BaseModel):
__tablename__ = 'sports'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
label = db.Column(db.String(50), unique=True, nullable=False)
img = db.Column(db.String(255), unique=True, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
stopped_speed_threshold = db.Column(db.Float, default=1.0, nullable=False)
workouts = db.relationship(
@ -86,12 +85,30 @@ class Sport(BaseModel):
def __init__(self, label: str) -> None:
self.label = label
def serialize(self, is_admin: Optional[bool] = False) -> Dict:
def serialize(
self,
is_admin: Optional[bool] = False,
sport_preferences: Optional[Dict] = None,
) -> Dict:
serialized_sport = {
'id': self.id,
'label': self.label,
'img': self.img,
'is_active': self.is_active,
'is_active_for_user': (
self.is_active
if sport_preferences is None
else (sport_preferences['is_active'] and self.is_active)
),
'color': (
None
if sport_preferences is None
else sport_preferences['color']
),
'stopped_speed_threshold': (
self.stopped_speed_threshold
if sport_preferences is None
else sport_preferences['stopped_speed_threshold']
),
}
if is_admin:
serialized_sport['has_workouts'] = len(self.workouts) > 0

View File

@ -11,7 +11,7 @@ from fittrackee.responses import (
handle_error_and_return_response,
)
from fittrackee.users.decorators import authenticate, authenticate_as_admin
from fittrackee.users.models import User
from fittrackee.users.models import User, UserSportPreference
from .models import Sport
@ -44,40 +44,52 @@ def get_sports(auth_user_id: int) -> Dict:
"data": {
"sports": [
{
"color": null,
"id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": true,
"label": "Cycling (Sport)"
"is_active_for_user": true,
"label": "Cycling (Sport)",
"stopped_speed_threshold": 1
},
{
"color": null,
"id": 2,
"img": "/img/sports/cycling-transport.png",
"is_active": true,
"label": "Cycling (Transport)"
"is_active_for_user": true,
"label": "Cycling (Transport)",
"stopped_speed_threshold": 1
},
{
"color": null,
"id": 3,
"img": "/img/sports/hiking.png",
"is_active": true,
"label": "Hiking"
"is_active_for_user": true,
"label": "Hiking",
"stopped_speed_threshold": 0.1
},
{
"color": null,
"id": 4,
"img": "/img/sports/mountain-biking.png",
"is_active": true,
"label": "Mountain Biking"
"is_active_for_user": true,
"label": "Mountain Biking",
"stopped_speed_threshold": 1
},
{
"color": null,
"id": 5,
"img": "/img/sports/running.png",
"is_active": true,
"label": "Running"
"is_active_for_user": true,
"label": "Running",
"stopped_speed_threshold": 0.1
},
{
"color": null,
"id": 6,
"img": "/img/sports/walking.png",
"is_active": true,
"label": "Walking"
"is_active_for_user": true,
"label": "Walking",
"stopped_speed_threshold": 0.1
}
]
},
@ -95,46 +107,58 @@ def get_sports(auth_user_id: int) -> Dict:
"data": {
"sports": [
{
"color": null,
"has_workouts": true,
"id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": true,
"label": "Cycling (Sport)"
"is_active_for_user": true,
"label": "Cycling (Sport)",
"stopped_speed_threshold": 1
},
{
"color": null,
"has_workouts": false,
"id": 2,
"img": "/img/sports/cycling-transport.png",
"is_active": true,
"label": "Cycling (Transport)"
"is_active_for_user": true,
"label": "Cycling (Transport)",
"stopped_speed_threshold": 1
},
{
"color": null,
"has_workouts": false,
"id": 3,
"img": "/img/sports/hiking.png",
"is_active": true,
"label": "Hiking"
"is_active_for_user": true,
"label": "Hiking",
"stopped_speed_threshold": 0.1
},
{
"color": null,
"has_workouts": false,
"id": 4,
"img": "/img/sports/mountain-biking.png",
"is_active": true,
"label": "Mountain Biking"
"is_active_for_user": true,
"label": "Mountain Biking",
"stopped_speed_threshold": 1
},
{
"color": null,
"has_workouts": false,
"id": 5,
"img": "/img/sports/running.png",
"is_active": true,
"label": "Running"
"is_active_for_user": true,
"label": "Running",
"stopped_speed_threshold": 0.1
},
{
"color": null,
"has_workouts": false,
"id": 6,
"img": "/img/sports/walking.png",
"is_active": true,
"label": "Walking"
"is_active_for_user": true,
"label": "Walking",
"stopped_speed_threshold": 0.1
}
]
},
@ -154,9 +178,22 @@ def get_sports(auth_user_id: int) -> Dict:
"""
user = User.query.filter_by(id=int(auth_user_id)).first()
sports = Sport.query.order_by(Sport.id).all()
sports_data = []
for sport in sports:
sport_preferences = UserSportPreference.query.filter_by(
user_id=user.id, sport_id=sport.id
).first()
sports_data.append(
sport.serialize(
is_admin=user.admin,
sport_preferences=sport_preferences.serialize()
if sport_preferences
else None,
)
)
return {
'status': 'success',
'data': {'sports': [sport.serialize(user.admin) for sport in sports]},
'data': {'sports': sports_data},
}
@ -186,10 +223,12 @@ def get_sport(auth_user_id: int, sport_id: int) -> Union[Dict, HttpResponse]:
"data": {
"sports": [
{
"color": null,
"id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": true,
"label": "Cycling (Sport)"
"is_active_for_user": true,
"label": "Cycling (Sport)",
"stopped_speed_threshold": 1
}
]
},
@ -207,11 +246,13 @@ def get_sport(auth_user_id: int, sport_id: int) -> Union[Dict, HttpResponse]:
"data": {
"sports": [
{
"color": null,
"has_workouts": false,
"id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": true,
"label": "Cycling (Sport)"
"is_active_for_user": true,
"label": "Cycling (Sport)",
"stopped_speed_threshold": 1
}
]
},
@ -248,9 +289,21 @@ def get_sport(auth_user_id: int, sport_id: int) -> Union[Dict, HttpResponse]:
user = User.query.filter_by(id=int(auth_user_id)).first()
sport = Sport.query.filter_by(id=sport_id).first()
if sport:
sport_preferences = UserSportPreference.query.filter_by(
user_id=user.id, sport_id=sport.id
).first()
return {
'status': 'success',
'data': {'sports': [sport.serialize(user.admin)]},
'data': {
'sports': [
sport.serialize(
is_admin=user.admin,
sport_preferences=sport_preferences.serialize()
if sport_preferences
else None,
)
]
},
}
return DataNotFoundErrorResponse('sports')
@ -284,11 +337,13 @@ def update_sport(
"data": {
"sports": [
{
"color": null,
"has_workouts": false,
"id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": false,
"label": "Cycling (Sport)"
"is_active_for_user": false,
"label": "Cycling (Sport)",
"stopped_speed_threshold": 1
}
]
},
@ -332,15 +387,28 @@ def update_sport(
return InvalidPayloadErrorResponse()
try:
user = User.query.filter_by(id=int(auth_user_id)).first()
sport = Sport.query.filter_by(id=sport_id).first()
if not sport:
return DataNotFoundErrorResponse('sports')
sport.is_active = sport_data.get('is_active')
db.session.commit()
sport_preferences = UserSportPreference.query.filter_by(
user_id=user.id, sport_id=sport.id
).first()
return {
'status': 'success',
'data': {'sports': [sport.serialize(True)]},
'data': {
'sports': [
sport.serialize(
is_admin=user.admin,
sport_preferences=sport_preferences.serialize()
if sport_preferences
else None,
)
]
},
}
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:

View File

@ -15,7 +15,7 @@ from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
from fittrackee import db
from fittrackee.users.models import User
from fittrackee.users.models import User, UserSportPreference
from .exceptions import WorkoutException
from .models import Sport, Workout, WorkoutSegment
@ -400,6 +400,14 @@ def process_files(
f"Sport id: {workout_data.get('sport_id')} does not exist",
)
user = User.query.filter_by(id=auth_user_id).first()
sport_preferences = UserSportPreference.query.filter_by(
user_id=user.id, sport_id=sport.id
).first()
stopped_speed_threshold = (
sport.stopped_speed_threshold
if sport_preferences is None
else sport_preferences.stopped_speed_threshold
)
common_params = {
'user': user,
@ -418,14 +426,14 @@ def process_files(
process_one_gpx_file(
common_params,
filename,
sport.stopped_speed_threshold,
stopped_speed_threshold,
)
]
else:
return process_zip_archive(
common_params,
folders['extract_dir'],
sport.stopped_speed_threshold,
stopped_speed_threshold,
)

View File

@ -24,7 +24,7 @@
</thead>
<tbody>
<tr v-for="sport in translatedSports" :key="sport.id">
<td class="center-text">
<td class="text-center">
<span class="cell-heading">id</span>
{{ sport.id }}
</td>
@ -35,6 +35,7 @@
<SportImage
:title="sport.translatedLabel"
:sport-label="sport.label"
:color="sport.color"
/>
</td>
<td class="sport-label">
@ -43,12 +44,12 @@
</span>
{{ sport.translatedLabel }}
</td>
<td class="center-text">
<td class="text-center">
<span class="cell-heading">
{{ $t('admin.SPORTS.TABLE.ACTIVE') }}
</span>
<i
:class="`fa fa${sport.is_active ? '-check' : ''}-square-o`"
:class="`fa fa${sport.is_active ? '-check' : ''}`"
aria-hidden="true"
/>
</td>
@ -127,9 +128,6 @@
font-style: italic;
padding: 0 $default-padding;
}
.text-left {
text-align: left;
}
.sport-action {
padding-left: $default-padding * 4;
}

View File

@ -63,13 +63,13 @@
)
}}
</td>
<td class="center-text">
<td class="text-center">
<span class="cell-heading">
{{ capitalize($t('workouts.WORKOUT', 0)) }}
</span>
{{ user.nb_workouts }}
</td>
<td class="center-text">
<td class="text-center">
<span class="cell-heading">
{{ $t('user.ADMIN') }}
</span>
@ -78,7 +78,7 @@
aria-hidden="true"
/>
</td>
<td class="center-text">
<td class="text-center">
<span class="cell-heading">
{{ $t('admin.ACTION') }}
</span>

View File

@ -1,7 +1,7 @@
<template>
<div
class="sport-img"
:style="{ fill: sportColors[sportLabel] }"
:style="{ fill: color ? color : sportColors[sportLabel] }"
:title="title ? title : $t(`sports.${sportLabel}.LABEL`)"
>
<CyclingSport v-if="sportLabel === 'Cycling (Sport)'" />
@ -37,12 +37,13 @@
interface Props {
sportLabel: string
color: string | null
title?: string
}
const props = withDefaults(defineProps<Props>(), {
title: '',
})
const { sportLabel, title } = toRefs(props)
const { color, sportLabel, title } = toRefs(props)
const sportColors = inject('sportColors')
</script>

View File

@ -5,7 +5,11 @@
$router.push({ name: 'Workout', params: { workoutId: workout.id } })
"
>
<SportImage :sport-label="sportLabel" :title="workout.title" />
<SportImage
:sport-label="sportLabel"
:title="workout.title"
:color="sportColor"
/>
<sup>
<i
v-if="workout.records.length > 0"
@ -28,6 +32,7 @@
interface Props {
workout: IWorkout
sportLabel: string
sportColor: string | null
}
const props = defineProps<Props>()

View File

@ -10,6 +10,7 @@
:key="index"
:workout="workout"
:sportLabel="getSportLabel(workout, sports)"
:sportColor="getSportColor(workout, sports)"
/>
</div>
<div v-else class="donut-display">
@ -41,7 +42,7 @@
import CalendarWorkoutsChart from '@/components/Dashboard/UserCalendar/CalendarWorkoutsChart.vue'
import { ISport } from '@/types/sports'
import { IWorkout } from '@/types/workouts'
import { getSportLabel, sportIdColors } from '@/utils/sports'
import { getSportColor, getSportLabel, sportIdColors } from '@/utils/sports'
import { getDonutDatasets } from '@/utils/workouts'
interface Props {

View File

@ -2,7 +2,7 @@
<div class="records-card">
<Card>
<template #title>
<SportImage :sport-label="records.label" />
<SportImage :sport-label="records.label" :color="records.color" />
{{ sportTranslatedLabel }}
</template>
<template #content>

View File

@ -4,7 +4,7 @@
v-for="sport in translatedSports"
type="checkbox"
:key="sport.id"
:style="{ color: sportColors[sport.label] }"
:style="{ color: sport.color ? sport.color : sportColors[sport.label] }"
>
<input
type="checkbox"
@ -13,7 +13,7 @@
:checked="selectedSportIds.includes(sport.id)"
@input="updateSelectedSportIds(sport.id)"
/>
<SportImage :sport-label="sport.label" />
<SportImage :sport-label="sport.label" :color="sport.color" />
<span class="sport-label">{{ sport.translatedLabel }}</span>
</label>
</div>

View File

@ -47,7 +47,7 @@
import { format } from 'date-fns'
import { ComputedRef, Ref, computed, ref, toRefs, withDefaults } from 'vue'
import { AUTH_USER_STORE } from '@/store/constants'
import { AUTH_USER_STORE, USERS_STORE } from '@/store/constants'
import { IUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
@ -81,7 +81,7 @@
displayModal.value = value
}
function deleteUserAccount(username: string) {
store.dispatch(AUTH_USER_STORE.ACTIONS.DELETE_ACCOUNT, { username })
store.dispatch(USERS_STORE.ACTIONS.DELETE_USER_ACCOUNT, { username })
}
</script>
@ -91,9 +91,5 @@
.user-bio {
white-space: pre-wrap;
}
.profile-buttons {
display: flex;
gap: $default-padding;
}
}
</style>

View File

@ -38,13 +38,3 @@
props.user.timezone ? props.user.timezone : 'Europe/Paris'
)
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
#user-preferences {
.profile-buttons {
display: flex;
gap: $default-padding;
}
}
</style>

View File

@ -22,7 +22,7 @@
const props = defineProps<Props>()
const { user, tab } = toRefs(props)
const tabs = ['PROFILE', 'PREFERENCES']
const tabs = ['PROFILE', 'PREFERENCES', 'SPORTS']
</script>
<style lang="scss" scoped>

View File

@ -34,7 +34,7 @@
const store = useStore()
const { user, tab } = toRefs(props)
const tabs = ['PROFILE', 'PICTURE', 'PREFERENCES']
const tabs = ['PROFILE', 'PICTURE', 'PREFERENCES', 'SPORTS']
const loading = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING]
)

View File

@ -38,7 +38,10 @@
case 'PICTURE':
return '/profile/edit/picture'
case 'PREFERENCES':
return `/profile${props.edition ? '/edit' : ''}/preferences`
case 'SPORTS':
return `/profile${
props.edition ? '/edit' : ''
}/${tab.toLocaleLowerCase()}`
default:
case 'PROFILE':
return `/profile${props.edition ? '/edit' : ''}`

View File

@ -0,0 +1,321 @@
<template>
<div id="user-sport-preferences">
<div class="responsive-table" v-if="sports.length > 0">
<div class="mobile-display">
<div v-if="isEdition" class="profile-buttons mobile-display">
<button
class="cancel"
@click.prevent="$router.push('/profile/sports')"
>
{{ $t('buttons.BACK') }}
</button>
</div>
<div v-else class="profile-buttons">
<button @click="$router.push('/profile/edit/sports')">
{{ $t('user.PROFILE.EDIT_SPORTS_PREFERENCES') }}
</button>
<button @click="$router.push('/')">{{ $t('common.HOME') }}</button>
</div>
</div>
<table>
<thead>
<tr>
<th>{{ $t('user.PROFILE.SPORT.COLOR') }}</th>
<th class="text-left">{{ $t('workouts.SPORT', 0) }}</th>
<th>{{ $t('workouts.WORKOUT', 0) }}</th>
<th>{{ $t('user.PROFILE.SPORT.IS_ACTIVE') }}</th>
<th>{{ $t('user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD') }}</th>
<th v-if="isEdition">{{ $t('user.PROFILE.SPORT.ACTION') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="sport in translatedSports" :key="sport.id">
<td>
<span class="cell-heading">
{{ $t('user.PROFILE.SPORT.COLOR') }}
</span>
<input
v-if="isSportInEdition(sport.id)"
class="sport-color"
type="color"
:value="sportPayload.color"
@input="updateColor"
/>
<SportImage
v-else
:title="sport.translatedLabel"
:sport-label="sport.label"
:color="sport.color ? sport.color : sportColors[sport.label]"
/>
</td>
<td
class="sport-label"
:class="{ 'disabled-sport': !sport.is_active }"
>
<span class="cell-heading">
{{ $t('user.PROFILE.SPORT.LABEL') }}
</span>
{{ sport.translatedLabel }}
<span class="disabled-message" v-if="!sport.is_active">
({{ $t('user.PROFILE.SPORT.DISABLED_BY_ADMIN') }})
</span>
<i
v-if="loading && isSportInEdition(sport.id)"
class="fa fa-refresh fa-spin fa-fw"
/>
<ErrorMessage
:message="errorMessages"
v-if="errorMessages && sportPayload.sport_id === sport.id"
/>
</td>
<td
class="text-center"
:class="{ 'disabled-sport': !sport.is_active }"
>
<span class="cell-heading">
{{ $t('workouts.WORKOUT', 0) }}
</span>
<i
:class="`fa fa${
user.sports_list.includes(sport.id) ? '-check' : ''
}`"
aria-hidden="true"
/>
</td>
<td
class="text-center"
:class="{ 'disabled-sport': !sport.is_active }"
>
<span class="cell-heading">
{{ $t('user.PROFILE.SPORT.IS_ACTIVE') }}
</span>
<input
v-if="isSportInEdition(sport.id) && sport.is_active"
type="checkbox"
:checked="sport.is_active_for_user"
@change="updateIsActive"
/>
<i
v-else
:class="`fa fa${sport.is_active_for_user ? '-check' : ''}`"
aria-hidden="true"
/>
</td>
<td
class="text-center"
:class="{ 'disabled-sport': !sport.is_active }"
>
<span class="cell-heading">
{{ $t('user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD') }}
</span>
<input
class="threshold-input"
v-if="isSportInEdition(sport.id) && sport.is_active"
type="number"
min="0"
step="0.1"
:value="sportPayload.stopped_speed_threshold"
@input="updateThreshold"
/>
<span v-else>
{{ sport.stopped_speed_threshold }}
</span>
</td>
<td v-if="isEdition" class="action-buttons">
<span class="cell-heading">
{{ $t('user.PROFILE.SPORT.ACTION') }}
</span>
<button
v-if="sportPayload.sport_id === 0"
@click="updateSportInEdition(sport)"
>
{{ $t('buttons.EDIT') }}
</button>
<div v-if="isSportInEdition(sport.id)" class="edition-buttons">
<button :disabled="loading" @click="updateSport">
{{ $t('buttons.SUBMIT') }}
</button>
<button :disabled="loading" @click="updateSportInEdition(null)">
{{ $t('buttons.CANCEL') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="isEdition" class="profile-buttons">
<button class="cancel" @click.prevent="$router.push('/profile/sports')">
{{ $t('buttons.BACK') }}
</button>
</div>
<div v-else class="profile-buttons">
<button @click="$router.push('/profile/edit/sports')">
{{ $t('user.PROFILE.EDIT_SPORTS_PREFERENCES') }}
</button>
<button @click="$router.push('/')">{{ $t('common.HOME') }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ComputedRef, computed, inject, reactive, toRefs, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AUTH_USER_STORE, ROOT_STORE, SPORTS_STORE } from '@/store/constants'
import { ISport, ITranslatedSport } from '@/types/sports'
import { IUserProfile, IUserSportPreferencesPayload } from '@/types/user'
import { useStore } from '@/use/useStore'
import { translateSports } from '@/utils/sports'
interface Props {
user: IUserProfile
isEdition: boolean
}
const props = defineProps<Props>()
const store = useStore()
const { t } = useI18n()
const { isEdition, user } = toRefs(props)
const sportColors = inject('sportColors')
const sports: ComputedRef<ISport[]> = computed(
() => store.getters[SPORTS_STORE.GETTERS.SPORTS]
)
const translatedSports: ComputedRef<ITranslatedSport[]> = computed(() =>
translateSports(sports.value, t, true, user.value.sports_list)
)
const loading = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING]
)
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
const sportPayload: IUserSportPreferencesPayload = reactive({
sport_id: 0,
color: null,
is_active: true,
stopped_speed_threshold: 1,
})
function updateSportInEdition(sport: ISport | null) {
if (sport !== null) {
sportPayload.sport_id = sport.id
sportPayload.color = sport.color ? sport.color : sportColors[sport.label]
sportPayload.is_active = sport.is_active_for_user
sportPayload.stopped_speed_threshold = sport.stopped_speed_threshold
} else {
resetSportPayload()
}
}
function isSportInEdition(sportId: number) {
return sportPayload.sport_id === sportId
}
function updateColor(event: Event & { target: HTMLInputElement }) {
sportPayload.color = event.target.value
}
function updateThreshold(event: Event & { target: HTMLInputElement }) {
sportPayload.stopped_speed_threshold = parseFloat(event.target.value)
}
function updateIsActive(event: Event & { target: HTMLInputElement }) {
sportPayload.is_active = event.target.checked
}
function resetSportPayload() {
sportPayload.sport_id = 0
sportPayload.color = null
sportPayload.is_active = true
sportPayload.stopped_speed_threshold = 1
store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
}
function updateSport(event: Event) {
event.preventDefault()
store.dispatch(
AUTH_USER_STORE.ACTIONS.UPDATE_USER_SPORT_PREFERENCES,
sportPayload
)
}
watch(
() => loading.value,
(newIsLoading) => {
if (!newIsLoading && !errorMessages.value) {
resetSportPayload()
}
}
)
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
#user-sport-preferences {
.sport-img {
height: 35px;
width: 35px;
margin: 0 auto;
}
.sport-color {
border: none;
margin: 6px 1px 6px 0;
padding: 0;
width: 40px;
}
.sport-label {
width: 170px;
}
.disabled-sport {
font-style: italic;
color: var(--disabled-sport-color);
.disabled-message {
font-size: 0.9em;
}
.cell-heading {
font-style: normal;
}
}
.action-buttons {
width: 70px;
}
.edition-buttons {
display: flex;
flex-wrap: wrap;
gap: $default-padding * 0.5;
line-height: 1.3em;
button {
text-align: center;
min-width: 80px;
}
}
.threshold-input {
padding: $default-padding * 0.5;
width: 50px;
}
.mobile-display {
display: none;
}
div.error-message {
margin: 0;
}
@media screen and (max-width: $small-limit) {
.sport-label,
.action-buttons {
width: 45%;
}
.edition-buttons {
justify-content: center;
}
.mobile-display {
display: flex;
margin: $default-margin * 2 0 $default-margin;
}
}
@media screen and (max-width: $x-small-limit) {
.sport-label,
.action-buttons {
width: 100%;
}
}
}
</style>

View File

@ -75,7 +75,11 @@
"
>
<div class="img">
<SportImage v-if="sport.label" :sport-label="sport.label" />
<SportImage
v-if="sport.label"
:sport-label="sport.label"
:color="sport.color"
/>
</div>
<div class="data">
<i class="fa fa-clock-o" aria-hidden="true" />

View File

@ -17,7 +17,7 @@
<i class="fa fa-chevron-left" aria-hidden="true" />
</div>
<div class="workout-card-title">
<SportImage :sport-label="sport.label" />
<SportImage :sport-label="sport.label" :color="sport.color" />
<div class="workout-title-date">
<div class="workout-title" v-if="workoutObject.type === 'WORKOUT'">
{{ workoutObject.title }}

View File

@ -46,7 +46,7 @@
v-model="workoutForm.sport_id"
>
<option
v-for="sport in translatedSports.filter((s) => s.is_active)"
v-for="sport in translatedSports"
:value="sport.id"
:key="sport.id"
>
@ -259,7 +259,12 @@
const { workout, isCreation, loading } = toRefs(props)
const translatedSports: ComputedRef<ISport[]> = computed(() =>
translateSports(props.sports, t)
translateSports(
props.sports,
t,
true,
workout.value.id ? [workout.value.sport_id] : null
)
)
const appConfig: ComputedRef<TAppConfig> = computed(
() => store.getters[ROOT_STORE.GETTERS.APP_CONFIG]

View File

@ -49,9 +49,8 @@
sports.filter((s) => s.id === workout.sport_id)[0]
.translatedLabel
"
:sport-label="
sports.filter((s) => s.id === workout.sport_id)[0].label
"
:sport-label="getSportLabel(workout, sports)"
:color="getSportColor(workout, sports)"
/>
</td>
<td
@ -163,6 +162,7 @@
import { useStore } from '@/use/useStore'
import { getQuery, sortList, workoutsPayloadKeys } from '@/utils/api'
import { getDateWithTZ } from '@/utils/dates'
import { getSportColor, getSportLabel } from '@/utils/sports'
import { defaultOrder } from '@/utils/workouts'
interface Props {

View File

@ -23,6 +23,7 @@
"BIRTH_DATE": "Birth date",
"EDIT": "Edit profile",
"EDIT_PREFERENCES": "Edit preferences",
"EDIT_SPORTS_PREFERENCES": "Edit sports preferences",
"FIRST_NAME": "First name",
"FIRST_DAY_OF_WEEK": "First day of week",
"LANGUAGE": "Language",
@ -36,11 +37,21 @@
"PREFERENCES_EDITION": "Preferences edition",
"PROFILE_EDITION": "Profile edition",
"REGISTRATION_DATE": "Registration date",
"SPORTS_EDITION": "Sports preferences edition",
"SUNDAY": "Sunday",
"TABS": {
"PICTURE": "picture",
"PREFERENCES": "preferences",
"PROFILE": "profile"
"PROFILE": "profile",
"SPORTS": "sports"
},
"SPORT": {
"ACTION": "action",
"COLOR": "color",
"DISABLED_BY_ADMIN": "disabled by admin",
"IS_ACTIVE": "active",
"LABEL": "label",
"STOPPED_SPEED_THRESHOLD": "stopped speed threshold"
},
"TIMEZONE": "Timezone"
},

View File

@ -23,6 +23,7 @@
"BIRTH_DATE": "Date de naissance",
"EDIT": "Modifier le profil",
"EDIT_PREFERENCES": "Modifier les préférences",
"EDIT_SPORTS_PREFERENCES": "Modifier les préférences des sports",
"FIRST_DAY_OF_WEEK": "Premier jour de la semaine",
"FIRST_NAME": "Prénom",
"LANGUAGE": "Langue",
@ -36,11 +37,21 @@
"PREFERENCES_EDITION": "Mise à jour des préférences",
"PROFILE_EDITION": "Mise à jour du profil",
"REGISTRATION_DATE": "Date d'inscription",
"SPORTS_EDITION": "Mise à jour des préférences des sports",
"SUNDAY": "Dimanche",
"TABS": {
"PICTURE": "image",
"PREFERENCES": "préférences",
"PROFILE": "profil"
"PROFILE": "profil",
"SPORTS": "sports"
},
"SPORT": {
"ACTION": "action",
"COLOR": "couleur",
"DISABLED_BY_ADMIN": "désactivé par l'administrateur",
"IS_ACTIVE": "actif",
"LABEL": "label",
"STOPPED_SPEED_THRESHOLD": "seuil de vitesse arrêtée"
},
"TIMEZONE": "Fuseau horaire"
},
@ -49,4 +60,4 @@
"RESET_PASSWORD": "Réinitialiser votre mot de passe",
"USER_PICTURE": "photo de l'utilisateur",
"USERNAME": "Nom d'utilisateur"
}
}

View File

@ -11,6 +11,7 @@ import ProfileEdition from '@/components/User/ProfileEdition/index.vue'
import UserInfosEdition from '@/components/User/ProfileEdition/UserInfosEdition.vue'
import UserPictureEdition from '@/components/User/ProfileEdition/UserPictureEdition.vue'
import UserPreferencesEdition from '@/components/User/ProfileEdition/UserPreferencesEdition.vue'
import UserSportPreferences from '@/components/User/UserSportPreferences.vue'
import store from '@/store'
import { AUTH_USER_STORE } from '@/store/constants'
@ -101,6 +102,12 @@ const routes: Array<RouteRecordRaw> = [
name: 'UserPreferences',
component: UserPreferences,
},
{
path: 'sports',
name: 'UserSportPreferences',
component: UserSportPreferences,
props: { isEdition: false },
},
],
},
{
@ -126,6 +133,12 @@ const routes: Array<RouteRecordRaw> = [
name: 'UserPreferencesEdition',
component: UserPreferencesEdition,
},
{
path: 'sports',
name: 'UserSportPreferencesEdition',
component: UserSportPreferences,
props: { isEdition: true },
},
],
},
],

View File

@ -254,9 +254,12 @@ button {
}
}
.center-text {
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.responsive-table {
margin-bottom: 15px;
@ -337,3 +340,14 @@ button {
}
}
}
.profile-buttons {
display: flex;
gap: $default-padding;
}
.medium-sport-img {
height: 35px;
width: 35px;
margin: 0 auto;
}

View File

@ -53,6 +53,7 @@
--disabled-background-color: #e0e0e0;
--disabled-color: #a3a3a3;
--disabled-sport-color: #616161;
--scroll-button-bg-color: rgba(255, 255, 255, .7);

View File

@ -17,6 +17,7 @@ import {
IAuthUserState,
} from '@/store/modules/authUser/types'
import { IRootState } from '@/store/modules/root/types'
import { deleteUserAccount } from '@/store/modules/users/actions'
import {
ILoginOrRegisterData,
IUserDeletionPayload,
@ -25,6 +26,7 @@ import {
IUserPayload,
IUserPicturePayload,
IUserPreferencesPayload,
IUserSportPreferencesPayload,
} from '@/types/user'
import { handleError } from '@/utils'
@ -172,6 +174,26 @@ export const actions: ActionTree<IAuthUserState, IRootState> &
context.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false)
)
},
[AUTH_USER_STORE.ACTIONS.UPDATE_USER_SPORT_PREFERENCES](
context: ActionContext<IAuthUserState, IRootState>,
payload: IUserSportPreferencesPayload
): void {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
context.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_USER_LOADING, true)
authApi
.post('auth/profile/edit/sports', payload)
.then((res) => {
if (res.data.status === 'success') {
context.dispatch(SPORTS_STORE.ACTIONS.GET_SPORTS)
} else {
handleError(context, null)
}
})
.catch((error) => {
handleError(context, error)
context.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false)
})
},
[AUTH_USER_STORE.ACTIONS.UPDATE_USER_PICTURE](
context: ActionContext<IAuthUserState, IRootState>,
payload: IUserPicturePayload
@ -207,19 +229,7 @@ export const actions: ActionTree<IAuthUserState, IRootState> &
context: ActionContext<IAuthUserState, IRootState>,
payload: IUserDeletionPayload
): void {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
authApi
.delete(`users/${payload.username}`)
.then((res) => {
if (res.status === 204) {
context
.dispatch(AUTH_USER_STORE.ACTIONS.LOGOUT)
.then(() => router.push('/'))
} else {
handleError(context, null)
}
})
.catch((error) => handleError(context, error))
deleteUserAccount(context, payload)
},
[AUTH_USER_STORE.ACTIONS.DELETE_PICTURE](
context: ActionContext<IAuthUserState, IRootState>

View File

@ -10,6 +10,7 @@ export enum AuthUserActions {
UPDATE_USER_PICTURE = 'UPDATE_USER_PICTURE',
UPDATE_USER_PROFILE = 'UPDATE_USER_PROFILE',
UPDATE_USER_PREFERENCES = 'UPDATE_USER_PREFERENCES',
UPDATE_USER_SPORT_PREFERENCES = 'UPDATE_USER_SPORT_PREFERENCES',
}
export enum AuthUserGetters {

View File

@ -16,6 +16,7 @@ import {
IUserPayload,
IUserPicturePayload,
IUserPreferencesPayload,
IUserSportPreferencesPayload,
} from '@/types/user'
export interface IAuthUserState {
@ -52,6 +53,11 @@ export interface IAuthUserActions {
payload: IUserPreferencesPayload
): void
[AUTH_USER_STORE.ACTIONS.UPDATE_USER_SPORT_PREFERENCES](
context: ActionContext<IAuthUserState, IRootState>,
payload: IUserSportPreferencesPayload
): void
[AUTH_USER_STORE.ACTIONS.UPDATE_USER_PICTURE](
context: ActionContext<IAuthUserState, IRootState>,
payload: IUserPicturePayload

View File

@ -1,7 +1,7 @@
import { ActionContext, ActionTree } from 'vuex'
import authApi from '@/api/authApi'
import { ROOT_STORE, SPORTS_STORE } from '@/store/constants'
import { AUTH_USER_STORE, ROOT_STORE, SPORTS_STORE } from '@/store/constants'
import { IRootState } from '@/store/modules/root/types'
import { ISportsActions, ISportsState } from '@/store/modules/sports/types'
import { ISportPayload } from '@/types/sports'
@ -20,6 +20,7 @@ export const actions: ActionTree<ISportsState, IRootState> & ISportsActions = {
SPORTS_STORE.MUTATIONS.SET_SPORTS,
res.data.data.sports
)
context.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false)
} else {
handleError(context, null)
}

View File

@ -1,13 +1,40 @@
import { ActionContext, ActionTree } from 'vuex'
import authApi from '@/api/authApi'
import { ROOT_STORE, USERS_STORE } from '@/store/constants'
import router from '@/router'
import { AUTH_USER_STORE, ROOT_STORE, USERS_STORE } from '@/store/constants'
import { IAuthUserState } from '@/store/modules/authUser/types'
import { IRootState } from '@/store/modules/root/types'
import { IUsersActions, IUsersState } from '@/store/modules/users/types'
import { TPaginationPayload } from '@/types/api'
import { IAdminUserPayload } from '@/types/user'
import { IAdminUserPayload, IUserDeletionPayload } from '@/types/user'
import { handleError } from '@/utils'
export const deleteUserAccount = (
context:
| ActionContext<IAuthUserState, IRootState>
| ActionContext<IUsersState, IRootState>,
payload: IUserDeletionPayload
): void => {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
authApi
.delete(`users/${payload.username}`)
.then((res) => {
if (res.status === 204) {
if (payload.fromAdmin) {
router.push('/admin/users')
} else {
context
.dispatch(AUTH_USER_STORE.ACTIONS.LOGOUT)
.then(() => router.push('/'))
}
} else {
handleError(context, null)
}
})
.catch((error) => handleError(context, error))
}
export const actions: ActionTree<IUsersState, IRootState> & IUsersActions = {
[USERS_STORE.ACTIONS.EMPTY_USER](
context: ActionContext<IUsersState, IRootState>
@ -94,4 +121,13 @@ export const actions: ActionTree<IUsersState, IRootState> & IUsersActions = {
context.commit(USERS_STORE.MUTATIONS.UPDATE_USERS_LOADING, false)
)
},
[USERS_STORE.ACTIONS.DELETE_USER_ACCOUNT](
context: ActionContext<IUsersState, IRootState>,
payload: IUserDeletionPayload
): void {
deleteUserAccount(context, {
username: payload.username,
fromAdmin: true,
})
},
}

View File

@ -4,6 +4,7 @@ export enum UsersActions {
GET_USER = 'GET_USER',
GET_USERS = 'GET_USERS',
UPDATE_USER = 'UPDATE_USER',
DELETE_USER_ACCOUNT = 'DELETE_USER_ACCOUNT',
}
export enum UsersGetters {

View File

@ -8,7 +8,11 @@ import {
import { USERS_STORE } from '@/store/constants'
import { IRootState } from '@/store/modules/root/types'
import { IPagination, TPaginationPayload } from '@/types/api'
import { IAdminUserPayload, IUserProfile } from '@/types/user'
import {
IAdminUserPayload,
IUserDeletionPayload,
IUserProfile,
} from '@/types/user'
export interface IUsersState {
user: IUserProfile
@ -36,6 +40,10 @@ export interface IUsersActions {
context: ActionContext<IUsersState, IRootState>,
payload: IAdminUserPayload
): void
[USERS_STORE.ACTIONS.DELETE_USER_ACCOUNT](
context: ActionContext<IUsersState, IRootState>,
payload: IUserDeletionPayload
): void
}
export interface IUsersGetters {

View File

@ -1,9 +1,12 @@
export interface ISport {
color: string | null
has_workouts: boolean
id: number
img: string
is_active: boolean
is_active_for_user: boolean
label: string
stopped_speed_threshold: number
}
export interface ITranslatedSport extends ISport {

View File

@ -45,6 +45,13 @@ export interface IUserPreferencesPayload {
weekm: boolean
}
export interface IUserSportPreferencesPayload {
sport_id: number
color: string | null
is_active: boolean
stopped_speed_threshold: number
}
export interface IUserPicturePayload {
picture: File
}
@ -61,6 +68,7 @@ export interface IUserPasswordResetPayload {
export interface IUserDeletionPayload {
username: string
fromAdmin?: boolean
}
export interface ILoginRegisterFormData {

View File

@ -27,8 +27,9 @@ export interface IRecord {
}
export interface IRecordsBySport {
[key: string]: string | Record<string, string | number>[]
[key: string]: string | Record<string, string | number>[] | null
label: string
color: string | null
records: Record<string, string | number>[]
}

View File

@ -44,6 +44,7 @@ export const getRecordsBySports = (
if (sportList[sport.translatedLabel] === void 0) {
sportList[sport.translatedLabel] = {
label: sport.label,
color: sport.color,
records: [],
}
}

View File

@ -18,7 +18,10 @@ export const sportColors: Record<string, string> = {
export const sportIdColors = (sports: ISport[]): Record<number, string> => {
const colors: Record<number, string> = {}
sports.map((sport) => (colors[sport.id] = sportColors[sport.label]))
sports.map(
(sport) =>
(colors[sport.id] = sport.color ? sport.color : sportColors[sport.label])
)
return colors
}
@ -35,10 +38,17 @@ const sortSports = (a: ITranslatedSport, b: ITranslatedSport): number => {
export const translateSports = (
sports: ISport[],
t: CallableFunction,
onlyActive = false
onlyActive = false,
userSports: number[] | null = null
): ITranslatedSport[] =>
sports
.filter((sport) => (onlyActive ? sport.is_active : true))
.filter((sport) =>
onlyActive
? userSports === null
? sport.is_active_for_user
: userSports.includes(sport.id) || sport.is_active
: true
)
.map((sport) => ({
...sport,
translatedLabel: t(`sports.${sport.label}.LABEL`),
@ -50,3 +60,12 @@ export const getSportLabel = (workout: IWorkout, sports: ISport[]): string => {
.filter((sport) => sport.id === workout.sport_id)
.map((sport) => sport.label)[0]
}
export const getSportColor = (
workout: IWorkout,
sports: ISport[]
): string | null => {
return sports
.filter((sport) => sport.id === workout.sport_id)
.map((sport) => sport.color)[0]
}

View File

@ -84,7 +84,7 @@ export const getDatasets = (displayedSports: ISport[]): TStatisticsDatasets => {
total_descent: [],
}
displayedSports.map((sport) => {
const color = sportColors[sport.label]
const color = sport.color ? sport.color : sportColors[sport.label]
datasets.nb_workouts.push(getStatisticsChartDataset(sport.label, color))
datasets.total_distance.push(getStatisticsChartDataset(sport.label, color))
datasets.total_duration.push(getStatisticsChartDataset(sport.label, color))

View File

@ -1,6 +1,7 @@
<template>
<div id="profile" class="container view" v-if="authUser.username">
<router-view :user="authUser"></router-view>
<div id="bottom" />
</div>
</template>

View File

@ -5,25 +5,34 @@ import { translateSports } from '@/utils/sports'
const { t } = createI18n.global
export const sports: ISport[] = [
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
},
{
has_workouts: false,
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
},
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
},
]

View File

@ -157,6 +157,7 @@ describe('getRecordsBySports', () => {
},
expected: {
'Cycling (Sport)': {
color: null,
label: 'Cycling (Sport)',
records: [
{
@ -206,6 +207,7 @@ describe('getRecordsBySports', () => {
},
expected: {
'Cycling (Sport)': {
color: null,
label: 'Cycling (Sport)',
records: [
{
@ -225,6 +227,7 @@ describe('getRecordsBySports', () => {
],
},
'Cycling (Transport)': {
color: '#000000',
label: 'Cycling (Transport)',
records: [
{

View File

@ -7,7 +7,7 @@ import { translateSports } from '@/utils/sports'
const { t, locale } = createI18n.global
describe('sortSports', () => {
describe('translateSports', () => {
const testsParams = [
{
description: "returns sorted all translated sports (with 'en' locale)",
@ -15,59 +15,65 @@ describe('sortSports', () => {
sports,
locale: 'en',
onlyActive: false,
userSports: null,
},
expected: [
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Sport)',
},
{
has_workouts: false,
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Transport)',
},
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Hiking',
},
],
},
{
description:
"returns sorted only active translated sports (with 'en' locales)",
"returns sorted only translated sports, active for user (with 'en' locales)",
inputParams: {
sports,
locale: 'en',
onlyActive: true,
userSports: null,
},
expected: [
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Sport)',
},
{
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
label: 'Hiking',
translatedLabel: 'Hiking',
},
],
},
{
@ -76,6 +82,7 @@ describe('sortSports', () => {
sports: [],
locale: 'en',
onlyActive: false,
userSports: null,
},
expected: [],
},
@ -85,57 +92,63 @@ describe('sortSports', () => {
sports,
locale: 'fr',
onlyActive: false,
userSports: [],
},
expected: [
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Randonnée',
},
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Sport)',
},
{
has_workouts: false,
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Transport)',
},
],
},
{
description:
"returns sorted only active translated sports (with 'fr' locales)",
"returns sorted only translated sports, active for user (with 'fr' locales)",
inputParams: {
sports,
locale: 'fr',
onlyActive: true,
userSports: null,
},
expected: [
{
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
label: 'Hiking',
translatedLabel: 'Randonnée',
},
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Sport)',
},
],
@ -146,6 +159,209 @@ describe('sortSports', () => {
sports: [],
locale: 'fr',
onlyActive: false,
userSports: null,
},
expected: [],
},
{
description:
"returns sorted all translated sports, even with user sports list provided (with 'en' locale)",
inputParams: {
sports,
locale: 'en',
onlyActive: false,
userSports: [2],
},
expected: [
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Sport)',
},
{
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Transport)',
},
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Hiking',
},
],
},
{
description:
"returns sorted only translated sports active on application and sports with user workouts (with 'en' locales)",
inputParams: {
sports,
locale: 'en',
onlyActive: true,
userSports: [2],
},
expected: [
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Sport)',
},
{
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Transport)',
},
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Hiking',
},
],
},
{
description:
"returns empty array, with user sports list provided (with 'en' locale)",
inputParams: {
sports: [],
locale: 'en',
onlyActive: false,
userSports: null,
},
expected: [],
},
{
description:
"returns sorted all translated sports, even with user sports list provided (with 'fr' locale)",
inputParams: {
sports,
locale: 'fr',
onlyActive: false,
userSports: [2],
},
expected: [
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Randonnée',
},
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Sport)',
},
{
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Transport)',
},
],
},
{
description:
"returns sorted only translated sports active on application and sports with user workouts (with 'fr' locales)",
inputParams: {
sports,
locale: 'fr',
onlyActive: true,
userSports: [2],
},
expected: [
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Randonnée',
},
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Sport)',
},
{
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Transport)',
},
],
},
{
description:
"returns empty array, with user sports list provided (with 'fr' locale)",
inputParams: {
sports: [],
locale: 'fr',
onlyActive: false,
userSports: [2],
},
expected: [],
},
@ -157,7 +373,8 @@ describe('sortSports', () => {
translateSports(
testParams.inputParams.sports,
t,
testParams.inputParams.onlyActive
testParams.inputParams.onlyActive,
testParams.inputParams.userSports
),
testParams.expected
)

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