Merge pull request #306 from SamR1/add-privacy-policy-and-user-export

Add privacy policy and user data export
This commit is contained in:
Sam 2023-03-04 19:17:01 +01:00 committed by GitHub
commit 4aa0c961a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
119 changed files with 3705 additions and 312 deletions

View File

@ -18,4 +18,8 @@ Authentication
auth.update_user_account,
auth.update_password,
auth.update_email,
auth.logout_user
auth.logout_user,
auth.accept_privacy_policy,
auth.get_user_data_export,
auth.request_user_data_export,
auth.download_data_export

View File

@ -104,3 +104,39 @@ Modify a user account (admin rights, active status, email and password).
- Reset user password (a new password will be displayed).
* - ``--update-email EMAIL``
- Update user email.
``ftcli users clean_archives``
""""""""""""""""""""""""""""""
.. versionadded:: 0.7.13
Delete export requests and related archives created more than provided number of days.
.. cssclass:: table-bordered
.. list-table::
:widths: 25 50
:header-rows: 1
* - Options
- Description
* - ``--days``
- Number of days.
``ftcli users export_archives``
"""""""""""""""""""""""""""""""
.. versionadded:: 0.7.13
Process incomplete user export requests.
Can be used if redis is not set (no dramatiq workers running).
.. cssclass:: table-bordered
.. list-table::
:widths: 25 50
:header-rows: 1
* - Options
- Description
* - ``--max``
- Maximum number of export requests to process.

View File

@ -74,9 +74,10 @@ Workouts
Account & preferences
^^^^^^^^^^^^^^^^^^^^^
- A user can create, update and deleted his account.
- The user must accept the privacy policy to register. If the privacy change, a message is displayed on the dashboard to review the new version and validate it (*new in 0.7.13*).
- On registration, the user account is created with selected language in dropdown as user preference (*new in 0.6.9*).
- After registration, the user account is inactive and an email with confirmation instructions is sent to activate it.
A user with an inactive account cannot log in. (*new in 0.6.0*)
A user with an inactive account cannot log in. (*new in 0.6.0*).
.. note::
In case email sending is not configured, a `command line <cli.html#ftcli-users-update>`__ allows to activate users account.
@ -98,42 +99,66 @@ Account & preferences
| A workout with a disabled sport will still be displayed in the application.
- A user can create `clients <apps.html>`__ for third-party applications (*new in 0.7.0*).
- A user can request a data export (*new in 0.7.13*).
Administration
^^^^^^^^^^^^^^
(*new in 0.3.0*)
- **Application**
Application
"""""""""""
The following parameters can be set:
**Configuration**
- active users limit. If 0, registration is enabled (no limit defined)
- maximum size of gpx file (individually uploaded or in a zip archive) (*changed in 0.7.4*)
- maximum size of zip archive
- maximum number of files in the zip archive (*changed in 0.7.4*)
- administrator email for contact (*new in 0.6.0*)
The following parameters can be set:
.. warning::
Updating server configuration may be necessary to handle large files (like `nginx <https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size>`_ for instance).
- active users limit. If 0, registration is enabled (no limit defined).
- maximum size of gpx file (individually uploaded or in a zip archive) (*changed in 0.7.4*)
- maximum size of zip archive
- maximum number of files in the zip archive (*changed in 0.7.4*)
- administrator email for contact (*new in 0.6.0*)
.. note::
If email sending is disabled, a warning is displayed.
.. warning::
Updating server configuration may be necessary to handle large files (like `nginx <https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size>`_ for instance).
.. note::
If email sending is disabled, a warning is displayed.
**About**
(*new in 0.7.13*)
| It is possible displayed additional information that may be useful to users in **About** page.
| Markdown syntax can be used.
- **Users**
**Privacy policy**
- display and filter users list
- edit a user to:
- add/remove administration rights
- activate his account (*new in 0.6.0*)
- update his email (in case his account is locked) (*new in 0.6.0*)
- reset his password (in case his account is locked) (*new in 0.6.0*). If email sending is disabled, it is only possible via CLI.
- delete a user
(*new in 0.7.13*)
- **Sports**
| A default privacy policy is available (originally adapted from the `Discourse <https://github.com/discourse/discourse>`__ privacy policy).
| A custom privacy policy can set if needed (Markdown syntax can be used). A policy update will display a message on users dashboard to review and validate it.
- enable or disable a sport (a sport can be disabled even if workout with this sport exists)
.. note::
Only the default privacy policy is translated (if translation is available).
Users
"""""
- display and filter users list
- edit a user to:
- add/remove administration rights
- activate his account (*new in 0.6.0*)
- update his email (in case his account is locked) (*new in 0.6.0*)
- reset his password (in case his account is locked) (*new in 0.6.0*). If email sending is disabled, it is only possible via CLI.
- delete a user
Sports
""""""
- enable or disable a sport (a sport can be disabled even if workout with this sport exists)
Translations

View File

@ -26,7 +26,7 @@ Prerequisites
- Python 3.7+
- PostgreSQL 11+
- optional
- Redis for task queue (if email sending is enabled) and API rate limits
- Redis for task queue (if email sending is enabled and for data export requests) and API rate limits
- SMTP provider (if email sending is enabled)
- API key from a `weather data provider <installation.html#weather-data>`__
- `Poetry <https://poetry.eustace.io>`__ (for installation from sources only)
@ -39,6 +39,9 @@ Prerequisites
| On other OS, some issues can be encountered and adaptations may be
necessary.
.. warning::
| If registration is enabled, it is recommended to set Redis and a SMTP provider for email sending and data export requests.
Environment variables
~~~~~~~~~~~~~~~~~~~~~
@ -273,11 +276,13 @@ Emails sent by FitTrackee are:
- password reset request
- email change (to old and new email adresses)
- password change
- when a data export archive is ready to download (*new in 0.7.13*)
.. versionchanged:: 0.6.5
| For single-user instance, it is possible to disable email sending with an empty ``EMAIL_URL`` (in this case, no need to start dramatiq workers).
| A `CLI <cli.html#ftcli-users-update>`__ is available to activate account and modify email and password.
For single-user instance, it is possible to disable email sending with an empty ``EMAIL_URL`` (in this case, no need to start dramatiq workers).
A `CLI <cli.html#ftcli-users-update>`__ is available to activate account, modify email and password and handle data export requests.
Map tile server

View File

@ -16,13 +16,15 @@ class TestRegistration:
selenium.implicitly_wait(1)
inputs = selenium.find_elements(By.TAG_NAME, 'input')
assert len(inputs) == 4
assert len(inputs) == 5
assert inputs[0].get_attribute('id') == 'username'
assert inputs[0].get_attribute('type') == 'text'
assert inputs[1].get_attribute('id') == 'email'
assert inputs[1].get_attribute('type') == 'email'
assert inputs[2].get_attribute('id') == 'password'
assert inputs[2].get_attribute('type') == 'password'
assert inputs[4].get_attribute('id') == 'accepted_policy'
assert inputs[4].get_attribute('type') == 'checkbox'
form_infos = selenium.find_elements(By.CLASS_NAME, 'form-info')
assert len(form_infos) == 3

View File

@ -32,6 +32,8 @@ def register(selenium, user):
email.send_keys(user.get('email'))
password = selenium.find_element(By.ID, 'password')
password.send_keys(user.get('password'))
accepted_policy = selenium.find_element(By.ID, 'accepted_policy')
accepted_policy.click()
submit_button = selenium.find_element(By.TAG_NAME, 'button')
submit_button.click()

View File

