Merge pull request #306 from SamR1/add-privacy-policy-and-user-export
Add privacy policy and user data export
This commit is contained in:
commit
4aa0c961a3
@ -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
|
@ -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.
|
@ -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,17 +99,21 @@ 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
|
||||
"""""""""""
|
||||
|
||||
**Configuration**
|
||||
|
||||
The following parameters can be set:
|
||||
|
||||
- active users limit. If 0, registration is enabled (no limit defined)
|
||||
- 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*)
|
||||
@ -120,19 +125,39 @@ Administration
|
||||
.. note::
|
||||
If email sending is disabled, a warning is displayed.
|
||||
|
||||
**About**
|
||||
|
||||
- **Users**
|
||||
(*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.
|
||||
|
||||
|
||||
**Privacy policy**
|
||||
|
||||
(*new in 0.7.13*)
|
||||
|
||||
| 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.
|
||||
|
||||
.. 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**
|
||||
|
||||
Sports
|
||||
""""""
|
||||
- enable or disable a sport (a sport can be disabled even if workout with this sport exists)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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": "© <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": "© <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(
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -69,6 +69,7 @@ class BaseConfig:
|
||||
'authorization_code': 864000, # 10 days
|
||||
}
|
||||
OAUTH2_REFRESH_TOKEN_GENERATOR = True
|
||||
DATA_EXPORT_EXPIRATION = 24 # hours
|
||||
|
||||
|
||||
class DevelopmentConfig(BaseConfig):
|
||||
|
2
fittrackee/dist/index.html
vendored
2
fittrackee/dist/index.html
vendored
@ -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>
|
2
fittrackee/dist/service-worker.js
vendored
2
fittrackee/dist/service-worker.js
vendored
File diff suppressed because one or more lines are too long
2
fittrackee/dist/service-worker.js.map
vendored
2
fittrackee/dist/service-worker.js.map
vendored
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/css/app.33ec7d01.css
vendored
Normal file
1
fittrackee/dist/static/css/app.33ec7d01.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/css/app.e2dfa8b7.css
vendored
1
fittrackee/dist/static/css/app.e2dfa8b7.css
vendored
File diff suppressed because one or more lines are too long
2
fittrackee/dist/static/js/app.27b920e4.js
vendored
Normal file
2
fittrackee/dist/static/js/app.27b920e4.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/js/app.27b920e4.js.map
vendored
Normal file
1
fittrackee/dist/static/js/app.27b920e4.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
fittrackee/dist/static/js/app.f7fbef48.js
vendored
2
fittrackee/dist/static/js/app.f7fbef48.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
74
fittrackee/dist/static/js/chunk-vendors.1d69c386.js
vendored
Normal file
74
fittrackee/dist/static/js/chunk-vendors.1d69c386.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/js/chunk-vendors.1d69c386.js.map
vendored
Normal file
1
fittrackee/dist/static/js/chunk-vendors.1d69c386.js.map
vendored
Normal file
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
2
fittrackee/dist/static/js/reset.24cb26e8.js
vendored
2
fittrackee/dist/static/js/reset.24cb26e8.js
vendored
File diff suppressed because one or more lines are too long
2
fittrackee/dist/static/js/reset.3771935b.js
vendored
Normal file
2
fittrackee/dist/static/js/reset.3771935b.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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
2
fittrackee/dist/static/js/workouts.2996c656.js
vendored
Normal file
2
fittrackee/dist/static/js/workouts.2996c656.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/js/workouts.2996c656.js.map
vendored
Normal file
1
fittrackee/dist/static/js/workouts.2996c656.js.map
vendored
Normal file
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
@ -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,
|
||||
)
|
||||
|
26
fittrackee/emails/templates/data_export_ready/body.html
Normal file
26
fittrackee/emails/templates/data_export_ready/body.html
Normal 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 %}
|
5
fittrackee/emails/templates/data_export_ready/body.txt
Normal file
5
fittrackee/emails/templates/data_export_ready/body.txt
Normal 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 %}
|
@ -0,0 +1 @@
|
||||
FitTrackee - {{ _('Your archive is ready to be downloaded') }}
|
Binary file not shown.
@ -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,6 +84,7 @@ 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 ""
|
||||
@ -99,6 +98,37 @@ msgstr ""
|
||||
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."
|
||||
|
||||
|
Binary file not shown.
@ -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"
|
||||
|
Binary file not shown.
@ -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."
|
||||
|
||||
|
Binary file not shown.
@ -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."
|
||||
@ -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."
|
||||
|
||||
|
Binary file not shown.
@ -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"
|
||||
|
Binary file not shown.
@ -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."
|
||||
|
||||
|
@ -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 ###
|
@ -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',
|
||||
[
|
||||
|
@ -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
|
||||
|
@ -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">© 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">© FitTrackee.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
89
fittrackee/tests/emails/test_email_data_export_ready.py
Normal file
89
fittrackee/tests/emails/test_email_data_export_ready.py
Normal 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
|
@ -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
|
||||
|
||||
|
||||
|
6
fittrackee/tests/fixtures/fixtures_emails.py
vendored
6
fittrackee/tests/fixtures/fixtures_emails.py
vendored
@ -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
|
||||
|
9
fittrackee/tests/fixtures/fixtures_users.py
vendored
9
fittrackee/tests/fixtures/fixtures_users.py
vendored
@ -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
|
||||
|
@ -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,7 +327,10 @@ class TestUserRegistration(ApiTestCaseMixin):
|
||||
client = app.test_client()
|
||||
username = self.random_string()
|
||||
email = self.random_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)
|
||||
client.post(
|
||||
'/api/auth/register',
|
||||
data=json.dumps(
|
||||
@ -278,6 +339,7 @@ class TestUserRegistration(ApiTestCaseMixin):
|
||||
email=email,
|
||||
password=self.random_string(),
|
||||
language=input_language,
|
||||
accepted_policy=True,
|
||||
)
|
||||
),
|
||||
content_type='application/json',
|
||||
@ -288,6 +350,7 @@ class TestUserRegistration(ApiTestCaseMixin):
|
||||
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,
|
||||
)
|
||||
|
@ -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',
|
||||
|
636
fittrackee/tests/users/test_users_export_data.py
Normal file
636
fittrackee/tests/users/test_users_export_data.py
Normal 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
|
||||
)
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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}.')
|
||||
|
183
fittrackee/users/export_data.py
Normal file
183
fittrackee/users/export_data.py
Normal 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
|
@ -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')
|
||||
|
7
fittrackee/users/tasks.py
Normal file
7
fittrackee/users/tasks.py
Normal 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)
|
@ -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,
|
||||
|
10
fittrackee/users/utils/language.py
Normal file
10
fittrackee/users/utils/language.py
Normal 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
|
@ -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(
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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,6 +218,7 @@
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/scss/vars.scss';
|
||||
|
||||
#admin-app {
|
||||
.user-limit-help {
|
||||
display: flex;
|
||||
span {
|
||||
@ -183,4 +231,17 @@
|
||||
.no-contact {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
textarea {
|
||||
margin-bottom: $default-padding;
|
||||
}
|
||||
.textarea-description {
|
||||
font-style: italic;
|
||||
}
|
||||
.textarea-content {
|
||||
margin-bottom: $default-margin;
|
||||
padding: $default-padding;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
91
fittrackee_client/src/components/PrivacyPolicy.vue
Normal file
91
fittrackee_client/src/components/PrivacyPolicy.vue
Normal 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>
|
25
fittrackee_client/src/components/PrivacyPolicyToAccept.vue
Normal file
25
fittrackee_client/src/components/PrivacyPolicyToAccept.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
@ -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]
|
||||
)
|
||||
|
@ -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) {
|
||||
|
@ -37,6 +37,7 @@
|
||||
switch (tab) {
|
||||
case 'ACCOUNT':
|
||||
case 'PICTURE':
|
||||
case 'PRIVACY-POLICY':
|
||||
return `/profile/edit/${tab.toLocaleLowerCase()}`
|
||||
case 'APPS':
|
||||
case 'PREFERENCES':
|
||||
|
@ -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": {
|
||||
|
@ -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,
|
||||
|
1
fittrackee_client/src/locales/de/privacy_policy.json
Normal file
1
fittrackee_client/src/locales/de/privacy_policy.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
@ -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 ",
|
||||
|
@ -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": {
|
||||
|
@ -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.",
|
||||
|
@ -12,6 +12,7 @@
|
||||
"LOGIN": "Log in",
|
||||
"NO": "No",
|
||||
"REGISTER": "Register",
|
||||
"REQUEST_DATA_EXPORT": "Request data export",
|
||||
"RESET": "Reset",
|
||||
"SUBMIT": "Submit",
|
||||
"YES": "Yes"
|
||||
|
@ -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,
|
||||
|
38
fittrackee_client/src/locales/en/privacy_policy.json
Normal file
38
fittrackee_client/src/locales/en/privacy_policy.json
Normal 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"
|
||||
}
|
@ -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}."
|
||||
}
|
@ -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) ",
|
||||
|
@ -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": {
|
||||
|
@ -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.",
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
|
38
fittrackee_client/src/locales/fr/privacy_policy.json
Normal file
38
fittrackee_client/src/locales/fr/privacy_policy.json
Normal 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 n’inclut 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 d’autres 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 d’accé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é"
|
||||
}
|
@ -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}."
|
||||
}
|
@ -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": {
|
||||
|
@ -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,
|
||||
|
1
fittrackee_client/src/locales/it/privacy_policy.json
Normal file
1
fittrackee_client/src/locales/it/privacy_policy.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
@ -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
Loading…
Reference in New Issue
Block a user