@ -1,3 +1,4 @@
from datetime import datetime
from typing import Dict, Union
from flask import Blueprint, current_app, request
@ -40,6 +41,7 @@ def get_application_config() -> Union[Dict, HttpResponse]:
{
"data": {
"about": null,
"admin_contact": "admin@example.com",
"gpx_limit_import": 10,
"is_email_sending_enabled": true,
@ -48,6 +50,8 @@ def get_application_config() -> Union[Dict, HttpResponse]:
"max_users": 0,
"max_zip_file_size": 10485760,
"map_attribution": "&copy; <a href=http://www.openstreetmap.org/copyright>OpenStreetMap</a> contributors",
"privacy_policy": null,
"privacy_policy_date": null,
"version": "0.7.12",
"weather_provider": null
},
@ -93,6 +97,7 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]:
{
"data": {
"about": null,
"admin_contact": "admin@example.com",
"gpx_limit_import": 10,
"is_email_sending_enabled": true,
@ -101,18 +106,22 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]:
"max_users": 10,
"max_zip_file_size": 10485760,
"map_attribution": "&copy; <a href=http://www.openstreetmap.org/copyright>OpenStreetMap</a> contributors",
"privacy_policy": null,
"privacy_policy_date": null,
"version": "0.7.12",
"weather_provider": null
},
"status": "success"
}
:<json string about: instance information
:<json string admin_contact: email to contact the administrator
:<json integer gpx_limit_import: max number of files in zip archive
:<json boolean is_registration_enabled: is registration enabled?
:<json integer max_single_file_size: max size of a single file
:<json integer max_users: max users allowed to register on instance
:<json integer max_zip_file_size: max size of a zip archive
:<json string privacy_policy: instance privacy policy
:reqheader Authorization: OAuth 2.0 Bearer Token
@ -151,6 +160,16 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]:
config.max_users = config_data.get('max_users')
if 'admin_contact' in config_data:
config.admin_contact = admin_contact if admin_contact else None
if 'about' in config_data:
config.about = (
config_data.get('about') if config_data.get('about') else None
)
if 'privacy_policy' in config_data:
privacy_policy = config_data.get('privacy_policy')
config.privacy_policy = privacy_policy if privacy_policy else None
config.privacy_policy_date = (
datetime.utcnow() if privacy_policy else None
)
if config.max_zip_file_size < config.max_single_file_size:
return InvalidPayloadErrorResponse(

View File

@ -25,6 +25,9 @@ class AppConfig(BaseModel):
)
max_zip_file_size = db.Column(db.Integer, default=10485760, nullable=False)
admin_contact = db.Column(db.String(255), nullable=True)
privacy_policy_date = db.Column(db.DateTime, nullable=True)
privacy_policy = db.Column(db.Text, nullable=True)
about = db.Column(db.Text, nullable=True)
@property
def is_registration_enabled(self) -> bool:
@ -46,6 +49,7 @@ class AppConfig(BaseModel):
def serialize(self) -> Dict:
weather_provider = os.getenv('WEATHER_API_PROVIDER', '').lower()
return {
'about': self.about,
'admin_contact': self.admin_contact,
'gpx_limit_import': self.gpx_limit_import,
'is_email_sending_enabled': current_app.config['CAN_SEND_EMAILS'],
@ -54,6 +58,8 @@ class AppConfig(BaseModel):
'max_zip_file_size': self.max_zip_file_size,
'max_users': self.max_users,
'map_attribution': self.map_attribution,
'privacy_policy': self.privacy_policy,
'privacy_policy_date': self.privacy_policy_date,
'version': VERSION,
'weather_provider': (
weather_provider

View File

@ -35,6 +35,7 @@ def update_app_config_from_database(
current_app.config[
'is_registration_enabled'
] = db_config.is_registration_enabled
current_app.config['privacy_policy_date'] = db_config.privacy_policy_date
def verify_app_config(config_data: Dict) -> List:

View File

@ -69,6 +69,7 @@ class BaseConfig:
'authorization_code': 864000, # 10 days
}
OAUTH2_REFRESH_TOKEN_GENERATOR = True
DATA_EXPORT_EXPIRATION = 24 # hours
class DevelopmentConfig(BaseConfig):

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><script defer="defer" src="/static/js/chunk-vendors.77cd7298.js"></script><script defer="defer" src="/static/js/app.f7fbef48.js"></script><link href="/static/css/app.e2dfa8b7.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></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><script defer="defer" src="/static/js/chunk-vendors.1d69c386.js"></script><script defer="defer" src="/static/js/app.27b920e4.js"></script><link href="/static/css/app.33ec7d01.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></body></html>

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

@ -1,2 +1,2 @@
"use strict";(self["webpackChunkfittrackee_client"]=self["webpackChunkfittrackee_client"]||[]).push([[193],{7885:function(e,s,t){t.r(s),t.d(s,{default:function(){return A}});var a=t(6252),r=t(2262),l=t(3577),o=(t(7658),t(9150)),n=t(436);const c={class:"chart-menu"},i={class:"chart-arrow"},u={class:"time-frames custom-checkboxes-group"},d={class:"time-frames-checkboxes custom-checkboxes"},p=["id","name","checked","onInput"],m={class:"chart-arrow"};var v=(0,a.aZ)({__name:"StatsMenu",emits:["arrowClick","timeFrameUpdate"],setup(e,{emit:s}){const t=(0,r.iH)("month"),o=["week","month","year"];function n(e){t.value=e,s("timeFrameUpdate",e)}return(e,r)=>((0,a.wg)(),(0,a.iD)("div",c,[(0,a._)("div",i,[(0,a._)("i",{class:"fa fa-chevron-left","aria-hidden":"true",onClick:r[0]||(r[0]=e=>s("arrowClick",!0))})]),(0,a._)("div",u,[(0,a._)("div",d,[((0,a.wg)(),(0,a.iD)(a.HY,null,(0,a.Ko)(o,(s=>(0,a._)("div",{class:"time-frame custom-checkbox",key:s},[(0,a._)("label",null,[(0,a._)("input",{type:"radio",id:s,name:s,checked:t.value===s,onInput:e=>n(s)},null,40,p),(0,a._)("span",null,(0,l.zw)(e.$t(`statistics.TIME_FRAMES.${s}`)),1)])]))),64))])]),(0,a._)("div",m,[(0,a._)("i",{class:"fa fa-chevron-right","aria-hidden":"true",onClick:r[1]||(r[1]=e=>s("arrowClick",!1))})])]))}}),k=t(3744);const _=(0,k.Z)(v,[["__scopeId","data-v-22d55de2"]]);var S=_,w=t(631);const f={class:"sports-menu"},h=["id","name","checked","onInput"],U={class:"sport-label"};var b=(0,a.aZ)({__name:"StatsSportsMenu",props:{userSports:null,selectedSportIds:{default:()=>[]}},emits:["selectedSportIdsUpdate"],setup(e,{emit:s}){const t=e,{t:n}=(0,o.QT)(),c=(0,a.f3)("sportColors"),{selectedSportIds:i}=(0,r.BK)(t),u=(0,a.Fl)((()=>(0,w.xH)(t.userSports,n)));function d(e){s("selectedSportIdsUpdate",e)}return(e,s)=>{const t=(0,a.up)("SportImage");return(0,a.wg)(),(0,a.iD)("div",f,[((0,a.wg)(!0),(0,a.iD)(a.HY,null,(0,a.Ko)((0,r.SU)(u),(e=>((0,a.wg)(),(0,a.iD)("label",{type:"checkbox",key:e.id,style:(0,l.j5)({color:e.color?e.color:(0,r.SU)(c)[e.label]})},[(0,a._)("input",{type:"checkbox",id:e.id,name:e.label,checked:(0,r.SU)(i).includes(e.id),onInput:s=>d(e.id)},null,40,h),(0,a.Wm)(t,{"sport-label":e.label,color:e.color},null,8,["sport-label","color"]),(0,a._)("span",U,(0,l.zw)(e.translatedLabel),1)],4)))),128))])}}});const I=b;var g=I,T=t(9318);const y={key:0,id:"user-statistics"};var C=(0,a.aZ)({__name:"index",props:{sports:null,user:null},setup(e){const s=e,{t:t}=(0,o.QT)(),{sports:l,user:c}=(0,r.BK)(s),i=(0,r.iH)("month"),u=(0,r.iH)(v(i.value)),d=(0,a.Fl)((()=>(0,w.xH)(s.sports,t))),p=(0,r.iH)(_(s.sports));function m(e){i.value=e,u.value=v(i.value)}function v(e){return(0,T.aZ)(new Date,e,s.user.weekm)}function k(e){u.value=(0,T.FN)(u.value,e,s.user.weekm)}function _(e){return e.map((e=>e.id))}function f(e){p.value.includes(e)?p.value=p.value.filter((s=>s!==e)):p.value.push(e)}return(0,a.YP)((()=>s.sports),(e=>{p.value=_(e)})),(e,s)=>(0,r.SU)(d)?((0,a.wg)(),(0,a.iD)("div",y,[(0,a.Wm)(S,{onTimeFrameUpdate:m,onArrowClick:k}),(0,a.Wm)(n.Z,{sports:(0,r.SU)(l),user:(0,r.SU)(c),chartParams:u.value,"displayed-sport-ids":p.value,fullStats:!0},null,8,["sports","user","chartParams","displayed-sport-ids"]),(0,a.Wm)(g,{"selected-sport-ids":p.value,"user-sports":(0,r.SU)(l),onSelectedSportIdsUpdate:f},null,8,["selected-sport-ids","user-sports"])])):(0,a.kq)("",!0)}});const F=(0,k.Z)(C,[["__scopeId","data-v-30799d13"]]);var Z=F,x=t(5630),D=t(5801),H=t(9917);const E={id:"statistics",class:"view"},R={key:0,class:"container"};var W=(0,a.aZ)({__name:"StatisticsView",setup(e){const s=(0,H.o)(),t=(0,a.Fl)((()=>s.getters[D.YN.GETTERS.AUTH_USER_PROFILE])),o=(0,a.Fl)((()=>s.getters[D.O8.GETTERS.SPORTS].filter((e=>t.value.sports_list.includes(e.id)))));return(e,s)=>{const n=(0,a.up)("Card");return(0,a.wg)(),(0,a.iD)("div",E,[(0,r.SU)(t).username?((0,a.wg)(),(0,a.iD)("div",R,[(0,a.Wm)(n,null,{title:(0,a.w5)((()=>[(0,a.Uk)((0,l.zw)(e.$t("statistics.STATISTICS")),1)])),content:(0,a.w5)((()=>[(0,a.Wm)(Z,{class:(0,l.C_)({"stats-disabled":0===(0,r.SU)(t).nb_workouts}),user:(0,r.SU)(t),sports:(0,r.SU)(o)},null,8,["class","user","sports"])])),_:1}),0===(0,r.SU)(t).nb_workouts?((0,a.wg)(),(0,a.j4)(x.Z,{key:0})):(0,a.kq)("",!0)])):(0,a.kq)("",!0)])}}});const P=(0,k.Z)(W,[["__scopeId","data-v-2e341d4e"]]);var A=P}}]);
//# sourceMappingURL=statistics.d3c3c7bd.js.map
//# sourceMappingURL=statistics.5228e1ba.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

View File

@ -51,3 +51,13 @@ def account_confirmation_email(user: Dict, email_data: Dict) -> None:
recipient=user['email'],
data=email_data,
)
@dramatiq.actor(queue_name='fittrackee_emails')
def data_export_email(user: Dict, email_data: Dict) -> None:
email_service.send(
template='data_export_ready',
lang=user['language'],
recipient=user['email'],
data=email_data,
)

View File

@ -0,0 +1,26 @@
{% extends "layout.html" %}
{% block title %}{{ _('Your archive is ready to be downloaded') }}{% endblock %}
{% block preheader %}{{ _('A download link is available in your account.') }}{% endblock %}
{% block content %}<p>{{ _('You have requested an export of your account on FitTrackee.') }} {{ _('The archive is now ready to be downloaded from your account.') }}</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="{{account_url}}" class="f-fallback button button--green" target="_blank">{{ _('Download your archive') }}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>{% endblock %}
{% block not_initiated %}{{ _("If you did not request the export, please change your password immediately or contact your administrator if your account is locked.") }}{% endblock %}
{% block url_to_paste %}<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">{{ _("If you're having trouble with the button above, copy and paste the URL below into your web browser.") }}</p>
<p class="f-fallback sub">{{account_url}}</p>
</td>
</tr>
</table>{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "layout.txt" %}{% block content %}{{ _('You have requested an export of your account on FitTrackee.') }}
{{ _('The archive is now ready to be downloaded from your account.') }}
{{ _('Download your archive') }}: {{ account_url }}
{{ _('If you did not request the export, please change your password immediately or contact your administrator if your account is locked.') }}{% endblock %}

View File

@ -0,0 +1 @@
FitTrackee - {{ _('Your archive is ready to be downloaded') }}

View File

@ -7,18 +7,17 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-07-03 07:58+0200\n"
"POT-Creation-Date: 2023-03-04 10:33+0100\n"
"PO-Revision-Date: 2022-07-04 21:17+0000\n"
"Last-Translator: J. Lavoie <j.lavoie@net-c.ca>\n"
"Language-Team: German <https://hosted.weblate.org/projects/fittrackee/"
"fittrackee-api-emails/de/>\n"
"Language: de\n"
"Language-Team: German <https://hosted.weblate.org/projects/fittrackee"
"/fittrackee-api-emails/de/>\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.13.1-dev\n"
"Generated-By: Babel 2.10.3\n"
"Generated-By: Babel 2.11.0\n"
#: fittrackee/emails/templates/layout.html:215
#: fittrackee/emails/templates/layout.txt:1
@ -66,8 +65,7 @@ msgstr "Du hast ein Konto bei FitTrackee angelegt."
#: fittrackee/emails/templates/account_confirmation/body.html:4
msgid "Use the button below to confirm your address email."
msgstr ""
"Verwende die unteren Schaltfläche, um deine E-Mail-Adresse zu bestätigen."
msgstr "Verwende die unteren Schaltfläche, um deine E-Mail-Adresse zu bestätigen."
#: fittrackee/emails/templates/account_confirmation/body.html:11
#: fittrackee/emails/templates/account_confirmation/body.txt:4
@ -86,19 +84,51 @@ msgstr ""
"E-Mail bitte."
#: fittrackee/emails/templates/account_confirmation/body.html:22
#: fittrackee/emails/templates/data_export_ready/body.html:22
#: fittrackee/emails/templates/email_update_to_new_email/body.html:22
#: fittrackee/emails/templates/password_reset_request/body.html:24
msgid ""
"If you're having trouble with the button above, copy and paste the URL "
"below into your web browser."
msgstr ""
"Falls du Probleme mit der oberen Schaltfläche hast, kopiere diese URL und "
"gebe sie in deinen Webbrowser ein."
"Falls du Probleme mit der oberen Schaltfläche hast, kopiere diese URL und"
" gebe sie in deinen Webbrowser ein."
#: fittrackee/emails/templates/account_confirmation/body.txt:2
msgid "Use the link below to confirm your address email."
msgstr "Verwende den unteren Link, um deine E-Mail-Adresse zu bestätigen."
#: fittrackee/emails/templates/data_export_ready/body.html:2
#: fittrackee/emails/templates/data_export_ready/subject.txt:1
msgid "Your archive is ready to be downloaded"
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:3
msgid "A download link is available in your account."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:1
msgid "You have requested an export of your account on FitTrackee."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:2
msgid "The archive is now ready to be downloaded from your account."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:11
#: fittrackee/emails/templates/data_export_ready/body.txt:4
msgid "Download your archive"
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:18
#: fittrackee/emails/templates/data_export_ready/body.txt:5
msgid ""
"If you did not request the export, please change your password "
"immediately or contact your administrator if your account is locked."
msgstr ""
#: fittrackee/emails/templates/email_update_to_current_email/body.html:2
#: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1
msgid "Email changed"
@ -114,8 +144,8 @@ msgid ""
"You recently requested to change your email address for your FitTrackee "
"account to:"
msgstr ""
"Du hast kürzlich beantragt, die E-Mail-Adresse deines FitTrackee-Kontos zu "
"ändern. Neue Adresse:"
"Du hast kürzlich beantragt, die E-Mail-Adresse deines FitTrackee-Kontos "
"zu ändern. Neue Adresse:"
#: fittrackee/emails/templates/email_update_to_current_email/body.html:18
#: fittrackee/emails/templates/email_update_to_current_email/body.txt:4
@ -123,9 +153,9 @@ msgid ""
"If this email change wasn't initiated by you, please change your password"
" immediately or contact your administrator if your account is locked."
msgstr ""
"Falls die Änderung der E-Mail-Adresse nicht von Dir initiiert wurde, ändere "
"bitte sofort Dein Passwort oder kontaktiere den Administrator, falls dein "
"Konto gesperrt ist."
"Falls die Änderung der E-Mail-Adresse nicht von Dir initiiert wurde, "
"ändere bitte sofort Dein Passwort oder kontaktiere den Administrator, "
"falls dein Konto gesperrt ist."
#: fittrackee/emails/templates/email_update_to_new_email/body.html:2
#: fittrackee/emails/templates/email_update_to_new_email/subject.txt:1
@ -142,8 +172,8 @@ msgid ""
"You recently requested to change your email address for your FitTrackee "
"account."
msgstr ""
"Du hast kürzlich beantragt, die E-Mail-Adresse deines FitTrackee-Kontos zu "
"ändern."
"Du hast kürzlich beantragt, die E-Mail-Adresse deines FitTrackee-Kontos "
"zu ändern."
#: fittrackee/emails/templates/email_update_to_new_email/body.html:4
msgid "Use the button below to confirm this address."
@ -181,9 +211,9 @@ msgid ""
"password immediately or contact your administrator if your account is "
"locked."
msgstr ""
"Falls die Änderung des Passworts nicht von dir initiiert wurde, ändere bitte "
"sofort dein Passwort oder kontaktiere den Administrator, falls dein Konto "
"gesperrt ist."
"Falls die Änderung des Passworts nicht von dir initiiert wurde, ändere "
"bitte sofort dein Passwort oder kontaktiere den Administrator, falls dein"
" Konto gesperrt ist."
#: fittrackee/emails/templates/password_reset_request/body.html:2
#: fittrackee/emails/templates/password_reset_request/subject.txt:1
@ -196,8 +226,8 @@ msgid ""
"Use this link to reset your password. The link is only valid for "
"%(expiration_delay)s."
msgstr ""
"Verwende den unteren Link, um dein Passwort zurückzusetzen. Der Link ist nur "
"für %(expiration_delay)s gültig."
"Verwende den unteren Link, um dein Passwort zurückzusetzen. Der Link ist "
"nur für %(expiration_delay)s gültig."
#: fittrackee/emails/templates/password_reset_request/body.html:4
#: fittrackee/emails/templates/password_reset_request/body.txt:1
@ -231,3 +261,4 @@ msgstr ""
#: fittrackee/emails/templates/password_reset_request/body.txt:1
msgid "Use the link below to reset it."
msgstr "Verwende den unteren Link, um es zurückzusetzen."

View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-07-03 07:58+0200\n"
"POT-Creation-Date: 2023-03-04 10:33+0100\n"
"PO-Revision-Date: 2022-07-02 18:25+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n"
@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.10.3\n"
"Generated-By: Babel 2.11.0\n"
#: fittrackee/emails/templates/layout.html:215
#: fittrackee/emails/templates/layout.txt:1
@ -83,6 +83,7 @@ msgstr ""
"email."
#: fittrackee/emails/templates/account_confirmation/body.html:22
#: fittrackee/emails/templates/data_export_ready/body.html:22
#: fittrackee/emails/templates/email_update_to_new_email/body.html:22
#: fittrackee/emails/templates/password_reset_request/body.html:24
msgid ""
@ -96,6 +97,39 @@ msgstr ""
msgid "Use the link below to confirm your address email."
msgstr "Use the link below to confirm your address email."
#: fittrackee/emails/templates/data_export_ready/body.html:2
#: fittrackee/emails/templates/data_export_ready/subject.txt:1
msgid "Your archive is ready to be downloaded"
msgstr "Your archive is ready to be downloaded"
#: fittrackee/emails/templates/data_export_ready/body.html:3
msgid "A download link is available in your account."
msgstr "A download link is available in your account."
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:1
msgid "You have requested an export of your account on FitTrackee."
msgstr "You have requested an export of your account on FitTrackee."
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:2
msgid "The archive is now ready to be downloaded from your account."
msgstr "The archive is now ready to be downloaded from your account."
#: fittrackee/emails/templates/data_export_ready/body.html:11
#: fittrackee/emails/templates/data_export_ready/body.txt:4
msgid "Download your archive"
msgstr "Download your archive"
#: fittrackee/emails/templates/data_export_ready/body.html:18
#: fittrackee/emails/templates/data_export_ready/body.txt:5
msgid ""
"If you did not request the export, please change your password "
"immediately or contact your administrator if your account is locked."
msgstr ""
"If you did not request the export, please change your password "
"immediately or contact your administrator if your account is locked."
#: fittrackee/emails/templates/email_update_to_current_email/body.html:2
#: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1
msgid "Email changed"

View File

@ -7,18 +7,17 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-07-03 07:58+0200\n"
"POT-Creation-Date: 2023-03-04 10:33+0100\n"
"PO-Revision-Date: 2022-07-04 21:17+0000\n"
"Last-Translator: J. Lavoie <j.lavoie@net-c.ca>\n"
"Language-Team: French <https://hosted.weblate.org/projects/fittrackee/"
"fittrackee-api-emails/fr/>\n"
"Language: fr\n"
"Language-Team: French <https://hosted.weblate.org/projects/fittrackee"
"/fittrackee-api-emails/fr/>\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 4.13.1-dev\n"
"Generated-By: Babel 2.10.3\n"
"Generated-By: Babel 2.11.0\n"
#: fittrackee/emails/templates/layout.html:215
#: fittrackee/emails/templates/layout.txt:1
@ -85,6 +84,7 @@ msgstr ""
"ignorer ce courriel."
#: fittrackee/emails/templates/account_confirmation/body.html:22
#: fittrackee/emails/templates/data_export_ready/body.html:22
#: fittrackee/emails/templates/email_update_to_new_email/body.html:22
#: fittrackee/emails/templates/password_reset_request/body.html:24
msgid ""
@ -96,8 +96,41 @@ msgstr ""
#: fittrackee/emails/templates/account_confirmation/body.txt:2
msgid "Use the link below to confirm your address email."
msgstr "Cliquez sur le lien ci-dessous pour confirmer votre adresse électronique."
#: fittrackee/emails/templates/data_export_ready/body.html:2
#: fittrackee/emails/templates/data_export_ready/subject.txt:1
msgid "Your archive is ready to be downloaded"
msgstr "Votre archive est prête à être téléchargée"
#: fittrackee/emails/templates/data_export_ready/body.html:3
msgid "A download link is available in your account."
msgstr "Un lien de téléchargement est disponible dans votre compte."
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:1
msgid "You have requested an export of your account on FitTrackee."
msgstr "Vous avez demandé un export des données de votre compte sur FitTrackee."
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:2
msgid "The archive is now ready to be downloaded from your account."
msgstr "L'archive est maintenant prête à être téléchargée depuis votre compte."
#: fittrackee/emails/templates/data_export_ready/body.html:11
#: fittrackee/emails/templates/data_export_ready/body.txt:4
msgid "Download your archive"
msgstr "Télécharger votre archive"
#: fittrackee/emails/templates/data_export_ready/body.html:18
#: fittrackee/emails/templates/data_export_ready/body.txt:5
msgid ""
"If you did not request the export, please change your password "
"immediately or contact your administrator if your account is locked."
msgstr ""
"Cliquez sur le lien ci-dessous pour confirmer votre adresse électronique."
"Si vous n'êtes pas à l'origine de cette demande, veuillez changer "
"votre mot de passe immédiatement ou contacter l'administrateur si votre "
"compte est bloqué."
#: fittrackee/emails/templates/email_update_to_current_email/body.html:2
#: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1
@ -148,19 +181,19 @@ msgstr ""
#: fittrackee/emails/templates/email_update_to_new_email/body.html:4
msgid "Use the button below to confirm this address."
msgstr ""
"Cliquez sur le bouton ci-dessous pour confirmer cette adresse électronique."
"Cliquez sur le bouton ci-dessous pour confirmer cette adresse "
"électronique."
#: fittrackee/emails/templates/email_update_to_new_email/body.html:18
#: fittrackee/emails/templates/email_update_to_new_email/body.txt:7
msgid "If this email change wasn't initiated by you, please ignore this email."
msgstr ""
"Si vous n'êtes pas à l'origine de cette modification, vous pouvez ignorer ce "
"courriel."
"Si vous n'êtes pas à l'origine de cette modification, vous pouvez ignorer"
" ce courriel."
#: fittrackee/emails/templates/email_update_to_new_email/body.txt:2
msgid "Use the link below to confirm this address."
msgstr ""
"Cliquez sur le lien ci-dessous pour confirmer cette adresse électronique."
msgstr "Cliquez sur le lien ci-dessous pour confirmer cette adresse électronique."
#: fittrackee/emails/templates/password_change/body.html:2
#: fittrackee/emails/templates/password_change/subject.txt:1
@ -233,3 +266,4 @@ msgstr ""
#: fittrackee/emails/templates/password_reset_request/body.txt:1
msgid "Use the link below to reset it."
msgstr "Cliquez sur le lien ci-dessous pour le réinitialiser."

View File

@ -7,17 +7,16 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-12-11 09:25+0100\n"
"POT-Creation-Date: 2023-03-04 10:33+0100\n"
"PO-Revision-Date: 2022-12-12 19:48+0000\n"
"Last-Translator: Donato Perruso <dperruso@protonmail.com>\n"
"Language-Team: Italian <https://hosted.weblate.org/projects/fittrackee/"
"fittrackee-api-emails/it/>\n"
"Language: it\n"
"Language-Team: Italian <https://hosted.weblate.org/projects/fittrackee"
"/fittrackee-api-emails/it/>\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.15-dev\n"
"Generated-By: Babel 2.11.0\n"
#: fittrackee/emails/templates/layout.html:215
@ -85,19 +84,51 @@ msgstr ""
"ignora quest'email."
#: fittrackee/emails/templates/account_confirmation/body.html:22
#: fittrackee/emails/templates/data_export_ready/body.html:22
#: fittrackee/emails/templates/email_update_to_new_email/body.html:22
#: fittrackee/emails/templates/password_reset_request/body.html:24
msgid ""
"If you're having trouble with the button above, copy and paste the URL "
"below into your web browser."
msgstr ""
"Se stai avendo problemi con il bottone qui sopra, copia ed incolla l'URL qui "
"sotto nel tuo web browser."
"Se stai avendo problemi con il bottone qui sopra, copia ed incolla l'URL "
"qui sotto nel tuo web browser."
#: fittrackee/emails/templates/account_confirmation/body.txt:2
msgid "Use the link below to confirm your address email."
msgstr "Usa il link qui sotto per confermare la tua email."
#: fittrackee/emails/templates/data_export_ready/body.html:2
#: fittrackee/emails/templates/data_export_ready/subject.txt:1
msgid "Your archive is ready to be downloaded"
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:3
msgid "A download link is available in your account."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:1
msgid "You have requested an export of your account on FitTrackee."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:2
msgid "The archive is now ready to be downloaded from your account."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:11
#: fittrackee/emails/templates/data_export_ready/body.txt:4
msgid "Download your archive"
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:18
#: fittrackee/emails/templates/data_export_ready/body.txt:5
msgid ""
"If you did not request the export, please change your password "
"immediately or contact your administrator if your account is locked."
msgstr ""
#: fittrackee/emails/templates/email_update_to_current_email/body.html:2
#: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1
msgid "Email changed"
@ -139,8 +170,8 @@ msgid ""
"You recently requested to change your email address for your FitTrackee "
"account."
msgstr ""
"Hai richiesto di recente di cambiare l'indirizzo email associato con il tuo "
"account FitTrackee."
"Hai richiesto di recente di cambiare l'indirizzo email associato con il "
"tuo account FitTrackee."
#: fittrackee/emails/templates/email_update_to_new_email/body.html:4
msgid "Use the button below to confirm this address."
@ -150,8 +181,8 @@ msgstr "Usa il bottone qui sotto per confermare questo indirizzo."
#: fittrackee/emails/templates/email_update_to_new_email/body.txt:7
msgid "If this email change wasn't initiated by you, please ignore this email."
msgstr ""
"Se il cambio d'email non è stato iniziato da te, per favore ignora questa "
"mail."
"Se il cambio d'email non è stato iniziato da te, per favore ignora questa"
" mail."
#: fittrackee/emails/templates/email_update_to_new_email/body.txt:2
msgid "Use the link below to confirm this address."
@ -178,9 +209,9 @@ msgid ""
"password immediately or contact your administrator if your account is "
"locked."
msgstr ""
"Se questo cambio di password non è stato iniziato da te, per favore cambia "
"immediatamente la tua password e contatta un amministratore se il tuo "
"account è bloccato."
"Se questo cambio di password non è stato iniziato da te, per favore "
"cambia immediatamente la tua password e contatta un amministratore se il "
"tuo account è bloccato."
#: fittrackee/emails/templates/password_reset_request/body.html:2
#: fittrackee/emails/templates/password_reset_request/subject.txt:1
@ -222,8 +253,10 @@ msgstr "Resetta password"
#: fittrackee/emails/templates/password_reset_request/body.txt:7
msgid "If you did not request a password reset, please ignore this email."
msgstr ""
"Se non hai richiesto un reset della password, per favore ignora quest'email."
"Se non hai richiesto un reset della password, per favore ignora "
"quest'email."
#: fittrackee/emails/templates/password_reset_request/body.txt:1
msgid "Use the link below to reset it."
msgstr "Usa il link qui sotto per resettarla."

View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-07-03 07:58+0200\n"
"POT-Creation-Date: 2023-03-04 10:33+0100\n"
"PO-Revision-Date: 2022-10-31 10:19+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: nb\n"
@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.10.3\n"
"Generated-By: Babel 2.11.0\n"
#: fittrackee/emails/templates/layout.html:215
#: fittrackee/emails/templates/layout.txt:1
@ -79,6 +79,7 @@ msgid ""
msgstr ""
#: fittrackee/emails/templates/account_confirmation/body.html:22
#: fittrackee/emails/templates/data_export_ready/body.html:22
#: fittrackee/emails/templates/email_update_to_new_email/body.html:22
#: fittrackee/emails/templates/password_reset_request/body.html:24
msgid ""
@ -90,6 +91,37 @@ msgstr ""
msgid "Use the link below to confirm your address email."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:2
#: fittrackee/emails/templates/data_export_ready/subject.txt:1
msgid "Your archive is ready to be downloaded"
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:3
msgid "A download link is available in your account."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:1
msgid "You have requested an export of your account on FitTrackee."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:2
msgid "The archive is now ready to be downloaded from your account."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:11
#: fittrackee/emails/templates/data_export_ready/body.txt:4
msgid "Download your archive"
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:18
#: fittrackee/emails/templates/data_export_ready/body.txt:5
msgid ""
"If you did not request the export, please change your password "
"immediately or contact your administrator if your account is locked."
msgstr ""
#: fittrackee/emails/templates/email_update_to_current_email/body.html:2
#: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1
msgid "Email changed"

View File

@ -7,17 +7,16 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-07-03 07:58+0200\n"
"POT-Creation-Date: 2023-03-04 10:33+0100\n"
"PO-Revision-Date: 2022-11-29 00:47+0000\n"
"Last-Translator: bjornclauw <bjorn.clauw.1@gmail.com>\n"
"Language-Team: Dutch <https://hosted.weblate.org/projects/fittrackee/"
"fittrackee-api-emails/nl/>\n"
"Language: nl\n"
"Language-Team: Dutch <https://hosted.weblate.org/projects/fittrackee"
"/fittrackee-api-emails/nl/>\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.15-dev\n"
"Generated-By: Babel 2.11.0\n"
#: fittrackee/emails/templates/layout.html:215
@ -37,8 +36,8 @@ msgid ""
"For security, this request was received from a %(operating_system)s "
"device using %(browser_name)s."
msgstr ""
"Voor beveiliging, werd deze aanvraag ontvangen van een %(operating_system)s "
"apparaat via %(browser_name)s."
"Voor beveiliging, werd deze aanvraag ontvangen van een "
"%(operating_system)s apparaat via %(browser_name)s."
#: fittrackee/emails/templates/layout.html:221
#: fittrackee/emails/templates/layout.txt:5
@ -80,23 +79,54 @@ msgstr "Verifieer uw email"
msgid ""
"If this account creation wasn't initiated by you, please ignore this "
"email."
msgstr ""
"Indien u deze account niet hebt aangemaakt, gelieve deze email te negeren."
msgstr "Indien u deze account niet hebt aangemaakt, gelieve deze email te negeren."
#: fittrackee/emails/templates/account_confirmation/body.html:22
#: fittrackee/emails/templates/data_export_ready/body.html:22
#: fittrackee/emails/templates/email_update_to_new_email/body.html:22
#: fittrackee/emails/templates/password_reset_request/body.html:24
msgid ""
"If you're having trouble with the button above, copy and paste the URL "
"below into your web browser."
msgstr ""
"Als u problemen hebt met bovenstaande knop, kopieer en plak de onderstaande "
"URL in uw web browser."
"Als u problemen hebt met bovenstaande knop, kopieer en plak de "
"onderstaande URL in uw web browser."
#: fittrackee/emails/templates/account_confirmation/body.txt:2
msgid "Use the link below to confirm your address email."
msgstr "Gebruik de onderstaande link om uw email adres te bevestigen."
#: fittrackee/emails/templates/data_export_ready/body.html:2
#: fittrackee/emails/templates/data_export_ready/subject.txt:1
msgid "Your archive is ready to be downloaded"
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:3
msgid "A download link is available in your account."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:1
msgid "You have requested an export of your account on FitTrackee."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:4
#: fittrackee/emails/templates/data_export_ready/body.txt:2
msgid "The archive is now ready to be downloaded from your account."
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:11
#: fittrackee/emails/templates/data_export_ready/body.txt:4
msgid "Download your archive"
msgstr ""
#: fittrackee/emails/templates/data_export_ready/body.html:18
#: fittrackee/emails/templates/data_export_ready/body.txt:5
msgid ""
"If you did not request the export, please change your password "
"immediately or contact your administrator if your account is locked."
msgstr ""
#: fittrackee/emails/templates/email_update_to_current_email/body.html:2
#: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1
msgid "Email changed"
@ -112,8 +142,8 @@ msgid ""
"You recently requested to change your email address for your FitTrackee "
"account to:"
msgstr ""
"U hebt een aanvraag ingediend om uw email adres voor uw FitTrackee account "
"te veranderen naar:"
"U hebt een aanvraag ingediend om uw email adres voor uw FitTrackee "
"account te veranderen naar:"
#: fittrackee/emails/templates/email_update_to_current_email/body.html:18
#: fittrackee/emails/templates/email_update_to_current_email/body.txt:4
@ -121,9 +151,9 @@ msgid ""
"If this email change wasn't initiated by you, please change your password"
" immediately or contact your administrator if your account is locked."
msgstr ""
"Indien deze verandering niet door u werd aangevraagd, gelieve uw wachtwoord "
"dan onmiddellijk te veranderen of contacteer uw administrator als uw account "
"is vergrendeld."
"Indien deze verandering niet door u werd aangevraagd, gelieve uw "
"wachtwoord dan onmiddellijk te veranderen of contacteer uw administrator "
"als uw account is vergrendeld."
#: fittrackee/emails/templates/email_update_to_new_email/body.html:2
#: fittrackee/emails/templates/email_update_to_new_email/subject.txt:1
@ -151,8 +181,8 @@ msgstr "Gebruik de onderstaande knop om dit adres te bevestigen."
#: fittrackee/emails/templates/email_update_to_new_email/body.txt:7
msgid "If this email change wasn't initiated by you, please ignore this email."
msgstr ""
"Indien u deze email aanpassing niet hebt aangevraagd, gelieve deze email te "
"negeren."
"Indien u deze email aanpassing niet hebt aangevraagd, gelieve deze email "
"te negeren."
#: fittrackee/emails/templates/email_update_to_new_email/body.txt:2
msgid "Use the link below to confirm this address."
@ -179,9 +209,9 @@ msgid ""
"password immediately or contact your administrator if your account is "
"locked."
msgstr ""
"Indien de aanpassing van uw wachtwoord niet door u werd aangevraagd, gelieve "
"uw wachtwoord dan onmiddellijk te veranderen of contacteer uw administrator "
"als uw account is vergrendeld."
"Indien de aanpassing van uw wachtwoord niet door u werd aangevraagd, "
"gelieve uw wachtwoord dan onmiddellijk te veranderen of contacteer uw "
"administrator als uw account is vergrendeld."
#: fittrackee/emails/templates/password_reset_request/body.html:2
#: fittrackee/emails/templates/password_reset_request/subject.txt:1
@ -194,8 +224,8 @@ msgid ""
"Use this link to reset your password. The link is only valid for "
"%(expiration_delay)s."
msgstr ""
"Gebruik deze link om uw wachtwoord te resetten. De link is enkel geldig voor "
"%(expiration_delay)s."
"Gebruik deze link om uw wachtwoord te resetten. De link is enkel geldig "
"voor %(expiration_delay)s."
#: fittrackee/emails/templates/password_reset_request/body.html:4
#: fittrackee/emails/templates/password_reset_request/body.txt:1
@ -225,9 +255,10 @@ msgstr "Reset uw wachtwoord"
#: fittrackee/emails/templates/password_reset_request/body.txt:7
msgid "If you did not request a password reset, please ignore this email."
msgstr ""
"Als u geen aanvraag hebt ingediend om uw wachtwoord aan te passen, gelieve "
"dan deze mail te negeren."
"Als u geen aanvraag hebt ingediend om uw wachtwoord aan te passen, "
"gelieve dan deze mail te negeren."
#: fittrackee/emails/templates/password_reset_request/body.txt:1
msgid "Use the link below to reset it."
msgstr "Gebruik onderstaande link om het te resetten."

View File

@ -0,0 +1,67 @@
"""add privacy policy
Revision ID: 374a670efe23
Revises: 0f375c44e659
Create Date: 2023-02-25 11:08:08.977217
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '374a670efe23'
down_revision = '0f375c44e659'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('app_config', schema=None) as batch_op:
batch_op.add_column(sa.Column('privacy_policy_date', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('privacy_policy', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('about', sa.Text(), nullable=True))
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('accepted_policy_date', sa.DateTime(), nullable=True))
batch_op.alter_column('date_format',
existing_type=sa.VARCHAR(length=50),
nullable=True)
op.create_table('users_data_export',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('completed', sa.Boolean(), nullable=False),
sa.Column('file_name', sa.String(length=100), nullable=True),
sa.Column('file_size', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('users_data_export', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_data_export_user_id'), ['user_id'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users_data_export', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_data_export_user_id'))
op.drop_table('users_data_export')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.alter_column('date_format',
existing_type=sa.VARCHAR(length=50),
nullable=False)
batch_op.drop_column('accepted_policy_date')
with op.batch_alter_table('app_config', schema=None) as batch_op:
batch_op.drop_column('about')
batch_op.drop_column('privacy_policy')
batch_op.drop_column('privacy_policy_date')
# ### end Alembic commands ###

View File

@ -1,9 +1,12 @@
import json
from datetime import datetime
from typing import Optional
from unittest.mock import Mock, patch
import pytest
from flask import Flask
from fittrackee import db
from fittrackee.application.models import AppConfig
from fittrackee.users.models import User
@ -296,7 +299,7 @@ class TestUpdateConfig(ApiTestCaseMixin):
@pytest.mark.parametrize(
'input_description,input_email', [('input string', ''), ('None', None)]
)
def test_it_empties_error_if_admin_contact_is_an_empty(
def test_it_empties_administator_contact(
self,
app: Flask,
user_1_admin: User,
@ -325,6 +328,108 @@ class TestUpdateConfig(ApiTestCaseMixin):
assert 'success' in data['status']
assert data['data']['admin_contact'] is None
def test_it_updates_about(
self,
app: Flask,
user_1_admin: User,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1_admin.email
)
about = self.random_string()
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(dict(about=about)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
data = json.loads(response.data.decode())
assert 'success' in data['status']
assert data['data']['about'] == about
def test_it_empties_about_text_when_text_is_an_empty_string(
self, app: Flask, user_1_admin: User
) -> None:
app_config = AppConfig.query.first()
app_config.about = self.random_string()
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1_admin.email
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(dict(about='')),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
data = json.loads(response.data.decode())
assert 'success' in data['status']
assert data['data']['about'] is None
def test_it_updates_privacy_policy(
self,
app: Flask,
user_1_admin: User,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1_admin.email
)
privacy_policy = self.random_string()
privacy_policy_date = datetime.utcnow()
with patch(
'fittrackee.application.app_config.datetime'
) as datetime_mock:
datetime_mock.utcnow = Mock(return_value=privacy_policy_date)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(dict(privacy_policy=privacy_policy)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
data = json.loads(response.data.decode())
assert 'success' in data['status']
assert data['data']['privacy_policy'] == privacy_policy
assert data['data'][
'privacy_policy_date'
] == privacy_policy_date.strftime('%a, %d %b %Y %H:%M:%S GMT')
@pytest.mark.parametrize('input_privacy_policy', ['', None])
def test_it_empties_privacy_policy_date_when_no_privacy_policy(
self,
app: Flask,
user_1_admin: User,
input_privacy_policy: Optional[str],
) -> None:
app_config = AppConfig.query.first()
app_config.privacy_policy = self.random_string()
app_config.privacy_policy_date = datetime.utcnow()
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1_admin.email
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(dict(privacy_policy=input_privacy_policy)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
data = json.loads(response.data.decode())
assert 'success' in data['status']
assert data['data']['privacy_policy'] is None
assert data['data']['privacy_policy_date'] is None
@pytest.mark.parametrize(
'client_scope, can_access',
[

View File

@ -1,3 +1,5 @@
from datetime import datetime
import pytest
from flask import Flask
@ -5,6 +7,8 @@ from fittrackee import VERSION
from fittrackee.application.models import AppConfig
from fittrackee.users.models import User
from ..utils import random_string
class TestConfigModel:
def test_application_config(
@ -88,3 +92,26 @@ class TestConfigModel:
serialized_app_config['weather_provider']
== expected_weather_provider
)
def test_it_returns_privacy_policy(self, app: Flask) -> None:
app_config = AppConfig.query.first()
privacy_policy = random_string()
privacy_policy_date = datetime.now()
app_config.privacy_policy = privacy_policy
app_config.privacy_policy_date = privacy_policy_date
serialized_app_config = app_config.serialize()
assert serialized_app_config["privacy_policy"] == privacy_policy
assert (
serialized_app_config["privacy_policy_date"] == privacy_policy_date
)
def test_it_returns_about(self, app: Flask) -> None:
app_config = AppConfig.query.first()
about = random_string()
app_config.about = about
serialized_app_config = app_config.serialize()
assert serialized_app_config["about"] == about

View File

@ -0,0 +1,167 @@
# flake8: noqa
expected_en_text_body = """Hi test,
You have requested an export of your account on FitTrackee.
The archive is now ready to be downloaded from your account.
Download your archive: http://localhost/profile/edit/account
If you did not request the export, please change your password immediately or contact your administrator if your account is locked.
Thanks,
The FitTrackee Team
http://localhost"""
expected_fr_text_body = """Bonjour test,
Vous avez demandé un export des données de votre compte sur FitTrackee.
L'archive est maintenant prête à être téléchargée depuis votre compte.
Télécharger votre archive: http://localhost/profile/edit/account
Si vous n'êtes pas à l'origine de cette demande, veuillez changer votre mot de passe immédiatement ou contacter l'administrateur si votre compte est bloqué.
Merci,
L'équipe FitTrackee
http://localhost"""
expected_en_html_body = """ <body>
<span class="preheader">A download link is available in your account.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="http://localhost" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Hi test,</h1>
<p>You have requested an export of your account on FitTrackee. The archive is now ready to be downloaded from your account.</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="http://localhost/profile/edit/account" class="f-fallback button button--green" target="_blank">Download your archive</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
If you did not request the export, please change your password immediately or contact your administrator if your account is locked.
</p>
<p>Thanks,
<br>The FitTrackee Team</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">If you're having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="f-fallback sub">http://localhost/profile/edit/account</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
expected_fr_html_body = """ <body>
<span class="preheader">Un lien de téléchargement est disponible dans votre compte.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="http://localhost" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Bonjour test,</h1>
<p>Vous avez demandé un export des données de votre compte sur FitTrackee. L'archive est maintenant prête à être téléchargée depuis votre compte.</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="http://localhost/profile/edit/account" class="f-fallback button button--green" target="_blank">Télécharger votre archive</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
Si vous n'êtes pas à l'origine de cette demande, veuillez changer votre mot de passe immédiatement ou contacter l'administrateur si votre compte est bloqué.
</p>
<p>Merci,
<br>L'équipe FitTrackee</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">Si vous avez des problèmes avec le bouton, vous pouvez copier et coller le lien suivant dans votre navigateur.</p>
<p class="f-fallback sub">http://localhost/profile/edit/account</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""

View File

@ -0,0 +1,89 @@
import pytest
from flask import Flask
from fittrackee.emails.email import EmailTemplate
from .template_results.email_data_export_ready import (
expected_en_html_body,
expected_en_text_body,
expected_fr_html_body,
expected_fr_text_body,
)
class TestEmailTemplateForDataExport:
EMAIL_DATA = {
'username': 'test',
'account_url': 'http://localhost/profile/edit/account',
'fittrackee_url': 'http://localhost',
}
@pytest.mark.parametrize(
'lang, expected_subject',
[
('en', 'FitTrackee - Your archive is ready to be downloaded'),
('fr', 'FitTrackee - Votre archive est prête à être téléchargée'),
],
)
def test_it_gets_subject(
self, app: Flask, lang: str, expected_subject: str
) -> None:
email_template = EmailTemplate(
app.config['TEMPLATES_FOLDER'],
app.config['TRANSLATIONS_FOLDER'],
app.config['LANGUAGES'],
)
subject = email_template.get_content(
'data_export_ready', lang, 'subject.txt', {}
)
assert subject == expected_subject
@pytest.mark.parametrize(
'lang, expected_text_body',
[
('en', expected_en_text_body),
('fr', expected_fr_text_body),
],
)
def test_it_gets_text_body(
self, app: Flask, lang: str, expected_text_body: str
) -> None:
email_template = EmailTemplate(
app.config['TEMPLATES_FOLDER'],
app.config['TRANSLATIONS_FOLDER'],
app.config['LANGUAGES'],
)
text_body = email_template.get_content(
'data_export_ready', lang, 'body.txt', self.EMAIL_DATA
)
assert text_body == expected_text_body
def test_it_gets_en_html_body(self, app: Flask) -> None:
email_template = EmailTemplate(
app.config['TEMPLATES_FOLDER'],
app.config['TRANSLATIONS_FOLDER'],
app.config['LANGUAGES'],
)
text_body = email_template.get_content(
'data_export_ready', 'en', 'body.html', self.EMAIL_DATA
)
assert expected_en_html_body in text_body
def test_it_gets_fr_html_body(self, app: Flask) -> None:
email_template = EmailTemplate(
app.config['TEMPLATES_FOLDER'],
app.config['TRANSLATIONS_FOLDER'],
app.config['LANGUAGES'],
)
text_body = email_template.get_content(
'data_export_ready', 'fr', 'body.html', self.EMAIL_DATA
)
assert expected_fr_html_body in text_body

View File

@ -105,11 +105,6 @@ class TestEmailTemplateForEmailUpdateToCurrentEmail:
'email_update_to_current_email', 'fr', 'body.html', self.EMAIL_DATA
)
print('')
print(expected_fr_current_email_html_body)
print('')
print(text_body)
assert expected_fr_current_email_html_body in text_body

View File

@ -52,3 +52,9 @@ def user_email_updated_to_new_address_mock() -> Iterator[MagicMock]:
def account_confirmation_email_mock() -> Iterator[MagicMock]:
with patch('fittrackee.users.auth.account_confirmation_email') as mock:
yield mock
@pytest.fixture()
def data_export_email_mock() -> Iterator[MagicMock]:
with patch('fittrackee.users.export_data.data_export_email') as mock:
yield mock

View File

@ -13,6 +13,7 @@ from ..utils import random_string
def user_1() -> User:
user = User(username='test', email='test@test.com', password='12345678')
user.is_active = True
user.accepted_policy = datetime.datetime.utcnow()
db.session.add(user)
db.session.commit()
return user
@ -22,6 +23,7 @@ def user_1() -> User:
def user_1_upper() -> User:
user = User(username='TEST', email='TEST@TEST.COM', password='12345678')
user.is_active = True
user.accepted_policy = datetime.datetime.utcnow()
db.session.add(user)
db.session.commit()
return user
@ -34,6 +36,7 @@ def user_1_admin() -> User:
)
admin.admin = True
admin.is_active = True
admin.accepted_policy = datetime.datetime.utcnow()
db.session.add(admin)
db.session.commit()
return admin
@ -50,6 +53,7 @@ def user_1_full() -> User:
user.timezone = 'America/New_York'
user.birth_date = datetime.datetime.strptime('01/01/1980', '%d/%m/%Y')
user.is_active = True
user.accepted_policy = datetime.datetime.utcnow()
db.session.add(user)
db.session.commit()
return user
@ -60,6 +64,7 @@ def user_1_paris() -> User:
user = User(username='test', email='test@test.com', password='12345678')
user.timezone = 'Europe/Paris'
user.is_active = True
user.accepted_policy = datetime.datetime.utcnow()
db.session.add(user)
db.session.commit()
return user
@ -69,6 +74,7 @@ def user_1_paris() -> User:
def user_2() -> User:
user = User(username='toto', email='toto@toto.com', password='12345678')
user.is_active = True
user.accepted_policy = datetime.datetime.utcnow()
db.session.add(user)
db.session.commit()
return user
@ -79,6 +85,7 @@ def user_2_admin() -> User:
user = User(username='toto', email='toto@toto.com', password='12345678')
user.is_active = True
user.admin = True
user.accepted_policy = datetime.datetime.utcnow()
db.session.add(user)
db.session.commit()
return user
@ -89,6 +96,7 @@ def user_3() -> User:
user = User(username='sam', email='sam@test.com', password='12345678')
user.is_active = True
user.weekm = True
user.accepted_policy = datetime.datetime.utcnow()
db.session.add(user)
db.session.commit()
return user
@ -100,6 +108,7 @@ def inactive_user() -> User:
username='inactive', email='inactive@example.com', password='12345678'
)
user.confirmation_token = random_string()
user.accepted_policy = datetime.datetime.utcnow()
db.session.add(user)
db.session.commit()
return user

View File

@ -1,7 +1,7 @@
import json
from datetime import datetime, timedelta
from io import BytesIO
from typing import Optional
from typing import Optional, Union
from unittest.mock import MagicMock, Mock, patch
import pytest
@ -9,7 +9,12 @@ from flask import Flask
from freezegun import freeze_time
from fittrackee import db
from fittrackee.users.models import BlacklistedToken, User, UserSportPreference
from fittrackee.users.models import (
BlacklistedToken,
User,
UserDataExport,
UserSportPreference,
)
from fittrackee.users.utils.token import get_user_token
from fittrackee.workouts.models import Sport
@ -33,6 +38,48 @@ class TestUserRegistration(ApiTestCaseMixin):
self.assert_400(response)
def test_it_returns_error_if_accepted_policy_is_missing(
self, app: Flask
) -> None:
client = app.test_client()
response = client.post(
'/api/auth/register',
data=json.dumps(
dict(
username=self.random_string(),
email=self.random_email(),
password=self.random_string(),
)
),
content_type='application/json',
)
self.assert_400(response)
def test_it_returns_error_if_accepted_policy_is_false(
self, app: Flask
) -> None:
client = app.test_client()
response = client.post(
'/api/auth/register',
data=json.dumps(
dict(
username=self.random_string(),
email=self.random_email(),
password=self.random_string(),
accepted_policy=False,
)
),
content_type='application/json',
)
self.assert_400(
response,
'sorry, you must agree privacy policy to register',
)
def test_it_returns_error_if_username_is_missing(self, app: Flask) -> None:
client = app.test_client()
@ -42,6 +89,7 @@ class TestUserRegistration(ApiTestCaseMixin):
dict(
email=self.random_email(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -65,6 +113,7 @@ class TestUserRegistration(ApiTestCaseMixin):
username=self.random_string(length=input_username_length),
email=self.random_email(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -91,6 +140,7 @@ class TestUserRegistration(ApiTestCaseMixin):
username=input_username,
email=self.random_email(),
password=self.random_email(),
accepted_policy=True,
)
),
content_type='application/json',
@ -121,6 +171,7 @@ class TestUserRegistration(ApiTestCaseMixin):
),
email=self.random_email(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -137,6 +188,7 @@ class TestUserRegistration(ApiTestCaseMixin):
dict(
username=self.random_string(),
email=self.random_email(),
accepted_policy=True,
)
),
content_type='application/json',
@ -156,6 +208,7 @@ class TestUserRegistration(ApiTestCaseMixin):
username=self.random_string(),
email=self.random_email(),
password=self.random_string(length=7),
accepted_policy=True,
)
),
content_type='application/json',
@ -172,6 +225,7 @@ class TestUserRegistration(ApiTestCaseMixin):
dict(
username=self.random_string(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -189,6 +243,7 @@ class TestUserRegistration(ApiTestCaseMixin):
username=self.random_string(),
email=self.random_string(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -207,6 +262,7 @@ class TestUserRegistration(ApiTestCaseMixin):
dict(
username=self.random_string(),
email=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -224,6 +280,7 @@ class TestUserRegistration(ApiTestCaseMixin):
username=self.random_string(),
email=self.random_email(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -248,6 +305,7 @@ class TestUserRegistration(ApiTestCaseMixin):
username=username,
email=self.random_email(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -269,25 +327,30 @@ class TestUserRegistration(ApiTestCaseMixin):
client = app.test_client()
username = self.random_string()
email = self.random_email()
accepted_policy_date = datetime.utcnow()
client.post(
'/api/auth/register',
data=json.dumps(
dict(
username=username,
email=email,
password=self.random_string(),
language=input_language,
)
),
content_type='application/json',
)
with patch('fittrackee.users.auth.datetime.datetime') as datetime_mock:
datetime_mock.utcnow = Mock(return_value=accepted_policy_date)
client.post(
'/api/auth/register',
data=json.dumps(
dict(
username=username,
email=email,
password=self.random_string(),
language=input_language,
accepted_policy=True,
)
),
content_type='application/json',
)
new_user = User.query.filter_by(username=username).first()
assert new_user.email == email
assert new_user.password is not None
assert new_user.is_active is False
assert new_user.language == expected_language
assert new_user.accepted_policy_date == accepted_policy_date
@pytest.mark.parametrize(
'input_language,expected_language',
@ -314,6 +377,7 @@ class TestUserRegistration(ApiTestCaseMixin):
email=email,
password='12345678',
language=input_language,
accepted_policy=True,
)
),
content_type='application/json',
@ -353,6 +417,7 @@ class TestUserRegistration(ApiTestCaseMixin):
username=username,
email=email,
password='12345678',
accepted_policy=True,
)
),
content_type='application/json',
@ -381,6 +446,7 @@ class TestUserRegistration(ApiTestCaseMixin):
else user_1.email.lower()
),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -404,6 +470,7 @@ class TestUserRegistration(ApiTestCaseMixin):
username=self.random_string(),
email=user_1.email,
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -1983,6 +2050,7 @@ class TestRegistrationConfiguration(ApiTestCaseMixin):
username=self.random_string(),
email=self.random_email(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -1995,6 +2063,7 @@ class TestRegistrationConfiguration(ApiTestCaseMixin):
username=self.random_string(),
email=self.random_email(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -2015,6 +2084,7 @@ class TestRegistrationConfiguration(ApiTestCaseMixin):
username=self.random_string(),
email=self.random_email(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -2027,6 +2097,7 @@ class TestRegistrationConfiguration(ApiTestCaseMixin):
username=self.random_string(),
email=self.random_email(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -2683,3 +2754,395 @@ class TestUserLogout(ApiTestCaseMixin):
)
self.assert_invalid_token(response)
class TestUserPrivacyPolicyUpdate(ApiTestCaseMixin):
def test_it_returns_error_if_user_is_not_authenticated(
self, app: Flask, user_1: User
) -> None:
client = app.test_client()
response = client.post(
'/api/auth/profile/edit/preferences',
content_type='application/json',
data=json.dumps(dict(accepted_policy=True)),
)
self.assert_401(response)
def test_it_returns_error_if_accepted_policy_is_missing(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/auth/account/privacy-policy',
content_type='application/json',
data=json.dumps(dict()),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_400(response)
def test_it_updates_accepted_policy(
self,
app: Flask,
user_1: User,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
accepted_policy_date = datetime.utcnow()
with patch('fittrackee.users.auth.datetime.datetime') as datetime_mock:
datetime_mock.utcnow = Mock(return_value=accepted_policy_date)
response = client.post(
'/api/auth/account/privacy-policy',
content_type='application/json',
data=json.dumps(dict(accepted_policy=True)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
assert user_1.accepted_policy_date == accepted_policy_date
@pytest.mark.parametrize('input_accepted_policy', [False, '', None, 'foo'])
def test_it_return_error_if_user_has_not_accepted_policy(
self,
app: Flask,
user_1: User,
input_accepted_policy: Union[str, bool, None],
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/auth/account/privacy-policy',
content_type='application/json',
data=json.dumps(dict(accepted_policy=input_accepted_policy)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 400
@patch('fittrackee.users.auth.export_data')
class TestPostUserDataExportRequest(ApiTestCaseMixin):
def test_it_returns_data_export_info_when_no_ongoing_request_exists_for_user( # noqa
self,
export_data_mock: Mock,
app: Flask,
user_1: User,
user_2: User,
) -> None:
db.session.add(UserDataExport(user_id=user_2.id))
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/auth/profile/export/request',
content_type='application/json',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
data = json.loads(response.data.decode())
data_export_request = UserDataExport.query.filter_by(
user_id=user_1.id
).first()
assert data["status"] == "success"
assert data["request"] == jsonify_dict(data_export_request.serialize())
def test_it_returns_error_if_ongoing_request_exist(
self,
export_data_mock: Mock,
app: Flask,
user_1: User,
) -> None:
db.session.add(UserDataExport(user_id=user_1.id))
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/auth/profile/export/request',
content_type='application/json',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_400(response, "ongoing request exists")
def test_it_returns_error_if_existing_request_has_not_expired(
self,
export_data_mock: Mock,
app: Flask,
user_1: User,
) -> None:
completed_export_request = UserDataExport(user_id=user_1.id)
db.session.add(completed_export_request)
completed_export_request.completed = True
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/auth/profile/export/request',
content_type='application/json',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_400(response, "completed request already exists")
def test_it_returns_new_request_if_existing_request_has_expired( # noqa
self,
export_data_mock: Mock,
app: Flask,
user_1: User,
) -> None:
export_expiration = app.config["DATA_EXPORT_EXPIRATION"]
completed_export_request = UserDataExport(
user_id=user_1.id,
created_at=datetime.utcnow() - timedelta(hours=export_expiration),
)
db.session.add(completed_export_request)
completed_export_request.completed = True
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/auth/profile/export/request',
content_type='application/json',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
data = json.loads(response.data.decode())
data_export_request = UserDataExport.query.filter_by(
user_id=user_1.id
).first()
assert data_export_request.id != completed_export_request.id
assert data["status"] == "success"
assert data["request"] == jsonify_dict(data_export_request.serialize())
def test_it_calls_export_data_tasks_when_request_is_created(
self,
export_data_mock: Mock,
app: Flask,
user_1: User,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
client.post(
'/api/auth/profile/export/request',
content_type='application/json',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data_export_request = UserDataExport.query.filter_by(
user_id=user_1.id
).first()
export_data_mock.send.assert_called_once_with(
export_request_id=data_export_request.id
)
def test_it_does_not_calls_export_data_tasks_when_request_already_exists(
self,
export_data_mock: Mock,
app: Flask,
user_1: User,
) -> None:
export_expiration = app.config["DATA_EXPORT_EXPIRATION"]
completed_export_request = UserDataExport(
user_id=user_1.id,
created_at=datetime.utcnow() - timedelta(hours=export_expiration),
)
db.session.add(completed_export_request)
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
client.post(
'/api/auth/profile/export/request',
content_type='application/json',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
export_data_mock.send.assert_not_called()
def test_it_returns_new_request_if_previous_request_has_expired(
self,
export_data_mock: Mock,
app: Flask,
user_1: User,
) -> None:
export_expiration = app.config["DATA_EXPORT_EXPIRATION"]
completed_export_request = UserDataExport(
user_id=user_1.id,
created_at=datetime.utcnow() - timedelta(hours=export_expiration),
)
db.session.add(completed_export_request)
completed_export_request.completed = True
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
client.post(
'/api/auth/profile/export/request',
content_type='application/json',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data_export_request = UserDataExport.query.filter_by(
user_id=user_1.id
).first()
export_data_mock.send.assert_called_once_with(
export_request_id=data_export_request.id
)
class TestGetUserDataExportRequest(ApiTestCaseMixin):
def test_it_returns_none_if_no_request(
self,
app: Flask,
user_1: User,
user_2: User,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.get(
'/api/auth/profile/export',
content_type='application/json',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
data = json.loads(response.data.decode())
assert data["status"] == "success"
assert data["request"] is None
def test_it_returns_existing_request(
self,
app: Flask,
user_1: User,
user_2: User,
) -> None:
export_expiration = app.config["DATA_EXPORT_EXPIRATION"]
completed_export_request = UserDataExport(
user_id=user_1.id,
created_at=datetime.utcnow() - timedelta(hours=export_expiration),
)
db.session.add(completed_export_request)
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.get(
'/api/auth/profile/export',
content_type='application/json',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 200
data = json.loads(response.data.decode())
assert data["status"] == "success"
assert data["request"] == jsonify_dict(
completed_export_request.serialize()
)
class TestDownloadExportDataArchive(ApiTestCaseMixin):
def test_it_returns_404_when_request_export_does_not_exist(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.get(
f'/api/auth/profile/export/{self.random_string()}',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_404_with_message(response, 'file not found')
def test_it_returns_404_when_request_export_from_another_user(
self, app: Flask, user_1: User, user_2: User
) -> None:
archive_file_name = self.random_string()
export_request = UserDataExport(user_id=user_2.id)
db.session.add(export_request)
export_request.completed = True
export_request.file_name = archive_file_name
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.get(
f'/api/auth/profile/export/{archive_file_name}',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_404_with_message(response, 'file not found')
def test_it_returns_404_when_file_name_does_not_match(
self, app: Flask, user_1: User
) -> None:
export_request = UserDataExport(user_id=user_1.id)
db.session.add(export_request)
export_request.completed = True
export_request.file_name = self.random_string()
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.get(
f'/api/auth/profile/export/{self.random_string()}',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_404_with_message(response, 'file not found')
def test_it_calls_send_from_directory_if_request_exist(
self, app: Flask, user_1: User
) -> None:
archive_file_name = self.random_string()
export_request = UserDataExport(user_id=user_1.id)
db.session.add(export_request)
export_request.completed = True
export_request.file_name = archive_file_name
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
with patch('fittrackee.users.auth.send_from_directory') as mock:
mock.return_value = 'file'
client.get(
f'/api/auth/profile/export/{archive_file_name}',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
mock.assert_called_once_with(
f"{app.config['UPLOAD_FOLDER']}/exports/{user_1.id}",
archive_file_name,
mimetype='application/zip',
as_attachment=True,
)

View File

@ -6,7 +6,8 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from fittrackee.users.models import User, UserSportPreference
from fittrackee import db
from fittrackee.users.models import User, UserDataExport, UserSportPreference
from fittrackee.utils import get_readable_duration
from fittrackee.workouts.models import Sport, Workout
@ -1551,6 +1552,30 @@ class TestDeleteUser(ApiTestCaseMixin):
assert response.status_code == 204
def test_user_with_export_request_can_delete_its_own_account(
self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str
) -> None:
db.session.add(UserDataExport(user_1.id))
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
client.post(
'/api/auth/picture',
data=dict(file=(BytesIO(b'avatar'), 'avatar.png')),
headers=dict(
content_type='multipart/form-data',
Authorization=f'Bearer {auth_token}',
),
)
response = client.delete(
'/api/users/test',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 204
def test_user_can_not_delete_another_user_account(
self, app: Flask, user_1: User, user_2: User
) -> None:
@ -1624,7 +1649,7 @@ class TestDeleteUser(ApiTestCaseMixin):
'you can not delete your account, no other user has admin rights',
)
def test_it_enables_registration_after_user_delete(
def test_it_enables_registration_after_user_delete_when_users_count_is_below_limit( # noqa
self,
app_with_3_users_max: Flask,
user_1_admin: User,
@ -1646,6 +1671,7 @@ class TestDeleteUser(ApiTestCaseMixin):
username=self.random_string(),
email=self.random_email(),
password=self.random_string(),
accepted_policy=True,
)
),
content_type='application/json',
@ -1653,7 +1679,7 @@ class TestDeleteUser(ApiTestCaseMixin):
assert response.status_code == 200
def test_it_does_not_enable_registration_on_user_delete(
def test_it_does_not_enable_registration_on_user_delete_when_users_count_is_not_below_limit( # noqa
self,
app_with_3_users_max: Flask,
user_1_admin: User,
@ -1677,6 +1703,7 @@ class TestDeleteUser(ApiTestCaseMixin):
email='test@test.com',
password='12345678',
password_conf='12345678',
accepted_policy=True,
)
),
content_type='application/json',

View File

@ -0,0 +1,636 @@
import os
import secrets
from datetime import datetime, timedelta
from typing import Optional, Tuple
from unittest.mock import Mock, call, patch
from flask import Flask
from fittrackee import db
from fittrackee.users.export_data import (
UserDataExporter,
clean_user_data_export,
export_user_data,
generate_user_data_archives,
)
from fittrackee.users.models import User, UserDataExport
from fittrackee.workouts.models import Sport, Workout
from ..mixins import CallArgsMixin
from ..utils import random_int, random_string
from ..workouts.utils import post_a_workout
class TestUserDataExporterGetData:
def test_it_return_serialized_user_as_info_info(
self, app: Flask, user_1: User
) -> None:
exporter = UserDataExporter(user_1)
user_data = exporter.get_user_info()
assert user_data == user_1.serialize(user_1)
def test_it_returns_empty_list_when_user_has_no_workouts(
self, app: Flask, user_1: User
) -> None:
exporter = UserDataExporter(user_1)
workouts_data = exporter.get_user_workouts_data()
assert workouts_data == []
def test_it_returns_data_for_workout_without_gpx(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
workout_cycling_user_1: Workout,
) -> None:
exporter = UserDataExporter(user_1)
workouts_data = exporter.get_user_workouts_data()
assert workouts_data == [
{
'id': workout_cycling_user_1.short_id,
'sport_id': sport_1_cycling.id,
'sport_label': sport_1_cycling.label,
'title': workout_cycling_user_1.title,
'creation_date': workout_cycling_user_1.creation_date,
'modification_date': workout_cycling_user_1.modification_date,
'workout_date': workout_cycling_user_1.workout_date,
'duration': str(workout_cycling_user_1.duration),
'pauses': None,
'moving': str(workout_cycling_user_1.moving),
'distance': float(workout_cycling_user_1.distance),
'min_alt': None,
'max_alt': None,
'descent': None,
'ascent': None,
'max_speed': float(workout_cycling_user_1.max_speed),
'ave_speed': float(workout_cycling_user_1.ave_speed),
'gpx': None,
'records': [
record.serialize()
for record in workout_cycling_user_1.records
],
'segments': [],
'weather_start': None,
'weather_end': None,
'notes': workout_cycling_user_1.notes,
}
]
def test_it_returns_data_for_workout_with_gpx(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
gpx_file: str,
) -> None:
_, workout_short_id = post_a_workout(app, gpx_file)
workout = Workout.query.first()
exporter = UserDataExporter(user_1)
workouts_data = exporter.get_user_workouts_data()
assert workouts_data == [
{
'id': workout.short_id,
'sport_id': sport_1_cycling.id,
'sport_label': sport_1_cycling.label,
'title': workout.title,
'creation_date': workout.creation_date,
'modification_date': workout.modification_date,
'workout_date': workout.workout_date,
'duration': str(workout.duration),
'pauses': None,
'moving': str(workout.moving),
'distance': float(workout.distance),
'min_alt': float(workout.min_alt),
'max_alt': float(workout.max_alt),
'descent': float(workout.descent),
'ascent': float(workout.ascent),
'max_speed': float(workout.max_speed),
'ave_speed': float(workout.ave_speed),
'gpx': workout.gpx.split('/')[-1],
'records': [record.serialize() for record in workout.records],
'segments': [
segment.serialize() for segment in workout.segments
],
'weather_start': None,
'weather_end': None,
'notes': workout.notes,
}
]
def test_it_stores_only_user_workouts(
self,
app: Flask,
user_1: User,
user_2: User,
sport_1_cycling: Sport,
workout_cycling_user_1: Workout,
workout_cycling_user_2: Workout,
) -> None:
exporter = UserDataExporter(user_1)
workouts_data = exporter.get_user_workouts_data()
assert [data["id"] for data in workouts_data] == [
workout_cycling_user_1.short_id
]
def test_export_data_generates_json_file_in_user_directory(
self,
app: Flask,
user_1: User,
) -> None:
data = {"foo": "bar"}
export = UserDataExporter(user_1)
user_directory = os.path.join(
app.config['UPLOAD_FOLDER'], 'exports', str(user_1.id)
)
os.makedirs(user_directory, exist_ok=True)
file_name = random_string()
export.export_data(data, file_name)
assert (
os.path.isfile(os.path.join(user_directory, f"{file_name}.json"))
is True
)
def test_it_returns_json_file_path(
self,
app: Flask,
user_1: User,
) -> None:
data = {"foo": "bar"}
exporter = UserDataExporter(user_1)
user_directory = os.path.join(
app.config['UPLOAD_FOLDER'], 'exports', str(user_1.id)
)
file_name = random_string()
file_path = exporter.export_data(data, file_name)
assert file_path == os.path.join(user_directory, f"{file_name}.json")
class TestUserDataExporterArchive(CallArgsMixin):
@patch.object(secrets, 'token_urlsafe')
@patch.object(UserDataExporter, 'export_data')
@patch('fittrackee.users.export_data.ZipFile')
def test_it_calls_export_data_twice(
self,
zipfile_mock: Mock,
export_data: Mock,
secrets_mock: Mock,
app: Flask,
user_1: User,
) -> None:
exporter = UserDataExporter(user_1)
exporter.generate_archive()
export_data.assert_has_calls(
[
call(exporter.get_user_info(), 'user_data'),
call(exporter.get_user_workouts_data(), 'workouts_data'),
]
)
@patch.object(secrets, 'token_urlsafe')
@patch.object(UserDataExporter, 'export_data')
@patch('fittrackee.users.export_data.ZipFile')
def test_it_calls_zipfile_with_expected_patch(
self,
zipfile_mock: Mock,
export_data: Mock,
secrets_mock: Mock,
app: Flask,
user_1: User,
) -> None:
exporter = UserDataExporter(user_1)
token_urlsafe = random_string()
secrets_mock.return_value = token_urlsafe
expected_path = os.path.join(
app.config['UPLOAD_FOLDER'],
'exports',
str(user_1.id),
f"archive_{token_urlsafe}.zip",
)
exporter.generate_archive()
zipfile_mock.assert_called_once_with(expected_path, 'w')
@patch.object(secrets, 'token_urlsafe')
@patch.object(UserDataExporter, 'export_data')
@patch('fittrackee.users.export_data.ZipFile')
def test_it_calls_zipfile_for_each_json_file(
self,
zipfile_mock: Mock,
export_data: Mock,
secrets_mock: Mock,
app: Flask,
user_1: User,
) -> None:
exporter = UserDataExporter(user_1)
token_urlsafe = random_string()
secrets_mock.return_value = token_urlsafe
export_data.side_effect = [call('user_info'), call('workouts_data')]
exporter.generate_archive()
# fmt: off
zipfile_mock.return_value.__enter__\
.return_value.write.assert_has_calls(
[
call(call('user_info'), 'user_data.json'),
call(call('workouts_data'), 'user_workouts_data.json'),
]
)
# fmt: on
@patch.object(secrets, 'token_urlsafe')
@patch.object(UserDataExporter, 'export_data')
@patch('fittrackee.users.export_data.ZipFile')
def test_it_calls_zipfile_for_gpx_file(
self,
zipfile_mock: Mock,
export_data: Mock,
secrets_mock: Mock,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
gpx_file: str,
) -> None:
_, workout_short_id = post_a_workout(app, gpx_file)
workout = Workout.query.first()
expected_path = os.path.join(
app.config['UPLOAD_FOLDER'],
workout.gpx,
)
exporter = UserDataExporter(user_1)
exporter.generate_archive()
# fmt: off
zipfile_mock.return_value.__enter__.\
return_value.write.assert_has_calls(
[
call(expected_path, f"gpx/{workout.gpx.split('/')[-1]}"),
]
)
# fmt: on
@patch.object(secrets, 'token_urlsafe')
@patch.object(UserDataExporter, 'export_data')
@patch('fittrackee.users.export_data.ZipFile')
def test_it_calls_zipfile_for_profile_image_when_exists(
self,
zipfile_mock: Mock,
export_data: Mock,
secrets_mock: Mock,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
gpx_file: str,
) -> None:
user_1.picture = random_string()
expected_path = os.path.join(
app.config['UPLOAD_FOLDER'],
user_1.picture,
)
exporter = UserDataExporter(user_1)
with patch(
'fittrackee.users.export_data.os.path.isfile', return_value=True
):
exporter.generate_archive()
# fmt: off
zipfile_mock.return_value.__enter__.\
return_value.write.assert_has_calls(
[
call(expected_path, user_1.picture.split('/')[-1]),
]
)
# fmt: on
@patch.object(secrets, 'token_urlsafe')
def test_it_test_it_generates_a_zip_archive(
self,
secrets_mock: Mock,
app: Flask,
user_1: User,
) -> None:
token_urlsafe = random_string()
secrets_mock.return_value = token_urlsafe
expected_path = os.path.join(
app.config['UPLOAD_FOLDER'],
'exports',
str(user_1.id),
f"archive_{token_urlsafe}.zip",
)
exporter = UserDataExporter(user_1)
exporter.generate_archive()
assert os.path.isfile(expected_path)
@patch('fittrackee.users.export_data.appLog')
@patch.object(UserDataExporter, 'generate_archive')
class TestExportUserData:
def test_it_logs_error_if_not_request_for_given_id(
self,
generate_archive: Mock,
logger_mock: Mock,
app: Flask,
) -> None:
request_id = random_int()
export_user_data(export_request_id=request_id)
logger_mock.error.assert_called_once_with(
f"No export to process for id '{request_id}'"
)
def test_it_logs_error_if_request_already_processed(
self,
generate_archive: Mock,
logger_mock: Mock,
app: Flask,
user_1: User,
) -> None:
export_request = UserDataExport(user_id=user_1.id)
db.session.add(export_request)
export_request.completed = True
db.session.commit()
export_user_data(export_request_id=export_request.id)
logger_mock.info.assert_called_once_with(
f"Export id '{export_request.id}' already processed"
)
def test_it_updates_export_request_when_export_is_successful(
self,
generate_archive_mock: Mock,
logger_mock: Mock,
data_export_email_mock: Mock,
app: Flask,
user_1: User,
) -> None:
export_request = UserDataExport(user_id=user_1.id)
db.session.add(export_request)
db.session.commit()
archive_name = random_string()
generate_archive_mock.return_value = (random_string(), archive_name)
archive_size = random_int()
with patch(
'fittrackee.users.export_data.os.path.getsize',
return_value=archive_size,
):
export_user_data(export_request_id=export_request.id)
assert export_request.completed is True
assert export_request.updated_at is not None
assert export_request.file_name == archive_name
assert export_request.file_size == archive_size
def test_it_updates_export_request_when_export_fails(
self,
generate_archive_mock: Mock,
logger_mock: Mock,
app: Flask,
user_1: User,
) -> None:
export_request = UserDataExport(user_id=user_1.id)
db.session.add(export_request)
db.session.commit()
generate_archive_mock.return_value = (None, None)
export_user_data(export_request_id=export_request.id)
assert export_request.completed is True
assert export_request.updated_at is not None
assert export_request.file_name is None
assert export_request.file_size is None
def test_it_does_not_call_data_export_email_when_export_failed(
self,
generate_archive_mock: Mock,
logger_mock: Mock,
data_export_email_mock: Mock,
app: Flask,
user_1: User,
) -> None:
export_request = UserDataExport(user_id=user_1.id)
db.session.add(export_request)
db.session.commit()
generate_archive_mock.return_value = (None, None)
export_user_data(export_request_id=export_request.id)
data_export_email_mock.send.assert_not_called()
def test_it_calls_data_export_email_when_export_is_successful(
self,
generate_archive_mock: Mock,
logger_mock: Mock,
data_export_email_mock: Mock,
app: Flask,
user_1: User,
) -> None:
export_request = UserDataExport(user_id=user_1.id)
db.session.add(export_request)
db.session.commit()
archive_name = random_string()
generate_archive_mock.return_value = (random_string(), archive_name)
archive_size = random_int()
with patch(
'fittrackee.users.export_data.os.path.getsize',
return_value=archive_size,
):
export_user_data(export_request_id=export_request.id)
data_export_email_mock.send.assert_called_once_with(
{
'language': 'en',
'email': user_1.email,
},
{
'username': user_1.username,
'account_url': 'http://0.0.0.0:5000/profile/edit/account',
'fittrackee_url': 'http://0.0.0.0:5000',
},
)
class UserDataExportTestCase:
@staticmethod
def create_user_request(
user: User, days: int = 0, completed: bool = True
) -> UserDataExport:
user_data_export = UserDataExport(
user_id=user.id,
created_at=datetime.now() - timedelta(days=days),
)
db.session.add(user_data_export)
user_data_export.completed = completed
db.session.commit()
return user_data_export
def generate_archive(
self, user: User
) -> Tuple[UserDataExport, Optional[str]]:
user_data_export = self.create_user_request(user, days=7)
exporter = UserDataExporter(user)
archive_path, archive_file_name = exporter.generate_archive()
user_data_export.file_name = archive_file_name
user_data_export.file_size = random_int()
db.session.commit()
return user_data_export, archive_path
class TestCleanUserDataExport(UserDataExportTestCase):
def test_it_returns_0_when_no_export_requests(self, app: Flask) -> None:
counts = clean_user_data_export(days=7)
assert counts["deleted_requests"] == 0
def test_it_returns_0_when_export_request_is_not_completed(
self, app: Flask, user_1: User
) -> None:
self.create_user_request(user_1, days=7, completed=False)
counts = clean_user_data_export(days=7)
assert counts["deleted_requests"] == 0
def test_it_returns_0_when_export_request_created_less_than_given_days(
self, app: Flask, user_1: User
) -> None:
self.create_user_request(user_1, days=1)
counts = clean_user_data_export(days=7)
assert counts["deleted_requests"] == 0
def test_it_returns_export_requests_created_more_than_given_days_count(
self, app: Flask, user_1: User, user_2: User
) -> None:
self.create_user_request(user_1, days=7)
self.create_user_request(user_2, days=7)
counts = clean_user_data_export(days=7)
assert counts["deleted_requests"] == 2
def test_it_returns_counts(
self, app: Flask, user_1: User, user_2: User, user_3: User
) -> None:
user_1_data_export, archive_path = self.generate_archive(user_1)
user_2_data_export, archive_path = self.generate_archive(user_2)
self.create_user_request(user_3, days=7)
counts = clean_user_data_export(days=7)
assert counts["deleted_requests"] == 3
assert counts["deleted_archives"] == 2
assert counts["freed_space"] == (
user_1_data_export.file_size + user_2_data_export.file_size
)
def test_it_deletes_archive(
self, app: Flask, user_1: User, user_2: User
) -> None:
_, archive_path = self.generate_archive(user_1)
clean_user_data_export(days=7)
assert os.path.exists(archive_path) is False # type: ignore
def test_it_deletes_requests(
self, app: Flask, user_1: User, user_2: User
) -> None:
self.generate_archive(user_1)
clean_user_data_export(days=7)
assert (
UserDataExport.query.filter_by(user_id=user_1.id).first() is None
)
class TestGenerateUsersArchives(UserDataExportTestCase):
def test_it_returns_0_when_no_request(self, app: Flask) -> None:
count = generate_user_data_archives(max_count=1)
assert count == 0
def test_it_returns_0_when_request_request_completed(
self, app: Flask, user_1: User
) -> None:
self.create_user_request(user_1, completed=True)
count = generate_user_data_archives(max_count=1)
assert count == 0
def test_it_returns_count_when_archive_is_generated_user_archive(
self, app: Flask, user_1: User
) -> None:
self.create_user_request(user_1, completed=False)
count = generate_user_data_archives(max_count=1)
assert count == 1
@patch.object(secrets, 'token_urlsafe')
def test_it_generates_user_archive(
self, secrets_mock: Mock, app: Flask, user_1: User
) -> None:
token_urlsafe = random_string()
secrets_mock.return_value = token_urlsafe
archive_path = os.path.join(
app.config['UPLOAD_FOLDER'],
'exports',
str(user_1.id),
f"archive_{token_urlsafe}.zip",
)
self.create_user_request(user_1, completed=False)
generate_user_data_archives(max_count=1)
assert os.path.exists(archive_path) is True # type: ignore
def test_it_generates_max_count_of_archives(
self, app: Flask, user_1: User, user_2: User, user_3: User
) -> None:
self.create_user_request(user_3, completed=False)
self.create_user_request(user_1, completed=False)
self.create_user_request(user_2, completed=False)
count = generate_user_data_archives(max_count=2)
assert count == 2
assert (
UserDataExport.query.filter_by(user_id=user_1.id).first().completed
is True
)
assert (
UserDataExport.query.filter_by(user_id=user_2.id).first().completed
is False
)
assert (
UserDataExport.query.filter_by(user_id=user_3.id).first().completed
is True
)

View File

@ -6,9 +6,14 @@ from flask import Flask
from freezegun import freeze_time
from fittrackee import db
from fittrackee.tests.utils import random_string
from fittrackee.tests.utils import random_int, random_string
from fittrackee.users.exceptions import UserNotFoundException
from fittrackee.users.models import BlacklistedToken, User, UserSportPreference
from fittrackee.users.models import (
BlacklistedToken,
User,
UserDataExport,
UserSportPreference,
)
from fittrackee.workouts.models import Sport, Workout
@ -78,6 +83,46 @@ class TestUserSerializeAsAuthUser(UserModelAssertMixin):
self.assert_workouts_keys_are_present(serialized_user)
def test_it_returns_user_did_not_accept_default_privacy_policy(
self, app: Flask, user_1: User
) -> None:
# default privacy policy
app.config['privacy_policy_date'] = None
user_1.accepted_policy_date = None
serialized_user = user_1.serialize(user_1)
assert serialized_user['accepted_privacy_policy'] is False
def test_it_returns_user_did_accept_default_privacy_policy(
self, app: Flask, user_1: User
) -> None:
# default privacy policy
app.config['privacy_policy_date'] = None
user_1.accepted_policy_date = datetime.utcnow()
serialized_user = user_1.serialize(user_1)
assert serialized_user['accepted_privacy_policy'] is True
def test_it_returns_user_did_not_accept_last_policy(
self, app: Flask, user_1: User
) -> None:
user_1.accepted_policy_date = datetime.utcnow()
# custom privacy policy
app.config['privacy_policy_date'] = datetime.utcnow()
serialized_user = user_1.serialize(user_1)
assert serialized_user['accepted_privacy_policy'] is False
def test_it_returns_user_did_accept_last_policy(
self, app: Flask, user_1: User
) -> None:
# custom privacy policy
app.config['privacy_policy_date'] = datetime.utcnow()
user_1.accepted_policy_date = datetime.utcnow()
serialized_user = user_1.serialize(user_1)
assert serialized_user['accepted_privacy_policy'] is True
def test_it_does_not_return_confirmation_token(
self, app: Flask, user_1_admin: User, user_2: User
) -> None:
@ -118,6 +163,13 @@ class TestUserSerializeAsAdmin(UserModelAssertMixin):
self.assert_workouts_keys_are_present(serialized_user)
def test_it_does_not_return_accepted_privacy_policy_date(
self, app: Flask, user_1_admin: User, user_2: User
) -> None:
serialized_user = user_2.serialize(user_1_admin)
assert 'accepted_privacy_policy' not in serialized_user
def test_it_does_not_return_confirmation_token(
self, app: Flask, user_1_admin: User, user_2: User
) -> None:
@ -334,3 +386,44 @@ class TestUserSportModel:
assert serialized_user_sport['color'] is None
assert serialized_user_sport['is_active']
assert serialized_user_sport['stopped_speed_threshold'] == 1
class TestUserDataExportSerializer:
def test_it_returns_ongoing_export(self, app: Flask, user_1: User) -> None:
created_at = datetime.utcnow()
data_export = UserDataExport(user_id=user_1.id, created_at=created_at)
serialized_data_export = data_export.serialize()
assert serialized_data_export["created_at"] == created_at
assert serialized_data_export["status"] == "in_progress"
assert serialized_data_export["file_name"] is None
assert serialized_data_export["file_size"] is None
def test_it_returns_successful_export(
self, app: Flask, user_1: User
) -> None:
created_at = datetime.utcnow()
data_export = UserDataExport(user_id=user_1.id, created_at=created_at)
data_export.completed = True
data_export.file_name = random_string()
data_export.file_size = random_int()
serialized_data_export = data_export.serialize()
assert serialized_data_export["created_at"] == created_at
assert serialized_data_export["status"] == "successful"
assert serialized_data_export["file_name"] == data_export.file_name
assert serialized_data_export["file_size"] == data_export.file_size
def test_it_returns_errored_export(self, app: Flask, user_1: User) -> None:
created_at = datetime.utcnow()
data_export = UserDataExport(user_id=user_1.id, created_at=created_at)
data_export.completed = True
serialized_data_export = data_export.serialize()
assert serialized_data_export["created_at"] == created_at
assert serialized_data_export["status"] == "errored"
assert serialized_data_export["file_name"] is None
assert serialized_data_export["file_size"] is None

View File

@ -2,10 +2,16 @@ import datetime
import os
import re
import secrets
from typing import Dict, Optional, Tuple, Union
from typing import Dict, Tuple, Union
import jwt
from flask import Blueprint, current_app, request
from flask import (
Blueprint,
Response,
current_app,
request,
send_from_directory,
)
from sqlalchemy import exc, func
from werkzeug.exceptions import RequestEntityTooLarge
from werkzeug.utils import secure_filename
@ -21,6 +27,7 @@ from fittrackee.emails.tasks import (
from fittrackee.files import get_absolute_file_path
from fittrackee.oauth2.server import require_auth
from fittrackee.responses import (
DataNotFoundErrorResponse,
ForbiddenErrorResponse,
HttpResponse,
InvalidPayloadErrorResponse,
@ -33,8 +40,10 @@ from fittrackee.responses import (
from fittrackee.utils import get_readable_duration
from fittrackee.workouts.models import Sport
from .models import BlacklistedToken, User, UserSportPreference
from .models import BlacklistedToken, User, UserDataExport, UserSportPreference
from .tasks import export_data
from .utils.controls import check_password, is_valid_email, register_controls
from .utils.language import get_language
from .utils.token import decode_user_token
auth_blueprint = Blueprint('auth', __name__)
@ -43,13 +52,6 @@ HEX_COLOR_REGEX = regex = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
NOT_FOUND_MESSAGE = 'the requested URL was not found on the server'
def get_language(language: Optional[str]) -> str:
# Note: some users may not have language preferences set
if not language or language not in current_app.config['LANGUAGES']:
language = 'en'
return language
def send_account_confirmation_email(user: User) -> None:
if current_app.config['CAN_SEND_EMAILS']:
ui_url = current_app.config['UI_URL']
@ -115,6 +117,7 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
:<json string password: password (8 characters required)
:<json string lang: user language preferences (if not provided or invalid,
fallback to 'en' (english))
:<json boolean accepted_policy: true if user accepted privacy policy
:statuscode 200: success
:statuscode 400:
@ -141,8 +144,16 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
or post_data.get('username') is None
or post_data.get('email') is None
or post_data.get('password') is None
or post_data.get('accepted_policy') is None
):
return InvalidPayloadErrorResponse()
accepted_policy = post_data.get('accepted_policy') is True
if not accepted_policy:
return InvalidPayloadErrorResponse(
'sorry, you must agree privacy policy to register'
)
username = post_data.get('username')
email = post_data.get('email')
password = post_data.get('password')
@ -176,6 +187,7 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
new_user.date_format = 'MM/dd/yyyy'
new_user.confirmation_token = secrets.token_urlsafe(30)
new_user.language = language
new_user.accepted_policy_date = datetime.datetime.utcnow()
db.session.add(new_user)
db.session.commit()
@ -288,6 +300,7 @@ def get_authenticated_user_profile(
{
"data": {
"accepted_privacy_policy": "Sat, 25 Fev 2023 13:52:58 GMT",
"admin": false,
"bio": null,
"birth_date": null,
@ -872,6 +885,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]:
:<json string language: language preferences
:<json string timezone: user time zone
:<json boolean weekm: does week start on Monday?
:<json boolean weekm: does week start on Monday?
:reqheader Authorization: OAuth 2.0 Bearer Token
@ -1620,3 +1634,250 @@ def logout_user(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]:
'status': 'success',
'message': 'successfully logged out',
}, 200
@auth_blueprint.route('/auth/account/privacy-policy', methods=['POST'])
@require_auth()
def accept_privacy_policy(auth_user: User) -> Union[Dict, HttpResponse]:
"""
The authenticated user accepts the privacy policy.
**Example request**:
.. sourcecode:: http
POST /auth/account/privacy-policy HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "success"
}
:<json boolean accepted_policy: true if user accepted privacy policy
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: success
:statuscode 400:
- invalid payload
:statuscode 401:
- provide a valid auth token
- signature expired, please log in again
- invalid token, please log in again
:statuscode 500: internal server error
"""
post_data = request.get_json()
if not post_data or not post_data.get('accepted_policy'):
return InvalidPayloadErrorResponse()
try:
if post_data.get('accepted_policy') is True:
auth_user.accepted_policy_date = datetime.datetime.utcnow()
db.session.commit()
return {"status": "success"}
else:
return InvalidPayloadErrorResponse()
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/profile/export/request', methods=['POST'])
@require_auth()
def request_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]:
"""
Request a data export for authenticated user.
**Example request**:
.. sourcecode:: http
POST /auth/profile/export/request HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "success",
"request": {
"created_at": "Wed, 01 Mar 2023 12:31:17 GMT",
"status": "in_progress",
"file_name": null,
"file_size": null
}
}
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: success
:statuscode 400:
- ongoing request exists
- completed request already exists
:statuscode 401:
- provide a valid auth token
- signature expired, please log in again
- invalid token, please log in again
:statuscode 500: internal server error
"""
existing_export_request = UserDataExport.query.filter_by(
user_id=auth_user.id
).first()
if existing_export_request:
if not existing_export_request.completed:
return InvalidPayloadErrorResponse("ongoing request exists")
export_expiration = current_app.config["DATA_EXPORT_EXPIRATION"]
if existing_export_request.created_at > (
datetime.datetime.utcnow()
- datetime.timedelta(hours=export_expiration)
):
return InvalidPayloadErrorResponse(
"completed request already exists"
)
try:
if existing_export_request:
db.session.delete(existing_export_request)
db.session.flush()
export_request = UserDataExport(user_id=auth_user.id)
db.session.add(export_request)
db.session.commit()
export_data.send(export_request_id=export_request.id)
return {"status": "success", "request": export_request.serialize()}
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/profile/export', methods=['GET'])
@require_auth()
def get_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]:
"""
Get a data export info for authenticated user if a request exists.
It returns:
- export creation date
- export status ("in_progress", "successful" and "errored")
- file name and size (in bytes) when export is successful
**Example request**:
.. sourcecode:: http
GET /auth/profile/export HTTP/1.1
Content-Type: application/json
**Example response**:
- if a request exists
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "success",
"request": {
"created_at": "Wed, 01 Mar 2023 12:31:17 GMT",
"status": "successful",
"file_name": "archive_rgjsR3fHt295ywNQr5Yp.zip",
"file_size": 924
}
}
- if no request
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "success",
"request": null
}
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: success
:statuscode 401:
- provide a valid auth token
- signature expired, please log in again
- invalid token, please log in again
"""
export_request = UserDataExport.query.filter_by(
user_id=auth_user.id
).first()
return {
"status": "success",
"request": export_request.serialize() if export_request else None,
}
@auth_blueprint.route(
'/auth/profile/export/<string:file_name>', methods=['GET']
)
@require_auth()
def download_data_export(
auth_user: User, file_name: str
) -> Union[Response, HttpResponse]:
"""
Download a data export archive
**Example request**:
.. sourcecode:: http
GET /auth/profile/export/download/archive_rgjsR3fHr5Yp.zip HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/x-gzip
:param string file_name: filename
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: success
:statuscode 401:
- provide a valid auth token
- signature expired, please log in again
- invalid token, please log in again
:statuscode 404: file not found
"""
export_request = UserDataExport.query.filter_by(
user_id=auth_user.id
).first()
if (
not export_request
or not export_request.completed
or export_request.file_name != file_name
):
return DataNotFoundErrorResponse(
data_type="archive", message="file not found"
)
return send_from_directory(
f"{current_app.config['UPLOAD_FOLDER']}/exports/{auth_user.id}",
export_request.file_name,
mimetype='application/zip',
as_attachment=True,
)

View File

@ -2,14 +2,19 @@ import logging
from typing import Optional
import click
from humanize import naturalsize
from fittrackee.cli.app import app
from fittrackee.users.exceptions import UserNotFoundException
from fittrackee.users.export_data import (
clean_user_data_export,
generate_user_data_archives,
)
from fittrackee.users.utils.admin import UserManagerService
from fittrackee.users.utils.token import clean_blacklisted_tokens
handler = logging.StreamHandler()
logger = logging.getLogger('fittrackee_clean_blacklisted_tokens')
logger = logging.getLogger('fittrackee_users_cli')
logger.setLevel(logging.INFO)
logger.addHandler(handler)
@ -80,3 +85,41 @@ def clean(
with app.app_context():
deleted_rows = clean_blacklisted_tokens(days)
logger.info(f'Blacklisted tokens deleted: {deleted_rows}.')
@users_cli.command('clean_archives')
@click.option('--days', type=int, required=True, help='Number of days.')
def clean_export_archives(
days: int,
) -> None:
"""
Clean user export archives created for more than provided number of days.
"""
with app.app_context():
counts = clean_user_data_export(days)
logger.info(
f'Deleted data export requests: {counts["deleted_requests"]}.'
)
logger.info(
f'Deleted data export archives: {counts["deleted_archives"]}.'
)
logger.info(f'Freed space: {naturalsize(counts["freed_space"])}.')
@users_cli.command('export_archives')
@click.option(
'--max',
type=int,
required=True,
help='Maximum number of export requests to process.',
)
def export_archives(
max: int,
) -> None:
"""
Export user data in zip archive if incomplete requests exist.
To use in case redis is not set.
"""
with app.app_context():
count = generate_user_data_archives(max)
logger.info(f'Generated data export archives: {count}.')

View File

@ -0,0 +1,183 @@
import json
import os
import secrets
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple, Union
from zipfile import ZipFile
from flask import current_app
from fittrackee import appLog, db
from fittrackee.emails.tasks import data_export_email
from fittrackee.files import get_absolute_file_path
from .models import User, UserDataExport
from .utils.language import get_language
class UserDataExporter:
"""
generates a zip archive with:
- user info from database (json file)
- data from database for all workouts if exist (json file)
- profile picture file if exists
- gpx files if exist
"""
def __init__(self, user: User) -> None:
self.user = user
self.export_directory = get_absolute_file_path(
os.path.join('exports', str(self.user.id))
)
os.makedirs(self.export_directory, exist_ok=True)
self.workouts_directory = get_absolute_file_path(
os.path.join('workouts', str(self.user.id))
)
def get_user_info(self) -> Dict:
return self.user.serialize(self.user)
def get_user_workouts_data(self) -> List[Dict]:
workouts_data = []
for workout in self.user.workouts:
workout_data = workout.get_workout_data()
workout_data["sport_label"] = workout.sport.label
workout_data["gpx"] = (
workout.gpx.split('/')[-1] if workout.gpx else None
)
workouts_data.append(workout_data)
return workouts_data
def export_data(self, data: Union[Dict, List], name: str) -> str:
"""export data in existing user upload directory"""
json_object = json.dumps(data, indent=4, default=str)
file_path = os.path.join(self.export_directory, f"{name}.json")
with open(file_path, "w") as export_file:
export_file.write(json_object)
return file_path
def generate_archive(self) -> Tuple[Optional[str], Optional[str]]:
try:
user_data_file_name = self.export_data(
self.get_user_info(), "user_data"
)
workout_data_file_name = self.export_data(
self.get_user_workouts_data(), "workouts_data"
)
zip_file = f"archive_{secrets.token_urlsafe(15)}.zip"
zip_path = os.path.join(self.export_directory, zip_file)
with ZipFile(zip_path, 'w') as zip_object:
zip_object.write(user_data_file_name, "user_data.json")
zip_object.write(
workout_data_file_name, "user_workouts_data.json"
)
if self.user.picture:
picture_path = get_absolute_file_path(self.user.picture)
if os.path.isfile(picture_path):
zip_object.write(
picture_path, self.user.picture.split('/')[-1]
)
if os.path.exists(self.workouts_directory):
for file in os.listdir(self.workouts_directory):
if os.path.isfile(
os.path.join(self.workouts_directory, file)
) and file.endswith('.gpx'):
zip_object.write(
os.path.join(self.workouts_directory, file),
f"gpx/{file}",
)
file_exists = os.path.exists(zip_path)
os.remove(user_data_file_name)
os.remove(workout_data_file_name)
return (zip_path, zip_file) if file_exists else (None, None)
except Exception as e:
appLog.error(f'Error when generating user data archive: {str(e)}')
return None, None
def export_user_data(export_request_id: int) -> None:
export_request = UserDataExport.query.filter_by(
id=export_request_id
).first()
if not export_request:
appLog.error(f"No export to process for id '{export_request_id}'")
return
if export_request.completed:
appLog.info(f"Export id '{export_request_id}' already processed")
return
user = User.query.filter_by(id=export_request.user_id).first()
exporter = UserDataExporter(user)
archive_file_path, archive_file_name = exporter.generate_archive()
try:
export_request.completed = True
if archive_file_name and archive_file_path:
export_request.file_name = archive_file_name
export_request.file_size = os.path.getsize(archive_file_path)
db.session.commit()
if current_app.config['CAN_SEND_EMAILS']:
ui_url = current_app.config['UI_URL']
email_data = {
'username': user.username,
'fittrackee_url': ui_url,
'account_url': f'{ui_url}/profile/edit/account',
}
user_data = {
'language': get_language(user.language),
'email': user.email,
}
data_export_email.send(user_data, email_data)
else:
db.session.commit()
except Exception as e:
appLog.error(f'Error when exporting user data: {str(e)}')
def clean_user_data_export(days: int) -> Dict:
counts = {"deleted_requests": 0, "deleted_archives": 0, "freed_space": 0}
limit = datetime.now() - timedelta(days=days)
export_requests = UserDataExport.query.filter(
UserDataExport.created_at < limit,
UserDataExport.completed == True, # noqa
).all()
if not export_requests:
return counts
archive_directory = get_absolute_file_path("exports")
for request in export_requests:
if request.file_name:
archive_path = os.path.join(
archive_directory, f"{request.user_id}", request.file_name
)
if os.path.exists(archive_path):
counts["deleted_archives"] += 1
counts["freed_space"] += request.file_size
# Archive is deleted when row is deleted
db.session.delete(request)
counts["deleted_requests"] += 1
db.session.commit()
return counts
def generate_user_data_archives(max_count: int) -> int:
count = 0
export_requests = (
db.session.query(UserDataExport)
.filter(UserDataExport.completed == False) # noqa
.order_by(UserDataExport.created_at)
.limit(max_count)
.all()
)
for export_request in export_requests:
export_user_data(export_request.id)
count += 1
return count

View File

@ -1,14 +1,20 @@
import os
from datetime import datetime
from typing import Dict, Optional, Union
from typing import Any, Dict, Optional, Union
import jwt
from flask import current_app
from sqlalchemy import func
from sqlalchemy.engine.base import Connection
from sqlalchemy.event import listens_for
from sqlalchemy.ext.declarative import DeclarativeMeta
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.orm.session import Session
from sqlalchemy.sql.expression import select
from fittrackee import bcrypt, db
from fittrackee import appLog, bcrypt, db
from fittrackee.files import get_absolute_file_path
from fittrackee.workouts.models import Workout
from .exceptions import UserNotFoundException
@ -52,6 +58,7 @@ class User(BaseModel):
email_to_confirm = db.Column(db.String(255), nullable=True)
confirmation_token = db.Column(db.String(255), nullable=True)
display_ascent = db.Column(db.Boolean, default=True, nullable=False)
accepted_policy_date = db.Column(db.DateTime, nullable=True)
def __repr__(self) -> str:
return f'<User {self.username!r}>'
@ -188,9 +195,18 @@ class User(BaseModel):
'username': self.username,
}
if role == UserRole.AUTH_USER:
accepted_privacy_policy = False
if self.accepted_policy_date:
accepted_privacy_policy = (
True
if current_app.config['privacy_policy_date'] is None
else current_app.config['privacy_policy_date']
< self.accepted_policy_date
)
serialized_user = {
**serialized_user,
**{
'accepted_privacy_policy': accepted_privacy_policy,
'date_format': self.date_format,
'display_ascent': self.display_ascent,
'imperial_units': self.imperial_units,
@ -266,3 +282,62 @@ class BlacklistedToken(BaseModel):
@classmethod
def check(cls, auth_token: str) -> bool:
return cls.query.filter_by(token=str(auth_token)).first() is not None
class UserDataExport(BaseModel):
__tablename__ = 'users_data_export'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
user_id = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
index=True,
unique=True,
)
created_at = db.Column(
db.DateTime, nullable=False, default=datetime.utcnow
)
updated_at = db.Column(
db.DateTime, nullable=True, onupdate=datetime.utcnow
)
completed = db.Column(db.Boolean, nullable=False, default=False)
file_name = db.Column(db.String(100), nullable=True)
file_size = db.Column(db.Integer, nullable=True)
def __init__(
self,
user_id: int,
created_at: Optional[datetime] = None,
):
self.user_id = user_id
self.created_at = (
datetime.utcnow() if created_at is None else created_at
)
def serialize(self) -> Dict:
if self.completed:
status = "successful" if self.file_name else "errored"
else:
status = "in_progress"
return {
"created_at": self.created_at,
"status": status,
"file_name": self.file_name if status == "successful" else None,
"file_size": self.file_size if status == "successful" else None,
}
@listens_for(UserDataExport, 'after_delete')
def on_users_data_export_delete(
mapper: Mapper, connection: Connection, old_record: 'UserDataExport'
) -> None:
@listens_for(db.Session, 'after_flush', once=True)
def receive_after_flush(session: Session, context: Any) -> None:
if old_record.file_name:
try:
file_path = (
f"exports/{old_record.user_id}/{old_record.file_name}"
)
os.remove(get_absolute_file_path(file_path))
except OSError:
appLog.error('archive found when deleting export request')

View File

@ -0,0 +1,7 @@
from fittrackee import dramatiq
from fittrackee.users.export_data import export_user_data
@dramatiq.actor(queue_name='fittrackee_users_exports')
def export_data(export_request_id: int) -> None:
export_user_data(export_request_id)

View File

@ -24,10 +24,10 @@ from fittrackee.responses import (
from fittrackee.utils import get_readable_duration
from fittrackee.workouts.models import Record, Workout, WorkoutSegment
from .auth import get_language
from .exceptions import InvalidEmailException, UserNotFoundException
from .models import User, UserSportPreference
from .models import User, UserDataExport, UserSportPreference
from .utils.admin import UserManagerService
from .utils.language import get_language
users_blueprint = Blueprint('users', __name__)
@ -667,6 +667,9 @@ def delete_user(
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.query(UserDataExport).filter(
UserDataExport.user_id == user.id
).delete()
db.session.flush()
user_picture = user.picture
db.session.delete(user)
@ -675,6 +678,10 @@ def delete_user(
picture_path = get_absolute_file_path(user.picture)
if os.path.isfile(picture_path):
os.remove(picture_path)
shutil.rmtree(
get_absolute_file_path(f'exports/{user.id}'),
ignore_errors=True,
)
shutil.rmtree(
get_absolute_file_path(f'workouts/{user.id}'),
ignore_errors=True,

View File

@ -0,0 +1,10 @@
from typing import Optional
from flask import current_app
def get_language(language: Optional[str]) -> str:
# Note: some users may not have language preferences set
if not language or language not in current_app.config['LANGUAGES']:
language = 'en'
return language

View File

@ -192,6 +192,33 @@ class Workout(BaseModel):
def short_id(self) -> str:
return encode_uuid(self.uuid)
def get_workout_data(self) -> Dict:
return {
'id': self.short_id, # WARNING: client use uuid as id
'sport_id': self.sport_id,
'title': self.title,
'creation_date': self.creation_date,
'modification_date': self.modification_date,
'workout_date': self.workout_date,
'duration': str(self.duration) if self.duration else None,
'pauses': str(self.pauses) if self.pauses else None,
'moving': str(self.moving) if self.moving else None,
'distance': float(self.distance) if self.distance else None,
'min_alt': float(self.min_alt) if self.min_alt else None,
'max_alt': float(self.max_alt) if self.max_alt else None,
'descent': float(self.descent)
if self.descent is not None
else None,
'ascent': float(self.ascent) if self.ascent is not None else None,
'max_speed': float(self.max_speed) if self.max_speed else None,
'ave_speed': float(self.ave_speed) if self.ave_speed else None,
'records': [record.serialize() for record in self.records],
'segments': [segment.serialize() for segment in self.segments],
'weather_start': self.weather_start,
'weather_end': self.weather_end,
'notes': self.notes,
}
def serialize(self, params: Optional[Dict] = None) -> Dict:
date_from = params.get('from') if params else None
date_to = params.get('to') if params else None
@ -282,41 +309,21 @@ class Workout(BaseModel):
.order_by(Workout.workout_date.asc())
.first()
)
return {
'id': self.short_id, # WARNING: client use uuid as id
'user': self.user.username,
'sport_id': self.sport_id,
'title': self.title,
'creation_date': self.creation_date,
'modification_date': self.modification_date,
'workout_date': self.workout_date,
'duration': str(self.duration) if self.duration else None,
'pauses': str(self.pauses) if self.pauses else None,
'moving': str(self.moving) if self.moving else None,
'distance': float(self.distance) if self.distance else None,
'min_alt': float(self.min_alt) if self.min_alt else None,
'max_alt': float(self.max_alt) if self.max_alt else None,
'descent': float(self.descent)
if self.descent is not None
else None,
'ascent': float(self.ascent) if self.ascent is not None else None,
'max_speed': float(self.max_speed) if self.max_speed else None,
'ave_speed': float(self.ave_speed) if self.ave_speed else None,
'with_gpx': self.gpx is not None,
'bounds': [float(bound) for bound in self.bounds]
if self.bounds
else [], # noqa
'previous_workout': previous_workout.short_id
if previous_workout
else None, # noqa
'next_workout': next_workout.short_id if next_workout else None,
'segments': [segment.serialize() for segment in self.segments],
'records': [record.serialize() for record in self.records],
'map': self.map_id if self.map else None,
'weather_start': self.weather_start,
'weather_end': self.weather_end,
'notes': self.notes,
}
workout = self.get_workout_data()
workout["next_workout"] = (
next_workout.short_id if next_workout else None
)
workout["previous_workout"] = (
previous_workout.short_id if previous_workout else None
)
workout["bounds"] = (
[float(bound) for bound in self.bounds] if self.bounds else []
)
workout["user"] = self.user.username
workout["map"] = self.map_id if self.map else None
workout["with_gpx"] = self.gpx is not None
return workout
@classmethod
def get_user_workout_records(

View File

@ -30,6 +30,7 @@
"linkifyjs": "^4.0.2",
"register-service-worker": "^1.7.1",
"sanitize-html": "^2.10.0",
"snarkdown": "^2.0.0",
"vue": "^3.2.45",
"vue-chart-3": "3.1.1",
"vue-fullscreen": "^3.1.1",

View File

@ -46,16 +46,24 @@
{{ weather_provider.name }}
</a>
</div>
<template v-if="appConfig.about">
<p class="about-instance">{{ $t('about.ABOUT_THIS_INSTANCE') }}</p>
<div
v-html="snarkdown(linkifyAndClean(appConfig.about))"
/>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import snarkdown from 'snarkdown'
import { ComputedRef, computed, capitalize } from 'vue'
import { ROOT_STORE } from '@/store/constants'
import { TAppConfig } from '@/types/application'
import { useStore } from '@/use/useStore'
import { linkifyAndClean } from '@/utils/inputs'
const store = useStore()
const appConfig: ComputedRef<TAppConfig> = computed(
@ -84,11 +92,17 @@
.about-text {
margin-top: 200px;
margin-right: 100px;
@media screen and (max-width: $small-limit) {
margin-top: 0;
margin-right: 0;
}
.fa-padding {
padding-right: $default-padding;
}
.about-instance {
font-weight: bold;
margin-top: $default-margin*3;
}
}
</style>

View File

@ -73,6 +73,42 @@
:disabled="!edition"
/>
</label>
<label class="about-label" for="about">
{{ $t('admin.ABOUT.TEXT') }}:
</label>
<span class="textarea-description">
{{ $t('admin.ABOUT.DESCRIPTION') }}
</span>
<textarea
v-if="edition"
id="about"
name="about"
rows="10"
v-model="appData.about"
/>
<div
v-else
v-html="snarkdown(linkifyAndClean(appData.about ? appData.about : $t('admin.NO_TEXT_ENTERED')))"
class="textarea-content"
/>
<label class="privacy-policy-label" for="privacy_policy">
{{ capitalize($t('privacy_policy.TITLE')) }}:
</label>
<span class="textarea-description">
{{ $t('admin.PRIVACY_POLICY_DESCRIPTION') }}
</span>
<textarea
v-if="edition"
id="privacy_policy"
name="privacy_policy"
rows="20"
v-model="appData.privacy_policy"
/>
<div
v-else
v-html="snarkdown(linkifyAndClean(appData.privacy_policy ? appData.privacy_policy : $t('admin.NO_TEXT_ENTERED')))"
class="textarea-content"
/>
<ErrorMessage :message="errorMessages" v-if="errorMessages" />
<div class="form-buttons" v-if="edition">
<button class="confirm" type="submit">
@ -100,8 +136,10 @@
</template>
<script setup lang="ts">
import snarkdown from 'snarkdown'
import {
ComputedRef,
capitalize,
computed,
reactive,
withDefaults,
@ -114,6 +152,7 @@
import { TAppConfig, TAppConfigForm } from '@/types/application'
import { useStore } from '@/use/useStore'
import { getFileSizeInMB } from '@/utils/files'
import { linkifyAndClean } from '@/utils/inputs'
interface Props {
appConfig: TAppConfig
@ -133,6 +172,8 @@
max_single_file_size: 0,
max_zip_file_size: 0,
gpx_limit_import: 0,
about: '',
privacy_policy: '',
})
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
@ -150,9 +191,15 @@
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(appData[key] = getFileSizeInMB(appConfig[key]))
: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
: ['about', 'privacy_policy'].includes(key)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(appData[key] = appConfig[key])
? appData[key] = appConfig[key]!== null
? appConfig[key]
: ''
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
: (appData[key] = appConfig[key])
})
}
function onCancel() {
@ -171,16 +218,30 @@
<style lang="scss" scoped>
@import '~@/scss/vars.scss';
.user-limit-help {
display: flex;
span {
#admin-app {
.user-limit-help {
display: flex;
span {
font-style: italic;
}
.fa-info-circle {
margin-right: $default-margin;
}
}
.no-contact {
font-style: italic;
}
.fa-info-circle {
margin-right: $default-margin;
textarea {
margin-bottom: $default-padding;
}
}
.no-contact {
font-style: italic;
.textarea-description {
font-style: italic;
}
.textarea-content {
margin-bottom: $default-margin;
padding: $default-padding;
}
}
</style>

View File

@ -17,13 +17,9 @@
</div>
<div class="footer-item bullet"></div>
<div class="footer-item">
<a
href="https://samr1.github.io/FitTrackee/"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('common.DOCUMENTATION') }}
</a>
<router-link to="/privacy-policy">
{{ $t('privacy_policy.TITLE') }}
</router-link>
</div>
</div>
</div>
@ -61,6 +57,7 @@
.footer-items {
display: flex;
flex-wrap: wrap;
align-content: center;
justify-content: center;
@ -76,14 +73,17 @@
@media screen and (max-width: $x-small-limit) {
.footer-items {
border-top: solid 1px var(--footer-border-color);
font-size: 0.85em;
padding: 0 0 2px;
.footer-item {
padding: 5px 5px;
border-top: none;
padding: 1px 5px;
}
.bullet {
padding: 5px 0;
padding: 1px 0;
}
}
}

View File

@ -0,0 +1,91 @@
<template>
<div class="privacy-policy-text">
<h1>
{{ capitalize($t('privacy_policy.TITLE')) }}
</h1>
<p class="last-update">
{{ $t('privacy_policy.LAST_UPDATE')}}: {{ private_policy_date }}
</p>
<template v-if="appConfig.privacy_policy">
<div
v-html="snarkdown(linkifyAndClean(appConfig.privacy_policy))"
/>
</template>
<template v-else>
<template v-for="paragraph in paragraphs" :key="paragraph">
<h2>
{{ $t(`privacy_policy.CONTENT.${paragraph}.TITLE`)}}
</h2>
<p v-html="snarkdown($t(`privacy_policy.CONTENT.${paragraph}.CONTENT`))" />
</template>
</template>
</div>
</template>
<script lang="ts" setup>
import snarkdown from 'snarkdown'
import { ComputedRef, capitalize, computed } from 'vue'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
import { TAppConfig } from '@/types/application'
import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
import { dateStringFormats, formatDate } from '@/utils/dates'
import { linkifyAndClean } from '@/utils/inputs'
const store = useStore()
const fittrackee_private_policy_date = 'Sun, 26 Feb 2023 17:00:00 GMT'
const appConfig: ComputedRef<TAppConfig> = computed(
() => store.getters[ROOT_STORE.GETTERS.APP_CONFIG]
)
const language: ComputedRef<string> = computed(
() => store.getters[ROOT_STORE.GETTERS.LANGUAGE]
)
const authUser: ComputedRef<IAuthUserProfile> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.AUTH_USER_PROFILE]
)
const dateFormat = computed(() => getDateFormat())
const timezone = computed(() => getTimezone())
const private_policy_date = computed(() => getPolicyDate())
const paragraphs = [
'DATA_COLLECTED', 'INFORMATION_USAGE', 'INFORMATION_PROTECTION',
'INFORMATION_DISCLOSURE', 'SITE_USAGE_BY_CHILDREN', 'YOUR_CONSENT',
'ACCOUNT_DELETION', 'CHANGES_TO_OUR_PRIVACY_POLICY'
]
function getTimezone() {
return authUser.value.timezone
? authUser.value.timezone
: Intl.DateTimeFormat().resolvedOptions().timeZone
? Intl.DateTimeFormat().resolvedOptions().timeZone
: 'Europe/Paris'
}
function getDateFormat() {
return dateStringFormats[language.value]
}
function getPolicyDate() {
return formatDate(
appConfig.value.privacy_policy && appConfig.value.privacy_policy_date
? `${appConfig.value.privacy_policy_date}`
: fittrackee_private_policy_date,
timezone.value,
dateFormat.value,
false,
)
}
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
.privacy-policy-text {
margin: 10px 50px 20px;
padding: $default-padding;
width: 100%;
@media screen and (max-width: $small-limit) {
margin: 0;
}
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<div class="privacy-policy-message">
<span>
<i18n-t keypath="user.LAST_PRIVACY_POLICY_TO_VALIDATE">
<router-link to="/profile/edit/privacy-policy">
{{ $t('user.REVIEW') }}
</router-link>
</i18n-t>
</span>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
@import '~@/scss/vars.scss';
.privacy-policy-message {
background: var(--alert-background-color);
color: var(--alert-color);
border-radius: $border-radius;
padding: $default-padding $default-padding*2;
}
</style>

View File

@ -62,13 +62,43 @@
<button class="danger" @click.prevent="updateDisplayModal(true)">
{{ $t('buttons.DELETE_MY_ACCOUNT') }}
</button>
<button class="confirm" v-if="canRequestExport()" @click.prevent="requestExport">
{{ $t('buttons.REQUEST_DATA_EXPORT') }}
</button>
</div>
</form>
<div class="data-export">
<span class="info-box">
<i class="fa fa-info-circle" aria-hidden="true" />
{{ $t('user.EXPORT_REQUEST.ONLY_ONE_EXPORT_PER_DAY') }}
</span>
<div v-if="exportRequest" class="data-export-archive">
{{$t('user.EXPORT_REQUEST.DATA_EXPORT')}}
({{ exportRequestDate }}):
<span
v-if="exportRequest.status=== 'successful'"
class="archive-link"
@click.prevent="downloadArchive(exportRequest.file_name)"
>
<i class="fa fa-download" aria-hidden="true" />
{{ $t("user.EXPORT_REQUEST.DOWNLOAD_ARCHIVE") }}
({{ getReadableFileSize(exportRequest.file_size) }})
</span>
<span v-else>
{{ $t(`user.EXPORT_REQUEST.STATUS.${exportRequest.status}`)}}
</span>
<span v-if="generatingLink">
{{ $t(`user.EXPORT_REQUEST.GENERATING_LINK`)}}
<i class="fa fa-spinner fa-pulse" aria-hidden="true" />
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { isBefore, subDays } from 'date-fns'
import {
ComputedRef,
Ref,
@ -81,14 +111,17 @@
onUnmounted,
} from 'vue'
import authApi from "@/api/authApi";
import PasswordInput from '@/components/Common/PasswordInput.vue'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
import { TAppConfig } from '@/types/application'
import { IUserProfile, IUserAccountPayload } from '@/types/user'
import {IAuthUserProfile, IUserAccountPayload, IExportRequest} from '@/types/user'
import { useStore } from '@/use/useStore'
import { formatDate } from '@/utils/dates'
import { getReadableFileSize } from '@/utils/files'
interface Props {
user: IUserProfile
user: IAuthUserProfile
}
const props = defineProps<Props>()
const { user } = toRefs(props)
@ -114,9 +147,17 @@
)
const formErrors = ref(false)
const displayModal: Ref<boolean> = ref(false)
const exportRequest: ComputedRef<IExportRequest | null> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.EXPORT_REQUEST]
)
const exportRequestDate: ComputedRef<string | null> = computed(
() => getExportRequestDate()
)
const generatingLink: Ref<boolean> = ref(false)
onMounted(() => {
if (props.user) {
store.dispatch(AUTH_USER_STORE.ACTIONS.GET_REQUEST_DATA_EXPORT)
updateUserForm(props.user)
}
})
@ -124,7 +165,7 @@
function invalidateForm() {
formErrors.value = true
}
function updateUserForm(user: IUserProfile) {
function updateUserForm(user: IAuthUserProfile) {
userForm.email = user.email
}
function updatePassword(password: string) {
@ -133,6 +174,21 @@
function updateNewPassword(new_password: string) {
userForm.new_password = new_password
}
function getExportRequestDate() {
return exportRequest.value ? formatDate(
exportRequest.value.created_at,
user.value.timezone,
user.value.date_format,
true,
null, true
) : null
}
function canRequestExport() {
return exportRequestDate.value
? isBefore(new Date(exportRequestDate.value), subDays(new Date(), 1))
: true
}
function updateProfile() {
const payload: IUserAccountPayload = {
email: userForm.email,
@ -150,6 +206,27 @@
function deleteAccount(username: string) {
store.dispatch(AUTH_USER_STORE.ACTIONS.DELETE_ACCOUNT, { username })
}
function requestExport() {
store.dispatch(AUTH_USER_STORE.ACTIONS.REQUEST_DATA_EXPORT)
}
async function downloadArchive(filename: string) {
generatingLink.value = true
await authApi
.get(`/auth/profile/export/${filename}`, {
responseType: 'blob',
})
.then((response) => {
const archiveFileUrl = window.URL.createObjectURL(
new Blob([response.data], { type: 'application/zip' })
)
const archive_link = document.createElement('a')
archive_link.href = archiveFileUrl
archive_link.setAttribute('download', filename)
document.body.appendChild(archive_link)
archive_link.click()
})
.finally(() => generatingLink.value = false)
}
onUnmounted(() => {
store.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_IS_SUCCESS, false)
@ -203,4 +280,17 @@
flex-direction: column;
}
}
.data-export {
padding: $default-padding 0;
.data-export-archive {
padding-top: $default-padding*2;
font-size: .9em;
.archive-link {
color: var(--app-a-color);
cursor: pointer;
}
}
}
</style>

View File

@ -0,0 +1,121 @@
<template>
<div id="user-privacy-policy">
<ErrorMessage :message="errorMessages" v-if="errorMessages" />
<div v-if="user.accepted_privacy_policy">
<p>
<i18n-t keypath="user.YOU_HAVE_ACCEPTED_PRIVACY_POLICY">
<router-link to="/privacy-policy">
{{ $t('privacy_policy.TITLE') }}
</router-link>
</i18n-t>
</p>
<button class="cancel" @click="$router.push('/profile')">
{{ $t('user.PROFILE.BACK_TO_PROFILE') }}
</button>
</div>
<form v-else @submit.prevent="onSubmit()">
<div class="policy-content">
<PrivacyPolicy />
</div>
<label
for="accepted_policy"
class="accepted_policy"
>
<input
type="checkbox"
id="accepted_policy"
required
v-model="acceptedPolicy"
/>
<span>
<i18n-t keypath="user.READ_AND_ACCEPT_PRIVACY_POLICY">
{{ $t('privacy_policy.TITLE') }}
</i18n-t>
</span>
</label>
<router-link to="/profile/edit/account">
{{ $t('user.I_WANT_TO_DELETE_MY_ACCOUNT') }}
</router-link>
<div class="form-buttons">
<button class="confirm" type="submit">
{{ $t('buttons.SUBMIT') }}
</button>
<button class="cancel" @click="$router.push('/profile')">
{{ $t('user.PROFILE.BACK_TO_PROFILE') }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ComputedRef, computed, ref, onUnmounted, toRefs } from 'vue'
import PrivacyPolicy from '@/components/PrivacyPolicy.vue'
import {AUTH_USER_STORE, ROOT_STORE} from '@/store/constants'
import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
interface Props {
user: IAuthUserProfile
}
const props = defineProps<Props>()
const { user } = toRefs(props)
const store = useStore()
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
const acceptedPolicy= ref(false)
function onSubmit() {
store.dispatch(
AUTH_USER_STORE.ACTIONS.ACCEPT_PRIVACY_POLICY, acceptedPolicy.value
)
}
onUnmounted(() => {
store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
})
</script>
<style lang="scss" scoped>
@import '~@/scss/vars.scss';
#user-privacy-policy {
padding: $default-padding 0;
form {
display: flex;
flex-direction: column;
gap: $default-padding;
.policy-content {
height: 500px;
border:1px solid #ccc;
overflow: auto;
margin: $default-margin;
border-radius: $border-radius;
@media screen and (max-width: $small-limit) {
margin: $default-margin 0;
font-size: .9em;
}
.privacy-policy-text {
width: auto;
}
}
.form-buttons {
display: flex;
gap: $default-padding;
flex-direction: row;
@media screen and (max-width: $x-small-limit) {
flex-direction: column;
}
}
}
}
</style>

View File

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

View File

@ -98,6 +98,27 @@
@updatePassword="updatePassword"
@passwordError="invalidateForm"
/>
<label
v-if="action === 'register'"
for="accepted_policy"
class="accepted_policy"
>
<input
type="checkbox"
id="accepted_policy"
:disabled="registration_disabled"
required
@invalid="invalidateForm"
v-model="formData.accepted_policy"
/>
<span>
<i18n-t keypath="user.READ_AND_ACCEPT_PRIVACY_POLICY">
<router-link to="/privacy-policy" target="_blank">
{{ $t('privacy_policy.TITLE') }}
</router-link>
</i18n-t>
</span>
</label>
</div>
<button
type="submit"
@ -176,6 +197,7 @@
username: '',
email: '',
password: '',
accepted_policy: false
})
const buttonText: ComputedRef<string> = computed(() =>
getButtonText(props.action)
@ -261,6 +283,7 @@
formData.username = ''
formData.email = ''
formData.password = ''
formData.accepted_policy = false
}
onUnmounted(() => store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES))
@ -310,6 +333,12 @@
.success-message {
margin: $default-margin;
}
.accepted_policy {
display: flex;
align-items: center;
font-size: .85em;
font-weight: normal;
}
}
@media screen and (max-width: $medium-limit) {

View File

@ -37,6 +37,7 @@
switch (tab) {
case 'ACCOUNT':
case 'PICTURE':
case 'PRIVACY-POLICY':
return `/profile/edit/${tab.toLocaleLowerCase()}`
case 'APPS':
case 'PREFERENCES':

View File

@ -37,7 +37,7 @@
},
"TITLE": "Sportarten Administration"
},
"UPDATE_APPLICATION_DESCRIPTION": "Aktualisiere Anwemdungskonfiguration (maximale Anzahl an registrierten Nutzern, maximale Dateigröße).",
"UPDATE_APPLICATION_DESCRIPTION": "Aktualisiere Anwemdungskonfiguration.",
"UPDATE_USER_EMAIL": "Aktualisiere E-Mail",
"USER": "Nutzer",
"USERS": {

View File

@ -6,6 +6,7 @@ import CommonTranslations from './common.json'
import DashboardTranslations from './dashboard.json'
import ErrorTranslations from './error.json'
import OAuth2Translations from './oauth2.json'
import PrivacyPolicyTranslations from './privacy_policy.json'
import SportsTranslations from './sports.json'
import StatisticsTranslations from './statistics.json'
import UserTranslations from './user.json'
@ -20,6 +21,7 @@ export default {
dashboard: DashboardTranslations,
error: ErrorTranslations,
oauth2: OAuth2Translations,
privacy_policy: PrivacyPolicyTranslations,
sports: SportsTranslations,
statistics: StatisticsTranslations,
user: UserTranslations,

View File

@ -0,0 +1 @@
{}

View File

@ -1,4 +1,5 @@
{
"ABOUT_THIS_INSTANCE": "About this instance",
"CONTACT_ADMIN": "Contact the administrator",
"FITTRACKEE_DESCRIPTION": "<strong>FitTrackee</strong> is a self-hosted outdoor activity tracker.",
"FITTRACKEE_LICENSE": "under {0} license ",

View File

@ -1,4 +1,8 @@
{
"ABOUT": {
"TEXT": "Detailed instance information",
"DESCRIPTION": "Any additional information that may be useful to your users. Markdown syntax can be used."
},
"ACTION": "Action",
"ACTIVATE_USER_ACCOUNT": "Activate account",
"ACTIVE": "Active",
@ -24,7 +28,9 @@
"EMAIL_SENDING_DISABLED": "Email sending is disabled.",
"ENABLE_DISABLE_SPORTS": "Enable/disable sports.",
"NEW_EMAIL": "New email",
"NO_TEXT_ENTERED": "No text entered",
"PASSWORD_RESET_SUCCESSFUL": "The password has been reset.",
"PRIVACY_POLICY_DESCRIPTION": "Add your own privacy policy or leave blank to use the default one. Markdown syntax can be used.",
"REGISTRATION_DISABLED": "Registration is currently disabled.",
"REGISTRATION_ENABLED": "Registration is currently enabled.",
"RESET_USER_PASSWORD": "Reset password",
@ -37,7 +43,7 @@
},
"TITLE": "Sports administration"
},
"UPDATE_APPLICATION_DESCRIPTION": "Update application configuration (maximum number of registered users, maximum files size).",
"UPDATE_APPLICATION_DESCRIPTION": "Update application configuration.",
"UPDATE_USER_EMAIL": "Update email",
"USER": "user | users",
"USERS": {

View File

@ -3,6 +3,7 @@
"Network Error": "Network Error.",
"UNKNOWN": "Error. Please try again or contact the administrator.",
"at least one file in zip archive exceeds size limit, please check the archive": "At least one file in zip archive exceeds size limit, please check the archive.",
"completed request already exists": "A completed export request already exists.",
"email: valid email must be provided": "Email: valid email must be provided.",
"error during gpx file parsing": "Error during gpx file parsing.",
"error during gpx processing": "Error during gpx processing.",
@ -19,6 +20,7 @@
"new email must be different than curent email": "The new email must be different than curent email",
"no file part": "No file provided.",
"no selected file": "No selected file.",
"ongoing request exists": "A data export request already exists.",
"password: password and password confirmation do not match": "Password: password and password confirmation don't match.",
"provide a valid auth token": "Provide a valid auth token.",
"signature expired, please log in again": "Signature expired. Please log in again.",

View File

@ -12,6 +12,7 @@
"LOGIN": "Log in",
"NO": "No",
"REGISTER": "Register",
"REQUEST_DATA_EXPORT": "Request data export",
"RESET": "Reset",
"SUBMIT": "Submit",
"YES": "Yes"

View File

@ -6,6 +6,7 @@ import CommonTranslations from './common.json'
import DashboardTranslations from './dashboard.json'
import ErrorTranslations from './error.json'
import OAuth2Translations from './oauth2.json'
import PrivacyPolicyTranslations from './privacy_policy.json'
import SportsTranslations from './sports.json'
import StatisticsTranslations from './statistics.json'
import UserTranslations from './user.json'
@ -20,6 +21,7 @@ export default {
dashboard: DashboardTranslations,
error: ErrorTranslations,
oauth2: OAuth2Translations,
privacy_policy: PrivacyPolicyTranslations,
sports: SportsTranslations,
statistics: StatisticsTranslations,
user: UserTranslations,

View File

@ -0,0 +1,38 @@
{
"CONTENT": {
"ACCOUNT_DELETION": {
"CONTENT": "You can request the deletion of your account at any time by going to this address (after logging in) and clicking on \"Delete My Account\" button in your account edition.",
"TITLE": "Account deletion"
},
"CHANGES_TO_OUR_PRIVACY_POLICY": {
"CONTENT": "If we decide to change our privacy policy, we will post those changes on this page.\n\nThis document is under [CC-BY-SA](https://creativecommons.org/licenses/by-sa/4.0/) license. Originally adapted from the [Discourse](https://github.com/discourse/discourse) privacy policy.",
"TITLE": "Changes to our Privacy Policy"
},
"DATA_COLLECTED": {
"CONTENT": "The following information are collected:\n- Account information (username, e-mail address and password). You may also enter additional profile information such as a first name, last name, birth date, location, biography and upload a profile picture.\n- [GPX](https://en.wikipedia.org/wiki/GPS_Exchange_Format) files. These files contain data related to your activities (geographic coordinates, date, distance, duration, max and average speeds, elevation, heart rate…). If you don't want to expose some data, clean them before upload or add workouts without GPX files.\n- Workout data (sport, title, date, duration, distance, ascent, descent, notes).\n- Technical information (browser name and operating system).",
"TITLE": "What information do we collect?"
},
"INFORMATION_DISCLOSURE": {
"CONTENT": "We do not sell, trade or otherwise transfer to outside parties your personally identifiable information.\n\nThis does not include trusted third parties who assist us in operating our site and servicing you, so long as those parties agree to keep this information confidential. \n\nWe may also release your information when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or others rights, property, or safety.\n\nWhen you authorize a third-party application to use your account, depending on the scope of permissions you approve, it may access your profile information or your workouts. Applications can never access your password.",
"TITLE": "Do we disclose any information to outside parties?"
},
"INFORMATION_PROTECTION": {
"CONTENT": "We implement a variety of security measures to maintain the safety of your personal information when you enter, submit, or access your personal information.",
"TITLE": "How do we protect your information?"
},
"INFORMATION_USAGE": {
"CONTENT": "Any of the information we collect from you may be used to provide the core functionality of **FitTrackee**:\n- GPX files are used to create workouts, display tracks on map (with [OpenStreetMap](https://www.openstreetmap.org) and the configured tile server) and charts, generate map thumbnails, calculate records and get weather data (if a weather provider is set).\n- Profile information and workouts are not displayed publicly. A registered user can only display his own workouts.\n- The email address you provide may be used to send you information or confirm your account modifications.",
"TITLE": "What do we use your information for?"
},
"SITE_USAGE_BY_CHILDREN": {
"CONTENT": "If this server is in the EU or the EEA: Our site and services are all directed to people who are at least 16 years old. If you are under the age of 16, per the requirements of the [GDPR](https://en.wikipedia.org/wiki/General_Data_Protection_Regulation) (General Data Protection Regulation) do not use this site.\n\nIf this server is in the USA: Our site, products and services are all directed to people who are at least 13 years old. If you are under the age of 13, per the requirements of [COPPA](https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act) (Children's Online Privacy Protection Act) do not use this site.\n\nLaw requirements can be different if this server is in another jurisdiction.",
"TITLE": "Site usage by children"
},
"YOUR_CONSENT": {
"CONTENT": "By using our site, you consent to our web site privacy policy.",
"TITLE": "Your Consent"
}
},
"LAST_UPDATE": "Last update",
"TITLE": "privacy policy"
}

View File

@ -3,15 +3,27 @@
"ACCOUNT_CONFIRMATION_SENT": "Check your email. A new confirmation email has been sent to the address provided.",
"ADMIN": "Admin",
"ALREADY_HAVE_ACCOUNT": "Already have an account?",
"CONFIRM_ACCOUNT_DELETION": "Are you sure you want to delete your account? All data will be deleted, this cannot be undone",
"CONFIRM_ACCOUNT_DELETION": "Are you sure you want to delete your account? All data will be deleted, this cannot be undone.",
"CURRENT_PASSWORD": "Current password",
"EMAIL": "Email",
"EMAIL_INFO": "Enter a valid email address.",
"ENTER_PASSWORD": "Enter a password",
"EXPORT_REQUEST": {
"DATA_EXPORT": "Data export",
"DOWNLOAD_ARCHIVE": "Download archive",
"ONLY_ONE_EXPORT_PER_DAY": "You can request an archive by 24 hours",
"STATUS": {
"errored": "errored (please request another export)",
"in_progress": "in progres..."
},
"GENERATING_LINK": "generating link..."
},
"FILTER_ON_USERNAME": "Filter on username",
"HIDE_PASSWORD": "hide password",
"I_WANT_TO_DELETE_MY_ACCOUNT": "I want to delete my account",
"INVALID_TOKEN": "Invalid token, please request a new password reset.",
"LANGUAGE": "Language",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "The privacy policy has been updated, please {0} it before proceeding.",
"LOGIN": "Login",
"LOGOUT": "Logout",
"LOG_IN": "log in",
@ -68,6 +80,7 @@
"PICTURE_REMOVE": "Remove picture",
"PICTURE_UPDATE": "Update picture",
"PREFERENCES_EDITION": "Preferences edition",
"PRIVACY-POLICY_EDITION": "Privacy policy",
"PROFILE_EDITION": "Profile edition",
"REGISTRATION_DATE": "Registration date",
"SPORT": {
@ -89,6 +102,7 @@
"APPS": "apps",
"PICTURE": "picture",
"PREFERENCES": "preferences",
"PRIVACY-POLICY": "privacy policy",
"PROFILE": "profile",
"SPORTS": "sports"
},
@ -99,13 +113,16 @@
"METRIC": "Metric system (m, km, m/s, °C)"
}
},
"READ_AND_ACCEPT_PRIVACY_POLICY": "I have read and agree to the {0}.",
"REGISTER": "Register",
"REGISTER_DISABLED": "Sorry, registration is disabled.",
"RESENT_ACCOUNT_CONFIRMATION": "Resend account confirmation email",
"RESET_PASSWORD": "Reset your password",
"REVIEW": "review",
"SHOW_PASSWORD": "show password",
"THIS_USER_ACCOUNT_IS_INACTIVE": "This user account is inactive.",
"USERNAME": "Username",
"USERNAME_INFO": "3 to 30 characters required, only alphanumeric characters and the underscore character \"_\" allowed.",
"USER_PICTURE": "user picture"
"USER_PICTURE": "user picture",
"YOU_HAVE_ACCEPTED_PRIVACY_POLICY": "You have accepted the {0}."
}

View File

@ -1,4 +1,5 @@
{
"ABOUT_THIS_INSTANCE": "A propos de cette instance",
"CONTACT_ADMIN": "Contacter l'administrateur",
"FITTRACKEE_DESCRIPTION": "<strong>FitTrackee</strong> est un <em>tracker</em> d'activités sportives (en extérieur).",
"FITTRACKEE_LICENSE": "sous licence {0} (en) ",

View File

@ -1,4 +1,8 @@
{
"ABOUT": {
"TEXT": "Information détaillée de l'instance",
"DESCRIPTION": "Toute information supplémentaire qui peut être utile à vos utilisateurs. La syntaxe Markdown peut être utilisée."
},
"ACTION": "Action",
"ACTIVATE_USER_ACCOUNT": "Activer le compte",
"ACTIVE": "Actif",
@ -24,7 +28,9 @@
"EMAIL_SENDING_DISABLED": "L'envoi d'emails est désactivé.",
"ENABLE_DISABLE_SPORTS": "Activer/désactiver des sports.",
"NEW_EMAIL": "Nouvelle adresse email",
"NO_TEXT_ENTERED": "pas de texte saisi",
"PASSWORD_RESET_SUCCESSFUL": "Le mot de passe a été réinitialisé.",
"PRIVACY_POLICY_DESCRIPTION": "Ajouter votre propre politique de confidentialité ou laisser vider pour utiliser la politique par défaut. La syntaxe Markdown peut être utilisée.",
"REGISTRATION_DISABLED": "Les inscriptions sont actuellement désactivées.",
"REGISTRATION_ENABLED": "Les inscriptions sont actuellement activées.",
"RESET_USER_PASSWORD": "Réinit. le mot de passe",
@ -37,7 +43,7 @@
},
"TITLE": "Administration - Sports"
},
"UPDATE_APPLICATION_DESCRIPTION": "Configurer l'application (nombre maximum d'utilisateurs inscrits, taille maximale des fichers).",
"UPDATE_APPLICATION_DESCRIPTION": "Configurer l'application.",
"UPDATE_USER_EMAIL": "Changer l'email",
"USER": "utilisateur | utilisateurs",
"USERS": {

View File

@ -3,6 +3,7 @@
"Network Error": "Erreur réseau.",
"UNKNOWN": "Erreur. Veuillez réessayer ou contacter l'administrateur.",
"at least one file in zip archive exceeds size limit, please check the archive": "Au moins un fichier de l'archive zip dépasse la taille maximale, veuillez vérifier l'archive.",
"completed request already exists": "Une demande d'export terminée existe déjà.",
"email: valid email must be provided": "Courriel : une adresse électronique valide doit être fournie.",
"error during gpx file parsing": "Erreur lors de l'analyse du fichier.",
"error during gpx processing": "Erreur lors du traitement du fichier gpx.",
@ -19,6 +20,7 @@
"new email must be different than curent email": "La nouvelle addresse électronique doit être differente de l'adresse actuelle",
"no file part": "Pas de fichier fourni.",
"no selected file": "Pas de fichier sélectionné.",
"ongoing request exists": "Une demande d'export de données est en cours",
"password: password and password confirmation do not match": "Mot de passe : les mots de passe saisis sont différents.",
"provide a valid auth token": "Merci de fournir un jeton de connexion valide.",
"signature expired, please log in again": "Signature expirée. Merci de vous reconnecter.",

View File

@ -12,6 +12,7 @@
"LOGIN": "Se connecter",
"NO": "Non",
"REGISTER": "S'inscrire",
"REQUEST_DATA_EXPORT": "Demander un export de données",
"RESET": "Réinit.",
"SUBMIT": "Valider",
"YES": "Oui"

View File

@ -6,6 +6,7 @@ import CommonTranslations from './common.json'
import DashboardTranslations from './dashboard.json'
import ErrorTranslations from './error.json'
import OAuth2Translations from './oauth2.json'
import PrivacyPolicyTranslations from './privacy_policy.json'
import SportsTranslations from './sports.json'
import StatisticsTranslations from './statistics.json'
import UserTranslations from './user.json'
@ -20,6 +21,7 @@ export default {
dashboard: DashboardTranslations,
error: ErrorTranslations,
oauth2: OAuth2Translations,
privacy_policy: PrivacyPolicyTranslations,
sports: SportsTranslations,
statistics: StatisticsTranslations,
user: UserTranslations,

View File

@ -0,0 +1,38 @@
{
"CONTENT": {
"ACCOUNT_DELETION": {
"CONTENT": "Vous pouvez demander à tout moment la suppression de votre compte en vous rendant à cette adresse (après vous être connecté à votre compte), puis en cliquant sur le bouton sous \"Supprimer mon compte\" dans l'espace de mise à jour de votre compte.",
"TITLE": "Suppression du compte"
},
"CHANGES_TO_OUR_PRIVACY_POLICY": {
"CONTENT": "Si nous décidons de changer notre politique de confidentialité, nous afficherons ces modifications sur cette page.\n\nCe document est sous licence [CC-BY-SA](https://creativecommons.org/licenses/by-sa/4.0/). Adaptée de la politique de confidentialité de [Discourse](https://github.com/discourse/discourse).",
"TITLE": "Modifications de notre politique de confidentialité"
},
"DATA_COLLECTED": {
"CONTENT": "Les informations suivantes sont collectées :\n- Informations liées au compte (nom d'utilisateur, courriel et mot de passe). Vous pouvez également saisir les informations du profil tel que le prénom, le nom de famille, la date de naissance, la localisation, une biographie et envoyer une image de profil.\n- Fichiers [GPX](https://fr.wikipedia.org/wiki/GPX_(format_de_fichier). Ces fichiers contiennent les données liées à vos activités (coordonnées géographiques, date, distance, durée, vitesses maximale et moyenne, altitude, rythme cardiaque…). Si vous ne souhaitez pas exposer certaines données, nettoyer les fichiers avant de les envoyer ou ajouter des activités sans fichier GPX.\n- Données d'activités (sport, titre, date, durée, distance, dénivelé positif et négatif, notes).\n- Données techniques (nom du navigateur et du système d'exploitation).",
"TITLE": "Quelles sont les informations que nous recueillons ?"
},
"INFORMATION_DISCLOSURE": {
"CONTENT": "Nous ne vendons pas, ni échangeons ou même transférons vos renseignements personnelles à des tiers.\n\nCeci ninclut pas les tiers de confiance qui nous aident à exploiter notre site ou vous servir, tant que ces parties conviennent à garder ces informations confidentielles.\n\nNous pouvons également divulguer vos informations lorsque nous croyons nécessaire de se conformer à la loi, appliquer nos politiques de site, ou la nôtre ou dautres droits, la propriété ou la sécurité.\n\nSi vous autorisez une application tierce à utiliser votre compte, selon le périmètre des permissions accordées, elle pourra avoir accès à vos informations de profil ou vos activités. Les applications tierces ne peuvent jamais accéder à votre mot de passe.",
"TITLE": "Divulguons-nous des informations à des tiers ?"
},
"INFORMATION_PROTECTION": {
"CONTENT": "Nous mettons en œuvre une variété de mesures de sécurité pour maintenir la sécurité de vos informations personnelles lorsque vous saisissez, soumettez ou daccédez à vos renseignements personnels.",
"TITLE": "Comment protégeons-nous vos informations ?"
},
"INFORMATION_USAGE": {
"CONTENT": "Toutes les informations que nous recueillons auprès de vous peuvent être utilisées afin de fournir les fonctionnalités de **FitTrackee** :\n- Les fichiers GPX sont utilisés pour créer des activités, afficher des traces sur une carte (avec [OpenStreetMap](https://www.openstreetmap.org) et le serveur de tuiles configuré) et des graphiques, générer des vignettes de cartes, calculer des records et obtenir des données météo (si un fournisseur de données météorologiques est configuré).\n- Les informations du profil et les activités ne sont pas affichées publiquement. Un utilisateur enregistré ne peut voir que ses propres activités.\n- Le courriel que vous avez fourni peut être utilisé pour vous envoyer des informations ou confirmer des actions de modification de votre compte.",
"TITLE": "Comment utilisons-nous vos informations ?"
},
"SITE_USAGE_BY_CHILDREN": {
"CONTENT": "Si ce serveur est localisé dans l'Union Européenne (UE) ou l'Espace Economique Européen (EEA) : notre site et nos services sont tous destinés aux personnes âgées d'au moins 16 ans. Si vous avez moins de 16 ans, conformément aux exigences du [RGPD](https://fr.wikipedia.org/wiki/R%C3%A8glement_g%C3%A9n%C3%A9ral_sur_la_protection_des_donn%C3%A9es) (Règlement général sur la protection des données), n'utilisez pas ce site.\n\nSi ce serveur se trouve aux États-Unis : notre site et nos services sont tous destinés à des personnes âgées d'au moins 13 ans. Si vous avez moins de 13 ans, conformément aux exigences de la loi [COPPA](https://fr.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act) (Children's Online Privacy Protection Act), n'utilisez pas ce site.\n\nLes exigences légales peuvent être différentes si ce serveur se trouve dans une autre juridiction.",
"TITLE": "Protection des mineurs"
},
"YOUR_CONSENT": {
"CONTENT": "En utilisant notre site, vous acceptez la politique de confidentialité de notre site web.",
"TITLE": "Votre consentement"
}
},
"LAST_UPDATE": "Dernière mise à jour",
"TITLE": "politique de confidentialité"
}

View File

@ -3,15 +3,27 @@
"ACCOUNT_CONFIRMATION_SENT": "Vérifiez vos courriels. Un nouveau courriel de confirmation a été envoyé à l'adresse électronique fournie.",
"ADMIN": "Admin",
"ALREADY_HAVE_ACCOUNT": "Vous avez déjà un compte ?",
"CONFIRM_ACCOUNT_DELETION": "Êtes-vous sûr·e de vouloir supprimer votre compte ? Toutes les données seront définitivement effacés",
"CONFIRM_ACCOUNT_DELETION": "Êtes-vous sûr·e de vouloir supprimer votre compte ? Toutes les données seront définitivement effacées.",
"CURRENT_PASSWORD": "Mot de passe actuel",
"EMAIL": "Courriel",
"EMAIL_INFO": "Saisissez une adresse électronique valide.",
"ENTER_PASSWORD": "Saisissez un mot de passe",
"EXPORT_REQUEST": {
"DATA_EXPORT": "Export des données",
"DOWNLOAD_ARCHIVE": "Télécharger l'archive",
"ONLY_ONE_EXPORT_PER_DAY": "Vous pouvez demander un export par 24h",
"STATUS": {
"errored": "en erreur (veuillez demander une nouvelle archive)",
"in_progress": "en cours..."
},
"GENERATING_LINK": "lien en cours de génération..."
},
"FILTER_ON_USERNAME": "Filtrer sur le nom d'utilisateur",
"HIDE_PASSWORD": "masquer le mot de passe",
"I_WANT_TO_DELETE_MY_ACCOUNT": "Je souhaite supprimer mon compte",
"INVALID_TOKEN": "Jeton invalide, veuillez demander une nouvelle réinitialisation de mot de passe.",
"LANGUAGE": "Langue",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "La politique de confidentialité a été mise à jour. Veuillez l'{0} avant de poursuivre.",
"LOGIN": "Se connecter",
"LOGOUT": "Se déconnecter",
"LOG_IN": "connecter",
@ -68,6 +80,7 @@
"PICTURE_REMOVE": "Supprimer",
"PICTURE_UPDATE": "Mettre à jour l'image",
"PREFERENCES_EDITION": "Mise à jour des préférences",
"PRIVACY-POLICY_EDITION": "Politique de confidentialité",
"PROFILE_EDITION": "Mise à jour du profil",
"REGISTRATION_DATE": "Date d'inscription",
"SPORT": {
@ -89,6 +102,7 @@
"APPS": "apps",
"PICTURE": "image",
"PREFERENCES": "préférences",
"PRIVACY-POLICY": "politique de confidentialité",
"PROFILE": "profil",
"SPORTS": "sports"
},
@ -99,13 +113,16 @@
"METRIC": "Système métrique (m, km, m/s, °C)"
}
},
"READ_AND_ACCEPT_PRIVACY_POLICY": "J'ai lu et accepte la {0}.",
"REGISTER": "S'inscrire",
"REGISTER_DISABLED": "Désolé, les inscriptions sont désactivées.",
"RESENT_ACCOUNT_CONFIRMATION": "Envoyer à nouveau le courriel de confirmation de compte",
"RESET_PASSWORD": "Réinitialiser votre mot de passe",
"REVIEW": "accepter",
"SHOW_PASSWORD": "afficher le mot de passe",
"THIS_USER_ACCOUNT_IS_INACTIVE": "Le compte de cet utilisateur est inactif.",
"USERNAME": "Nom d'utilisateur",
"USERNAME_INFO": "3 à 30 caractères requis, seuls les caractères alphanumériques et le caractère _ sont autorisés.",
"USER_PICTURE": "photo de l'utilisateur"
"USER_PICTURE": "photo de l'utilisateur",
"YOU_HAVE_ACCEPTED_PRIVACY_POLICY": "Vous avez accepté la {0}."
}

View File

@ -37,7 +37,7 @@
},
"TITLE": "Amministrazione sport"
},
"UPDATE_APPLICATION_DESCRIPTION": "Aggiorna configurazione applicazione (numero massimo di utenti registrati, dimensione massima dei files).",
"UPDATE_APPLICATION_DESCRIPTION": "Aggiorna configurazione applicazione.",
"UPDATE_USER_EMAIL": "Aggiorna email",
"USER": "utente | utenti",
"USERS": {

View File

@ -6,6 +6,7 @@ import CommonTranslations from './common.json'
import DashboardTranslations from './dashboard.json'
import ErrorTranslations from './error.json'
import OAuth2Translations from './oauth2.json'
import PrivacyPolicyTranslations from './privacy_policy.json'
import SportsTranslations from './sports.json'
import StatisticsTranslations from './statistics.json'
import UserTranslations from './user.json'
@ -20,6 +21,7 @@ export default {
dashboard: DashboardTranslations,
error: ErrorTranslations,
oauth2: OAuth2Translations,
privacy_policy: PrivacyPolicyTranslations,
sports: SportsTranslations,
statistics: StatisticsTranslations,
user: UserTranslations,

View File

@ -0,0 +1 @@
{}

View File

@ -6,6 +6,7 @@ import CommonTranslations from './common.json'
import DashboardTranslations from './dashboard.json'
import ErrorTranslations from './error.json'
import OAuth2Translations from './oauth2.json'
import PrivacyPolicyTranslations from './privacy_policy.json'
import SportsTranslations from './sports.json'
import StatisticsTranslations from './statistics.json'
import UserTranslations from './user.json'
@ -20,6 +21,7 @@ export default {
dashboard: DashboardTranslations,
error: ErrorTranslations,
oauth2: OAuth2Translations,
privacy_policy: PrivacyPolicyTranslations,
sports: SportsTranslations,
statistics: StatisticsTranslations,
user: UserTranslations,

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